Resolve #7 - Players can now leave a game.

This commit is contained in:
Gareth Latty
2015-12-14 17:57:07 +00:00
parent 4ded8a6131
commit 7b30e057b5
18 changed files with 178 additions and 64 deletions
+9
View File
@@ -27,6 +27,15 @@ newPlayer lobbyId name = send defaultSettings
} |> fromJson playerSecretDecoder
leave : String -> Secret -> Task.Task Http.Error LobbyAndHand
leave lobbyId secret = send defaultSettings
{ verb = "POST"
, headers = [("Content-Type", "application/json")]
, url = url ("/lobbies/" ++ lobbyId ++ "/players/" ++ (toString secret.id) ++ "/leave") []
, body = Http.string ("{ \"secret\": \"" ++ secret.secret ++ "\"}")
} |> fromJson lobbyAndHandDecoder
addDeck : String -> Secret -> String -> Task.Task Http.Error LobbyAndHand
addDeck lobbyId secret deckId = lobbyAction lobbyId (commandEncoder "addDeck" secret [ ("deckId", string deckId) ])
@@ -40,6 +40,7 @@ type Action
| GameEvent Event
| AddAi
| DismissPlayerNotification (Maybe Notification.Player)
| LeaveLobby
eventEffects : Lobby -> Lobby -> Effects.Effects Action
+5 -5
View File
@@ -74,7 +74,7 @@ diffRound oldRound newRound =
in
if differentRound then
List.filterMap identity
[ Maybe.map roundEnd oldRound
[ oldRound `Maybe.andThen` roundEnd
, Maybe.map roundStart newRound
, newRound `Maybe.andThen` (\newRound -> case newRound.responses of
Hidden count -> Just (roundPlayed count)
@@ -124,13 +124,13 @@ roundJudging round =
in
RoundJudging played
roundEnd : Round -> Event
roundEnd : Round -> Maybe Event
roundEnd round =
let
responses = case round.responses of
Hidden _ -> Nothing
Revealed responses -> Just responses
played = Maybe.map .cards responses |> Maybe.withDefault []
playedByAndWinner = responses `Maybe.andThen` .playedByAndWinner |> Maybe.withDefault (PlayedByAndWinner [] 0)
played = Maybe.map .cards responses
playedByAndWinner = responses `Maybe.andThen` .playedByAndWinner
in
RoundEnd round.call round.czar played playedByAndWinner
Maybe.map2 (RoundEnd round.call round.czar) played playedByAndWinner
+4 -4
View File
@@ -38,14 +38,14 @@ port tasks = game.tasks
port notifications : Signal String
port jsAction : Signal (Maybe LobbyIdAndSecret)
port jsAction
port subscription : Signal (Maybe LobbyIdAndSecret)
port subscription
= game.model
|> Signal.map (\setupModel -> case setupModel of
Waiting -> Nothing
Started model -> Just model.jsAction
Started model -> Just model.subscription
)
|> Signal.filterMap identity Nothing
|> Signal.filterMap (Maybe.withDefault Nothing) Nothing
port initialState : Signal InitialState
+1 -1
View File
@@ -11,7 +11,7 @@ import MassiveDecks.Models.Notification as Notification
type alias Model =
{ state : State
, jsAction : Maybe LobbyIdAndSecret
, subscription : Maybe (Maybe LobbyIdAndSecret)
, global: Global
}
+8 -2
View File
@@ -90,6 +90,12 @@ update action global data = case action of
in
(model global updatedData, Effects.none)
LeaveLobby ->
({ state = SStart { name = "", lobbyId = "" }, subscription = Just Nothing, global = global },
(API.leave data.lobby.id data.secret)
|> Task.map (\_ -> NoAction)
|> API.toEffect)
GameEvent event ->
case event of
PlayerStatus id status ->
@@ -135,7 +141,7 @@ updateLobby data lobby =
model : Global -> ConfigData -> Model
model global data =
{ state = SConfig data
, jsAction = Nothing
, subscription = Nothing
, global = global
}
@@ -143,7 +149,7 @@ model global data =
modelSub : Global -> String -> Secret -> ConfigData -> Model
modelSub global lobbyId secret data =
{ state = SConfig data
, jsAction = Just { lobbyId = lobbyId, secret = secret }
, subscription = Just (Just { lobbyId = lobbyId, secret = secret })
, global = global
}
+28 -7
View File
@@ -81,6 +81,22 @@ update action global data = case action of
in
(model { global | seed = seed } { data | shownPlayed = shownPlayed }, Effects.none)
DismissPlayerNotification notification ->
let
updatedData =
if data.playerNotification == notification then
{ data | playerNotification = Maybe.map Notification.hide data.playerNotification }
else
data
in
(model global updatedData, Effects.none)
LeaveLobby ->
({ state = SStart { name = "", lobbyId = "" }, subscription = Just Nothing, global = global },
(API.leave data.lobby.id data.secret)
|> Task.map (\_ -> NoAction)
|> API.toEffect)
GameEvent event ->
case event of
RoundPlayed amount ->
@@ -115,10 +131,15 @@ update action global data = case action of
notificationChange : Global -> PlayingData -> Maybe Notification.Player -> (Model, Effects.Effects Action)
notificationChange global data notification =
(model global { data | playerNotification = Maybe.oneOf
[ notification
, data.playerNotification
]} , Effects.none)
let
newNotification = Maybe.oneOf
[ notification
, data.playerNotification
]
in
( model global { data | playerNotification = newNotification}
, Task.sleep (Time.second * 5) `Task.andThen` (\_ -> Task.succeed (DismissPlayerNotification newNotification)) |> Effects.task
)
addShownPlayed : Int -> List Attribute -> Seed -> (List Attribute, Effects.Effects Action, Seed)
@@ -167,7 +188,7 @@ positioning rotation horizontalPos left verticalPos =
model : Global -> PlayingData -> Model
model global data =
{ state = SPlaying data
, jsAction = Nothing
, subscription = Nothing
, global = global
}
@@ -175,7 +196,7 @@ model global data =
modelSub : Global -> String -> Secret -> PlayingData -> Model
modelSub global lobbyId secret data =
{ state = SPlaying data
, jsAction = Just { lobbyId = lobbyId, secret = secret }
, subscription = Just (Just { lobbyId = lobbyId, secret = secret })
, global = global
}
@@ -183,7 +204,7 @@ modelSub global lobbyId secret data =
configModel : Global -> ConfigData -> Model
configModel global data =
{ state = SConfig data
, jsAction = Nothing
, subscription = Nothing
, global = global
}
@@ -46,17 +46,16 @@ roundContents address data round =
|> List.any (\player -> player.status == Played)
callFill = case round.responses of
Revealed responses ->
Maybe.withDefault [] (Maybe.map (Util.get responses.cards) data.considering)
Maybe.withDefault [] (data.considering `Maybe.andThen` (Util.get responses.cards))
Hidden _ ->
picked
pickedOrChosen = case round.responses of
Revealed responses ->
case data.considering of
Just considering ->
let
consideringCards = Util.get responses.cards considering
in
[ consideringView address considering consideringCards isCzar ]
case Util.get responses.cards considering of
Just consideringCards -> [ consideringView address considering consideringCards isCzar ]
Nothing -> []
Nothing -> []
Hidden _ -> pickedView address pickedWithIndex (Card.slots round.call) data.shownPlayed
playedOrHand = case round.responses of
@@ -84,7 +83,7 @@ winnerContentsAndHeader address round players =
let
cards = round.responses
winning = Card.winningCards cards round.playedByAndWinner |> Maybe.withDefault []
winner = (Util.get players round.playedByAndWinner.winner).name
winner = Maybe.map .name (Util.get players round.playedByAndWinner.winner) |> Maybe.withDefault ""
in
([ div [ class "winner mui-panel" ]
[ h1 [] [ icon "trophy" ]
@@ -40,14 +40,14 @@ root : List Html -> Html
root contents = div [ class "content" ] contents
gameMenu : Html
gameMenu = div [ class "menu mui-dropdown" ]
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" ]
, ul [ class "mui-dropdown__menu mui-dropdown__menu--right" ]
[ li [] [ a [ href "#", attribute "onClick" "inviteOverlay(event)" ] [ icon "bullhorn", text " Invite Players" ] ]
, li [] [ a [ href "#" ] [ icon "sign-out", text " Leave Game" ] ]
, li [] [ a [ onClick address LeaveLobby ] [ icon "sign-out", text " Leave Game" ] ]
, li [ class "mui-divider" ] []
, li [] [ a [ href "#", attribute "onClick" "aboutOverlay(event)" ] [ icon "info-circle", text " About" ] ]
, li [] [ a [ href "https://github.com/Lattyware/massivedecks/issues/new", target "_blank" ]
@@ -54,7 +54,7 @@ appHeader address contents notification = (header [] [ div [ class "mui-appbar m
[ div [ class "mui--appbar-line-height" ]
[ span [] (List.append [ scoresButton True, scoresButton False ] (notificationPopup address notification))
, span [ id "title", class "mui--text-title mui--visible-xs-inline-block" ] contents
, gameMenu ] ] ])
, gameMenu address ] ] ])
scoresButton : Bool -> Html
+7 -1
View File
@@ -54,6 +54,12 @@ update action global data = case action of
(Config.modelSub global lobbyId secret
(Config.initialData lobbyAndHand.lobby secret), Effects.none)
Notification _ ->
(model global data, Effects.none)
DismissPlayerNotification _ ->
(model global data, Effects.none)
other ->
(model global data,
DisplayError ("Got an action (" ++ (toString other) ++ ") that can't be handled in the current state (Start).")
@@ -64,7 +70,7 @@ update action global data = case action of
model : Global -> StartData -> Model
model global data =
{ state = SStart data
, jsAction = Nothing
, subscription = Nothing
, global = global
}
+4 -5
View File
@@ -24,16 +24,15 @@ remove list index =
(List.take index list) ++ (List.drop (index + 1) list)
get : List a -> Int -> a
get : List a -> Int -> Maybe a
get list index = case List.drop index list of
[] -> Native.Error.raise <| "Attempted to take element " ++ toString index
++ " of list " ++ toString list
(item::_) -> item
[] -> Nothing
(item :: _) -> Just item
getAll : List a -> List Int -> List a
getAll list indices =
List.map (get list) indices
List.filterMap (get list) indices
getAllWithIndex : List a -> List Int -> List (Int, a)
@@ -17,7 +17,7 @@ import akka.util.Timeout
import controllers.massivedecks.game.Actions.Lobby
import controllers.massivedecks.game.Actions.Lobby.GetLobby
import controllers.massivedecks.game.Actions.Player.Formatters._
import controllers.massivedecks.game.Actions.Player.{AddAi, GetHand, NewPlayer}
import controllers.massivedecks.game.Actions.Player.{Leave, AddAi, GetHand, NewPlayer}
import controllers.massivedecks.game.Actions.Store.{LobbyAction, NewLobby, PlayerAction}
import controllers.massivedecks.game.NotFoundException
import models.massivedecks.Player.{Id, Secret}
@@ -75,6 +75,16 @@ class Application @Inject() (@Named("store") store: ActorRef)(implicit ec: Execu
resultOrError(store ? PlayerAction(lobbyId, AddAi))
}
def leave(lobbyId: String, playerId: Int) = Action.async(parse.json) { request: Request[JsValue] =>
(request.body \ "secret").validate[String].asOpt match {
case Some(secret) =>
resultOrError(store ? PlayerAction(lobbyId, Leave(Secret(Id(playerId), secret))))
case None =>
Future.successful(BadRequest("Badly formed secret provided."))
}
}
private def resultOrError(response: Future[Any]): Future[Result] = {
val result: Future[Try[JsValue]] = response.mapTo[Try[JsValue]]
result.map({
@@ -21,13 +21,13 @@ object Actions {
sealed trait Action
case object GetLobby extends Action
sealed trait Command extends Action
case class Register(secret: Secret, socket: ActorRef) extends Action
case class Unregister(secret: Secret, socket: ActorRef) extends Action
case class AddDeck(secret: Secret, deckId: String) extends Command
case class NewGame(secret: Secret) extends Command
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 Register(secret: Secret, socket: ActorRef) extends Action
case class Unregister(secret: Secret, socket: ActorRef) extends Action
object Action {
def apply(json: JsValue): Option[Command] =
@@ -61,9 +61,11 @@ object Actions {
sealed trait Action
case class NewPlayer(name: String) extends Action
case class GetHand(secret: Secret) extends Action
case class Leave(secret: Secret) extends Action
case object AddAi extends Action
object Formatters {
implicit val leaveFormat : Format[Leave] = Json.format[Leave]
implicit val newPlayerFormat: Format[NewPlayer] = Json.format[NewPlayer]
implicit val getHandFormat: Format[GetHand] = Json.format[GetHand]
}
@@ -15,7 +15,7 @@ import models.massivedecks.Game.Formatters._
import models.massivedecks.Player.Secret
import controllers.massivedecks.cardcast.CardCastDeck
import controllers.massivedecks.game.Game.AddRetrievedDeck
import controllers.massivedecks.game.Actions.Player.{AddAi, GetHand, NewPlayer}
import controllers.massivedecks.game.Actions.Player.{Leave, AddAi, GetHand, NewPlayer}
import controllers.massivedecks.game.Actions.Lobby._
class Game @Inject()(private val state: State, @Assisted private val id: String)(implicit ec: ExecutionContext) extends Actor {
@@ -100,6 +100,12 @@ class Game @Inject()(private val state: State, @Assisted private val id: String)
Json.toJson("")
}
case Leave(secret) =>
sender() ! Try {
state.leave(secret)
Json.toJson(state.lobbyAndHand(secret))
}
case _ =>
sender() ! Try {
throw new IllegalArgumentException("Unknown message: " + message)
@@ -74,7 +74,7 @@ class State @Inject()(private val cardCast: CardCastAPI, @Assisted val id: Strin
}
def newGame(secret: Secret): Unit = {
if (players.length < State.minimumPlayers) {
if (numberOfPlayers < State.minimumPlayers) {
throw new IllegalStateException(s"You need a minimum of ${State.minimumPlayers} to start a game.")
}
if (game.isDefined) {
@@ -169,14 +169,30 @@ class State @Inject()(private val cardCast: CardCastAPI, @Assisted val id: Strin
sendNotifications()
}
def sendNotifications(): Unit = {
for (socket <- notifications) {
socket ! Json.toJson(lobby).toString()
def leave(secret: Secret): Unit = {
val id = validateSecretAndGetId(secret)
setPlayerStatus(id, Left, force=true)
playedInRound = playedInRound.filterKeys(pId => pId != id)
if (numberOfPlayers < State.minimumPlayers) {
endGame()
}
game match {
case Some(state) =>
if (state.round.czar == id) {
invalidateRound()
} else {
if (numberOfPlayersInRound == numberOfPlayersWhoHavePlayed) {
beginJudging()
}
}
case None =>
}
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)
notifications = socket :: notifications
sendNotifications()
@@ -189,6 +205,23 @@ class State @Inject()(private val cardCast: CardCastAPI, @Assisted val id: Strin
sendNotifications()
}
private def endGame(): Unit = {
playedOrder = None
playedInRound = Map()
game = None
czarIndex = 0
}
private def invalidateRound(): Unit = {
advanceRound()
}
private def sendNotifications(): Unit = {
for (socket <- notifications) {
socket ! Json.toJson(lobby).toString()
}
}
private def playerForId(id: Id): Player = players.find(item => item.id == id).get
private def nextCzar(): Id = {
@@ -197,7 +230,7 @@ class State @Inject()(private val cardCast: CardCastAPI, @Assisted val id: Strin
}
val playerId = players(czarIndex).id
czarIndex += 1
if (ais.exists(ai => ai.id == playerId)) {
if (ais.exists(ai => ai.id == playerId) || playerForId(playerId).status == Left) {
nextCzar()
} else {
playerId
@@ -222,6 +255,7 @@ class State @Inject()(private val cardCast: CardCastAPI, @Assisted val id: Strin
}
}
private def numberOfPlayers = players.count(player => player.status != Left)
private def numberOfPlayersInRound = playedInRound.size
private def numberOfPlayersWhoHavePlayed = playedInRound.values.count(play => play.isDefined)
@@ -236,9 +270,9 @@ class State @Inject()(private val cardCast: CardCastAPI, @Assisted val id: Strin
}
private def advanceRound(): Unit = {
val state = validateInGameAndGetState()
playedOrder = None
playedInRound = Map()
val state = validateInGameAndGetState()
game = Some(state.copy(round = Round(nextCzar(), state.deck.drawCall(), Responses.hidden(0))))
for (player <- players) {
setPlayerStatus(player.id, NotPlayed)
@@ -248,16 +282,19 @@ class State @Inject()(private val cardCast: CardCastAPI, @Assisted val id: Strin
private val stickyStatus: Set[Status] = Set(Disconnected, Left, Ai)
private def setPlayerStatus(id: Id, status: Status, force: Boolean = false): Unit =
players = players.map(player => if (player.id == id) {
if (force || !stickyStatus.contains(player.status)) {
player.copy(status = status)
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)) {
player.copy(status = status)
} else {
player
}
} else {
player
}
} else {
player
})
})
}
}
private def validateInGameAndGetState(): GameState = game match {
case Some(state) => state
+21 -4
View File
@@ -17,6 +17,7 @@
<body>
<script src="@routes.Assets.at("javascripts/Main.js")"></script>
<script>
var socket = null;
var gameCode = window.location.hash.substr(1);
var existingGame = localStorage.getItem("existingGame");
if (existingGame != null) {
@@ -34,7 +35,9 @@
};
var game = Elm.fullscreen(Elm.MassiveDecks.Main, { notifications: "", initialState: initialState });
game.ports.initialState.send(initialState)
game.ports.initialState.send(initialState);
function openWebSocket(lobbyId, secret) {
var loc = window.location, new_uri;
if (loc.protocol === "https:") {
@@ -44,7 +47,10 @@
}
new_uri += "//" + loc.host;
new_uri += loc.pathname + "lobbies/" + lobbyId + "/notifications";
var socket = new WebSocket(new_uri);
if (socket != null) {
socket.close();
}
socket = new WebSocket(new_uri);
socket.onopen = function (event) {
socket.send(JSON.stringify(secret));
}
@@ -52,15 +58,26 @@
game.ports.notifications.send(event.data);
}
socket.onclose = function (event) {
openWebSocket(lobbyId, secret);
if (socket != null) {
openWebSocket(lobbyId, secret);
}
}
}
game.ports.jsAction.subscribe(function (lobbyIdAndSecret) {
game.ports.subscription.subscribe(function (lobbyIdAndSecret) {
console.log(lobbyIdAndSecret);
if (lobbyIdAndSecret != null) {
localStorage.setItem("existingGame", JSON.stringify(lobbyIdAndSecret))
openWebSocket(lobbyIdAndSecret.lobbyId, lobbyIdAndSecret.secret);
window.location = "#" + lobbyIdAndSecret.lobbyId;
} else {
if (socket != null) {
window.location = "#";
localStorage.removeItem("existingGame");
var oldSocket = socket;
socket = null;
oldSocket.close();
}
}
});
</script>
+8 -7
View File
@@ -4,13 +4,14 @@
GET / controllers.massivedecks.Application.index()
POST /lobbies controllers.massivedecks.Application.createLobby()
GET /lobbies/:id controllers.massivedecks.Application.getLobby(id: String)
GET /lobbies/:id/notifications controllers.massivedecks.Application.notifications(id: String)
POST /lobbies/:lobbyId controllers.massivedecks.Application.command(lobbyId: String)
POST /lobbies/:lobbyId/players controllers.massivedecks.Application.newPlayer(lobbyId: String)
POST /lobbies/:lobbyId/players/newAi controllers.massivedecks.Application.newAi(lobbyId: String)
POST /lobbies/:lobbyId/players/:playerId controllers.massivedecks.Application.getPlayer(lobbyId: String, playerId: Int)
POST /lobbies controllers.massivedecks.Application.createLobby()
GET /lobbies/:id controllers.massivedecks.Application.getLobby(id: String)
GET /lobbies/:id/notifications controllers.massivedecks.Application.notifications(id: String)
POST /lobbies/:lobbyId controllers.massivedecks.Application.command(lobbyId: String)
POST /lobbies/:lobbyId/players controllers.massivedecks.Application.newPlayer(lobbyId: String)
POST /lobbies/:lobbyId/players/newAi controllers.massivedecks.Application.newAi(lobbyId: String)
POST /lobbies/:lobbyId/players/:playerId controllers.massivedecks.Application.getPlayer(lobbyId: String, playerId: Int)
POST /lobbies/:lobbyId/players/:playerId/leave controllers.massivedecks.Application.leave(lobbyId: String, playerId: Int)
# Map static resources from the /public folder to the /assets URL path
GET /assets/*file controllers.Assets.at(path="/public", file: String)