From 3a1189a912d02004953d2d5c50c308f0df40c11d Mon Sep 17 00:00:00 2001 From: Gareth Latty Date: Sun, 19 Jan 2020 23:33:37 +0000 Subject: [PATCH] Resolve #100: Likes now actually have an effect. --- README.md | 2 +- client/src/elm/MassiveDecks/Card/Play.elm | 21 ++++- client/src/elm/MassiveDecks/Game.elm | 58 ++++++++++---- client/src/elm/MassiveDecks/Game/Action.elm | 2 - client/src/elm/MassiveDecks/Game/History.elm | 18 +++-- client/src/elm/MassiveDecks/Game/Player.elm | 2 + client/src/elm/MassiveDecks/Game/Round.elm | 4 +- .../elm/MassiveDecks/Game/Round/Complete.elm | 13 ++-- .../src/elm/MassiveDecks/Game/Round/Plays.elm | 5 +- .../src/elm/MassiveDecks/Models/Decoders.elm | 21 ++++- client/src/elm/MassiveDecks/Pages/Lobby.elm | 5 +- .../elm/MassiveDecks/Pages/Lobby/Actions.elm | 6 ++ .../elm/MassiveDecks/Pages/Lobby/Events.elm | 2 +- .../Pages/Spectate/Stages/Round.elm | 39 +++++----- client/src/elm/MassiveDecks/Strings.elm | 2 + .../elm/MassiveDecks/Strings/Languages/En.elm | 7 ++ .../src/elm/MassiveDecks/Strings/Render.elm | 3 + client/src/scss/game/plays.scss | 4 +- client/src/scss/pages/lobby.scss | 1 + client/src/scss/pages/lobby/variables.scss | 2 +- client/webpack.config.js | 2 +- .../src/ts/action/game-action/czar/judge.ts | 17 ++++- server/src/ts/action/game-action/player.ts | 8 +- .../src/ts/action/game-action/player/like.ts | 47 ++++++++++++ .../ts/action/game-action/player/submit.ts | 5 +- .../ts/action/game-action/player/take-back.ts | 2 +- server/src/ts/action/validation.validator.ts | 76 +++++++++++++------ .../src/ts/errors/action-execution-error.ts | 6 +- .../ts/events/game-event/round-finished.ts | 5 +- server/src/ts/games/cards/play.ts | 2 +- server/src/ts/games/game.ts | 3 +- server/src/ts/games/game/round.ts | 26 ++++--- server/src/ts/games/game/round/public.ts | 13 +++- server/src/ts/games/game/round/storedPlay.ts | 1 + server/src/ts/games/player.ts | 13 +++- 35 files changed, 330 insertions(+), 113 deletions(-) create mode 100644 server/src/ts/action/game-action/player/like.ts diff --git a/README.md b/README.md index c41941e..0fee64f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ bunch of people in the same room on phones, or on voice chat online. **[Play Massive Decks][hosted]** -[hosted]: http://md.rereadgames.com/ +[hosted]: https://md.rereadgames.com/ [cah]: https://cardsagainsthumanity.com/ ## About diff --git a/client/src/elm/MassiveDecks/Card/Play.elm b/client/src/elm/MassiveDecks/Card/Play.elm index 3a1ed7e..ee071b4 100644 --- a/client/src/elm/MassiveDecks/Card/Play.elm +++ b/client/src/elm/MassiveDecks/Card/Play.elm @@ -1,12 +1,15 @@ module MassiveDecks.Card.Play exposing - ( Id + ( Details + , Id , Known , Play + , WithLikes , asKnown , asPlay ) import MassiveDecks.Card.Model as Card +import MassiveDecks.User as User {-| The id for a play. @@ -23,6 +26,22 @@ type alias Play = } +{-| Details we find out about a play when the round is complete. +-} +type alias Details = + { playedBy : User.Id + , likes : Maybe Int + } + + +{-| A play with the number of likes it received. +-} +type alias WithLikes = + { play : List Card.Response + , likes : Maybe Int + } + + {-| A known play. -} type alias Known = diff --git a/client/src/elm/MassiveDecks/Game.elm b/client/src/elm/MassiveDecks/Game.elm index 27ed858..7501f9f 100644 --- a/client/src/elm/MassiveDecks/Game.elm +++ b/client/src/elm/MassiveDecks/Game.elm @@ -177,23 +177,24 @@ update wrap shared msg model = ( model, cmd ) Like -> - -- TODO: Actually sending this somewhere. let - newRound = + ( newRound, action ) = case game.round of Round.J judging -> - { judging + ( { judging | liked = judging.pick |> Maybe.map (\p -> Set.insert p judging.liked) |> Maybe.withDefault judging.liked - } + } |> Round.J + , judging.pick |> Maybe.map Actions.like |> Maybe.withDefault Cmd.none + ) _ -> - game.round + ( game.round, Cmd.none ) in - ( { model | game = { game | round = newRound } }, Cmd.none ) + ( { model | game = { game | round = newRound } }, action ) SetPlayStyles playStyles -> ( { model | playStyles = playStyles }, Cmd.none ) @@ -413,10 +414,10 @@ applyGameEvent wrap wrapEvent auth shared gameEvent model = game = model.game - newPlayers = + playersWithWinner = game.players |> Dict.update winner (Maybe.map (\p -> { p | score = p.score + 1 })) - ( newRound, history, speech ) = + { newRound, history, speech, newPlayers } = case game.round of Round.J r -> let @@ -439,16 +440,36 @@ applyGameEvent wrap wrapEvent auth shared gameEvent model = tts = Dict.get winner playsDict - |> Maybe.map (\p -> speak shared r.call (Just p)) + |> Maybe.map (\p -> speak shared r.call (Just p.play)) |> Maybe.withDefault Cmd.none + + ps = + playersWithWinner |> Dict.map (updateLikes playsDict) in - ( complete |> Round.C, complete :: game.history, tts ) + { newRound = complete |> Round.C + , history = complete :: game.history + , speech = tts + , newPlayers = ps + } _ -> -- TODO: Error - ( game.round, game.history, Cmd.none ) + { newRound = game.round + , history = game.history + , speech = Cmd.none + , newPlayers = playersWithWinner + } in - ( { model | game = { game | round = newRound, players = newPlayers, history = history } }, speech ) + ( { model + | game = + { game + | round = newRound + , players = newPlayers + , history = history + } + } + , speech + ) Events.RoundStarted { id, czar, players, call, drawn } -> let @@ -629,6 +650,15 @@ hotJoinPlayer player model = {- Private -} +updateLikes : Dict User.Id Play.WithLikes -> User.Id -> Player -> Player +updateLikes plays uid player = + plays + |> Dict.get uid + |> Maybe.andThen .likes + |> Maybe.map (\likes -> { player | likes = likes + player.likes }) + |> Maybe.withDefault player + + viewWinner : Shared -> Dict User.Id User -> Set User.Id -> List (Html msg) viewWinner shared users winners = [ Wl.card [ HtmlA.id "game-winner" ] @@ -911,9 +941,9 @@ reveal target responses play = play -resolvePlayedBy : Dict Play.Id User.Id -> Play.Known -> Maybe ( User.Id, List Card.Response ) +resolvePlayedBy : Dict Play.Id Play.Details -> Play.Known -> Maybe ( User.Id, Play.WithLikes ) resolvePlayedBy playedBy k = - Dict.get k.id playedBy |> Maybe.map (\u -> ( u, k.responses )) + Dict.get k.id playedBy |> Maybe.map (\d -> ( d.playedBy, { play = k.responses, likes = d.likes } )) type alias ActionDetails msg = diff --git a/client/src/elm/MassiveDecks/Game/Action.elm b/client/src/elm/MassiveDecks/Game/Action.elm index 8c27f24..14dc39d 100644 --- a/client/src/elm/MassiveDecks/Game/Action.elm +++ b/client/src/elm/MassiveDecks/Game/Action.elm @@ -8,9 +8,7 @@ import Html.Events as HtmlE import MassiveDecks.Components as Components import MassiveDecks.Game.Action.Model exposing (..) import MassiveDecks.Game.Messages as Game exposing (Msg) -import MassiveDecks.Messages as Global import MassiveDecks.Model exposing (..) -import MassiveDecks.Pages.Lobby.Messages as Lobby import MassiveDecks.Strings as Strings exposing (MdString) import MassiveDecks.Strings.Languages as Lang import Weightless.Attributes as WlA diff --git a/client/src/elm/MassiveDecks/Game/History.elm b/client/src/elm/MassiveDecks/Game/History.elm index 965ca45..d24bf06 100644 --- a/client/src/elm/MassiveDecks/Game/History.elm +++ b/client/src/elm/MassiveDecks/Game/History.elm @@ -8,6 +8,7 @@ import Html.Events as HtmlE import Html.Keyed as HtmlK import MassiveDecks.Card.Call as Call import MassiveDecks.Card.Model as Card +import MassiveDecks.Card.Play as Play import MassiveDecks.Card.Response as Response import MassiveDecks.Components as Components import MassiveDecks.Game.Messages exposing (Msg(..)) @@ -54,13 +55,16 @@ viewRound : Shared -> Config -> Dict User.Id User -> Round.Complete -> ( String, viewRound shared config users round = let winning = - round.plays |> Dict.get round.winner |> Maybe.withDefault [] |> List.map .body + round.plays |> Dict.get round.winner + + winningBody = + winning |> Maybe.map .play |> Maybe.withDefault [] |> List.map .body in ( Round.idString round.id , Html.li [ HtmlA.class "historic-round" ] [ Html.div [ HtmlA.class "historic-call with-byline" ] - [ Plays.byLine shared users round.czar (Just Icon.gavel) - , Html.div [] [ Call.viewFilled shared config Card.Front [] winning round.call ] + [ Plays.byLine shared users round.czar (Just Icon.gavel) Nothing + , Html.div [] [ Call.viewFilled shared config Card.Front [] winningBody round.call ] ] , HtmlK.ul [ HtmlA.class "plays cards" ] (round.plays |> Dict.toList |> List.map (viewPlay shared config users round.winner)) @@ -68,15 +72,15 @@ viewRound shared config users round = ) -viewPlay : Shared -> Config -> Dict User.Id User -> User.Id -> ( User.Id, List Card.Response ) -> ( String, Html msg ) -viewPlay shared config users winner ( id, responses ) = +viewPlay : Shared -> Config -> Dict User.Id User -> User.Id -> ( User.Id, Play.WithLikes ) -> ( String, Html msg ) +viewPlay shared config users winner ( id, { play, likes } ) = let cards = - responses |> List.map (\r -> ( r.details.id, Html.li [] [ Response.view config Card.Front [] r ] )) + play |> List.map (\r -> ( r.details.id, Html.li [] [ Response.view config Card.Front [] r ] )) in ( id , Html.li [ HtmlA.class "with-byline" ] - [ Plays.byLine shared users id (Icon.trophy |> Maybe.justIf (winner == id)) + [ Plays.byLine shared users id (Icon.trophy |> Maybe.justIf (winner == id)) likes , HtmlK.ol [ HtmlA.class "play card-set" ] cards ] ) diff --git a/client/src/elm/MassiveDecks/Game/Player.elm b/client/src/elm/MassiveDecks/Game/Player.elm index 491cf22..10fc907 100644 --- a/client/src/elm/MassiveDecks/Game/Player.elm +++ b/client/src/elm/MassiveDecks/Game/Player.elm @@ -21,6 +21,7 @@ import Set -} type alias Player = { score : Score + , likes : Int , presence : Presence } @@ -28,6 +29,7 @@ type alias Player = default : Player default = { score = 0 + , likes = 0 , presence = Active } diff --git a/client/src/elm/MassiveDecks/Game/Round.elm b/client/src/elm/MassiveDecks/Game/Round.elm index 9b9e9a1..82f4832 100644 --- a/client/src/elm/MassiveDecks/Game/Round.elm +++ b/client/src/elm/MassiveDecks/Game/Round.elm @@ -179,10 +179,10 @@ judging id czar players call plays startedAt timedOut = {-| A round that has been finished. -} type alias Complete = - Data { plays : Dict User.Id (List Card.Response), playOrder : List User.Id, winner : User.Id } + Data { plays : Dict User.Id Play.WithLikes, playOrder : List User.Id, winner : User.Id } -complete : Id -> User.Id -> Set User.Id -> Card.Call -> Dict User.Id (List Card.Response) -> List User.Id -> User.Id -> Time -> Complete +complete : Id -> User.Id -> Set User.Id -> Card.Call -> Dict User.Id Play.WithLikes -> List User.Id -> User.Id -> Time -> Complete complete id czar players call plays playOrder winner startedAt = { id = id , czar = czar diff --git a/client/src/elm/MassiveDecks/Game/Round/Complete.elm b/client/src/elm/MassiveDecks/Game/Round/Complete.elm index 38b5a06..a965298 100644 --- a/client/src/elm/MassiveDecks/Game/Round/Complete.elm +++ b/client/src/elm/MassiveDecks/Game/Round/Complete.elm @@ -6,6 +6,7 @@ import Html exposing (Html) import Html.Attributes as HtmlA import Html.Keyed as HtmlK import MassiveDecks.Card.Model as Card +import MassiveDecks.Card.Play as Play import MassiveDecks.Card.Response as Response import MassiveDecks.Game.Action.Model as Action import MassiveDecks.Game.Model exposing (..) @@ -29,22 +30,22 @@ view shared nextRoundReady config users round = , content = HtmlK.ul [ HtmlA.class "complete plays cards" ] (round.playOrder - |> List.map (\u -> ( u, Dict.get u round.plays |> Maybe.withDefault [] )) + |> List.map (\u -> ( u, Dict.get u round.plays )) |> List.map (viewPlay shared config users round.winner) ) - , fillCallWith = winning |> Maybe.withDefault [] + , fillCallWith = winning |> Maybe.map .play |> Maybe.withDefault [] } -viewPlay : Shared -> Config -> Dict User.Id User -> User.Id -> ( User.Id, List Card.Response ) -> ( String, Html msg ) -viewPlay shared config users winner ( id, responses ) = +viewPlay : Shared -> Config -> Dict User.Id User -> User.Id -> ( User.Id, Maybe Play.WithLikes ) -> ( String, Html msg ) +viewPlay shared config users winner ( id, play ) = let cards = - responses |> List.map (\r -> ( r.details.id, Html.li [] [ Response.view config Card.Front [] r ] )) + play |> Maybe.map (.play >> List.map (\r -> ( r.details.id, Html.li [] [ Response.view config Card.Front [] r ] ))) |> Maybe.withDefault [] in ( id , Html.li [ HtmlA.class "with-byline" ] - [ Plays.byLine shared users id (Icon.trophy |> Maybe.justIf (winner == id)) + [ Plays.byLine shared users id (Icon.trophy |> Maybe.justIf (winner == id)) (play |> Maybe.andThen .likes) , HtmlK.ol [ HtmlA.class "play card-set" ] cards ] ) diff --git a/client/src/elm/MassiveDecks/Game/Round/Plays.elm b/client/src/elm/MassiveDecks/Game/Round/Plays.elm index 6dc670b..eb596d4 100644 --- a/client/src/elm/MassiveDecks/Game/Round/Plays.elm +++ b/client/src/elm/MassiveDecks/Game/Round/Plays.elm @@ -34,8 +34,8 @@ view class picked details = {-| Create a byline. -} -byLine : Shared -> Dict User.Id User -> User.Id -> Maybe Icon -> Html msg -byLine shared users id icon = +byLine : Shared -> Dict User.Id User -> User.Id -> Maybe Icon -> Maybe Int -> Html msg +byLine shared users id icon likes = let name = users |> Dict.get id |> Maybe.map .name |> Maybe.withDefault (Strings.UnknownUser |> Lang.string shared) @@ -43,6 +43,7 @@ byLine shared users id icon = Html.span [ HtmlA.class "byline", HtmlA.title name ] [ icon |> Maybe.map Icon.viewIcon |> Maybe.withDefault Html.nothing , Html.text name + , likes |> Maybe.map (\l -> Strings.Likes { total = l } |> Lang.html shared) |> Maybe.withDefault Html.nothing ] diff --git a/client/src/elm/MassiveDecks/Models/Decoders.elm b/client/src/elm/MassiveDecks/Models/Decoders.elm index be146d3..9236bb5 100644 --- a/client/src/elm/MassiveDecks/Models/Decoders.elm +++ b/client/src/elm/MassiveDecks/Models/Decoders.elm @@ -310,8 +310,9 @@ rando = player : Json.Decoder Player player = - Json.map2 Player + Json.map3 Player (Json.field "score" score) + (Json.field "likes" Json.int) (Json.field "presence" playerPresence) @@ -621,7 +622,14 @@ roundFinished : Json.Decoder Events.TimedGameEvent roundFinished = Json.map2 (\wi -> \pb -> Events.RoundFinished { winner = wi, playedBy = pb }) (Json.field "winner" userId) - (Json.field "playedBy" (Json.dict userId)) + (Json.field "playDetails" (Json.dict playDetails)) + + +playDetails : Json.Decoder Play.Details +playDetails = + Json.map2 Play.Details + (Json.field "playedBy" userId) + (Json.maybe (Json.field "likes" Json.int)) playRevealed : Json.Decoder Events.TimedGameEvent @@ -1026,12 +1034,19 @@ completeRound = (Json.field "czar" userId) (Json.field "players" playerSet) (Json.field "call" call) - (Json.field "plays" (Json.dict (Json.list response))) + (Json.field "plays" (Json.dict playWithLikes)) (Json.field "playOrder" (Json.list userId)) (Json.field "winner" userId) (Json.field "startedAt" Time.timeDecoder) +playWithLikes : Json.Decoder Play.WithLikes +playWithLikes = + Json.map2 Play.WithLikes + (Json.field "play" (Json.list response)) + (Json.maybe (Json.field "likes" Json.int)) + + playerRole : Json.Decoder Player.Role playerRole = Json.string |> Json.andThen playerRoleByName diff --git a/client/src/elm/MassiveDecks/Pages/Lobby.elm b/client/src/elm/MassiveDecks/Pages/Lobby.elm index 0ffeb3d..af697b5 100644 --- a/client/src/elm/MassiveDecks/Pages/Lobby.elm +++ b/client/src/elm/MassiveDecks/Pages/Lobby.elm @@ -772,8 +772,11 @@ userDetails shared game userId user = score = player |> Maybe.map (\p -> Strings.Score { total = p.score }) + + likes = + player |> Maybe.map (\p -> Strings.Likes { total = p.likes }) in - ( viewDetails shared details, viewDetails shared [ score ] ) + ( viewDetails shared details, viewDetails shared [ score, likes ] ) playStateDetail : Maybe Round -> User.Id -> Maybe MdString diff --git a/client/src/elm/MassiveDecks/Pages/Lobby/Actions.elm b/client/src/elm/MassiveDecks/Pages/Lobby/Actions.elm index 469bb1c..5f4baad 100644 --- a/client/src/elm/MassiveDecks/Pages/Lobby/Actions.elm +++ b/client/src/elm/MassiveDecks/Pages/Lobby/Actions.elm @@ -8,6 +8,7 @@ module MassiveDecks.Pages.Lobby.Actions exposing , judge , kick , leave + , like , redraw , removeDeck , reveal @@ -95,6 +96,11 @@ judge play = action "Judge" [ ( "winner", play |> Json.string ) ] +like : Play.Id -> Cmd msg +like play = + action "Like" [ ( "play", play |> Json.string ) ] + + redraw : Cmd msg redraw = action "Redraw" [] diff --git a/client/src/elm/MassiveDecks/Pages/Lobby/Events.elm b/client/src/elm/MassiveDecks/Pages/Lobby/Events.elm index cd8a71e..0b4f41b 100644 --- a/client/src/elm/MassiveDecks/Pages/Lobby/Events.elm +++ b/client/src/elm/MassiveDecks/Pages/Lobby/Events.elm @@ -90,5 +90,5 @@ type TimedGameEvent , drawn : Maybe (List Card.Response) } | StartRevealing { plays : List Play.Id, drawn : Maybe (List Card.Response) } - | RoundFinished { winner : User.Id, playedBy : Dict Play.Id User.Id } + | RoundFinished { winner : User.Id, playedBy : Dict Play.Id Play.Details } | PlayRevealed { id : Play.Id, play : List Card.Response } diff --git a/client/src/elm/MassiveDecks/Pages/Spectate/Stages/Round.elm b/client/src/elm/MassiveDecks/Pages/Spectate/Stages/Round.elm index 586e07c..42ee9f0 100644 --- a/client/src/elm/MassiveDecks/Pages/Spectate/Stages/Round.elm +++ b/client/src/elm/MassiveDecks/Pages/Spectate/Stages/Round.elm @@ -7,12 +7,14 @@ import Html exposing (Html) import Html.Attributes as HtmlA import MassiveDecks.Card.Call as Call import MassiveDecks.Card.Model as Card exposing (Call) -import MassiveDecks.Card.Play exposing (Play) +import MassiveDecks.Card.Play as Play exposing (Play) import MassiveDecks.Card.Response as Response import MassiveDecks.Game.Model as Game import MassiveDecks.Game.Round as Round exposing (Round) import MassiveDecks.Model exposing (Shared) import MassiveDecks.Pages.Lobby.Configure.Model exposing (Config) +import MassiveDecks.Strings as Strings +import MassiveDecks.Strings.Languages as Lang import MassiveDecks.User as User exposing (User) import MassiveDecks.Util.Maybe as Maybe import Round @@ -62,10 +64,10 @@ viewRevealing shared config users round = Nothing plays = - round.plays |> List.map (\p -> ( Nothing, p.responses )) + round.plays |> List.map (\p -> ( Nothing, p.responses |> Maybe.map (\r -> { play = r, likes = Nothing }) )) in [ viewCall shared config fillWith round.call - , viewPlays config (round.call |> Call.slotCount) users Nothing plays + , viewPlays shared config (round.call |> Call.slotCount) users Nothing plays ] @@ -73,10 +75,10 @@ viewJudging : Shared -> Config -> Dict User.Id User -> Round.Judging -> List (Ht viewJudging shared config users round = let plays = - round.plays |> List.map (\p -> ( Nothing, Just p.responses )) + round.plays |> List.map (\p -> ( Nothing, Just { play = p.responses, likes = Nothing } )) in [ viewCall shared config Nothing round.call - , viewPlays config (round.call |> Call.slotCount) users Nothing plays + , viewPlays shared config (round.call |> Call.slotCount) users Nothing plays ] @@ -87,10 +89,10 @@ viewComplete shared config users round = round.playOrder |> List.map (\u -> ( Just u, Dict.get u round.plays )) winner = - Dict.get round.winner round.plays + Dict.get round.winner round.plays |> Maybe.map .play in [ viewCall shared config winner round.call - , viewPlays config (round.call |> Call.slotCount) users (Just round.winner) plays + , viewPlays shared config (round.call |> Call.slotCount) users (Just round.winner) plays ] @@ -117,17 +119,17 @@ viewUnknownPlays slotCount playStyles players played = ) -viewPlays : Config -> Int -> Dict User.Id User -> Maybe User.Id -> List ( Maybe User.Id, Maybe (List Card.Response) ) -> Html msg -viewPlays config slotCount users winner plays = +viewPlays : Shared -> Config -> Int -> Dict User.Id User -> Maybe User.Id -> List ( Maybe User.Id, Maybe Play.WithLikes ) -> Html msg +viewPlays shared config slotCount users winner plays = let angle = plays |> List.length |> anglePerPlay in - Html.ul [ HtmlA.id "plays" ] (plays |> List.indexedMap (viewPlayByIndex config slotCount users winner angle)) + Html.ul [ HtmlA.id "plays" ] (plays |> List.indexedMap (viewPlayByIndex shared config slotCount users winner angle)) -viewPlayByIndex : Config -> Int -> Dict User.Id User -> Maybe User.Id -> Float -> Int -> ( Maybe User.Id, Maybe (List Card.Response) ) -> Html msg -viewPlayByIndex config slotCount users winner angle index ( user, play ) = +viewPlayByIndex : Shared -> Config -> Int -> Dict User.Id User -> Maybe User.Id -> Float -> Int -> ( Maybe User.Id, Maybe Play.WithLikes ) -> Html msg +viewPlayByIndex shared config slotCount users winner angle index ( user, play ) = let isWinner = case winner of @@ -137,7 +139,7 @@ viewPlayByIndex config slotCount users winner angle index ( user, play ) = Nothing -> False in - viewPlay config slotCount (toFloat index * angle) (user |> Maybe.andThen (\u -> Dict.get u users)) isWinner play + viewPlay shared config slotCount (toFloat index * angle) (user |> Maybe.andThen (\u -> Dict.get u users)) isWinner play anglePerPlay : Int -> Float @@ -152,11 +154,11 @@ closeDistance = farDistance : number farDistance = - 85 + 150 -viewPlay : Config -> Int -> Float -> Maybe User -> Bool -> Maybe (List Card.Response) -> Html msg -viewPlay config slotCount angle playedBy isWinner responses = +viewPlay : Shared -> Config -> Int -> Float -> Maybe User -> Bool -> Maybe Play.WithLikes -> Html msg +viewPlay shared config slotCount angle playedBy isWinner play = Html.li (positionFromAngle angle closeDistance) [ Html.div [ HtmlA.class "with-byline" ] @@ -164,13 +166,14 @@ viewPlay config slotCount angle playedBy isWinner responses = (List.filterMap identity [ Icon.viewIcon Icon.trophy |> Maybe.justIf isWinner , playedBy |> Maybe.map (.name >> Html.text) + , play |> Maybe.andThen .likes |> Maybe.map (\l -> Strings.Likes { total = l } |> Lang.html shared) ] ) , Html.ol [ HtmlA.classList [ ( "play", True ), ( "card-set", True ) ] ] - (responses - |> Maybe.map (List.map (\response -> Html.li [] [ Response.view config Card.Front [] response ])) + (play + |> Maybe.map (.play >> List.map (\response -> Html.li [] [ Response.view config Card.Front [] response ])) |> Maybe.withDefault (List.repeat slotCount (Html.li [] [ Response.viewUnknown [] ])) ) ] diff --git a/client/src/elm/MassiveDecks/Strings.elm b/client/src/elm/MassiveDecks/Strings.elm index 5ca90fb..07fe64f 100644 --- a/client/src/elm/MassiveDecks/Strings.elm +++ b/client/src/elm/MassiveDecks/Strings.elm @@ -141,6 +141,8 @@ type MdString | AiDescription -- A description of a player who is controlled by the computer. | Score { total : Int } -- A display of a score. | ScoreDescription -- A description of a player's score. + | Likes { total : Int } -- A display of a number of likes. + | LikesDescription -- A description of the number of likes a play received or a player has recieved. | ToggleUserList -- A description of the action of showing or hiding the user list. | GameMenu -- A description of the game menu. | UnknownUser -- A name for a user that doesn't have a known name. diff --git a/client/src/elm/MassiveDecks/Strings/Languages/En.elm b/client/src/elm/MassiveDecks/Strings/Languages/En.elm index 910b649..0d33c2f 100644 --- a/client/src/elm/MassiveDecks/Strings/Languages/En.elm +++ b/client/src/elm/MassiveDecks/Strings/Languages/En.elm @@ -503,6 +503,13 @@ translate mdString = , Text " this player has." ] + Likes { total } -> + [ Text (String.fromInt total) ] + + LikesDescription -> + [ Text "The number of likes received." + ] + ToggleUserList -> [ Text "Show or hide the scoreboard." ] diff --git a/client/src/elm/MassiveDecks/Strings/Render.elm b/client/src/elm/MassiveDecks/Strings/Render.elm index d1180c8..9d03a11 100644 --- a/client/src/elm/MassiveDecks/Strings/Render.elm +++ b/client/src/elm/MassiveDecks/Strings/Render.elm @@ -156,6 +156,9 @@ enhanceHtml context mdString unenhanced = Score _ -> [ Html.span [ HtmlA.class "no-wrap", (ScoreDescription |> asString context) |> HtmlA.title ] (suffixed unenhanced Icon.star) ] + Likes _ -> + [ Html.span [ HtmlA.class "no-wrap", (LikesDescription |> asString context) |> HtmlA.title ] (suffixed unenhanced Icon.thumbsUp) ] + NumberOfCards _ -> [ Html.span [ HtmlA.class "amount" ] unenhanced ] diff --git a/client/src/scss/game/plays.scss b/client/src/scss/game/plays.scss index 5c8f8c6..b34444f 100644 --- a/client/src/scss/game/plays.scss +++ b/client/src/scss/game/plays.scss @@ -16,7 +16,7 @@ max-width: 100%; overflow: hidden; - svg { - margin-right: 0.5em; + > * { + margin: 0 0.25em 0 0.25em; } } diff --git a/client/src/scss/pages/lobby.scss b/client/src/scss/pages/lobby.scss index a9d459e..bbe9883 100644 --- a/client/src/scss/pages/lobby.scss +++ b/client/src/scss/pages/lobby.scss @@ -108,6 +108,7 @@ text-overflow: ellipsis; white-space: nowrap; overflow: hidden; + max-width: $users-width - 8rem; } ol { diff --git a/client/src/scss/pages/lobby/variables.scss b/client/src/scss/pages/lobby/variables.scss index ed472e9..e42e963 100644 --- a/client/src/scss/pages/lobby/variables.scss +++ b/client/src/scss/pages/lobby/variables.scss @@ -1,2 +1,2 @@ $top-bar-height: 4rem; -$users-width: 15rem; +$users-width: 18rem; diff --git a/client/webpack.config.js b/client/webpack.config.js index fd0d9c1..1de2afc 100644 --- a/client/webpack.config.js +++ b/client/webpack.config.js @@ -278,7 +278,7 @@ module.exports = (env, argv) => { }, devServer: { hot: true, - allowedHosts: ["localhost", "f2a7ec0b.ngrok.io"], + allowedHosts: ["localhost"], proxy: { // Forward to the server. "/api/**": { diff --git a/server/src/ts/action/game-action/czar/judge.ts b/server/src/ts/action/game-action/czar/judge.ts index 79fdc7b..bd52e8b 100644 --- a/server/src/ts/action/game-action/czar/judge.ts +++ b/server/src/ts/action/game-action/czar/judge.ts @@ -26,13 +26,22 @@ export const handle: gameAction.Handler = (auth, lobby, action) => { const lobbyRound = game.round; const plays = lobbyRound.plays; if (lobbyRound.verifyStage(action, "Judging")) { - const play = plays.find(play => play.id === action.winner); - if (play === undefined) { + let winningPlay = undefined; + for (const play of plays) { + if (play.likes.size > 0) { + const player = game.players.get(play.playedBy) as Player; + player.likes += play.likes.size; + } + if (play.id === action.winner) { + winningPlay = play; + } + } + if (winningPlay === undefined) { throw new InvalidActionError("Given play doesn't exist."); } - const player = game.players.get(play.playedBy) as Player; + const player = game.players.get(winningPlay.playedBy) as Player; player.score += 1; - const completedRound = lobbyRound.advance(play.playedBy); + const completedRound = lobbyRound.advance(winningPlay.playedBy); game.round = completedRound; game.history.splice(0, 0, completedRound.public()); diff --git a/server/src/ts/action/game-action/player.ts b/server/src/ts/action/game-action/player.ts index 673e184..cc4405a 100644 --- a/server/src/ts/action/game-action/player.ts +++ b/server/src/ts/action/game-action/player.ts @@ -2,6 +2,8 @@ import wu from "wu"; import { Action } from "../../action"; import * as util from "../../util"; import * as gameAction from "../game-action"; +import { Like } from "./player/like"; +import * as like from "./player/like"; import * as submit from "./player/submit"; import { Submit } from "./player/submit"; import * as takeBack from "./player/take-back"; @@ -10,9 +12,9 @@ import { TakeBack } from "./player/take-back"; /** * An action only the czar can perform. */ -export type Player = Submit | TakeBack; +export type Player = Submit | TakeBack | Like; -const possible = new Set([submit.is, takeBack.is]); +const possible = new Set([submit.is, takeBack.is, like.is]); /** * Check if an action is a non-czar action. @@ -32,6 +34,8 @@ export const handle: gameAction.Handler = ( return submit.handle(auth, lobby, action, server); } else if (takeBack.is(action)) { return takeBack.handle(auth, lobby, action, server); + } else if (like.is(action)) { + return like.handle(auth, lobby, action, server); } else { return util.assertNever(action); } diff --git a/server/src/ts/action/game-action/player/like.ts b/server/src/ts/action/game-action/player/like.ts new file mode 100644 index 0000000..91b802e --- /dev/null +++ b/server/src/ts/action/game-action/player/like.ts @@ -0,0 +1,47 @@ +import { Action } from "../../../action"; +import * as round from "../../../games/game/round"; +import * as gameAction from "../../game-action"; +import * as play from "../../../games/cards/play"; + +/** + * A player plays a white card into a round. + */ +export interface Like { + action: NameType; + play: play.Id; +} + +type NameType = "Like"; +const name: NameType = "Like"; + +/** + * Check if an action is a take back action. + * @param action The action to check. + */ +export const is = (action: Action): action is Like => action.action === name; + +export const handle: gameAction.Handler = (auth, lobby, action) => { + if ( + lobby.game.round.verifyStage( + action, + "Judging" + ) + ) { + const cRound = lobby.game.round; + const target = cRound.plays.find(p => p.id === action.play); + if ( + target !== undefined && + target.playedBy !== auth.uid && + !target.likes.has(auth.uid) + ) { + target.likes.add(auth.uid); + return { + lobby + }; + } else { + return {}; + } + } else { + return {}; + } +}; diff --git a/server/src/ts/action/game-action/player/submit.ts b/server/src/ts/action/game-action/player/submit.ts index 0dc9c7d..ded2a44 100644 --- a/server/src/ts/action/game-action/player/submit.ts +++ b/server/src/ts/action/game-action/player/submit.ts @@ -14,7 +14,7 @@ import * as gameAction from "../../game-action"; * A player plays a white card into a round. */ export interface Submit { - action: "Submit"; + action: NameType; play: card.Id[]; } @@ -67,7 +67,8 @@ export const handle: gameAction.Handler = ( id: playId, play: extractedPlay, playedBy: auth.uid, - revealed: false + revealed: false, + likes: new Set() }); const events = [event.targetAll(playSubmitted.of(auth.uid))]; const timeouts = []; diff --git a/server/src/ts/action/game-action/player/take-back.ts b/server/src/ts/action/game-action/player/take-back.ts index 5a32185..655e490 100644 --- a/server/src/ts/action/game-action/player/take-back.ts +++ b/server/src/ts/action/game-action/player/take-back.ts @@ -9,7 +9,7 @@ import * as gameAction from "../../game-action"; * A player plays a white card into a round. */ export interface TakeBack { - action: "TakeBack"; + action: NameType; } type NameType = "TakeBack"; diff --git a/server/src/ts/action/validation.validator.ts b/server/src/ts/action/validation.validator.ts index 68496e9..bcfc703 100644 --- a/server/src/ts/action/validation.validator.ts +++ b/server/src/ts/action/validation.validator.ts @@ -34,6 +34,9 @@ export const Schema = { { $ref: "#/definitions/EnforceTimeLimit" }, + { + $ref: "#/definitions/Like" + }, { $ref: "#/definitions/Submit" }, @@ -96,7 +99,7 @@ export const Schema = { description: "Authenticate with the game.", properties: { action: { - $ref: "#/definitions/NameType_11" + $ref: "#/definitions/NameType_14" }, token: { $ref: "#/definitions/Token" @@ -161,7 +164,7 @@ export const Schema = { description: "Make a change to the configuration of decks for the lobby.", properties: { action: { - $ref: "#/definitions/NameType_2" + $ref: "#/definitions/NameType_5" }, change: { $ref: "#/definitions/PlayerDriven" @@ -184,7 +187,7 @@ export const Schema = { description: "Set the hand size for the lobby.", properties: { action: { - $ref: "#/definitions/NameType_3" + $ref: "#/definitions/NameType_6" }, change: { $ref: "#/definitions/Change" @@ -246,7 +249,7 @@ export const Schema = { description: "Change the time limit for a given stage.", properties: { action: { - $ref: "#/definitions/NameType_4" + $ref: "#/definitions/NameType_7" }, if: { description: @@ -270,7 +273,7 @@ export const Schema = { "Change the time limit mode, or disable the time limits completely.", properties: { action: { - $ref: "#/definitions/NameType_4" + $ref: "#/definitions/NameType_7" }, if: { description: @@ -323,7 +326,7 @@ export const Schema = { description: "End the current game.", properties: { action: { - $ref: "#/definitions/NameType_9" + $ref: "#/definitions/NameType_12" } }, required: ["action"], @@ -400,6 +403,21 @@ export const Schema = { required: ["action"], type: "object" }, + Like: { + additionalProperties: false, + defaultProperties: [], + description: "A player plays a white card into a round.", + properties: { + action: { + $ref: "#/definitions/NameType_2" + }, + play: { + $ref: "#/definitions/Id" + } + }, + required: ["action", "play"], + type: "object" + }, Name: { description: "The name the user goes by.", maxLength: 100, @@ -415,43 +433,55 @@ export const Schema = { type: "string" }, NameType_10: { - enum: ["StartGame"], + enum: ["SetPublic"], type: "string" }, NameType_11: { + enum: ["SetScoreLimit"], + type: "string" + }, + NameType_12: { + enum: ["EndGame"], + type: "string" + }, + NameType_13: { + enum: ["StartGame"], + type: "string" + }, + NameType_14: { enum: ["Authenticate"], type: "string" }, NameType_2: { - enum: ["ChangeDecks"], + enum: ["Like"], type: "string" }, NameType_3: { - enum: ["ChangeHouseRule"], + enum: ["Submit"], type: "string" }, NameType_4: { - enum: ["ChangeTimeLimit"], + enum: ["TakeBack"], type: "string" }, NameType_5: { - enum: ["SetHandSize"], + enum: ["ChangeDecks"], type: "string" }, NameType_6: { - enum: ["SetPassword"], + enum: ["ChangeHouseRule"], type: "string" }, NameType_7: { - enum: ["SetPublic"], + enum: ["ChangeTimeLimit"], type: "string" }, NameType_8: { - enum: ["SetScoreLimit"], + enum: ["SetHandSize"], type: "string" }, NameType_9: { - enum: ["EndGame"], + enum: ["SetPassword"], type: "string" }, PackingHeat: { @@ -562,7 +592,7 @@ export const Schema = { description: "Set the hand size for the lobby.", properties: { action: { - $ref: "#/definitions/NameType_5" + $ref: "#/definitions/NameType_8" }, handSize: { description: "The number of cards in each player's hand.", @@ -584,7 +614,7 @@ export const Schema = { description: "Set (or unset) the password for the lobby.", properties: { action: { - $ref: "#/definitions/NameType_6" + $ref: "#/definitions/NameType_9" }, if: { description: @@ -658,7 +688,7 @@ export const Schema = { description: "Set (or unset) the password for the lobby.", properties: { action: { - $ref: "#/definitions/NameType_7" + $ref: "#/definitions/NameType_10" }, if: { description: @@ -678,7 +708,7 @@ export const Schema = { description: "(Un)Set the score limit for the lobby.", properties: { action: { - $ref: "#/definitions/NameType_8" + $ref: "#/definitions/NameType_11" }, if: { description: @@ -705,7 +735,7 @@ export const Schema = { description: "Start a game in the lobby if possible.", properties: { action: { - $ref: "#/definitions/NameType_10" + $ref: "#/definitions/NameType_13" } }, required: ["action"], @@ -717,8 +747,7 @@ export const Schema = { description: "A player plays a white card into a round.", properties: { action: { - enum: ["Submit"], - type: "string" + $ref: "#/definitions/NameType_3" }, play: { items: { @@ -736,8 +765,7 @@ export const Schema = { description: "A player plays a white card into a round.", properties: { action: { - enum: ["TakeBack"], - type: "string" + $ref: "#/definitions/NameType_4" } }, required: ["action"], diff --git a/server/src/ts/errors/action-execution-error.ts b/server/src/ts/errors/action-execution-error.ts index 2c1a31f..678ec59 100644 --- a/server/src/ts/errors/action-execution-error.ts +++ b/server/src/ts/errors/action-execution-error.ts @@ -115,18 +115,18 @@ export class IncorrectUserRoleError extends ActionExecutionError { interface IncorrectRoundStageDetails extends errors.Details { stage: round.Stage; - expected: round.Stage; + expected: round.Stage[]; } // Could happen if the round changes unexpectedly (e.g: czar leaves game). export class IncorrectRoundStageError extends ActionExecutionError { public readonly stage: round.Stage; - public readonly expected: round.Stage; + public readonly expected: round.Stage[]; public constructor( action: Action, stage: round.Stage, - expected: round.Stage + ...expected: round.Stage[] ) { super( `For this action the round must be in the ${expected} stage but is in ` + diff --git a/server/src/ts/events/game-event/round-finished.ts b/server/src/ts/events/game-event/round-finished.ts index d7f8214..61eef8a 100644 --- a/server/src/ts/events/game-event/round-finished.ts +++ b/server/src/ts/events/game-event/round-finished.ts @@ -1,4 +1,5 @@ import * as gameRound from "../../games/game/round"; +import * as publicRound from "../../games/game/round/public"; import * as user from "../../user"; /** @@ -8,11 +9,11 @@ import * as user from "../../user"; export interface RoundFinished { event: "RoundFinished"; winner: user.Id; - playedBy: { [id: string]: user.Id }; + playDetails: { [id: string]: publicRound.PlayDetails }; } export const of = (round: gameRound.Complete): RoundFinished => ({ event: "RoundFinished", winner: round.winner, - playedBy: round.playedBy() + playDetails: round.playDetails() }); diff --git a/server/src/ts/games/cards/play.ts b/server/src/ts/games/cards/play.ts index 5bb0080..ba8ef6c 100644 --- a/server/src/ts/games/cards/play.ts +++ b/server/src/ts/games/cards/play.ts @@ -21,7 +21,7 @@ export interface PotentiallyRevealed { /** * A play with its id. */ -export type Revealed = PotentiallyRevealed & { play: Play }; +export type Revealed = PotentiallyRevealed & { play: Play; likes?: number }; /** * Create a new user id. diff --git a/server/src/ts/games/game.ts b/server/src/ts/games/game.ts index 215da36..f4d55be 100644 --- a/server/src/ts/games/game.ts +++ b/server/src/ts/games/game.ts @@ -92,7 +92,8 @@ export const atStartOfRound = ( id: playId, play: player.hand.slice(0, slotCount), playedBy: ai, - revealed: false + revealed: false, + likes: new Set() }); events.push(event.targetAll(playSubmitted.of(ai))); } diff --git a/server/src/ts/games/game/round.ts b/server/src/ts/games/game/round.ts index 6417f96..e07b794 100644 --- a/server/src/ts/games/game/round.ts +++ b/server/src/ts/games/game/round.ts @@ -2,14 +2,13 @@ import wu from "wu"; import { Action } from "../../action"; import { IncorrectRoundStageError } from "../../errors/action-execution-error"; import * as user from "../../user"; +import * as util from "../../util"; import * as card from "../cards/card"; import * as play from "../cards/play"; -import { Play } from "../cards/play"; import * as publicRound from "./round/public"; import { Public as PublicRound } from "./round/public"; import * as storedPlay from "./round/storedPlay"; import { StoredPlay } from "./round/storedPlay"; -import * as util from "../../util"; export type Round = Playing | Revealing | Judging | Complete; @@ -109,18 +108,24 @@ export class Complete extends Base<"Complete"> { }; } - public playedBy(): { [player: string]: user.Id } { - const obj: { [player: string]: user.Id } = {}; + public playDetails(): { [player: string]: publicRound.PlayDetails } { + const obj: { [player: string]: publicRound.PlayDetails } = {}; for (const roundPlay of this.plays) { - obj[roundPlay.id] = roundPlay.playedBy; + obj[roundPlay.id] = { + playedBy: roundPlay.playedBy, + ...(roundPlay.likes.size > 0 ? { likes: roundPlay.likes.size } : {}) + }; } return obj; } - private playsObj(): { [player: string]: Play } { - const obj: { [player: string]: Play } = {}; + private playsObj(): { [player: string]: publicRound.PlayWithLikes } { + const obj: { [player: string]: publicRound.PlayWithLikes } = {}; for (const roundPlay of this.plays) { - obj[roundPlay.playedBy] = roundPlay.play; + obj[roundPlay.playedBy] = { + play: roundPlay.play, + ...(roundPlay.likes.size > 0 ? { likes: roundPlay.likes.size } : {}) + }; } return obj; } @@ -178,7 +183,10 @@ export class Judging extends Base<"Judging"> implements Timed { private *revealedPlays(): Iterable { for (const roundPlay of this.plays) { - yield { id: roundPlay.id, play: roundPlay.play }; + yield { + id: roundPlay.id, + play: roundPlay.play + }; } } } diff --git a/server/src/ts/games/game/round/public.ts b/server/src/ts/games/game/round/public.ts index bee078d..33011ab 100644 --- a/server/src/ts/games/game/round/public.ts +++ b/server/src/ts/games/game/round/public.ts @@ -2,6 +2,7 @@ import * as user from "../../../user"; import * as card from "../../cards/card"; import { Play } from "../../cards/play"; import * as play from "../../cards/play"; +import * as player from "../../player"; export type Public = Playing | Revealing | Judging | Complete; @@ -33,9 +34,19 @@ export interface Judging extends Base, Timed { plays: play.Revealed[]; } +export interface PlayWithLikes { + play: Play; + likes?: number; +} + export interface Complete extends Base { stage: "Complete"; winner: user.Id; - plays: { [player: string]: Play }; + plays: { [player: string]: PlayWithLikes }; playOrder: user.Id[]; } + +export interface PlayDetails { + playedBy: user.Id; + likes?: player.Likes; +} diff --git a/server/src/ts/games/game/round/storedPlay.ts b/server/src/ts/games/game/round/storedPlay.ts index 3b21e6b..64d395a 100644 --- a/server/src/ts/games/game/round/storedPlay.ts +++ b/server/src/ts/games/game/round/storedPlay.ts @@ -8,6 +8,7 @@ export interface StoredPlay { playedBy: user.Id; play: Play; revealed: boolean; + likes: Set; } export type Unrevealed = StoredPlay & { revealed: false }; diff --git a/server/src/ts/games/player.ts b/server/src/ts/games/player.ts index 7bf056c..f26ed94 100644 --- a/server/src/ts/games/player.ts +++ b/server/src/ts/games/player.ts @@ -7,6 +7,7 @@ import { Game } from "./game"; */ export interface Public { score: Score; + likes: Likes; presence: Presence; } @@ -22,6 +23,13 @@ export type Role = "Czar" | "Player"; */ export type Score = number; +/** + * How many likes the player has received. + * @TJS-type integer + * @minimum 0 + */ +export type Likes = number; + /** * If the player is active in the game or has been marked as away. */ @@ -34,17 +42,20 @@ export class Player { public hand: Hand; public score: Score; public presence: Presence; + public likes: Likes; public constructor(hand: Hand) { this.hand = hand; this.score = 0; + this.likes = 0; this.presence = "Active"; } public public(): Public { return { score: this.score, - presence: this.presence + presence: this.presence, + likes: this.likes }; }