From 00108e6edf8a664dc546d64adc7b384e2cf40b76 Mon Sep 17 00:00:00 2001 From: Gareth Latty Date: Fri, 18 Dec 2015 13:40:42 +0000 Subject: [PATCH] Work on #10 - Add skipping for disconnected players. --- client/src/MassiveDecks/API.elm | 7 +- client/src/MassiveDecks/Actions/Action.elm | 6 +- client/src/MassiveDecks/Actions/Event.elm | 22 +-- .../src/MassiveDecks/Models/Json/Decode.elm | 4 +- .../src/MassiveDecks/Models/Notification.elm | 40 ++---- client/src/MassiveDecks/Models/Player.elm | 15 +- client/src/MassiveDecks/States/Config.elm | 12 +- client/src/MassiveDecks/States/Playing.elm | 29 ++-- client/src/MassiveDecks/States/Playing/UI.elm | 58 +++++++- .../MassiveDecks/States/SharedUI/General.elm | 15 +- .../MassiveDecks/States/SharedUI/Lobby.elm | 62 ++++++--- client/src/MassiveDecks/States/Start/UI.elm | 6 - client/src/MassiveDecks/Util.elm | 21 +++ server/app/assets/javascripts/massivedecks.js | 9 +- server/app/assets/stylesheets/lobby.less | 90 +++++++++++- .../app/assets/stylesheets/massivedecks.less | 23 ++- server/app/assets/stylesheets/variables.less | 1 + .../massivedecks/game/Actions.scala | 6 + .../controllers/massivedecks/game/Game.scala | 12 ++ .../controllers/massivedecks/game/State.scala | 131 ++++++++++++------ server/app/models/massivedecks/Player.scala | 13 +- server/project/plugins.sbt | 2 + 22 files changed, 419 insertions(+), 165 deletions(-) diff --git a/client/src/MassiveDecks/API.elm b/client/src/MassiveDecks/API.elm index e9a6f66..81d6b3c 100644 --- a/client/src/MassiveDecks/API.elm +++ b/client/src/MassiveDecks/API.elm @@ -8,7 +8,7 @@ import Effects import Http exposing (post, url, empty, send, defaultSettings, fromJson) import MassiveDecks.Actions.Action exposing (Action(..)) -import MassiveDecks.Models.Player exposing (Secret) +import MassiveDecks.Models.Player exposing (Secret, Id) import MassiveDecks.Models.Game exposing (Lobby, LobbyAndHand) import MassiveDecks.Models.Json.Encode exposing (..) import MassiveDecks.Models.Json.Decode exposing (..) @@ -61,6 +61,11 @@ choose : String -> Secret -> Int -> Task.Task Http.Error LobbyAndHand choose lobbyId secret winner = lobbyAction lobbyId (commandEncoder "choose" secret [ ("winner", int winner) ]) +skip : String -> Secret -> List Id -> Task.Task Http.Error LobbyAndHand +skip lobbyId secret players = + lobbyAction lobbyId (commandEncoder "skip" secret [ ("players", list (List.map int players)) ]) + + getLobbyAndHand : String -> Secret -> Task.Task Http.Error LobbyAndHand getLobbyAndHand lobbyId secret = lobbyAction lobbyId (commandEncoder "getLobbyAndHand" secret []) diff --git a/client/src/MassiveDecks/Actions/Action.elm b/client/src/MassiveDecks/Actions/Action.elm index 273a5e7..38384cc 100644 --- a/client/src/MassiveDecks/Actions/Action.elm +++ b/client/src/MassiveDecks/Actions/Action.elm @@ -28,11 +28,11 @@ type Action | FailAddDeck String Error | StartGame (APICall LobbyAndHand) | Pick Int - | Play (APICall LobbyAndHand) + | Play | Withdraw Int | Notification Lobby | Consider Int - | Choose Int (APICall LobbyAndHand) + | Choose Int | RemoveErrorPanel Int | NextRound | SetInitialState InitialState @@ -41,6 +41,8 @@ type Action | AddAi | DismissPlayerNotification (Maybe Notification.Player) | LeaveLobby + | Skip (List Player.Id) + | UpdateLobbyAndHand LobbyAndHand eventEffects : Lobby -> Lobby -> Effects.Effects Action diff --git a/client/src/MassiveDecks/Actions/Event.elm b/client/src/MassiveDecks/Actions/Event.elm index 107bbd1..d4eac9d 100644 --- a/client/src/MassiveDecks/Actions/Event.elm +++ b/client/src/MassiveDecks/Actions/Event.elm @@ -9,6 +9,8 @@ import MassiveDecks.Util as Util type Event = PlayerJoin Id | PlayerStatus Id Status + | PlayerLeft Id + | PlayerDisconnect Id | PlayerReconnect Id | PlayerScore Id Int | RoundStart Call Id @@ -50,17 +52,11 @@ diffPlayer oldPlayers newPlayer = case oldPlayer of Just oldPlayer -> List.concat - [ if oldPlayer.status /= newPlayer.status then - List.append - (if oldPlayer.status == Disconnected then - [ playerReconnect newPlayer ] - else - [] - ) - [ playerStatus newPlayer ] - else - [] + [ if oldPlayer.status /= newPlayer.status then [ playerStatus newPlayer ] else [] , if oldPlayer.score /= newPlayer.score then [ playerScore newPlayer ] else [] + , if (not oldPlayer.left) && newPlayer.left then [ playerLeft newPlayer ] else [] + , if (not oldPlayer.disconnected) && newPlayer.disconnected then [ playerDisconnect newPlayer ] else [] + , if oldPlayer.disconnected && (not newPlayer.disconnected) then [ playerReconnect newPlayer ] else [] ] Nothing -> @@ -108,6 +104,12 @@ playerStatus player = PlayerStatus player.id player.status playerScore : Player -> Event playerScore player = PlayerScore player.id player.score +playerLeft : Player -> Event +playerLeft player = PlayerLeft player.id + +playerDisconnect : Player -> Event +playerDisconnect player = PlayerDisconnect player.id + roundStart : Round -> Event roundStart round = RoundStart round.call round.czar diff --git a/client/src/MassiveDecks/Models/Json/Decode.elm b/client/src/MassiveDecks/Models/Json/Decode.elm index 501aff2..43fc098 100644 --- a/client/src/MassiveDecks/Models/Json/Decode.elm +++ b/client/src/MassiveDecks/Models/Json/Decode.elm @@ -40,11 +40,13 @@ handDecoder = object1 Hand playerDecoder : Decoder Player -playerDecoder = object4 Player +playerDecoder = object6 Player ("id" := playerIdDecoder) ("name" := string) ("status" := playerStatusDecoder) ("score" := int) + ("disconnected" := bool) + ("left" := bool) playerStatusDecoder : Decoder Status diff --git a/client/src/MassiveDecks/Models/Notification.elm b/client/src/MassiveDecks/Models/Notification.elm index b752854..d0f3573 100644 --- a/client/src/MassiveDecks/Models/Notification.elm +++ b/client/src/MassiveDecks/Models/Notification.elm @@ -19,16 +19,6 @@ hide : Player -> Player hide notification = { notification | visible = False } -playerStatus : Player.Id -> Player.Status -> List Player.Player -> Maybe Player -playerStatus id status players = - let - name = Player.byId id players |> Maybe.map .name - icon = statusIcon status - description = name `Maybe.andThen` (statusDescription status) - in - Maybe.map3 player icon name description - - playerJoin : Player.Id -> List Player.Player -> Maybe Player playerJoin id players = let @@ -45,23 +35,17 @@ playerReconnect id players = Maybe.map (\name -> player "sign-in" name (name ++ " has reconnected to the game.")) name -statusDescription : Player.Status -> String -> Maybe String -statusDescription status name = case status of - Player.NotPlayed -> Nothing - Player.Played -> Nothing - Player.Czar -> Nothing - Player.Disconnected -> Just (name ++ " has disconnected from the game.") - Player.Left -> Just (name ++ " has left the game.") - Player.Ai -> Nothing - Player.Neutral -> Nothing +playerDisconnect : Player.Id -> List Player.Player -> Maybe Player +playerDisconnect id players = + let + name = Player.byId id players |> Maybe.map .name + in + Maybe.map (\name -> player "minus-circle" name (name ++ " has disconnected from the game.")) name -statusIcon : Player.Status -> Maybe String -statusIcon status = case status of - Player.NotPlayed -> Nothing - Player.Played -> Nothing - Player.Czar -> Nothing - Player.Disconnected -> Just ("minus-circle") - Player.Left -> Just ("sign-out") - Player.Ai -> Nothing - Player.Neutral -> Nothing +playerLeft : Player.Id -> List Player.Player -> Maybe Player +playerLeft id players = + let + name = Player.byId id players |> Maybe.map .name + in + Maybe.map (\name -> player "sign-out" name (name ++ " has left the game.")) name diff --git a/client/src/MassiveDecks/Models/Player.elm b/client/src/MassiveDecks/Models/Player.elm index e1684af..24f8901 100644 --- a/client/src/MassiveDecks/Models/Player.elm +++ b/client/src/MassiveDecks/Models/Player.elm @@ -11,6 +11,8 @@ type alias Player = , name : String , status : Status , score : Int + , disconnected : Bool + , left : Bool } @@ -24,10 +26,9 @@ type Status = NotPlayed | Played | Czar - | Disconnected - | Left | Ai | Neutral + | Skipping type alias Secret = @@ -41,10 +42,9 @@ statusName status = case status of NotPlayed -> "not-played" Played -> "played" Czar -> "czar" - Disconnected -> "disconnected" - Left -> "left" - Neutral -> "neutral" Ai -> "ai" + Neutral -> "neutral" + Skipping -> "skipping" nameToStatus : String -> Maybe Status @@ -52,10 +52,9 @@ nameToStatus name = case name of "not-played" -> Just NotPlayed "played" -> Just Played "czar" -> Just Czar - "disconnected" -> Just Disconnected - "left" -> Just Left - "neutral" -> Just Neutral "ai" -> Just Ai + "neutral" -> Just Neutral + "skipping" -> Just Skipping _ -> Nothing diff --git a/client/src/MassiveDecks/States/Config.elm b/client/src/MassiveDecks/States/Config.elm index e9012c4..9109632 100644 --- a/client/src/MassiveDecks/States/Config.elm +++ b/client/src/MassiveDecks/States/Config.elm @@ -98,15 +98,18 @@ update action global data = case action of GameEvent event -> case event of - PlayerStatus id status -> - notificationChange global data (Notification.playerStatus id status data.lobby.players) - PlayerJoin id -> notificationChange global data (Notification.playerJoin id data.lobby.players) PlayerReconnect id -> notificationChange global data (Notification.playerReconnect id data.lobby.players) + PlayerDisconnect id -> + notificationChange global data (Notification.playerDisconnect id data.lobby.players) + + PlayerLeft id -> + notificationChange global data (Notification.playerLeft id data.lobby.players) + _ -> (model global data, Effects.none) @@ -126,7 +129,8 @@ notificationChange global data notification = ] in ( model global { data | playerNotification = newNotification} - , Task.sleep (Time.second * 5) `Task.andThen` (\_ -> Task.succeed (DismissPlayerNotification newNotification)) |> Effects.task + , Task.sleep (Time.second * 5) `Task.andThen` (\_ -> Task.succeed (DismissPlayerNotification newNotification)) + |> Effects.task ) diff --git a/client/src/MassiveDecks/States/Playing.elm b/client/src/MassiveDecks/States/Playing.elm index c6980da..c24a8be 100644 --- a/client/src/MassiveDecks/States/Playing.elm +++ b/client/src/MassiveDecks/States/Playing.elm @@ -37,15 +37,8 @@ update action global data = case action of Withdraw card -> (model global { data | picked = List.filter ((/=) card) data.picked }, Effects.none) - Play Request -> - (model global data, (API.play data.lobby.id data.secret data.picked) |> Task.map (Play << Result) |> API.toEffect) - - Play (Result lobbyAndHand) -> - (model global - { data | lobby = lobbyAndHand.lobby - , hand = lobbyAndHand.hand - , picked = [] - }, eventEffects data.lobby lobbyAndHand.lobby) + Play -> + (model global data, (API.play data.lobby.id data.secret data.picked) |> Task.map UpdateLobbyAndHand |> API.toEffect) Notification lobby -> case lobby.round of @@ -62,10 +55,13 @@ update action global data = case action of Consider potentialWinner -> (model global { data | considering = Just potentialWinner } , Effects.none) - Choose winner Request -> - (model global data, (API.choose data.lobby.id data.secret winner) |> Task.map (Choose winner << Result) |> API.toEffect) + Choose winner -> + (model global data, (API.choose data.lobby.id data.secret winner) |> Task.map UpdateLobbyAndHand |> API.toEffect) - Choose winner (Result lobbyAndHand) -> + Skip players -> + (model global data, (API.skip data.lobby.id data.secret players) |> Task.map UpdateLobbyAndHand |> API.toEffect) + + UpdateLobbyAndHand lobbyAndHand -> (model global { data | lobby = lobbyAndHand.lobby , hand = lobbyAndHand.hand @@ -110,15 +106,18 @@ update action global data = case action of , shownPlayed = [] } , Effects.none) - PlayerStatus id status -> - notificationChange global data (Notification.playerStatus id status data.lobby.players) - PlayerJoin id -> notificationChange global data (Notification.playerJoin id data.lobby.players) PlayerReconnect id -> notificationChange global data (Notification.playerReconnect id data.lobby.players) + PlayerDisconnect id -> + notificationChange global data (Notification.playerDisconnect id data.lobby.players) + + PlayerLeft id -> + notificationChange global data (Notification.playerLeft id data.lobby.players) + _ -> (model global data, Effects.none) diff --git a/client/src/MassiveDecks/States/Playing/UI.elm b/client/src/MassiveDecks/States/Playing/UI.elm index 2f16c9c..d26651c 100644 --- a/client/src/MassiveDecks/States/Playing/UI.elm +++ b/client/src/MassiveDecks/States/Playing/UI.elm @@ -31,7 +31,9 @@ view address global data = Nothing -> ([], []) in LobbyUI.view address global.initialState.url data.lobby.id header lobby.players data.playerNotification - (List.concat [ content, [ errorMessages address errors ] ]) + (List.concat [ content, + [ warningDrawer address (disconnectedNotice address lobby.players) ], + [ errorMessages address errors ] ]) roundContents : Signal.Address Action -> PlayingData -> Round -> List Html @@ -181,7 +183,7 @@ pickedView address picked slots shownPlayed = playButton : Signal.Address Action -> Html playButton address = li [ class "play-button" ] [ button - [ class "mui-btn mui-btn--small mui-btn--accent mui-btn--fab", onClick address (Play Request) ] + [ class "mui-btn mui-btn--small mui-btn--accent mui-btn--fab", onClick address Play ] [ icon "check" ] ] @@ -207,5 +209,55 @@ playedResponse contents = chooseButton : Signal.Address Action -> Int -> Html chooseButton address playedId = li [ class "choose-button" ] [ button - [ class "mui-btn mui-btn--small mui-btn--accent mui-btn--fab", onClick address (Choose playedId Request) ] + [ class "mui-btn mui-btn--small mui-btn--accent mui-btn--fab", onClick address (Choose playedId) ] [ icon "trophy" ] ] + + +warningDrawer : Signal.Address Action -> List Html -> Html +warningDrawer address contents = + let + hidden = List.isEmpty contents + classes = + [ ("hidden", hidden) + ] + in + div [ id "warning-drawer", classList classes ] + [ button [ attribute "onClick" "toggleWarningDrawer()" + , class "toggle mui-btn mui-btn--small mui-btn--fab" + , title "Warning notices." + ] [ icon "exclamation-triangle" ] + , div [ class "top" ] [] + , div [ class "contents" ] contents + ] + + +disconnectedNotice : Signal.Address Action -> List Player -> List Html +disconnectedNotice address players = + let + disconnected = List.filter (\player -> player.disconnected && (not (player.status == Skipping))) players + disconnectedNames = Util.joinWithAnd (List.map .name disconnected) + disconnectedIds = List.map .id disconnected + has = Util.pluralHas disconnected + in + Maybe.map2 (renderDisconnectedNotice address disconnectedIds) disconnectedNames has + |> Maybe.map (\item -> [ item ]) + |> Maybe.withDefault [] + + +renderDisconnectedNotice : Signal.Address Action -> List Id -> String -> String -> Html +renderDisconnectedNotice address ids disconnectedNames has = + div [ id "disconnected-notice" ] + [ h3 [] [ icon "minus-circle" ] + , span [] [ text disconnectedNames + , text " " + , text has + , text " disconnected from the game." + ] + , div [ class "actions" ] + [ button [ class "mui-btn mui-btn--small" + , onClick address (Skip ids) + , title "They will be removed from this round, and won't be in future rounds until they reconnect." + ] + [ icon "fast-forward", text " Skip" ] + ] + ] diff --git a/client/src/MassiveDecks/States/SharedUI/General.elm b/client/src/MassiveDecks/States/SharedUI/General.elm index b6fffe0..ac1c961 100644 --- a/client/src/MassiveDecks/States/SharedUI/General.elm +++ b/client/src/MassiveDecks/States/SharedUI/General.elm @@ -11,6 +11,10 @@ icon : String -> Html icon name = i [ class ("fa fa-" ++ name) ] [] +fwIcon : String -> Html +fwIcon name = i [ class ("fa fa-fw fa-" ++ name) ] [] + + spinner : Html spinner = i [ class "fa fa-circle-o-notch fa-spin" ] [] @@ -52,26 +56,27 @@ gameMenu : Signal.Address Action -> Html gameMenu address = div [ class "menu mui-dropdown" ] [ button [ class "mui-btn mui-btn--small mui-btn--primary" , attribute "data-mui-toggle" "dropdown" - ] [ icon "ellipsis-h" ] + , title "Game menu." + ] [ fwIcon "ellipsis-h" ] , ul [ class "mui-dropdown__menu mui-dropdown__menu--right" ] [ li [] [ a [ class "link" , attribute "tabindex" "0" , attribute "role" "button" , attribute "onClick" "inviteOverlay()" - ] [ icon "bullhorn", text " Invite Players" ] ] + ] [ fwIcon "bullhorn", text " Invite Players" ] ] , li [] [ a [ class "link" , attribute "tabindex" "0" , attribute "role" "button" , onClick address LeaveLobby - ] [ icon "sign-out", text " Leave Game" ] ] + ] [ fwIcon "sign-out", text " Leave Game" ] ] , li [ class "mui-divider" ] [] , li [] [ a [ class "link" , attribute "tabindex" "0" , attribute "role" "button" , attribute "onClick" "aboutOverlay()" - ] [ icon "info-circle", text " About" ] ] + ] [ fwIcon "info-circle", text " About" ] ] , li [] [ a [ href "https://github.com/Lattyware/massivedecks/issues/new", target "_blank" ] - [ icon "bug", text " Report a bug" ] ] + [ fwIcon "bug", text " Report a bug" ] ] ] ] diff --git a/client/src/MassiveDecks/States/SharedUI/Lobby.elm b/client/src/MassiveDecks/States/SharedUI/Lobby.elm index a66a5f5..1df9830 100644 --- a/client/src/MassiveDecks/States/SharedUI/Lobby.elm +++ b/client/src/MassiveDecks/States/SharedUI/Lobby.elm @@ -30,7 +30,7 @@ spacer = div [ class "mui--appbar-height" ] [] scores : List Player -> Html scores players = div [ id "scores" ] - [ div [ id "scores-title", class "mui--appbar-line-height mui--text-title" ] [ text "Scores" ] + [ div [ id "scores-title", class "mui--appbar-line-height mui--text-title" ] [ text "Players" ] , div [ class "mui-divider" ] [] , table [ class "mui-table" ] (List.concat [ [ thead [] [ tr [] [ th [ class "state", title "State" ] [ icon "tasks" ] @@ -40,19 +40,27 @@ scores players = div [ id "scores" ] ], List.map score players]) ] - score : Player -> Html -score player = tr [ class (statusName player.status), title (statusDescription player.status) ] - [ td [ class "state" ] [ (statusIcon player.status) ] - , td [ class "name", title player.name ] [ text player.name ] - , td [ class "score" ] [ text (toString player.score) ] - ] +score player = + let + classes = classList + [ (statusName player.status, True) + , ("disconnected", player.disconnected) + , ("left", player.left) + ] + prename = if player.disconnected then [ icon "minus-circle", text " " ] else [] + in + tr [ classes ] + [ td [ class "state", title (statusDescription player) ] [ (playerIcon player) ] + , td [ class "name", title player.name ] (List.append prename [ text player.name ]) + , td [ class "score" ] [ text (toString player.score) ] + ] appHeader : Signal.Address Action -> List Html -> Maybe Notification.Player -> Html appHeader address contents notification = (header [] [ div [ class "mui-appbar mui--appbar-line-height" ] [ div [ class "mui--appbar-line-height" ] - [ span [] (List.append [ scoresButton True, scoresButton False ] (notificationPopup address notification)) + [ span [ class "score-buttons" ] (List.append [ scoresButton True, scoresButton False ] (notificationPopup address notification)) , span [ id "title", class "mui--text-title mui--visible-xs-inline-block" ] contents , gameMenu address ] ] ]) @@ -62,7 +70,9 @@ scoresButton shown = let showHideClasses = if shown then " mui--hidden-xs js-hide-scores" else " mui--visible-xs-inline-block js-show-scores" in - button [ class ("scores-toggle mui-btn mui-btn--small mui-btn--primary badged" ++ showHideClasses)] [ icon "users" ] + button [ class ("scores-toggle mui-btn mui-btn--small mui-btn--primary badged" ++ showHideClasses) + , title "Players." + ] [ fwIcon "users" ] notificationPopup : Signal.Address Action -> Maybe Notification.Player -> List Html @@ -82,23 +92,33 @@ notificationPopup address notification = [ div [ class ("badge mui--z2 hide") ] [] ] -statusDescription : Status -> String -statusDescription status = case status of +statusDescription : Player -> String +statusDescription player = (case player.status of NotPlayed -> "Choosing" Played -> "Played" Czar -> "Round Czar" - Disconnected -> "Disconnected" - Left -> "Left Game" Ai -> "A Computer" Neutral -> "" + Skipping -> "Being Skipped") ++ if player.disconnected then " (Disconnected)" else "" statusIcon : Status -> Html -statusIcon status = (case status of - NotPlayed -> icon "hourglass" - Played -> icon "check" - Czar -> icon "gavel" - Disconnected -> icon "minus-circle" - Left -> icon "sign-out" - Ai -> icon "cogs" - Neutral -> text "") +statusIcon status = Maybe.map fwIcon (statusIconName status) |> Maybe.withDefault (text "") + + +statusIconName : Status -> Maybe String +statusIconName status = case status of + NotPlayed -> Just "hourglass" + Played -> Just "check" + Czar -> Just "gavel" + Ai -> Just "cogs" + Neutral -> Nothing + Skipping -> Just "fast-forward" + + +playerIcon : Player -> Html +playerIcon player = + if player.left then + icon "sign-out" + else + statusIcon player.status diff --git a/client/src/MassiveDecks/States/Start/UI.elm b/client/src/MassiveDecks/States/Start/UI.elm index 225cfe5..e9b0ca9 100644 --- a/client/src/MassiveDecks/States/Start/UI.elm +++ b/client/src/MassiveDecks/States/Start/UI.elm @@ -51,12 +51,6 @@ view address global data = ] -isNothing : Maybe a -> Bool -isNothing maybe = case maybe of - Just _ -> False - Nothing -> True - - nameEntry : Signal.Address Action -> Html nameEntry address = div [ class "nickname-entry mui-textfield" ] [ input [ type' "text" diff --git a/client/src/MassiveDecks/Util.elm b/client/src/MassiveDecks/Util.elm index 67787d6..59fdff2 100644 --- a/client/src/MassiveDecks/Util.elm +++ b/client/src/MassiveDecks/Util.elm @@ -68,3 +68,24 @@ find : (a -> Bool) -> List a -> Maybe a find check items = List.filter check items |> List.head + + +isNothing : Maybe a -> Bool +isNothing maybe = case maybe of + Just _ -> False + Nothing -> True + + +pluralHas : List a -> Maybe String +pluralHas items = case List.length items of + 0 -> Nothing + 1 -> Just "has" + _ -> Just "have" + + +joinWithAnd : List String -> Maybe String +joinWithAnd items = case items of + [] -> Nothing + head :: [] -> Just head + first :: second :: [] -> Just (first ++ " and " ++ second) + head :: rest -> Just (head ++ ", " ++ (joinWithAnd rest |> Maybe.withDefault "")) diff --git a/server/app/assets/javascripts/massivedecks.js b/server/app/assets/javascripts/massivedecks.js index 7c3473e..44b9c3b 100644 --- a/server/app/assets/javascripts/massivedecks.js +++ b/server/app/assets/javascripts/massivedecks.js @@ -19,14 +19,18 @@ jQuery(function($) { $('body').on('click', '.js-hide-scores', hideScores); }); -function inviteOverlay(event) { +function inviteOverlay() { mui.overlay('on', $('#invite')[0].cloneNode(true)); } -function aboutOverlay(event) { +function aboutOverlay() { mui.overlay('on', $('#about')[0].cloneNode(true)); } +function toggleWarningDrawer() { + $('#warning-drawer').toggleClass('shut'); +} + function start(url) { var socket = null; var gameCode = window.location.hash.substr(1); @@ -76,7 +80,6 @@ function start(url) { } game.ports.subscription.subscribe(function (lobbyIdAndSecret) { - console.log(lobbyIdAndSecret); if (lobbyIdAndSecret != null) { localStorage.setItem("existingGame", JSON.stringify(lobbyIdAndSecret)) openWebSocket(lobbyIdAndSecret.lobbyId, lobbyIdAndSecret.secret); diff --git a/server/app/assets/stylesheets/lobby.less b/server/app/assets/stylesheets/lobby.less index 4c39904..53bb76a 100644 --- a/server/app/assets/stylesheets/lobby.less +++ b/server/app/assets/stylesheets/lobby.less @@ -251,6 +251,8 @@ ol.played { transition: opacity 1s; + white-space: nowrap; + &.hide { opacity: 0; } @@ -291,7 +293,7 @@ body { overflow: auto; z-index: 2; background-color: #fff; - transition: transform 0.2s; + transition: transform @sidebar-transition-time; @media (min-width: @enhance-width) { transform: translate(@sidebar-width); } @@ -301,7 +303,7 @@ body { } &.active { - transform: translate(@sidebar-width); + transform: translate(@sidebar-width) !important; } & > table { @@ -321,7 +323,7 @@ body { opacity: 0.3; } - &.disconnected { + &.skipping { opacity: 0.6; } @@ -345,6 +347,86 @@ body { body.hide-scores { #scores { - transform: translate(0px); + transform: translate(0px); + } +} + +#warning-drawer { + position: fixed; + bottom: 0px; + left: 0; + right: 0px; + + z-index: 100; + + transition: opacity 1s, left @sidebar-transition-time; + + max-height: 75%; + + & > .contents { + overflow: auto; + } + + & > .toggle { + float: right; + margin: 5px; + background-color: #FFCC00; + opacity: 0.75; + } + + & > .top { + clear: both; + width: 100%; + height: 0px; + background-color: #FFCC00; + } + + &.shut { + & > .top { + height: 3px; + } + + & > .contents { + display: none; + } + } + + &.hidden { + opacity: 0; + } + + @media (min-width: @enhance-width) { + left: @sidebar-width; + } + + #disconnected-notice { + display: flex; + align-items: center; + align-content: center; + justify-content: space-between; + + background-color: #FFCC00; + padding: 2px; + + & > h3 { + margin: 2px 5px; + } + + & > .actions { + display: flex; + flex-wrap: nowrap; + + & > button { + margin: 2px; + } + } + } +} + +body.hide-scores { + #warning-drawer { + @media (min-width: @enhance-width) { + left: 0px; + } } } diff --git a/server/app/assets/stylesheets/massivedecks.less b/server/app/assets/stylesheets/massivedecks.less index f7e8bb8..ce5e307 100644 --- a/server/app/assets/stylesheets/massivedecks.less +++ b/server/app/assets/stylesheets/massivedecks.less @@ -17,6 +17,8 @@ body { min-height: 100%; margin-left: 0px; + transition: margin-left @sidebar-transition-time; + @media (min-width: @enhance-width) { margin-left: @sidebar-width; } @@ -35,7 +37,7 @@ header { } right: 0; z-index: 2; - transition: left 0.2s; + transition: left @sidebar-transition-time; & > div { width: 100%; @@ -46,6 +48,15 @@ header { justify-content: space-between; align-items: baseline; padding: 0px; + + & > .score-buttons { + position: relative; + + & > .badge { + position:absolute; + top: 15px; + } + } } } } @@ -64,12 +75,6 @@ header { margin: 0px 5px; padding: 0px; } - - & > ul > li > a > .fa { - display: inline-block; - text-align: center; - width: 20px; - } } body.hide-scores { @@ -158,3 +163,7 @@ body.hide-scores { width: 100%; } } + +.clear { + clear: both; +} diff --git a/server/app/assets/stylesheets/variables.less b/server/app/assets/stylesheets/variables.less index ab49623..9cbadd8 100644 --- a/server/app/assets/stylesheets/variables.less +++ b/server/app/assets/stylesheets/variables.less @@ -1,5 +1,6 @@ @enhance-width: 768px; @sidebar-width: 200px; +@sidebar-transition-time: 0.2s; @card-width: 135px; @card-height: 160px; diff --git a/server/app/controllers/massivedecks/game/Actions.scala b/server/app/controllers/massivedecks/game/Actions.scala index f3c4cec..b85915e 100644 --- a/server/app/controllers/massivedecks/game/Actions.scala +++ b/server/app/controllers/massivedecks/game/Actions.scala @@ -28,6 +28,8 @@ object Actions { case class Play(secret: Secret, ids: List[Int]) extends Command case class Choose(secret: Secret, winner: Int) extends Command case class GetLobbyAndHand(secret: Secret) extends Command + case class Skip(secret: Secret, players: List[Id]) extends Command + case class Back(secret: Secret) extends Command object Action { def apply(json: JsValue): Option[Command] = @@ -40,6 +42,8 @@ object Actions { case "play" => Json.fromJson[Play](command).asOpt case "choose" => Json.fromJson[Choose](command).asOpt case "getLobbyAndHand" => Json.fromJson[GetLobbyAndHand](command).asOpt + case "skip" => Json.fromJson[Skip](command).asOpt + case "back" => Json.fromJson[Back](command).asOpt case _ => None } }) @@ -54,6 +58,8 @@ object Actions { implicit val playFormat: Format[Play] = Json.format[Play] implicit val chooseFormat: Format[Choose] = Json.format[Choose] implicit val getLobbyAndHandFormat: Format[GetLobbyAndHand] = Json.format[GetLobbyAndHand] + implicit val skipFormat: Format[Skip] = Json.format[Skip] + implicit val backFormat: Format[Back] = Json.format[Back] } } diff --git a/server/app/controllers/massivedecks/game/Game.scala b/server/app/controllers/massivedecks/game/Game.scala index 8f3f3a6..626937f 100644 --- a/server/app/controllers/massivedecks/game/Game.scala +++ b/server/app/controllers/massivedecks/game/Game.scala @@ -106,6 +106,18 @@ class Game @Inject()(private val state: State, @Assisted private val id: String) Json.toJson(state.lobbyAndHand(secret)) } + case Skip(secret, players) => + sender() ! Try { + state.skip(secret, players) + Json.toJson(state.lobbyAndHand(secret)) + } + + case Back(secret) => + sender() ! Try { + state.back(secret) + Json.toJson(state.lobbyAndHand(secret)) + } + case _ => sender() ! Try { throw new IllegalArgumentException("Unknown message: " + message) diff --git a/server/app/controllers/massivedecks/game/State.scala b/server/app/controllers/massivedecks/game/State.scala index b0fa440..97c000f 100644 --- a/server/app/controllers/massivedecks/game/State.scala +++ b/server/app/controllers/massivedecks/game/State.scala @@ -3,9 +3,11 @@ package controllers.massivedecks.game import java.util.UUID import javax.inject.Inject -import scala.concurrent.Future +import scala.concurrent.{ExecutionContext, Future} import scala.util.Random +import scala.concurrent.duration._ +import akka.pattern.after import akka.actor.ActorRef import com.google.inject.assistedinject.Assisted import controllers.massivedecks.cardcast.{CardCastAPI, CardCastDeck} @@ -13,9 +15,11 @@ import models.massivedecks.Game._ import models.massivedecks.Lobby.Formatters._ import models.massivedecks.Lobby.{Lobby, LobbyAndHand} import models.massivedecks.Player._ +import play.api.libs.concurrent.Akka import play.api.libs.json.Json +import play.api.Play.current -class State @Inject()(private val cardCast: CardCastAPI, @Assisted val id: String) { +class State @Inject()(private val cardCast: CardCastAPI, @Assisted val id: String)(implicit ec: ExecutionContext) { private var decks: Set[CardCastDeck] = Set() private var players: List[Player] = List() private var lastPlayerId: Int = -1 @@ -27,6 +31,7 @@ class State @Inject()(private val cardCast: CardCastAPI, @Assisted val id: Strin private var czarIndex: Int = 0 private var history: List[Round] = List() private var ais: Set[Secret] = Set() + private var connected: Set[Id] = Set() def config = Config(decks.map(deck => deck.info).toList) def lobby = Lobby(id, config, players, game.map(game => game.round)) @@ -41,15 +46,25 @@ class State @Inject()(private val cardCast: CardCastAPI, @Assisted val id: Strin } val secret = newPlayer(name) setPlayerStatus(secret.id, Ai, force=true) + connected += secret.id ais += secret sendNotifications() } + private def setPlayerDisconnectedAfterGracePeriod(id: Id) = { + after(State.disconnectGracePeriod, using=Akka.system.scheduler)(Future { + if (setPlayerDisconnected(id, !connected.contains(id))) { + sendNotifications() + } + }) + } + def newPlayer(name: String): Secret = { require(players.forall(player => player.name != name), "The name is already in use.") lastPlayerId += 1 val id = Id(lastPlayerId) - players = players ++ List(Player(id, name, Disconnected, 0)) + players = players ++ List(Player(id, name, Neutral, 0, disconnected=false, left=false)) + setPlayerDisconnectedAfterGracePeriod(id) val secret = Secret(id, UUID.randomUUID().toString) secrets += (id -> secret) if (game.isDefined) { @@ -87,13 +102,17 @@ class State @Inject()(private val cardCast: CardCastAPI, @Assisted val id: Strin sendNotifications() } - private val statusNotInRound: Set[Status] = Set(Left, Czar) + private val statusNotInRound: Set[Status] = Set(Skipping, Czar) def beginRound() = { + for (player <- players) { + setPlayerStatus(player.id, NotPlayed) + } val round = game.get.round val czar = round.czar setPlayerStatus(czar, Czar) - playedInRound = (for (player <- players if !statusNotInRound.contains(player.status)) yield player.id -> None).toMap + playedInRound = (for (player <- players if !statusNotInRound.contains(player.status) && !player.left) + yield player.id -> None).toMap val firstSlots = (0 until round.call.slots).toList for (ai <- ais) { play(ai, firstSlots) @@ -171,7 +190,7 @@ class State @Inject()(private val cardCast: CardCastAPI, @Assisted val id: Strin def leave(secret: Secret): Unit = { val id = validateSecretAndGetId(secret) - setPlayerStatus(id, Left, force=true) + setPlayerLeft(id, left=true) playedInRound = playedInRound.filterKeys(pId => pId != id) if (numberOfPlayers < State.minimumPlayers) { endGame() @@ -190,17 +209,52 @@ class State @Inject()(private val cardCast: CardCastAPI, @Assisted val id: Strin sendNotifications() } + def skip(secret: Secret, players: List[Id]): Unit = { + validateSecretAndGetId(secret) + require((numberOfPlayers - players.length) > State.minimumPlayers, "Not enough players to skip.") + for (id <- players) { + setPlayerStatus(id, Skipping) + playedInRound = playedInRound.filterKeys(pId => pId != id) + } + game match { + case Some(state) => + if (players.contains(state.round.czar)) { + invalidateRound() + } else { + if (numberOfPlayersInRound == numberOfPlayersWhoHavePlayed) { + beginJudging() + } + } + case None => + } + sendNotifications() + } + + def back(secret: Secret): Unit = { + val id = validateSecretAndGetId(secret) + val player = playerForId(id) + require(player.status == Skipping, "You are not being skipped.") + setPlayerStatus(id, Neutral, force=true) + sendNotifications() + } + def register(secret: Secret, socket: ActorRef): Unit = { val id = validateSecretAndGetId(secret) - require(playerForId(id).status != Left, "You have left this game.") - setPlayerStatus(id, playerStatusIfConnected(id), force=true) + val player = playerForId(id) + require(!player.left, "You have left this game.") + setPlayerDisconnected(id, disconnected=false) + connected += id + if (player.status == Skipping) { + back(secret) + } notifications = socket :: notifications sendNotifications() } def unregister(secret: Secret, socket: ActorRef): Unit = { val id = validateSecretAndGetId(secret) - setPlayerStatus(id, Disconnected) + connected -= id + setPlayerDisconnectedAfterGracePeriod(id) notifications.filter(s => s != socket) sendNotifications() } @@ -228,34 +282,17 @@ class State @Inject()(private val cardCast: CardCastAPI, @Assisted val id: Strin if (czarIndex >= players.length) { czarIndex = 0 } - val playerId = players(czarIndex).id + val player = players(czarIndex) + val playerId = player.id czarIndex += 1 - if (ais.exists(ai => ai.id == playerId) || playerForId(playerId).status == Left) { + if (ais.exists(ai => ai.id == playerId) || player.left || player.status == Skipping) { nextCzar() } else { playerId } } - private def playerStatusIfConnected(id: Id): Status = { - val player = playerForId(id) - if (player.status == Left) { Left } else { - game match { - case Some(state) => - if (state.round.czar == id) { - Czar - } else { - playedInRound.get(id) match { - case Some(playedState) => if (playedState.isDefined) { Played } else { NotPlayed } - case None => Neutral - } - } - case None => Neutral - } - } - } - - private def numberOfPlayers = players.count(player => player.status != Left) + private def numberOfPlayers = players.count(player => !player.left) private def numberOfPlayersInRound = playedInRound.size private def numberOfPlayersWhoHavePlayed = playedInRound.values.count(play => play.isDefined) @@ -274,26 +311,41 @@ class State @Inject()(private val cardCast: CardCastAPI, @Assisted val id: Strin playedOrder = None playedInRound = Map() game = Some(state.copy(round = Round(nextCzar(), state.deck.drawCall(), Responses.hidden(0)))) - for (player <- players) { - setPlayerStatus(player.id, NotPlayed) - } beginRound() } - private val stickyStatus: Set[Status] = Set(Disconnected, Left, Ai) + private val stickyStatus: Set[Status] = Set(Ai, Skipping) private def setPlayerStatus(id: Id, status: Status, force: Boolean = false): Unit = { - if (playerForId(id).status != Left) { - players = players.map(player => if (player.id == id) { - if (force || !stickyStatus.contains(player.status)) { + if (!playerForId(id).left) { + players = players.map(player => + if (player.id == id && (force || !stickyStatus.contains(player.status))) { player.copy(status = status) } else { player - } + }) + } + } + + private def setPlayerDisconnected(id: Id, disconnected: Boolean): Boolean = { + var changed = false + players = players.map(player => + if (player.id == id) { + changed = true + player.copy(disconnected=disconnected) + } else { + player + }) + changed + } + + private def setPlayerLeft(id: Id, left: Boolean): Unit = { + players = players.map(player => + if (player.id == id) { + player.copy(status=Neutral, left=left) } else { player }) - } } private def validateInGameAndGetState(): GameState = game match { @@ -316,6 +368,7 @@ class State @Inject()(private val cardCast: CardCastAPI, @Assisted val id: Strin } object State { val minimumPlayers: Int = 2 + val disconnectGracePeriod: FiniteDuration = 5.seconds trait Factory { def apply(id: String): State diff --git a/server/app/models/massivedecks/Player.scala b/server/app/models/massivedecks/Player.scala index c596bb6..5731f8c 100644 --- a/server/app/models/massivedecks/Player.scala +++ b/server/app/models/massivedecks/Player.scala @@ -10,7 +10,7 @@ object Player { val name: String } object Status { - private val types = List(NotPlayed, Played, Czar, Disconnected, Left, Ai, Neutral) + private val types = List(NotPlayed, Played, Czar, Ai, Neutral, Skipping) val fromName: Map[String, Status] = (for (status <- types) yield status.name -> status).toMap } case object NotPlayed extends Status { @@ -22,20 +22,17 @@ object Player { case object Czar extends Status { val name = "czar" } - case object Disconnected extends Status { - val name = "disconnected" - } - case object Left extends Status { - val name = "left" - } case object Ai extends Status { val name = "ai" } case object Neutral extends Status { val name = "neutral" } + case object Skipping extends Status { + val name = "skipping" + } - case class Player(id: Id, name: String, status: Status, score: Int) { + case class Player(id: Id, name: String, status: Status, score: Int, disconnected: Boolean, left: Boolean) { require(!name.isEmpty, "Name can't be empty!") } diff --git a/server/project/plugins.sbt b/server/project/plugins.sbt index a9260cb..7033808 100644 --- a/server/project/plugins.sbt +++ b/server/project/plugins.sbt @@ -6,3 +6,5 @@ addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.4.4") addSbtPlugin("com.typesafe.sbt" % "sbt-less" % "1.1.1") addSbtPlugin("com.typesafe.sbt" % "sbt-digest" % "1.1.0") addSbtPlugin("com.typesafe.sbt" % "sbt-rjs" % "1.0.7") +addSbtPlugin("com.typesafe.sbt" % "sbt-uglify" % "1.0.3") +addSbtPlugin("com.typesafe.sbt" % "sbt-gzip" % "1.0.0")