diff options
| author | Jasper Van der Jeugt | 2020-08-13 19:19:22 +0200 | 
|---|---|---|
| committer | Jasper Van der Jeugt | 2020-08-13 19:19:22 +0200 | 
| commit | 8d5c0405565ad4afd976efd1262b3224efd6ee2f (patch) | |
| tree | 8d24ecb97212d54943d104ed95f1fda4dea7c1fd /server/lib/Uplcg | |
| parent | a39fe7ff759a552c64a060f0d98a0d4e8a577b01 (diff) | |
cafp -> uplcg
Diffstat (limited to 'server/lib/Uplcg')
| -rw-r--r-- | server/lib/Uplcg/CookieSocket.hs | 86 | ||||
| -rw-r--r-- | server/lib/Uplcg/Game.hs | 332 | ||||
| -rw-r--r-- | server/lib/Uplcg/Main/GenerateElmTypes.hs | 22 | ||||
| -rw-r--r-- | server/lib/Uplcg/Main/Server.hs | 214 | ||||
| -rw-r--r-- | server/lib/Uplcg/Messages.hs | 87 | 
5 files changed, 741 insertions, 0 deletions
diff --git a/server/lib/Uplcg/CookieSocket.hs b/server/lib/Uplcg/CookieSocket.hs new file mode 100644 index 0000000..7efb8b2 --- /dev/null +++ b/server/lib/Uplcg/CookieSocket.hs @@ -0,0 +1,86 @@ +-- | Allows websockets to reconnect and recover state by storing a cookie client +-- side. +{-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE OverloadedStrings          #-} +module Uplcg.CookieSocket +    ( Handle +    , withHandle +    , CookieName +    , acceptRequest +    , persist +    ) where + +import           Control.Concurrent       (threadDelay) +import qualified Control.Concurrent.Async as Async +import           Control.Concurrent.MVar  (MVar) +import qualified Control.Concurrent.MVar  as MVar +import           Control.Monad            (forever, guard) +import           Data.Hashable            (Hashable) +import           Data.HashMap.Strict      (HashMap) +import qualified Data.HashMap.Strict      as HMS +import           Data.Maybe               (listToMaybe) +import qualified Data.Text                as T +import qualified Data.Text.Encoding       as T +import qualified Data.Time                as Time +import           Data.UUID                (UUID) +import qualified Data.UUID                as UUID +import qualified Data.UUID.V4             as UUID.V4 +import qualified Network.WebSockets       as WS + +type CookieName = T.Text + +newtype Secret = Secret UUID deriving (Eq, Hashable) + +data Handle v = Handle +    { hMinutes :: Int  -- Minutes after which expiry happens +    , hStore   :: MVar (HashMap Secret (Time.UTCTime, v)) +    } + +withHandle :: Int -> (Handle v -> IO a) -> IO a +withHandle minutes f = do +    store <- MVar.newMVar HMS.empty +    Async.withAsync (reaper store) $ \_ -> f $ Handle minutes store +  where +    -- This is really shitty and we should probably do something with +    -- monotonic time. +    reaper store = forever $ do +        threadDelay $ minutes * 60 * 1000 * 1000 +        now <- Time.getCurrentTime +        MVar.modifyMVar_ store $ pure . HMS.filter ((> now) . fst) + +parseCookie :: CookieName -> WS.PendingConnection -> Maybe T.Text +parseCookie name pc = listToMaybe $ do +    (header, values) <- WS.requestHeaders $ WS.pendingRequest pc +    guard $ header == "Cookie" +    part <- T.split (== ';') $ T.decodeUtf8 values +    let (key, val) = T.break (== '=') part +    guard $ T.strip key == name +    guard $ "=" `T.isPrefixOf` val +    pure . T.strip $ T.drop 1 val + +makeCookie :: CookieName -> T.Text -> WS.Headers +makeCookie name val = [("Set-Cookie", T.encodeUtf8 $ name <> "=" <> val)] + +acceptRequest +    :: Handle a -> CookieName -> WS.PendingConnection +    -> IO (WS.Connection, Secret, Maybe a) +acceptRequest h name pc = case parseCookie name pc >>= UUID.fromText of +    Just uuid -> do +        conn <- WS.acceptRequest pc +        store <- MVar.readMVar (hStore h) +        pure (conn, Secret uuid, snd <$> HMS.lookup (Secret uuid) store) +    Nothing -> do +        uuid <- UUID.V4.nextRandom +        conn <- WS.acceptRequestWith pc WS.defaultAcceptRequest +            { WS.acceptHeaders = +                makeCookie name (UUID.toText uuid) <> +                WS.acceptHeaders WS.defaultAcceptRequest +            } +        pure (conn, Secret uuid, Nothing) + +persist :: Handle a -> Secret -> a -> IO () +persist h key x = MVar.modifyMVar_ (hStore h) $ \store -> do +    expiry <- Time.addUTCTime diffTime <$> Time.getCurrentTime +    pure $ HMS.insert key (expiry, x) store +  where +    diffTime = fromIntegral (60 * hMinutes h) diff --git a/server/lib/Uplcg/Game.hs b/server/lib/Uplcg/Game.hs new file mode 100644 index 0000000..02e40cb --- /dev/null +++ b/server/lib/Uplcg/Game.hs @@ -0,0 +1,332 @@ +{-# LANGUAGE LambdaCase        #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE Rank2Types        #-} +{-# LANGUAGE RecordWildCards   #-} +{-# LANGUAGE TemplateHaskell   #-} +module Uplcg.Game +    ( PlayerId +    , Table (..) +    , Player (..) +    , Game (..) +    , gameLog, gameCards, gamePlayers, gameNextPlayerId + +    , newGame +    , joinGame +    , leaveGame + +    , processClientMessage + +    , gameViewForPlayer +    ) where + +import           Uplcg.Messages +import           Control.Lens                 (Lens', at, iall, ifor_, imap, ix, +                                               orOf, to, (%%=), (%=), (%~), (&), +                                               (+=), (.=), (.~), (^.), (^..), +                                               (^?), _1, _2, _3) +import           Control.Lens.TH              (makeLenses, makePrisms) +import           Control.Monad                (guard) +import           Control.Monad.State          (State, execState, modify, +                                               runState, state) +import           Data.Bifunctor               (first) +import           Data.Foldable                (for_) +import qualified Data.HashMap.Strict          as HMS +import           Data.List                    (sort) +import           Data.Maybe                   (fromMaybe) +import           Data.Ord                     (Down (..), comparing) +import           Data.Text                    (Text) +import qualified Data.Text                    as T +import qualified Data.Vector                  as V +import qualified Data.Vector.Algorithms.Merge as V +import           Data.Vector.Instances        () +import           System.Random                (StdGen) +import           VectorShuffling.Immutable    (shuffle) + +type PlayerId = Int + +type Proposal = V.Vector WhiteCard + +data Table +    = TableProposing +        !BlackCard +        !(HMS.HashMap PlayerId Proposal) +    | TableVoting +        !BlackCard +        !(V.Vector (Proposal, [PlayerId])) +        !(HMS.HashMap PlayerId Int) +    | TableTally +        !BlackCard +        !(V.Vector VotedView) +    deriving (Show) + +data Player = Player +    { _playerId     :: !PlayerId +    , _playerName   :: !Text +    , _playerHand   :: !(V.Vector WhiteCard) +    , _playerAdmin  :: !Bool +    , _playerPoints :: !Int +    } deriving (Show) + +data Game = Game +    { _gameCards        :: !Cards +    , _gameSeed         :: !StdGen +    , _gameLog          :: ![Text] +    , _gameBlack        :: ![BlackCard] +    , _gameWhite        :: ![WhiteCard] +    , _gamePlayers      :: !(HMS.HashMap PlayerId Player) +    , _gameTable        :: !Table +    , _gameNextPlayerId :: !Int +    } deriving (Show) + +makePrisms ''Table +makeLenses ''Player +makeLenses ''Game + +popCard +    :: (Cards -> V.Vector t) -> (Int -> c) -> Lens' Game [c] +    -> State Game c +popCard getDeck mk queue = state $ \game -> case game ^. queue of +    (x : xs) -> (x, game & queue .~ xs) +    []        -> +        let deck = game ^. gameCards . to getDeck +            idxs = V.imap (\i _ -> mk i) deck +            (cs, seed) = first V.toList $ shuffle idxs (game ^. gameSeed) in +        case cs of +            []     -> error "popCard: Cards are empty" +            x : xs -> (x, game & queue .~ xs & gameSeed .~ seed) + +popBlackCard :: State Game BlackCard +popBlackCard = popCard cardsBlack BlackCard gameBlack + +popWhiteCard :: State Game WhiteCard +popWhiteCard = popCard cardsWhite WhiteCard gameWhite + +newGame :: Cards -> StdGen -> Game +newGame cards gen = flip execState state0 $ do +    black <- popBlackCard +    gameTable .= TableProposing black HMS.empty +  where +    state0 = Game +        { _gameCards        = cards +        , _gameSeed         = gen +        , _gameLog          = [] +        , _gameBlack        = [] +        , _gameWhite        = [] +        , _gamePlayers      = HMS.empty +        , _gameTable        = TableProposing (BlackCard 0) HMS.empty +        , _gameNextPlayerId = 1 +        } + +defaultHandSize :: Int +defaultHandSize = 8 + +drawNewWhiteCards :: Game -> Game +drawNewWhiteCards game = flip execState game $ do +    ifor_ (game ^. gamePlayers) $ \pid player -> do +        let num = defaultHandSize - V.length (player ^. playerHand) +        new <- V.replicateM num popWhiteCard +        gamePlayers . ix pid . playerHand %= (<> new) + +assignAdmin :: Game -> Game +assignAdmin game +    -- Admin already assigned. +    | orOf (gamePlayers . traverse . playerAdmin) game = game +    -- Assign to first player +    | (p1 : _) <- sort (game ^. gamePlayers . to HMS.keys) = +        game & gamePlayers . ix p1 . playerAdmin .~ True +    -- No players +    | otherwise = game + +joinGame :: Maybe Player -> Game -> (PlayerId, Game) +joinGame mbPlayer = runState $ do +    player <- case mbPlayer of +        Nothing -> do +            pid <- gameNextPlayerId %%= (\x -> (x, x + 1)) +            let name = "Player " <> T.pack (show pid) +            hand <- V.replicateM defaultHandSize popWhiteCard +            pure $ Player pid name hand False 0 +        Just p -> pure $ p & playerAdmin .~ False +    gamePlayers %= HMS.insert (player ^. playerId) player +    modify assignAdmin +    pure $ player ^. playerId + +leaveGame :: PlayerId -> Game -> (Maybe Player, Game) +leaveGame pid game = case game ^? gamePlayers . ix pid of +    Nothing -> (Nothing, game) +    Just p  -> (Just p, assignAdmin $ game & gamePlayers %~ HMS.delete pid) + +blackCardBlanks :: Cards -> BlackCard -> Int +blackCardBlanks cards (BlackCard c) = +    maybe 0 (length . T.breakOnAll "\\BLANK") $ cardsBlack cards V.!? c + +maximaOn :: Ord o => (a -> o) -> [a] -> [a] +maximaOn f = \case [] -> []; x : xs -> go [x] (f x) xs +  where +    go best _         []       = reverse best +    go best bestScore (x : xs) = +        let score = f x in +        case compare score bestScore of +            LT -> go best bestScore xs +            EQ -> go (x : best) bestScore xs +            GT -> go [x] score xs + +tallyVotes +    :: Game +    -> (V.Vector (Proposal, [PlayerId])) +    -> (HMS.HashMap PlayerId Int) +    -> (V.Vector VotedView, [PlayerId]) +tallyVotes game shuffled votes = +    let counts :: HMS.HashMap Int Int  -- Index, votes received. +        counts = HMS.fromListWith (+) [(idx, 1) | (_, idx) <- HMS.toList votes] +        best = map fst . maximaOn snd $ HMS.toList counts in +    ( byScore $ V.imap (\i (proposal, players) -> VotedView +        { votedProposal = proposal +        , votedScore    = fromMaybe 0 $ HMS.lookup i counts +        , votedWinners  = V.fromList $ do +            guard $ i `elem` best +            p <- players +            game ^.. gamePlayers . ix p . playerName +        }) +        shuffled +    , [player | idx <- best, player <- snd $ shuffled V.! idx] +    ) +  where +    byScore = V.modify $ V.sortBy . comparing $ Down . votedScore + +-- | Create nice messages about the winners in the logs. +votedMessages :: Cards -> BlackCard -> V.Vector VotedView -> [T.Text] +votedMessages cards (BlackCard black) voteds = do +    voted <- V.toList voteds +    guard $ V.length (votedWinners voted) > 0 +    pure $ +        T.intercalate ", " (V.toList $ votedWinners voted) <> " won with " <> +        cardsBlack cards V.! black <> " | " <> +        T.intercalate " / " +            [ cardsWhite cards V.! i +            | WhiteCard i <- V.toList $ votedProposal voted +            ] + +stepGame :: Bool -> Game -> Game +stepGame skip game = case game ^. gameTable of +    TableProposing black proposals +        -- Everyone has proposed. +        | skip || iall (const . (`HMS.member` proposals)) (game ^. gamePlayers) -> +            let proposalsMap = HMS.fromListWith (++) $ do +                    (pid, proposal) <- HMS.toList proposals +                    pure (proposal, [pid]) +                (shuffled, seed) = shuffle +                    (V.fromList $ HMS.toList proposalsMap) (game ^. gameSeed) in +            -- There's a recursive call because in some one-player cases we +            -- skip the voting process entirely. +            stepGame False $ game +                & gameSeed .~ seed +                & gameTable .~ TableVoting black shuffled HMS.empty +                & gamePlayers %~ imap (\pid player -> +                    let used = fromMaybe V.empty $ HMS.lookup pid proposals in +                    player & playerHand %~ V.filter (not . (`V.elem` used))) +        | otherwise -> game + +    TableVoting black shuffled votes +        -- Everyone has voted. +        | skip || iall hasVoted (game ^. gamePlayers) -> +            let (voted, wins) = tallyVotes game shuffled votes in +            flip execState game $ do +            for_ wins $ \win -> gamePlayers . ix win . playerPoints += 1 +            gameTable .= TableTally black voted +            gameLog %= (votedMessages (game ^. gameCards) black voted ++) +        | otherwise -> game +      where +        hasVoted pid _ = HMS.member pid votes || +            -- The person cannot vote for anything since all the proposals +            -- are theirs.  This can happen when the game starts out with a +            -- single person. +            V.all (\(_, pids) -> pid `elem` pids) shuffled + +    TableTally _ _ -> game + +processClientMessage :: PlayerId -> ClientMessage -> Game -> Game +processClientMessage pid msg game = case msg of +    ChangeMyName name +        | T.length name > 32 -> game +        | otherwise -> game & gamePlayers . ix pid . playerName .~ name + +    ProposeWhiteCards cs +        -- Bad card(s) proposed, i.e. not in hand of player. +        | any (not . (`elem` hand)) cs -> game +        -- Proposal already made. +        | Just _ <- game ^? gameTable . _TableProposing . _2 . ix pid -> game +        -- Not enough cards submitted. +        | Just b <- game ^? gameTable . _TableProposing . _1 +        , blackCardBlanks (game ^. gameCards) b /= length cs -> game +        -- All good. +        | otherwise -> stepGame False $ +            game & gameTable . _TableProposing . _2 . at pid .~ Just cs + +    SubmitVote i -> case game ^. gameTable of +        TableProposing _ _ -> game +        TableTally _ _ -> game +        TableVoting _ shuffled votes +            -- Vote out of bounds. +            | i < 0 || i >= V.length shuffled -> game +            -- Already voted. +            | pid `HMS.member` votes -> game +            -- Can't vote for self. +            | pid `elem` snd (shuffled V.! i) -> game +            -- Ok vote. +            | otherwise -> stepGame False $ game +                & gameTable . _TableVoting . _3 . at pid .~ Just i + +    AdminConfirmTally +        | TableTally _ _ <- game ^. gameTable, admin -> +            flip execState game $ do +            black <- popBlackCard +            gameTable .= TableProposing black HMS.empty +            modify drawNewWhiteCards +        | otherwise -> game + +    AdminSkipProposals +        | TableProposing _ _ <- game ^. gameTable, admin -> stepGame True $ +            game & gameLog %~ ("Admin skipped proposals" :) +        | otherwise -> game + +    AdminSkipVotes +        | TableVoting _ _ _ <- game ^. gameTable, admin -> stepGame True $ +            game & gameLog %~ ("Admin skipped votes" :) +        | otherwise -> game +  where +    hand  = game ^.. gamePlayers . ix pid . playerHand . traverse +    admin = fromMaybe False $ game ^? gamePlayers . ix pid . playerAdmin + +gameViewForPlayer :: PlayerId -> Game -> GameView +gameViewForPlayer self game = +    let playerView pid player = PlayerView +            { playerViewName = player ^. playerName +            , playerViewAdmin = player ^. playerAdmin +            , playerViewReady = case game ^. gameTable of +                TableProposing _ proposals -> HMS.member pid proposals +                TableVoting _ _ votes      -> HMS.member pid votes +                TableTally _ _             -> False +            , playerViewPoints = player ^. playerPoints +            } + +        table = case game ^. gameTable of +            TableProposing black proposals -> +                Proposing black . fromMaybe V.empty $ HMS.lookup self proposals +            TableVoting black shuffled votes -> Voting +                black +                (fst <$> shuffled) +                (V.findIndex ((self `elem`) . snd) shuffled) +                (HMS.lookup self votes) +            TableTally black voted -> Tally black voted in +    GameView +        { gameViewPlayers = V.fromList . map snd . HMS.toList +            . HMS.delete self . imap playerView $ game ^. gamePlayers +        , gameViewMe      = maybe dummy (playerView self) $ +            game ^? gamePlayers . ix self +        , gameViewTable   = table +        , gameViewHand    = fromMaybe V.empty $ +            game ^? gamePlayers . ix self . playerHand +        } + +  where +    dummy = PlayerView "" False False 0 diff --git a/server/lib/Uplcg/Main/GenerateElmTypes.hs b/server/lib/Uplcg/Main/GenerateElmTypes.hs new file mode 100644 index 0000000..bc2481c --- /dev/null +++ b/server/lib/Uplcg/Main/GenerateElmTypes.hs @@ -0,0 +1,22 @@ +{-# OPTIONS_GHC -fno-warn-orphans #-} +{-# LANGUAGE TemplateHaskell #-} +module Uplcg.Main.GenerateElmTypes +    ( main +    ) where + +import           Uplcg.Messages +import           Data.Proxy +import           Elm.Module + +main :: IO () +main = putStrLn $ makeElmModule "Messages" +    [ DefineElm (Proxy :: Proxy BlackCard) +    , DefineElm (Proxy :: Proxy WhiteCard) +    , DefineElm (Proxy :: Proxy Cards) +    , DefineElm (Proxy :: Proxy PlayerView) +    , DefineElm (Proxy :: Proxy VotedView) +    , DefineElm (Proxy :: Proxy TableView) +    , DefineElm (Proxy :: Proxy GameView) +    , DefineElm (Proxy :: Proxy ServerMessage) +    , DefineElm (Proxy :: Proxy ClientMessage) +    ] diff --git a/server/lib/Uplcg/Main/Server.hs b/server/lib/Uplcg/Main/Server.hs new file mode 100644 index 0000000..a2914ab --- /dev/null +++ b/server/lib/Uplcg/Main/Server.hs @@ -0,0 +1,214 @@ +{-# LANGUAGE OverloadedStrings #-} +module Uplcg.Main.Server +    ( main +    ) where + +import           Control.Concurrent.MVar        (MVar) +import qualified Control.Concurrent.MVar        as MVar +import           Control.Concurrent.STM         (STM, TVar, atomically) +import qualified Control.Concurrent.STM         as STM +import           Control.Exception              (bracket) +import           Control.Lens                   ((&), (.~), (^.)) +import           Control.Monad                  (forever, when) +import qualified Data.Aeson                     as Aeson +import qualified Data.ByteString                as B +import qualified Data.ByteString.Lazy           as BL +import           Data.Char                      (isAlphaNum) +import           Data.Foldable                  (for_) +import qualified Data.HashMap.Strict            as HMS +import qualified Data.List                      as L +import           Data.Maybe                     (fromMaybe, isNothing) +import           Data.String                    (fromString) +import qualified Data.Text                      as T +import qualified Data.Text.Encoding             as T +import qualified Data.Text.IO                   as T +import qualified Data.Vector                    as V +import qualified Network.Wai                    as Wai +import qualified Network.Wai.Handler.Warp       as Warp +import qualified Network.Wai.Handler.WebSockets as WaiWs +import qualified Network.WebSockets             as WS +import           System.Environment             (getEnv) +import qualified System.Log.FastLogger          as FL +import           System.Random                  (StdGen, newStdGen) +import qualified Uplcg.CookieSocket             as CookieSocket +import           Uplcg.Game +import           Uplcg.Messages +import qualified Web.Scotty                     as Scotty + +type RoomId = T.Text + +type Sink = BL.ByteString -> IO () + +data Room = Room +    { roomId    :: RoomId +    , roomGame  :: TVar Game +    , roomSinks :: TVar (HMS.HashMap PlayerId Sink) +    } + +data Server = Server +    { serverLogger       :: FL.FastLogger +    , serverCookieSocket :: CookieSocket.Handle Player +    , serverCards        :: Cards +    , serverRooms        :: MVar (HMS.HashMap RoomId Room) +    } + +readCards :: IO Cards +readCards = Cards +    <$> fmap parseCards (T.readFile "assets/black.txt") +    <*> fmap parseCards (T.readFile "assets/white.txt") +  where +    parseCards  = V.fromList . filter (not . T.null) . map dropComment . T.lines +    dropComment = T.strip . fst . T.break (== '#') + +withServer :: FL.FastLogger -> (Server -> IO a) -> IO a +withServer fl f = CookieSocket.withHandle 5 $ \cs -> do +    f =<< Server fl cs <$> readCards <*> MVar.newMVar HMS.empty + +newRoom :: RoomId -> Cards -> StdGen -> STM Room +newRoom rid cards gen = Room rid +    <$> STM.newTVar (newGame cards gen) +    <*> STM.newTVar HMS.empty + +parseRoomId :: T.Text -> Either String T.Text +parseRoomId txt +    | T.all isAlphaNum txt && T.length txt >= 6 = Right txt +    | otherwise                                 = Left "Bad room name" + +scottyApp :: IO Wai.Application +scottyApp = Scotty.scottyApp $ do +    Scotty.get "/rooms/:id/" $ do +        rid <- Scotty.param "id" +        when (T.length rid < 6) $ +            Scotty.raise "Room ID should be at least 6 characters" +        Scotty.setHeader "Content-Type" "text/html" +        Scotty.file "assets/client.html" + +    Scotty.get "/assets/client.js" $ do +        Scotty.setHeader "Content-Type" "application/JavaScript" +        Scotty.file "assets/client.js" + +    Scotty.get "/assets/style.css" $ do +        Scotty.setHeader "Content-Type" "text/css" +        Scotty.file "assets/style.css" + +routePendingConnection :: WS.PendingConnection -> Maybe RoomId +routePendingConnection pending = +    let path = T.decodeUtf8 . WS.requestPath $ WS.pendingRequest pending in +    case splitPath path of +        ["rooms", txt, "events"] | Right r <- parseRoomId txt -> Just r +        _                                                     -> Nothing + +getOrCreateRoom :: Server -> RoomId -> IO Room +getOrCreateRoom server rid = MVar.modifyMVar (serverRooms server) $ \rooms -> +    case HMS.lookup rid rooms of +        Just room -> pure (rooms, room) +        Nothing   -> do +            gen <- newStdGen +            serverLogger server $ "[" <> FL.toLogStr rid <> "] Created room" +            room <- atomically $ newRoom rid (serverCards server) gen +            pure (HMS.insert rid room rooms, room) + +deleteRoom :: Server -> RoomId -> IO () +deleteRoom server rid = do +    serverLogger server $ "[" <> FL.toLogStr rid <> "] Deleting room" +    MVar.modifyMVar_ (serverRooms server) $ pure . HMS.delete rid + +joinRoom :: Room -> Sink -> Maybe Player -> STM PlayerId +joinRoom room sink mbRecovered = do +    pid <- STM.stateTVar (roomGame room) $ joinGame mbRecovered +    STM.modifyTVar' (roomSinks room) $ HMS.insert pid sink +    pure pid + +leaveRoom :: Room -> PlayerId -> STM (Bool, Maybe Player) +leaveRoom room pid = do +    player <- STM.stateTVar (roomGame room) $ leaveGame pid +    STM.stateTVar (roomSinks room) $ \sinks -> +        let sinks' = HMS.delete pid sinks in +        ((HMS.null sinks', player), sinks') + +syncRoom :: Server -> Room -> IO () +syncRoom server room = do +    (game, sinks) <- atomically $ (,) +        <$> STM.stateTVar (roomGame room) (\g -> (g, g & gameLog .~ [])) +        <*> STM.readTVar (roomSinks room) +    for_ (reverse $ game ^. gameLog) $ \msg -> +        serverLogger server $ "[" <> FL.toLogStr (roomId room) <> "] " <> +        FL.toLogStr msg +    for_ (HMS.toList sinks) $ \(pid, sink) -> do +        let view = gameViewForPlayer pid game +        sink . Aeson.encode $ SyncGameView view + +wsApp :: Server -> WS.ServerApp +wsApp server pc = case routePendingConnection pc of +    Nothing -> WS.rejectRequest pc "Invalid URL" +    Just rid -> do +        room <- getOrCreateRoom server rid +        (conn, secret, mbRecovered) <- +            CookieSocket.acceptRequest (serverCookieSocket server) rid pc +        let sink = WS.sendTextData conn +        WS.withPingThread conn 30 (pure ()) $ bracket +            (do +                pid <- atomically $ joinRoom room sink mbRecovered +                serverLogger server $ "[" <> FL.toLogStr rid <> +                    "] Player " <> FL.toLogStr pid <> +                    if isNothing mbRecovered then " joined" else " rejoined" +                pure pid) +            (\pid -> do +                (roomEmpty, mbPlayer) <- atomically $ leaveRoom room pid +                serverLogger server $ "[" <> FL.toLogStr rid <> +                    "] Player " <> FL.toLogStr pid <> " left" +                if roomEmpty +                    then deleteRoom server rid +                    else do +                        for_ mbPlayer $ CookieSocket.persist +                            (serverCookieSocket server) secret +                        syncRoom server room) +            (\playerId -> do +                sink . Aeson.encode $ Welcome rid +                syncRoom server room +                cards <- fmap (^. gameCards) . atomically . STM.readTVar $ +                    roomGame room +                sink . Aeson.encode $ SyncCards cards +                loop conn rid playerId) +  where +    loop conn rid playerId = forever $ do +        msg <- WS.receiveData conn +        case Aeson.decode msg of +            Just cm -> do +                room <- getOrCreateRoom server rid  -- TODO: only get? +                atomically . STM.modifyTVar' (roomGame room) $ +                    processClientMessage playerId cm +                syncRoom server room +            Nothing -> do +                serverLogger server $ "Could not decode client message: " <> +                    FL.toLogStr (show msg) + +splitPath :: T.Text -> [T.Text] +splitPath = filter (not . T.null) . T.split (== '/') + +baseUrl :: [T.Text] -> Wai.Middleware +baseUrl prefix application = \req -> +    case L.stripPrefix prefix (Wai.pathInfo req) of +        Nothing   -> application req +        Just path -> application req +            { Wai.pathInfo = path +            , Wai.rawPathInfo = fromMaybe (Wai.rawPathInfo req) . +                B.stripPrefix bs $ Wai.rawPathInfo req +            } +  where +    bs = T.encodeUtf8 $ "/" <> T.intercalate "/" prefix + +main :: IO () +main = do +    host <- fromString <$> getEnv "UPLCG_HOSTNAME" +    port <- read <$> getEnv "UPLCG_PORT" +    base <- splitPath . T.pack <$> getEnv "UPLCG_BASE" +    let settings = Warp.setPort port . Warp.setHost host $ Warp.defaultSettings +    timeCache <- FL.newTimeCache FL.simpleTimeFormat +    FL.withTimedFastLogger timeCache +            (FL.LogStderr FL.defaultBufSize) $ \tfl -> +        let fl s = tfl (\time -> FL.toLogStr time <> " " <> s <> "\n") in +        withServer fl $ \server -> do +        sapp <- scottyApp +        Warp.runSettings settings $ baseUrl base $ +            WaiWs.websocketsOr WS.defaultConnectionOptions (wsApp server) sapp diff --git a/server/lib/Uplcg/Messages.hs b/server/lib/Uplcg/Messages.hs new file mode 100644 index 0000000..b1627e9 --- /dev/null +++ b/server/lib/Uplcg/Messages.hs @@ -0,0 +1,87 @@ +{-# LANGUAGE DeriveGeneric   #-} +{-# LANGUAGE TemplateHaskell #-} +module Uplcg.Messages +    ( BlackCard (..) +    , WhiteCard (..) +    , Cards (..) +    , PlayerView (..) +    , VotedView (..) +    , TableView (..) +    , GameView (..) +    , ServerMessage (..) +    , ClientMessage (..) +    ) where + +import           Data.Hashable (Hashable) +import           Data.Text     (Text) +import           Data.Vector   (Vector) +import           Elm.Derive +import           GHC.Generics  (Generic) + +data BlackCard = BlackCard Int deriving (Eq, Generic, Show) + +instance Hashable BlackCard + +data WhiteCard = WhiteCard Int deriving (Eq, Generic, Show) + +instance Hashable WhiteCard + +data Cards = Cards +    { cardsBlack :: !(Vector Text) +    , cardsWhite :: !(Vector Text) +    } deriving (Show) + +data PlayerView = PlayerView +    { playerViewName   :: !Text +    , playerViewAdmin  :: !Bool +    , playerViewReady  :: !Bool +    , playerViewPoints :: !Int +    } deriving (Show) + +data VotedView = VotedView +    { votedProposal :: !(Vector WhiteCard) +    , votedScore    :: !Int +    , votedWinners  :: !(Vector Text) +    } deriving (Show) + +data TableView +    = Proposing !BlackCard !(Vector WhiteCard) +    | Voting +        !BlackCard +        !(Vector (Vector WhiteCard))  -- ^ Proposals to vote for +        !(Maybe Int)                  -- ^ My proposal +        !(Maybe Int)                  -- ^ My vote +    | Tally !BlackCard !(Vector VotedView) +    deriving (Show) + +data GameView = GameView +    { gameViewPlayers :: !(Vector PlayerView) +    , gameViewMe      :: !PlayerView +    , gameViewTable   :: !TableView +    , gameViewHand    :: !(Vector WhiteCard) +    } deriving (Show) + +data ServerMessage +    = Welcome !Text +    | SyncCards !Cards +    | SyncGameView !GameView +    deriving (Show) + +data ClientMessage +    = ChangeMyName !Text +    | ProposeWhiteCards !(Vector WhiteCard) +    | SubmitVote !Int +    | AdminSkipProposals +    | AdminSkipVotes +    | AdminConfirmTally +    deriving (Show) + +deriveBoth defaultOptions ''BlackCard +deriveBoth defaultOptions ''WhiteCard +deriveBoth (defaultOptionsDropLower 5) ''Cards +deriveBoth (defaultOptionsDropLower 10) ''PlayerView +deriveBoth (defaultOptionsDropLower 5) ''VotedView +deriveBoth defaultOptions ''TableView +deriveBoth (defaultOptionsDropLower 8) ''GameView +deriveBoth defaultOptions ''ServerMessage +deriveBoth defaultOptions ''ClientMessage  | 
