Work on #10 - Add skipping for disconnected players.

This commit is contained in:
Gareth Latty
2015-12-18 13:40:42 +00:00
parent 3278b42fa7
commit 00108e6edf
22 changed files with 419 additions and 165 deletions
+6 -1
View File
@@ -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 [])
+4 -2
View File
@@ -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
+12 -10
View File
@@ -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
+12 -28
View File
@@ -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
+7 -8
View File
@@ -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
+8 -4
View File
@@ -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
)
+14 -15
View File
@@ -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)
+55 -3
View File
@@ -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"
+21
View File
@@ -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);
+86 -4
View File
@@ -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
+5 -8
View File
@@ -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!")
}
+2
View File
@@ -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")