diff --git a/client/src/MassiveDecks/API.elm b/client/src/MassiveDecks/API.elm index 49c576a..e9a6f66 100644 --- a/client/src/MassiveDecks/API.elm +++ b/client/src/MassiveDecks/API.elm @@ -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) ]) diff --git a/client/src/MassiveDecks/Actions/Action.elm b/client/src/MassiveDecks/Actions/Action.elm index cd254e9..273a5e7 100644 --- a/client/src/MassiveDecks/Actions/Action.elm +++ b/client/src/MassiveDecks/Actions/Action.elm @@ -40,6 +40,7 @@ type Action | GameEvent Event | AddAi | DismissPlayerNotification (Maybe Notification.Player) + | LeaveLobby eventEffects : Lobby -> Lobby -> Effects.Effects Action diff --git a/client/src/MassiveDecks/Actions/Event.elm b/client/src/MassiveDecks/Actions/Event.elm index 0e97674..107bbd1 100644 --- a/client/src/MassiveDecks/Actions/Event.elm +++ b/client/src/MassiveDecks/Actions/Event.elm @@ -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 diff --git a/client/src/MassiveDecks/Main.elm b/client/src/MassiveDecks/Main.elm index bb3c7d3..c933c2e 100644 --- a/client/src/MassiveDecks/Main.elm +++ b/client/src/MassiveDecks/Main.elm @@ -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 diff --git a/client/src/MassiveDecks/Models/State.elm b/client/src/MassiveDecks/Models/State.elm index 662a1bd..307d30f 100644 --- a/client/src/MassiveDecks/Models/State.elm +++ b/client/src/MassiveDecks/Models/State.elm @@ -11,7 +11,7 @@ import MassiveDecks.Models.Notification as Notification type alias Model = { state : State - , jsAction : Maybe LobbyIdAndSecret + , subscription : Maybe (Maybe LobbyIdAndSecret) , global: Global } diff --git a/client/src/MassiveDecks/States/Config.elm b/client/src/MassiveDecks/States/Config.elm index e383560..e9012c4 100644 --- a/client/src/MassiveDecks/States/Config.elm +++ b/client/src/MassiveDecks/States/Config.elm @@ -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 } diff --git a/client/src/MassiveDecks/States/Playing.elm b/client/src/MassiveDecks/States/Playing.elm index 85eb981..c6980da 100644 --- a/client/src/MassiveDecks/States/Playing.elm +++ b/client/src/MassiveDecks/States/Playing.elm @@ -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 } diff --git a/client/src/MassiveDecks/States/Playing/UI.elm b/client/src/MassiveDecks/States/Playing/UI.elm index eff65e3..2f16c9c 100644 --- a/client/src/MassiveDecks/States/Playing/UI.elm +++ b/client/src/MassiveDecks/States/Playing/UI.elm @@ -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" ] diff --git a/client/src/MassiveDecks/States/SharedUI/General.elm b/client/src/MassiveDecks/States/SharedUI/General.elm index c430771..bfc8c33 100644 --- a/client/src/MassiveDecks/States/SharedUI/General.elm +++ b/client/src/MassiveDecks/States/SharedUI/General.elm @@ -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" ] diff --git a/client/src/MassiveDecks/States/SharedUI/Lobby.elm b/client/src/MassiveDecks/States/SharedUI/Lobby.elm index 608cb41..a66a5f5 100644 --- a/client/src/MassiveDecks/States/SharedUI/Lobby.elm +++ b/client/src/MassiveDecks/States/SharedUI/Lobby.elm @@ -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 diff --git a/client/src/MassiveDecks/States/Start.elm b/client/src/MassiveDecks/States/Start.elm index 99dff5d..6cde107 100644 --- a/client/src/MassiveDecks/States/Start.elm +++ b/client/src/MassiveDecks/States/Start.elm @@ -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 } diff --git a/client/src/MassiveDecks/Util.elm b/client/src/MassiveDecks/Util.elm index c4c4d55..67787d6 100644 --- a/client/src/MassiveDecks/Util.elm +++ b/client/src/MassiveDecks/Util.elm @@ -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) diff --git a/server/app/controllers/massivedecks/Application.scala b/server/app/controllers/massivedecks/Application.scala index 897cd7c..7ce4667 100644 --- a/server/app/controllers/massivedecks/Application.scala +++ b/server/app/controllers/massivedecks/Application.scala @@ -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({ diff --git a/server/app/controllers/massivedecks/game/Actions.scala b/server/app/controllers/massivedecks/game/Actions.scala index 54626a6..f3c4cec 100644 --- a/server/app/controllers/massivedecks/game/Actions.scala +++ b/server/app/controllers/massivedecks/game/Actions.scala @@ -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] } diff --git a/server/app/controllers/massivedecks/game/Game.scala b/server/app/controllers/massivedecks/game/Game.scala index 0abbc52..8f3f3a6 100644 --- a/server/app/controllers/massivedecks/game/Game.scala +++ b/server/app/controllers/massivedecks/game/Game.scala @@ -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) diff --git a/server/app/controllers/massivedecks/game/State.scala b/server/app/controllers/massivedecks/game/State.scala index 4d28acf..b0fa440 100644 --- a/server/app/controllers/massivedecks/game/State.scala +++ b/server/app/controllers/massivedecks/game/State.scala @@ -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 diff --git a/server/app/views/massivedecks/index.scala.html b/server/app/views/massivedecks/index.scala.html index 1e99a38..303d352 100644 --- a/server/app/views/massivedecks/index.scala.html +++ b/server/app/views/massivedecks/index.scala.html @@ -17,6 +17,7 @@
diff --git a/server/conf/routes b/server/conf/routes index c00b61e..958da33 100644 --- a/server/conf/routes +++ b/server/conf/routes @@ -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)