Work on #10 - Add skipping for disconnected players.
This commit is contained in:
@@ -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 [])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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" ]
|
||||
]
|
||||
]
|
||||
|
||||
@@ -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" ] ]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ""))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@enhance-width: 768px;
|
||||
@sidebar-width: 200px;
|
||||
@sidebar-transition-time: 0.2s;
|
||||
|
||||
@card-width: 135px;
|
||||
@card-height: 160px;
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!")
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user