aboutsummaryrefslogtreecommitdiff
path: root/server/lib/Cafp/Main/Server.hs
blob: 9ded57192c7312aa46b0b17c50188574b2d3b7f6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
{-# LANGUAGE OverloadedStrings #-}
module Cafp.Main.Server
    ( main
    ) where

import           Cafp.Game
import           Cafp.Messages
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.Foldable                  (for_)
import qualified Data.HashMap.Strict            as HMS
import qualified Data.List                      as L
import           Data.Maybe                     (fromMaybe)
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.IO                      as IO
import           System.Random                  (StdGen, newStdGen)
import qualified Web.Scotty                     as Scotty

warning :: String -> IO ()
warning = IO.hPutStrLn IO.stderr

type RoomId = T.Text

type Sink = BL.ByteString -> IO ()

data Room = Room
    { roomGame  :: TVar Game
    , roomSinks :: TVar (HMS.HashMap PlayerId Sink)
    }

data Server = Server
    { 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 (== '#')

newServer :: IO Server
newServer = Server <$> readCards <*> MVar.newMVar HMS.empty

newRoom :: Server -> StdGen -> STM Room
newRoom server gen = Room
    <$> (STM.newTVar $ newGame (serverCards server) gen)
    <*> STM.newTVar HMS.empty


scottyApp :: IO Wai.Application
scottyApp = Scotty.scottyApp $ do
    Scotty.get "/rooms/:id/" $ do
        roomId <- Scotty.param "id"
        when (T.length roomId < 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", roomId, "events"] -> Just roomId
        _                           -> Nothing

getOrCreateRoom :: Server -> RoomId -> IO Room
getOrCreateRoom server roomId = MVar.modifyMVar (serverRooms server) $ \rooms ->
    case HMS.lookup roomId rooms of
        Just room -> pure (rooms, room)
        Nothing   -> do
            gen <- newStdGen
            room <- atomically $ newRoom server gen
            pure (HMS.insert roomId room rooms, room)

joinRoom :: Room -> Sink -> STM PlayerId
joinRoom room sink = do
    pid <- STM.stateTVar (roomGame room) joinGame
    STM.modifyTVar' (roomSinks room) $ HMS.insert pid sink
    pure pid

leaveRoom :: Room -> PlayerId -> STM ()
leaveRoom room pid = do
    STM.modifyTVar' (roomGame room) $ leaveGame pid
    STM.modifyTVar' (roomSinks room) $ HMS.delete pid

syncRoom :: Room -> IO ()
syncRoom room = do
    (game, sinks) <- atomically $ (,)
        <$> STM.readTVar (roomGame room)
        <*> STM.readTVar (roomSinks room)
    for_ (HMS.toList sinks) $ \(pid, sink) -> do
        let view = gameViewForPlayer pid game
        warning $ "New state: " ++ show view
        sink . Aeson.encode $ SyncGameView view

wsApp :: Server -> WS.ServerApp
wsApp server pc = case routePendingConnection pc of
    Nothing -> WS.rejectRequest pc "Invalid URL"
    Just roomId -> do
        room <- getOrCreateRoom server roomId
        conn <- WS.acceptRequest pc
        let sink = WS.sendTextData conn
        WS.withPingThread conn 30 (pure ()) $ bracket
            (atomically $ joinRoom room sink)
            (\playerId -> do
                atomically $ leaveRoom room playerId
                syncRoom room)
            (\playerId -> do
                syncRoom room
                cards <- fmap (^. gameCards) . atomically . STM.readTVar $
                    roomGame room
                sink . Aeson.encode $ SyncCards cards
                loop conn roomId playerId)
  where
    loop conn roomId playerId = forever $ do
        msg <- WS.receiveData conn
        case Aeson.decode msg of
            Just cm -> do
                warning $ "Client: " ++ show cm
                room <- getOrCreateRoom server roomId  -- TODO: only get?
                atomically . STM.modifyTVar' (roomGame room) $
                    processClientMessage playerId cm
                syncRoom room
            Nothing -> do
                warning $ "Could not decode client message: " ++ 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 "CAFP_HOSTNAME"
    port <- read <$> getEnv "CAFP_PORT"
    base <- splitPath . T.pack <$> getEnv "CAFP_BASE"
    let settings = Warp.setPort port . Warp.setHost host $ Warp.defaultSettings
    server <- newServer
    sapp <- scottyApp
    Warp.runSettings settings $ baseUrl base $
        WaiWs.websocketsOr WS.defaultConnectionOptions (wsApp server) sapp