Compare commits

..

No commits in common. "stateless-saltine" and "main" have entirely different histories.

28 changed files with 654 additions and 919 deletions

View File

@ -1,4 +1,4 @@
# Revision history for hanafuda-server
# Revision history for hanafudapi
## 0.2.3.0 -- 2019-08-24

View File

@ -1,11 +1,11 @@
-- Initial hanafuda-server.cabal generated by cabal init. For further
-- Initial hanafudapi.cabal generated by cabal init. For further
-- documentation, see http://haskell.org/cabal/users-guide/
name: hanafuda-server
name: hanafuda-webapp
version: 0.2.3.0
synopsis: A webapp for the Haskell hanafuda library
-- description:
homepage: https://git.marvid.fr/hanafuda/server
homepage: https://git.marvid.fr/hanafuda
license: BSD3
license-file: LICENSE
author: Tissevert
@ -17,17 +17,15 @@ extra-source-files: ChangeLog.md
cabal-version: >=1.10
source-repository head
type: git
location: https://git.marvid.fr/hanafuda/server
location: https://git.marvid.fr/hanafuda/webapp
executable hanafudapi
main-is: Main.hs
other-modules: App
, Automaton
, Config
, Game
, Keys
, Messaging
, Player
, Game
, RW
, Server
, Session
@ -35,15 +33,12 @@ executable hanafudapi
build-depends: base >=4.9 && <4.13
, bytestring
, containers >= 0.5.9
, directory
, filepath
, unordered-containers
, hanafuda >= 0.3.3
, hanafuda-protocol >= 0.1.0
, hanafuda-APILanguage >= 0.1.0
, http-types
, aeson
, mtl
, random
, saltine
, text
, vector
, wai

View File

@ -2,51 +2,63 @@
module App (
T
, Context(..)
, connection
, debug
, exec
, get
, player
, session
, current
, server
, try
, update
, update_
) where
import Control.Concurrent (MVar, modifyMVar, readMVar)
import Control.Concurrent (MVar, modifyMVar, putMVar, readMVar, takeMVar)
import Control.Monad.Reader (ReaderT(..), ask, asks, lift)
import Data.Map ((!), (!?))
import qualified Player (T)
import Data.Map ((!))
import Hanafuda.KoiKoi (PlayerID)
import Network.WebSockets (Connection)
import qualified Server (T(..))
import qualified Session (ID, T(..))
import qualified Session (T(..))
data Context = Context {
mServer :: MVar Server.T
, sessionID :: Session.ID
, playerID :: PlayerID
}
type T a = ReaderT Context IO a
get :: (Server.T -> a) -> T a
get projector =
lift . fmap projector . readMVar =<< asks mServer
server :: T Server.T
server = asks mServer >>= lift . readMVar
session :: T Session.T
session = do
Context {sessionID} <- ask
get $ (! sessionID) . Server.sessions
get :: PlayerID -> T Session.T
get playerID =
(! playerID) . Server.sessions <$> server
player :: T (Maybe Player.T)
player = do
Context {sessionID} <- ask
get $ (Session.player =<<) . (!? sessionID) . Server.sessions
current :: T Session.T
current = do
asks playerID >>= get
connection :: T Connection
connection = Session.connection <$> current
debug :: String -> T ()
debug message =
show <$> asks sessionID
show <$> asks playerID
>>= lift . putStrLn . (++ ' ':message)
exec :: (Server.T -> IO (Server.T, a)) -> T a
exec f = do
try :: (Server.T -> Either String Server.T) -> T (Maybe String)
try f = do
Context {mServer} <- ask
lift $ modifyMVar mServer f
currentValue <- lift $ takeMVar mServer
lift $ case f currentValue of
Left message -> putMVar mServer currentValue >> return (Just message)
Right updated -> putMVar mServer updated >> return Nothing
update :: (Server.T -> Server.T) -> T ()
update f = exec $ (\x -> return (x, ())) . f
{- Not using the previous to minimize the duration mServer gets locked -}
update :: (Server.T -> (Server.T, a)) -> T a
update f = do
Context {mServer} <- ask
lift $ modifyMVar mServer (return . f)
update_ :: (Server.T -> Server.T) -> T ()
update_ f = update $ (\x -> (x, ())) . f

View File

@ -1,91 +1,110 @@
{-# LANGUAGE NamedFieldPuns #-}
module Automaton (
loop
start
) where
import qualified App (Context(..), T, exec, get, player, update)
import qualified App (Context(..), T, current, debug, get, server, try, update_)
import Control.Monad.Reader (asks)
import Data.Map ((!))
import qualified Game (fromPublic, new, play, toPublic)
import qualified Hanafuda.KoiKoi as KoiKoi (Game(..))
import qualified Hanafuda.Message as Message (
FromClient(..), PublicGame(..), T(..)
import Data.Map (Map, (!?))
import qualified Game (new, play)
import qualified Hanafuda.KoiKoi as KoiKoi (
Game, GameBlueprint(..), GameID, Step(..)
)
import qualified Hanafuda.Message as Message (FromClient(..), T(..))
import qualified Messaging (
broadcast, get, notifyPlayers, relay, send, sendTo
broadcast, get, notifyPlayers, relay, send, sendTo, update
)
import qualified Player (T(..))
import qualified Server (T(..), logIn, register, room, update)
import qualified Session (Status, T(..), setPlayer)
import qualified RW (RW(..))
import qualified Server (endGame, get, logIn, logOut, update, room)
import qualified Session (Status(..), T(..), Update)
receive :: Message.FromClient -> Session.Status -> App.T ()
receive :: Session.Status -> Message.FromClient -> App.T ()
receive (Message.Hello {Message.name}) Nothing = do
sessionID <- asks App.sessionID
playerID <- App.exec (Server.register [sessionID])
room <- App.get Server.room
Messaging.send $ Message.Welcome room playerID
App.update (Server.update sessionID $ Session.setPlayer playerID name)
Messaging.broadcast $ Message.LogIn playerID name
receive (Session.LoggedIn False) logIn@(Message.LogIn login) =
asks App.playerID >>= App.try . (Server.logIn login)
>>= maybe
(Messaging.relay logIn Messaging.broadcast >> setSessionStatus (Session.LoggedIn True))
sendError
receive (Message.Hello {Message.name}) (Just player) = do
sessionIDs <- (! playerID) <$> App.get Server.sessionIDsByPlayerID
App.update (\server -> foldr (flip Server.update setName) server sessionIDs)
Messaging.broadcast $ Message.LogIn playerID name
where
playerID = Player.playerID player
setName session = session {Session.player = Just $ player {Player.name}}
receive (Session.LoggedIn True) logOut@Message.LogOut = do
Messaging.relay logOut Messaging.broadcast
asks App.playerID >>= App.update_ . Server.logOut
setSessionStatus (Session.LoggedIn False)
receive (Message.Tadaima {Message.myID, Message.name}) Nothing = do
sessionID <- asks App.sessionID
Message.Okaeri <$> App.get Server.room >>= Messaging.send
App.update $ Server.logIn name myID sessionID
Messaging.broadcast $ Message.LogIn myID name
receive (Session.LoggedIn True) invitation@(Message.Invitation {Message.to}) = do
session <- App.get to
case Session.status session of
Session.LoggedIn True -> do
from <- asks App.playerID
App.update_ (Server.update to (RW.set $ Session.Answering from :: Session.Update))
Messaging.broadcast $ Messaging.update {Message.paired = [from, to]}
(Messaging.relay invitation $ Messaging.sendTo [to])
setSessionStatus (Session.Waiting to)
_ -> sendError "They just left"
receive (Message.Tadaima {}) (Just _) = sendError "You're already logged in"
receive (Session.Answering to) message@(Message.Answer {Message.accept}) = do
session <- App.get to
playerID <- asks App.playerID
case Session.status session of
Session.Waiting for | for == playerID -> do
Messaging.relay message $ Messaging.sendTo [to]
newStatus <-
if accept
then do
gameID <- Game.new (for, to)
game <- Server.get gameID <$> App.server
Messaging.notifyPlayers game []
return $ Session.Playing gameID
else do
Messaging.broadcast $ Messaging.update {Message.alone = [for, to]}
return $ Session.LoggedIn True
App.update_ $ Server.update to (RW.set newStatus :: Session.Update)
setSessionStatus newStatus
_ -> sendError "They're not waiting for your answer"
receive invitation@(Message.Invitation {}) (Just _) = relay invitation
receive (Session.Playing gameID) played@(Message.Play {}) = do
playerID <- asks App.playerID
game <- Server.get gameID <$> App.server
(result, logs) <- Game.play playerID (Message.move played) game
case result of
Left message -> sendError message
Right newGame -> do
case KoiKoi.step newGame of
KoiKoi.Over -> do
App.debug $ "Game " ++ show gameID ++ " ended"
App.update_ $ Server.endGame gameID
_ -> App.update_ $ Server.update gameID (const newGame)
Messaging.notifyPlayers newGame logs
receive answer@(Message.Answer {Message.accept, Message.to}) (Just player) =
if accept
then do
publicGames <- Game.new (Player.playerID player, to)
Messaging.relay answer (Messaging.sendTo [to])
Messaging.notifyPlayers (publicGames, [])
else Messaging.relay answer (Messaging.sendTo [to])
receive (Session.Playing gameID) Message.Quit = do
games <- (RW.get <$> App.server :: App.T (Map KoiKoi.GameID KoiKoi.Game))
case games !? gameID of
Nothing -> do
playerID <- asks App.playerID
Messaging.broadcast $ Messaging.update {Message.alone = [playerID]}
setSessionStatus (Session.LoggedIn True)
_ -> sendError "Game is still running"
receive (Message.Play {Message.move, Message.onGame}) (Just player) =
Game.play (Player.playerID player) move onGame
>>= either sendError Messaging.notifyPlayers
receive sync@(Message.Sync {}) (Just _) = relay sync
receive yield@(Message.Yield {}) (Just _) = relay yield
receive (Message.Share {Message.gameSave}) (Just player) =
either sendError share =<< Game.fromPublic gameSave
where
logs = Message.logs gameSave
share game =
let recipientID = KoiKoi.nextPlayer game ! (Player.playerID player) in
Game.toPublic recipientID game logs
>>= Messaging.sendTo [recipientID] . Message.Game
receive message state =
sendError $ "Invalid message " ++ show message ++ " in " ++ showState
where
showState =
case state of
Nothing -> "disconnected state"
Just _ -> "connected state"
relay :: Message.FromClient -> App.T ()
relay message = Messaging.relay message (Messaging.sendTo [Message.to message])
receive state _ = sendError $ "Invalid message in state " ++ show state
sendError :: String -> App.T ()
sendError = Messaging.send . Message.Error
setSessionStatus :: Session.Status -> App.T ()
setSessionStatus newStatus = do
playerID <- asks App.playerID
App.update_ $ Server.update playerID $ (RW.set newStatus :: Session.Update)
App.debug $ show newStatus
loop :: App.T ()
loop = do
message <- Messaging.get
receive message =<< App.player
status <- Session.status <$> App.current
status `receive` message
loop
start :: App.T ()
start = do
App.debug "Initial state"
Message.Welcome . Server.room <$> App.server <*> asks App.playerID >>= Messaging.send
loop

View File

@ -1,10 +1,6 @@
module Config (
libDir
, listenPort
listenPort
) where
libDir :: FilePath
libDir = "/var/lib/hanafuda-server"
listenPort :: Int
listenPort = 3000

View File

@ -1,154 +1,40 @@
{-# LANGUAGE NamedFieldPuns #-}
module Game (
fromPublic
export
, new
, play
, toPublic
) where
import qualified App (T, get)
import Control.Monad.Except (runExceptT)
import qualified App (T, update)
import Control.Monad.Except (runExceptT, throwError)
import Control.Monad.Reader (lift)
import Control.Monad.Writer (runWriterT)
import qualified Crypto.Saltine.Class as Saltine (IsEncoding(..))
import Crypto.Saltine.Core.SecretBox (newNonce, secretbox, secretboxOpen)
import Crypto.Saltine.Core.Sign (signDetached, signVerifyDetached)
import Data.Aeson (ToJSON, eitherDecode', encode)
import Data.ByteString (ByteString)
import Data.ByteString.Lazy (fromStrict, toStrict)
import Data.Map ((!), Map, mapWithKey)
import qualified Hanafuda (Pack)
import Hanafuda.KoiKoi (Game, Mode(..), Player, PlayerID, Players)
import Data.Map (mapWithKey)
import qualified Hanafuda (empty)
import Hanafuda.KoiKoi (Game, GameBlueprint(..), GameID, Mode(..), PlayerID)
import qualified Hanafuda.KoiKoi as KoiKoi (
Action, Game(..), Move(..), play, new
Action, Move(..), play, new
)
import Hanafuda.Message (
Coordinates(..), PrivateState(..), PublicGame(..), PublicPlayer(..)
, PublicState(..)
)
import qualified Hanafuda.Player as Player (Player(..), Players(..), get)
import Keys (T(..))
import qualified Keys (public, secret)
import qualified Server (T(..))
import Hanafuda.Message (PublicGame)
import qualified Hanafuda.Player (Player(..), Players(..))
import qualified Server (register)
new :: (PlayerID, PlayerID) -> App.T Game
new (for, to) = lift $ KoiKoi.new (for, to) WholeYear
new :: (PlayerID, PlayerID) -> App.T GameID
new (for, to) =
Server.register <$> (lift $ KoiKoi.new (for, to) WholeYear) >>= App.update
exportPlayers :: Game -> Map PlayerID Player
exportPlayers game =
let (Player.Players players) = KoiKoi.players game in
players
getCoordinates :: Game -> Coordinates
getCoordinates game = Coordinates {
gameID = KoiKoi.gameID game
, month = KoiKoi.month game
, turn = 24 - length (KoiKoi.deck game)
}
privateState :: Coordinates -> Game -> PrivateState
privateState link game = PrivateState {
link
, hands = Player.hand <$> players
, deck = KoiKoi.deck game
export :: PlayerID -> Game -> PublicGame
export playerID game = game {
deck = length $ deck game
, players = Hanafuda.Player.Players $ mapWithKey maskOpponentsHand unfiltered
}
where
Player.Players players = KoiKoi.players game
Hanafuda.Player.Players unfiltered = Hanafuda.KoiKoi.players game
maskOpponentsHand k player
| k == playerID = player
| otherwise = player {Hanafuda.Player.hand = Hanafuda.empty}
getHand :: PlayerID -> Players -> Hanafuda.Pack
getHand playerID = Player.hand . (Player.get playerID)
publicPlayer :: Player -> PublicPlayer
publicPlayer player = PublicPlayer {
meld = Player.meld player
, yakus = Player.yakus player
}
privatePlayer :: Map PlayerID PublicPlayer -> PlayerID -> Hanafuda.Pack -> Player
privatePlayer publicPlayers playerID hand = Player.Player {
Player.hand
, Player.meld = meld (publicPlayers ! playerID)
, Player.yakus = yakus (publicPlayers ! playerID)
}
publicState :: Coordinates -> Game -> PublicState
publicState coordinates game = PublicState {
coordinates
, mode = KoiKoi.mode game
, scores = KoiKoi.scores game
, nextPlayer = KoiKoi.nextPlayer game
, players = publicPlayer <$> exportPlayers game
, playing = KoiKoi.playing game
, winning = KoiKoi.winning game
, oyake = KoiKoi.oyake game
, river = KoiKoi.river game
, step = KoiKoi.step game
, trick = KoiKoi.trick game
, rounds = KoiKoi.rounds game
}
toPublic :: PlayerID -> Game -> [KoiKoi.Action] -> App.T PublicGame
toPublic playerID game logs = do
Keys.T {encrypt, sign} <- App.get Server.keys
n <- lift newNonce
return $ PublicGame {
nonce = Saltine.encode n
, logs
, playerHand = getHand playerID (KoiKoi.players game)
, private = secretbox encrypt n $ toJSON private
, public
, publicSignature = signDetached (Keys.secret sign) $ toJSON public
}
where
shared = getCoordinates game
public = publicState shared game
private = privateState shared game
toJSON :: ToJSON a => a -> ByteString
toJSON = toStrict . encode
merge :: PublicState -> PrivateState -> Game
merge public private = KoiKoi.Game {
KoiKoi.gameID = gameID $ coordinates public
, KoiKoi.mode = mode public
, KoiKoi.scores = scores public
, KoiKoi.month = month $ coordinates public
, KoiKoi.nextPlayer = nextPlayer public
, KoiKoi.players = Player.Players $
mapWithKey (privatePlayer $ players public) (hands private)
, KoiKoi.playing = playing public
, KoiKoi.winning = winning public
, KoiKoi.oyake = oyake public
, KoiKoi.deck = deck private
, KoiKoi.river = river public
, KoiKoi.step = step public
, KoiKoi.trick = trick public
, KoiKoi.rounds = rounds public
}
fromPublic :: PublicGame -> App.T (Either String Game)
fromPublic PublicGame {nonce, private, public, publicSignature} =
App.get Server.keys >>= \(Keys.T {encrypt, sign}) -> return $ do
check (signVerifyDetached (Keys.public sign) publicSignature (toJSON public))
`orDie` "The game state has been tampered with"
n <- Saltine.decode nonce `orDie` "Could not decode nonce"
decrypted <- secretboxOpen encrypt n private
`orDie` "Could not decrypt private state"
decoded <- eitherDecode' (fromStrict decrypted)
check (link decoded == coordinates public)
`orDie` "Private and public parts do not match"
return $ merge public decoded
where
orDie :: Maybe a -> String -> Either String a
orDie m errorMessage = maybe (Left errorMessage) Right m
check :: Bool -> Maybe ()
check test = if test then Just () else Nothing
play :: PlayerID -> KoiKoi.Move -> PublicGame -> App.T (Either String (Game, [KoiKoi.Action]))
play playerID move publicGame
| playing (public publicGame) == playerID = do
result <- fromPublic publicGame
case result of
Left errorMessage -> return $ Left errorMessage
Right game -> lift . runExceptT . runWriterT $ KoiKoi.play move game
| otherwise = return $ Left "Not your turn"
play :: PlayerID -> KoiKoi.Move -> Game -> App.T (Either String Game, [KoiKoi.Action])
play playerID move game = lift . runWriterT . runExceptT $
if playing game == playerID
then KoiKoi.play move game
else throwError "Not your turn"

View File

@ -1,62 +0,0 @@
{-# LANGUAGE NamedFieldPuns #-}
module Keys (
T(..)
, getKeys
, public
, secret
) where
import Config (libDir)
import Control.Monad.State (MonadState(..), StateT(..), evalStateT, lift)
import Crypto.Saltine.Class (IsEncoding(..))
import qualified Crypto.Saltine.Core.SecretBox as Encrypt (Key, newKey)
import qualified Crypto.Saltine.Core.Sign as Sign (
Keypair, PublicKey, SecretKey, newKeypair
)
import qualified Data.ByteString as BS (
ByteString, length, readFile, singleton, splitAt, uncons, writeFile
)
import System.Directory (createDirectoryIfMissing, doesFileExist)
import System.Exit (die)
import System.FilePath ((</>))
data T = T {
encrypt :: Encrypt.Key
, sign :: Sign.Keypair
}
serialize :: T -> BS.ByteString
serialize (T {encrypt, sign = (secretKey, publicKey)}) =
mconcat [encodeKey encrypt, encodeKey secretKey, encodeKey publicKey]
where
encodeKey key =
let encodedKey = encode key in
(BS.singleton . toEnum $ BS.length encodedKey) <> encodedKey
unserialize :: BS.ByteString -> Maybe T
unserialize = evalStateT $ T <$> decodeKey <*> ((,) <$> decodeKey <*> decodeKey)
where
decodeKey :: IsEncoding a => StateT BS.ByteString Maybe a
decodeKey = do
keyLength <- fromEnum <$> StateT BS.uncons
lift . decode =<< state (BS.splitAt keyLength)
getKeys :: IO T
getKeys = do
fileExists <- doesFileExist keyRing
if fileExists
then BS.readFile keyRing >>= tryUnserialize
else do
newT <- T <$> Encrypt.newKey <*> Sign.newKeypair
createDirectoryIfMissing True libDir
BS.writeFile keyRing $ serialize newT
return newT
where
keyRing = libDir </> "keys"
tryUnserialize = maybe (die "Could not unserialize key") return . unserialize
public :: Sign.Keypair -> Sign.PublicKey
public = snd
secret :: Sign.Keypair -> Sign.SecretKey
secret = fst

View File

@ -2,45 +2,41 @@
{-# LANGUAGE NamedFieldPuns #-}
module Main where
import qualified App (Context(..), T, exec)
import qualified Automaton (loop)
import qualified App (Context(..), T, update_)
import qualified Automaton (start)
import qualified Config (listenPort)
import Control.Concurrent (newMVar, modifyMVar)
import Control.Exception (finally)
import Control.Monad.Reader (ReaderT(..), asks)
import Crypto.Saltine (sodiumInit)
import qualified Hanafuda.Message as Message (T(..))
import Messaging (broadcast)
import qualified Hanafuda.Message as Message (FromClient(..))
import Messaging (broadcast, relay)
import Network.HTTP.Types.Status (badRequest400)
import Network.Wai (responseLBS)
import Network.Wai.Handler.Warp (run)
import Network.Wai.Handler.WebSockets (websocketsOr)
import Network.WebSockets (ServerApp, acceptRequest, defaultConnectionOptions)
import qualified Server (close, new, register)
import qualified Server (disconnect, new, register)
import qualified Session (open)
exit :: App.T ()
exit = do
mPlayerID <- asks App.sessionID >>= App.exec . Server.close
case mPlayerID of
Nothing -> return ()
Just playerID -> Messaging.broadcast $ Message.LogOut playerID
asks App.playerID >>= App.update_ . Server.disconnect
relay Message.LogOut broadcast
serverApp :: App.T () -> App.T () -> IO ServerApp
serverApp onEnter onExit = do
mServer <- newMVar =<< Server.new
mServer <- newMVar Server.new
return $ \pending -> do
session <- Session.open <$> acceptRequest pending
sessionID <- modifyMVar mServer (Server.register session)
let app = App.Context {App.mServer, App.sessionID}
playerID <- modifyMVar mServer (return . Server.register session)
let app = App.Context {App.mServer, App.playerID}
finally
(runReaderT onEnter app)
(runReaderT onExit app)
main :: IO ()
main = do
sodiumInit
app <- serverApp Automaton.loop exit
app <- serverApp Automaton.start exit
run Config.listenPort $ websocketsOr defaultConnectionOptions app blockNonWS
where
blockNonWS _ = ( $ responseLBS badRequest400 [] "Use a websocket")

View File

@ -10,62 +10,51 @@ module Messaging (
, relay
, send
, sendTo
, update
) where
import qualified App (T, debug, get, player, session)
import Control.Monad.Reader (lift)
import qualified App (Context(..), T, connection, debug, server)
import Control.Monad.Reader (asks, lift)
import Data.Aeson (eitherDecode', encode)
import Data.ByteString.Lazy.Char8 (unpack)
import Data.Foldable (forM_)
import Data.List (intercalate)
import Data.Map (elems, keys)
import Data.Maybe (maybeToList)
import qualified Data.Set as Set (fromList, member)
import qualified Game (toPublic)
import qualified Hanafuda.KoiKoi as KoiKoi (Action, Game(..), PlayerID)
import Data.Map (keys)
import qualified Hanafuda.KoiKoi as KoiKoi (Action, Game, GameBlueprint(..), PlayerID)
import Hanafuda.Message (FromClient(..), T(..))
import qualified Hanafuda.Message as Message (T)
import Network.WebSockets (receiveData, sendTextData)
import Player (playerID, showDebug)
import qualified Server (sessionsWhere)
import qualified Game (export)
import qualified Server (T(..), get)
import qualified Session (T(..))
sendToSessions :: [Session.T] -> Message.T -> App.T ()
sendToSessions sessions obj = do
App.debug $ '(' : intercalate ", " recipients ++ ") <" ++ (unpack encoded)
lift . mapM_ (flip sendTextData encoded) $ Session.connection <$> sessions
where
encoded = encode $ obj
recipients = fmap showDebug . maybeToList . Session.player =<< sessions
sendTo :: [KoiKoi.PlayerID] -> Message.T -> App.T ()
sendTo playerIDs obj = do
sessions <- App.get $ Server.sessionsWhere selectedPlayer
sendToSessions (foldl (++) [] sessions) obj
sessions <- getSessions <$> App.server
App.debug $ '(' : intercalate ", " recipients ++ ") <" ++ (unpack encoded)
lift $ forM_ (Session.connection <$> sessions) $ flip sendTextData encoded
where
selectedPlayer playerID _ = Set.member playerID $ Set.fromList playerIDs
encoded = encode $ obj
getSessions server = (\playerID -> Server.get playerID server) <$> playerIDs
recipients = show <$> playerIDs
send :: Message.T -> App.T ()
send obj = do
currentSession <- App.session
sendToSessions [currentSession] obj
playerID <- asks App.playerID
sendTo [playerID] obj
broadcast :: Message.T -> App.T ()
broadcast obj = do
App.get (concat . elems . allSessions) >>= flip sendToSessions obj
where
allSessions = Server.sessionsWhere (\_ _ -> True)
broadcast obj =
App.server >>= flip sendTo obj . keys . Server.sessions
relay :: FromClient -> (Message.T -> App.T ()) -> App.T ()
relay message f = do
App.debug "Relaying"
maybe (return ()) doRelay =<< App.player
where
doRelay player = f $ Relay {from = playerID player, message}
(\from -> f $ Relay {from, message}) =<< asks App.playerID
receive :: App.T FromClient
receive = do
received <- ((lift . receiveData . Session.connection) =<< App.session)
received <- ((lift . receiveData) =<< App.connection)
App.debug $ '>':(unpack received)
case eitherDecode' received of
Left errorMessage -> send (Error errorMessage) >> receive
@ -78,7 +67,10 @@ get =
pong Ping = send Pong >> get
pong m = return m
notifyPlayers :: (KoiKoi.Game, [KoiKoi.Action]) -> App.T ()
notifyPlayers (game, logs) =
forM_ (keys $ KoiKoi.nextPlayer game) $ \k ->
sendTo [k] . Game =<< Game.toPublic k game logs
update :: T
update = Update {alone = [], paired = []}
notifyPlayers :: KoiKoi.Game -> [KoiKoi.Action] -> App.T ()
notifyPlayers game logs =
forM_ (keys $ KoiKoi.scores game) $ \k ->
sendTo [k] $ Game {game = Game.export k game, logs}

View File

@ -1,17 +0,0 @@
{-# LANGUAGE NamedFieldPuns #-}
module Player (
T(..)
, showDebug
) where
import Data.Text (Text)
import Hanafuda.KoiKoi (PlayerID)
import Text.Printf (printf)
data T = T {
playerID :: PlayerID
, name :: Text
} deriving (Show)
showDebug :: T -> String
showDebug (T {playerID, name}) = printf "%s (%s)" name (show playerID)

View File

@ -5,113 +5,108 @@
{-# LANGUAGE ScopedTypeVariables #-}
module Server (
T(..)
, close
, disconnect
, endGame
, get
, logIn
, logOut
, new
, register
, room
, select
, sessionsWhere
, update
) where
import Data.Map (Map, (!), (!?), adjust, delete, insert, mapMaybe)
import qualified Data.Map as Map (empty, foldlWithKey, lookup)
import Data.Map (Map, (!), (!?), adjust, delete, insert, lookupMax, mapWithKey)
import qualified Data.Map as Map (empty)
import Data.Set (Set, member)
import qualified Data.Set as Set (delete, empty, insert)
import Data.Text (Text)
import Hanafuda.KoiKoi (PlayerID)
import Hanafuda.Message (Room)
import Keys (getKeys)
import qualified Keys (T)
import qualified Player (T(..))
import Hanafuda.KoiKoi (Game, GameID, PlayerID)
import Hanafuda.Message (PlayerStatus(..), Room)
import qualified RW (RW(..))
import qualified Session (ID, T(..), setPlayer)
import System.Random (Random(..))
import qualified Session (Status(..), T(..), Update)
type SessionIDs = Map PlayerID [Session.ID]
type Sessions = Map Session.ID Session.T
type Names = Set Text
type Players = Map PlayerID Text
type Sessions = Map PlayerID Session.T
type Games = Map GameID Game
data T = T {
keys :: Keys.T
, sessionIDsByPlayerID :: SessionIDs
names :: Names
, players :: Players
, sessions :: Sessions
, games :: Games
}
instance RW.RW SessionIDs T where
get = sessionIDsByPlayerID
set sessionIDsByPlayerID server = server {sessionIDsByPlayerID}
instance RW.RW Names T where
get = names
set names server = server {names}
instance RW.RW Players T where
get = players
set players server = server {players}
instance RW.RW Sessions T where
get = sessions
set sessions server = server {sessions}
new :: IO T
new = getKeys >>= \keys -> return $ T {
keys
, sessionIDsByPlayerID = Map.empty
, sessions = Map.empty
}
instance RW.RW Games T where
get = games
set games server = server {games}
export :: Sessions -> PlayerID -> Text -> PlayerStatus
export sessions playerID playerName = PlayerStatus (playerName, alone)
where
alone =
case Session.status (sessions ! playerID) of
Session.LoggedIn True -> True
_ -> False
room :: T -> Room
room = mapMaybe keepName . select (\_ -> Session.player)
where
keepName [] = Nothing
keepName (player:_) = Just $ Player.name player
room (T {players, sessions}) = mapWithKey (export sessions) players
push :: Ord k => k -> v -> Map k [v] -> Map k [v]
push key value m =
(maybe (insert key [value]) (insert key . (value:)) $ Map.lookup key m) m
new :: T
new = T {
names = Set.empty
, players = Map.empty
, sessions = Map.empty
, games = Map.empty
}
select :: (PlayerID -> Session.T -> Maybe a) -> T -> Map PlayerID [a]
select selector (T {sessionIDsByPlayerID, sessions}) =
Map.foldlWithKey (\accumulator playerID sessionIDs ->
foldl (\acc ->
maybe acc (\v -> push playerID v acc) . selected playerID
) accumulator sessionIDs
) Map.empty sessionIDsByPlayerID
where
selected playerID sessionID =
Map.lookup sessionID sessions >>= selector playerID
sessionsWhere :: (PlayerID -> Session.T -> Bool) -> T -> Map PlayerID [Session.T]
sessionsWhere predicate = select selectorOfPredicate where
selectorOfPredicate playerID session =
if predicate playerID session then Just session else Nothing
register :: forall a b. (Random a, Ord a, RW.RW (Map a b) T) => b -> T -> IO (T, a)
register x server = do
newID <- randomIO
return (RW.update (insert newID x) server, newID)
register :: forall a b. (Enum a, Ord a, RW.RW (Map a b) T) => b -> T -> (T, a)
register x server =
let playerID = maybe (toEnum 0) (\(n, _) -> succ n) $ lookupMax $ (RW.get server :: Map a b) in
(RW.update (insert playerID x) server, playerID)
get :: forall a b. (Ord a, RW.RW (Map a b) T) => a -> T -> b
get keyID server = (RW.get server :: Map a b) ! keyID
get playerID server = (RW.get server :: Map a b) ! playerID
update :: forall a b. (Ord a, RW.RW (Map a b) T) => a -> (b -> b) -> T -> T
update keyID updator =
RW.update (adjust updator keyID :: Map a b -> Map a b)
update playerID updator =
RW.update (adjust updator playerID :: Map a b -> Map a b)
logIn :: Text -> PlayerID -> Session.ID -> T -> T
logIn name playerID sessionID =
RW.update (push playerID sessionID) .
update sessionID (Session.setPlayer playerID name)
disconnect :: PlayerID -> T -> T
disconnect playerID =
RW.update (delete playerID :: Sessions -> Sessions) . logOut playerID
close :: Monad m => Session.ID -> T -> m (T, Maybe PlayerID)
close sessionID server =
return . performUpdates $ popSession sessionID server
where
performUpdates (updateSessionIDs, mPlayerID) = (
RW.update (delete sessionID :: Sessions -> Sessions)
. RW.update (updateSessionIDs :: SessionIDs -> SessionIDs) $ server
, mPlayerID
)
endGame :: GameID -> T -> T
endGame playerID =
RW.update (delete playerID :: Games -> Games)
popSession :: Session.ID -> T -> (SessionIDs -> SessionIDs, Maybe PlayerID)
popSession sessionID (T {sessions, sessionIDsByPlayerID}) =
case findPlayerID of
Nothing -> (id, Nothing)
Just (playerID, [_]) -> (delete playerID, Just playerID)
Just (playerID, _) -> (purgeSession playerID, Nothing)
where
findPlayerID = do
playerID <- fmap Player.playerID . Session.player =<< (sessions !? sessionID)
(,) playerID <$> (sessionIDsByPlayerID !? playerID)
purgeSession = adjust (filter (/= sessionID))
logIn :: Text -> PlayerID -> T -> Either String T
logIn name playerID server =
RW.update (Set.insert name) .
RW.update (insert playerID name) .
update playerID (RW.set $ Session.LoggedIn True :: Session.Update) <$>
if name `member` names server
then Left "This name is already registered"
else Right server
logOut :: PlayerID -> T -> T
logOut playerID server =
maybe
server
(\playerName ->
RW.update (delete playerID :: Players -> Players) $
update playerID (RW.set $ Session.LoggedIn False :: Session.Update) $
RW.update (Set.delete playerName :: Names -> Names) server)
(players server !? playerID)

View File

@ -1,36 +1,35 @@
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE MultiParamTypeClasses #-}
module Session (
ID
, Status
Status(..)
, T(..)
, Update
, open
, setPlayer
) where
import Data.Text (Text)
import qualified Hanafuda.ID as Hanafuda (ID, IDType(..), Prefix(..))
import Hanafuda.KoiKoi (PlayerID)
import Hanafuda.KoiKoi (GameID, PlayerID)
import Network.WebSockets (Connection)
import qualified Player (T(..))
import qualified RW (RW(..))
data Status =
LoggedIn Bool
| Answering PlayerID
| Waiting PlayerID
| Playing GameID
deriving (Show)
type ID = Hanafuda.ID T
instance Hanafuda.IDType T where
prefix = Hanafuda.Prefix "Session"
type Status = Maybe Player.T
data T = T {
connection :: Connection
, player :: Status
, status :: Status
}
type Update = T -> T
setPlayer :: PlayerID -> Text -> Session.Update
setPlayer playerID name session = session {
player = Just $ Player.T {Player.playerID, Player.name}
}
instance RW.RW Status T where
get = status
set status session = session {status}
open :: Connection -> T
open connection = T {
connection
, player = Nothing
, status = LoggedIn False
}

64
www/fun.js Normal file
View File

@ -0,0 +1,64 @@
function Fun() {
return {
defaultCompare: defaultCompare,
insert: insert,
map: map,
mapFilter: mapFilter,
isSet: isSet,
of: of,
proj: proj
};
function insert(obj, t, compare, min, max) {
min = min == undefined ? 0 : min;
max = max == undefined ? t.length : max;
compare = compare == undefined ? defaultCompare : compare;
if(max - min < 1) {
return min;
}
var avg = Math.floor((max + min) / 2);
if (compare(obj, t[avg]) < 0) {
return insert(obj, t, compare, min, avg);
} else {
return insert(obj, t, compare, avg+1, max);
}
}
function defaultCompare(a, b) {
if(a < b) {
return -1;
} else if(a > b) {
return 1;
} else {
return 0;
}
}
function map(mapper, f) {
return function() {
var args = Array.prototype.map.call(arguments, mapper);
return f.apply(null, args);
}
}
function of(o) {
return function(key) {return o[key];};
}
function proj(key) {
return function(o) {return o[key];};
}
function mapFilter(mapper, predicate) {
return function(array) {
return array.reduce(function(accumulator, elem) {
var v = mapper(elem);
return predicate(v) ? accumulator.concat(v) : accumulator;
}, []);
};
}
function isSet(x) {
return x != undefined;
}
}

View File

@ -111,39 +111,39 @@
margin: 0;
}
#rest.init, #rest.turn0 {
#rest.init, #rest.count24 {
box-shadow: 2px 3px 0 0 #555, 4px 6px 0 0 #555, 6px 9px 0 0 #555;
}
#rest.turn2 {
#rest.count22 {
box-shadow: 2px 3px 0 0 #555, 4px 6px 0 0 #555, 5.5px 8.3px 0 0 #555;
}
#rest.turn4 {
#rest.count20 {
box-shadow: 2px 3px 0 0 #555, 4px 6px 0 0 #555, 5px 7.5px 0 0 #555;
}
#rest.turn6 {
#rest.count18 {
box-shadow: 2px 3px 0 0 #555, 4px 6px 0 0 #555, 4.5px 6.8px 0 0 #555;
}
#rest.turn8 {
#rest.count16 {
box-shadow: 2px 3px 0 0 #555, 4px 6px 0 0 #555;
}
#rest.turn10 {
#rest.count14 {
box-shadow: 2px 3px 0 0 #555, 3.5px 5.3px 0 0 #555;
}
#rest.turn12 {
#rest.count12 {
box-shadow: 2px 3px 0 0 #555, 3px 4.5px 0 0 #555;
}
#rest.turn14 {
#rest.count10 {
box-shadow: 2px 3px 0 0 #555, 2.5px 3.8px 0 0 #555;
}
#rest.turn16 {
#rest.count8 {
box-shadow: 2px 3px 0 0 #555;
}

View File

@ -1,9 +1,8 @@
Screen.Game = function(modules) {
function Game(modules) {
var deck = document.getElementById("deck");
var rest = document.getElementById("rest");
var status = {
dom: document.getElementById("status"),
game: null,
playing: false,
step: null,
month: null
@ -54,7 +53,7 @@ Screen.Game = function(modules) {
}
function handleGameMessage(o) {
if(o.state.public.turns == 0) {
if(o.game.deck == 24) { // deck is full, means new round
if(o.logs.length > 0) { // but still some logs, from the previous round
return modules.async.sequence(applyDiff(o), setGame(o)); // so play the diff, then set the new round
} else {
@ -67,13 +66,13 @@ Screen.Game = function(modules) {
function setGame(o) {
return function(f) {
setStatus(o.state);
setCaptures(o.state);
setStatus(o.game);
setCaptures(o.game);
[
[sets.river, o.state.public.river, RiverCard],
[sets.you.hand, o.state.playerHand, HandCard]
[sets.river, o.game.river, RiverCard],
[sets.you.hand, o.game.players[modules.session.getKey()].hand, HandCard]
].forEach(function(args) {setCardSet.apply(null, args)});
setTheirCards(o.state);
setTheirCards(o.game);
handleStep(o)(f);
};
}
@ -97,10 +96,10 @@ Screen.Game = function(modules) {
function handleTurnedCard(o, f) {
if(status.step == "Turned") {
setTurned(o.state.public.step.contents);
setTurned(o.game.step.contents);
} else {
if(status.step == "ToPlay" && o.state.public.playing == o.state.public.oyake) {
rest.className = ["card", "turn" + o.state.public.turns].join(' ');
if(status.step == "ToPlay" && o.game.playing == o.game.oyake) {
rest.className = ["card", "count" + o.game.deck].join(' ');
}
if(deck.lastChild.id != "rest") {
deck.removeChild(deck.lastChild);
@ -121,7 +120,7 @@ Screen.Game = function(modules) {
function theyScored(o, f) {
modules.screen.dialog({
text: modules.i18n.get('theyScored')(modules.room.name(o.state.public.playing)),
text: modules.i18n.get('theyScored')(modules.room.name(o.game.playing)),
answers: [
{label: 'ok', action: f}
]
@ -130,10 +129,10 @@ Screen.Game = function(modules) {
function gameEnd(o, f) {
var winner, maxScore;
for(var key in o.state.public.scores) {
if(maxScore == undefined || o.state.public.scores[key] > maxScore) {
for(var key in o.game.scores) {
if(maxScore == undefined || o.game.scores[key] > maxScore) {
winner = key;
maxScore = o.state.public.scores[key];
maxScore = o.game.scores[key];
}
}
modules.screen.dialog({
@ -152,7 +151,7 @@ Screen.Game = function(modules) {
function applyDiff(o) {
return modules.async.sequence.apply(null,
o.logs.map(animate).concat(
modules.async.apply(setStatus, o.state),
modules.async.apply(setStatus, o.game),
handleStep(o)
)
);
@ -234,50 +233,48 @@ Screen.Game = function(modules) {
function play(move) {
modules.messaging.send({
tag: "Play",
move: move,
onGame: status.game
move: move
});
}
function matchingInRiver(card) {
return modules.fun.mapFilter(
modules.fun.of(sets.river.card),
modules.fun.defined
modules.fun.isSet
)(modules.hanafuda.sameMonth(card).map(modules.fun.proj('name')));
}
function setStatus(game) {
modules.dom.clear(status.dom);
status.game = game;
status.step = game.public.step.tag;
if(game.public.month != status.month) {
status.month = game.public.month;
status.step = game.step.tag;
if(game.month != status.month) {
status.month = game.month;
}
status.dom.appendChild(
modules.dom.make('li', {textContent: modules.i18n.get('monthFlower')(modules.i18n.get(status.month))})
);
var turn = null;
status.playing = modules.session.is(game.public.playing);
status.playing = modules.session.is(game.playing);
if(status.playing) {
sets.you.hand.dom.classList.toggle("yourTurn", status.step == "ToPlay");
turn = modules.i18n.get("yourTurn");
} else {
sets.you.hand.dom.classList.remove("yourTurn");
turn = modules.i18n.get('playing')(modules.room.name(game.public.playing));
turn = modules.i18n.get('playing')(modules.room.name(game.playing));
}
status.dom.appendChild(modules.dom.make('li', {textContent: turn}));
}
function setCaptures(game) {
for(var key in game.public.players) {
for(var key in game.players) {
var elem = document.getElementById(modules.session.is(key) ? "you" : "them");
elem.getElementsByClassName('score')[0].textContent = game.public.scores[key] + " pts";
elem.getElementsByClassName('score')[0].textContent = game.scores[key] + " pts";
var byClass = {}
Object.values(modules.hanafuda.Family).forEach(function(family) {
byClass[family.class] = elem.getElementsByClassName(family.class)[0];
modules.dom.clear(byClass[family.class]);
});
game.public.players[key].meld.forEach(function(cardName) {
game.players[key].meld.forEach(function(cardName) {
var card = new Card(cardName);
byClass[card.value.family.class].appendChild(card.dom);
});
@ -297,7 +294,7 @@ Screen.Game = function(modules) {
function setTheirCards(game) {
var turnsTheyPlayed = Math.floor(
(game.public.turns + (modules.session.is(game.public.oyake) ? 0 : 1)) / 2
(24 - game.deck + (modules.session.is(game.oyake) ? 0 : 1)) / 2
);
modules.dom.clear(sets.them.hand.dom);
for(var i = 0; i < 8 - turnsTheyPlayed; i++) {

View File

@ -3,52 +3,45 @@
<head>
<meta charset="utf-8">
<title>KoiKoi</title>
<script src="lib/unit.js"></script>
<script src="UnitJS/async.js"></script>
<script src="UnitJS/dom.js"></script>
<script src="translations.js"></script>
<script src="i18n.js"></script>
<script src="fun.js"></script>
<script src="screen.js"></script>
<script src="save.js"></script>
<script src="messaging.js"></script>
<script src="ui.js"></script>
<script src="session.js"></script>
<script src="room.js"></script>
<script src="statusHandler.js"></script>
<script src="login.js"></script>
<script src="hanafuda.js"></script>
<script src="screen/game.js"></script>
<script src="screen/hall.js"></script>
<script src="screen/login.js"></script>
<script src="game.js"></script>
<script src="main.js"></script>
<link rel="stylesheet" href="screen.css" type="text/css"/>
<link rel="stylesheet" href="screen/hall.css" type="text/css"/>
<link rel="stylesheet" href="screen/game.css" type="text/css"/>
<link rel="stylesheet" href="login.css" type="text/css"/>
<link rel="stylesheet" href="game.css" type="text/css"/>
</head>
<body>
<div id="reception" class="on">
<h1>Hanafuda - KoiKoi</h1>
<h1>Hanafuda</h1>
<form id="login">
<input type="submit" name="submitButton" hidden disabled/>
<p id="join">
<p id="join" class="on">
<label for="you"></label><input type="text" name="you"/>
<input type="submit" name="join" disabled/>
</p>
</form>
</div>
<div id="hall">
<form id="room">
<input type="submit" name="submitButton" hidden disabled/>
<p id="invite">
<label for="them"></label><input type="text" name="them"/>
<input type="submit" name="invite" disabled/>
</p>
<div class="listSelector" id="players">
<div id="players">
<span class="message"></span>
<ul></ul>
<ul class="list"></ul>
</div>
<p id="leave">
<input type="button" name="leave"/>
</p>
</form>
<div class="listSelector" id="games">
<span class="message"></span>
<ul></ul>
</div>
</div>
<div id="game">
<div id="them">

41
www/login.css Normal file
View File

@ -0,0 +1,41 @@
#join, #invite {
display: none;
}
#join.on, #invite.on {
display: block;
}
#leave {
display: none;
}
#login.on #join {
display: none;
}
#login.on #leave {
display: inline;
}
#players {
min-height: 4em;
border: 1px solid #ccc;
}
#players .message {
display: block;
text-align: center;
margin: 1em;
color: #555;
}
#players .message:empty {
display: none;
}
#players .list {
list-style: none;
margin: 0;
padding-left: 0;
}

148
www/login.js Normal file
View File

@ -0,0 +1,148 @@
function Login(modules) {
var root = document.getElementById('login');
var playersMessage = root.getElementsByClassName('message')[0];
var players = root.getElementsByClassName('list')[0];
var join = document.getElementById("join");
var invite = document.getElementById("invite");
var submit = root.submitButton;
var them = null;
var invitationAnswers = [{
label: 'accept',
action: function() {
modules.messaging.send({tag: "Answer", accept: true});
modules.screen.select("game");
}
}, {
label: 'decline',
action: function() {
modules.messaging.send({tag: "Answer", accept: false});
}
}];
root.addEventListener('submit', function(e) {
e.preventDefault();
if(modules.session.loggedIn()) {
modules.messaging.send({tag: "Invitation", to: them});
} else {
modules.messaging.send({tag: "LogIn", name: root.you.value});
}
});
root.leave.addEventListener('click', function(e) {
e.preventDefault();
lib.send({tag: "LogOut"})
});
root.you.addEventListener("input", function() {refreshPlayers(false);});
root.them.addEventListener("input", function() {refreshPlayers(true);});
modules.messaging.addEventListener(["Welcome"], function() {
refreshPlayers(modules.session.loggedIn());
});
modules.messaging.addEventListener(["Update"], function(o) {
refreshPlayers(modules.session.loggedIn());
});
modules.messaging.addEventListener(["Relay", "LogIn"], function() {
playersChanged();
});
modules.messaging.addEventListener(["Relay", "LogOut"], function() {
playersChanged();
});
modules.messaging.addEventListener(["Relay", "Invitation"], function(o) {
var name = modules.room.name(o.from);
// invitations should come only from known players, in doubt say «no»
if(name) {
modules.statusHandler.set("🎴");
modules.screen.dialog({
text: modules.i18n.get('invited')(name),
answers: invitationAnswers
});
} else {
modules.messaging.send({tag: "Answer", accept: false});
}
});
modules.messaging.addEventListener(["Relay", "Answer"], function(o) {
if(o.message.accept) {
modules.screen.select("game");
}
});
return {};
function playersChanged() {
var loggedIn = modules.session.loggedIn();
setMode(loggedIn);
refreshPlayers(loggedIn);
}
function refreshPlayers(loggedIn) {
modules.dom.clear(players);
if(loggedIn) {
refreshThem();
} else {
refreshYou();
}
}
function refreshYou() {
var nameTaken = false;
var name = root.you.value;
modules.room.filter(name).forEach(function(player) {
players.appendChild(player.dom);
nameTaken = nameTaken || name == player.name;
});
formDisable("join", name.length < 1 || nameTaken);
}
function refreshThem() {
them = null;
var name = root.them.value;
var filtered = modules.room.filter(name);
filtered.forEach(function(player) {
players.appendChild(player.dom);
});
var exact = filtered.find(exactMatch(name));
playersMessage.textContent = '';
if(exact != undefined) {
them = exact.key;
} else if(filtered.length == 1) {
them = filtered[0].key;
} else if(filtered.length == 0) {
playersMessage.textContent = modules.i18n.get(
name.length > 0 ? "notFound" : "alone"
);
}
formDisable("invite", them == undefined);
}
function formDisable(name, disabled) {
[submit, root[name]].forEach(function(button) {
button.disabled = disabled;
});
}
function exactMatch(name) {
return function(player) {
return player.name === name;
};
}
function setMode(loggedIn) {
root.join.disabled = loggedIn;
root.invite.disabled = !loggedIn;
if(loggedIn) {
join.className = "";
invite.className = "on";
root.them.focus();
} else {
join.className = "on";
invite.className = "";
root.you.focus();
}
}
}

View File

@ -1,23 +1,23 @@
window.addEventListener('load', function() {
var dom = unitJS.Dom();
var async = unitJS.Async();
var fun = unitJS.Fun();
var dom = Dom();
var async = Async();
var translations = Translations();
var i18n = I18n({translations: translations});
var save = Save();
var fun = Fun();
var screen = Screen({dom: dom, i18n: i18n});
var messaging = Messaging({screen: screen});
var ui = Ui({messaging: messaging});
var session = Session({messaging: messaging, save: save});
var room = Room({dom: dom, session: session, fun: fun});
var session = Session({messaging: messaging});
var room = Room({dom: dom, messaging: messaging, session: session, fun: fun});
var statusHandler = StatusHandler();
var login = Screen.Login({i18n: i18n, messaging: messaging, save: save, screen: screen, session: session, ui: ui});
var hall = Screen.Hall({dom: dom, i18n: i18n, messaging: messaging, room: room, save: save, screen: screen, session: session, statusHandler: statusHandler, ui: ui});
var login = Login({dom: dom, i18n: i18n, messaging: messaging, room: room, screen: screen, session: session, statusHandler: statusHandler});
var hanafuda = Hanafuda({fun: fun});
var game = Screen.Game({async: async, dom: dom, i18n: i18n, fun: fun, hanafuda: hanafuda, messaging: messaging, room: room, screen: screen, session: session, statusHandler: statusHandler});
var game = Game({async: async, dom: dom, i18n: i18n, fun: fun, hanafuda: hanafuda, messaging: messaging, room: room, screen: screen, session: session, statusHandler: statusHandler});
var domElems = {
invite: document.getElementById('room').invite,
join: document.getElementById('login').join,
invite: document.getElementById('login').invite,
leave: document.getElementById('login').leave,
pickName: document.getElementById('join').getElementsByTagName('label')[0],
startGameWith: document.getElementById('invite').getElementsByTagName('label')[0]
};
for(var key in domElems) {
@ -26,4 +26,6 @@ window.addEventListener('load', function() {
default: domElems[key].textContent = i18n.get(key);
}
}
messaging.start();
});

View File

@ -1,25 +1,15 @@
function Messaging(modules) {
var wsLocation = window.location.origin.replace(/^http/, 'ws') + '/play/';
var ws;
var ws = new WebSocket(window.location.origin.replace(/^http/, 'ws') + '/play/');
var debug = getParameters().debug;
var doLog = debug != undefined && debug.match(/^(?:1|t(?:rue)?|v(?:rai)?)$/i);
var on = false;
var s = 1000; /* ms */
var keepAlivePeriod = 20;
var reconnectDelay = 1;
var keepAlivePeriod = 20000;
var routes = {callbacks: [], children: {}};
var wsHandlers = {
open: [function() {on = true; reconnectDelay = 1}, ping],
close: [function() {on = false;}, reconnect]
};
init();
return {
addEventListener: addEventListener,
isOn: isOn,
send: send
};
send: send,
start: start
}
function get(obj, path, write) {
write = write || false;
@ -47,16 +37,8 @@ function Messaging(modules) {
}
function addEventListener(path, callback) {
if(Array.isArray(path)) {
var route = get(routes, path, true);
route.callbacks.push(callback);
} else {
if(wsHandlers[path] != undefined) {
wsHandlers[path].push(callback);
} else {
log('Unsupported websocket event "' + path + '"');
}
}
var route = get(routes, path, true);
route.callbacks.push(callback);
}
function messageListener(event) {
@ -77,51 +59,26 @@ function Messaging(modules) {
log(o);
};
function send(o) {
ws.send(JSON.stringify(o));
o.direction = 'client > server';
log(o);
}
function log(message) {
if(doLog) {
console.log(message);
}
}
function init() {
connect();
function start() {
ws.addEventListener('message', messageListener);
ws.addEventListener('open', ping);
addEventListener(["Pong"], ping);
addEventListener(["Error"], function(o) {modules.screen.error(o.error);});
}
function connect() {
ws = new WebSocket(window.location.origin.replace(/^http/, 'ws') + '/play/');
ws.addEventListener('message', messageListener);
ws.addEventListener('open', function(e) {
wsHandlers.open.forEach(function(handler) {handler(e);});
});
ws.addEventListener('close', function(e) {
wsHandlers.close.forEach(function(handler) {handler(e);});
});
}
function reconnect() {
setTimeout(connect, reconnectDelay * s);
if(reconnectDelay < 16) {
reconnectDelay *= 2;
}
}
function isOn() {
return on;
function send(o) {
ws.send(JSON.stringify(o));
o.direction = 'client > server';
log(o);
}
function ping() {
setTimeout(function() {
if(isOn()) {
send({tag: "Ping"});
}
}, keepAlivePeriod * s);
setTimeout(function() {send({tag: "Ping"});}, keepAlivePeriod);
}
}

View File

@ -1,8 +1,9 @@
function Room(modules) {
function Player(key, name) {
function Player(key, name, alone) {
this.key = key;
this.name = name;
this.alone = alone;
this.dom = modules.dom.make('li', {textContent: name});
this.position = null;
}
@ -15,20 +16,38 @@ function Room(modules) {
selected: null
};
modules.messaging.addEventListener(["Welcome"], function(o) {
for(var key in o.room) {
enter(parseInt(key), o.room[key]);
}
});
modules.messaging.addEventListener(["Update"], function(o) {
o.alone.forEach(function(key) {players[key].alone = true;});
o.paired.forEach(function(key) {players[key].alone = false;});
});
modules.messaging.addEventListener(["Relay", "LogIn"], function(o) {
enter(o.from, o.message);
});
modules.messaging.addEventListener(["Relay", "LogOut"], function(o) {
leave(o.from);
});
var compareKeysByLogin = modules.fun.map(function(key) {return players[key].name;}, modules.fun.defaultCompare);
return {
filter: filter,
enter: enter,
leave: leave,
name: name,
populate: populate
name: name
};
function filter(name) {
if(modules.session.isLoggedIn()) {
if(modules.session.loggedIn()) {
var keep = function(player) {
return player.name.match(name) && !modules.session.is(player.key);
return player.name.match(name) && !modules.session.is(player.key) && player.alone;
};
} else {
var keep = function(player) {return player.name.match(name);};
@ -36,20 +55,13 @@ function Room(modules) {
return modules.fun.mapFilter(modules.fun.of(players), keep)(sortedKeys);
}
function enter(key, name) {
if(!modules.session.is(key)) {
name = name || "anon";
var player = new Player(key, name);
players[key] = player;
player.position = modules.fun.insert(key, sortedKeys, compareKeysByLogin);
sortedKeys.splice(player.position, 0, key);
}
}
function populate(o) {
for(var key in o.room) {
enter(key, o.room[key]);
}
function enter(key, obj) {
var name = obj.name || "anon";
var alone = obj.alone != undefined ? obj.alone : true;
var player = new Player(key, name, alone);
players[key] = player;
player.position = modules.fun.insert(key, sortedKeys, compareKeysByLogin);
sortedKeys.splice(player.position, 0, key);
}
function leave(key) {

View File

@ -1,48 +0,0 @@
function Save(modules) {
var save = JSON.parse(localStorage.getItem('save')) || {};
return {
get: get,
set: set
};
function move(coordinates) {
if(coordinates.path.length == 1) {
return coordinates;
} else {
var newFocus = coordinates.focus[coordinates.path[0]];
if (newFocus != undefined) {
var newCoordinates = {path: coordinates.path.slice(1), focus: newFocus};
return move(newCoordinates);
} else {
return coordinates;
}
}
}
function get(key) {
if(key != undefined) {
var outputCoordinates = move({path: key.split('.'), focus: save});
if(outputCoordinates.focus != undefined && outputCoordinates.path.length == 1) {
return outputCoordinates.focus[outputCoordinates.path[0]]
} else {
return null;
}
}
}
function set(key, value) {
if(key != undefined) {
var outputCoordinates = move({path: key.split('.'), focus: save});
while(outputCoordinates.path.length > 1) {
outputCoordinates.focus[outputCoordinates.path[0]] = {};
outputCoordinates.focus = outputCoordinates.focus[outputCoordinates.path[0]];
outputCoordinates.path = outputCoordinates.path.slice(1);
}
outputCoordinates.focus[outputCoordinates.path[0]] = value;
} else {
save = value;
}
localStorage.setItem('save', JSON.stringify(save));
}
}

View File

@ -1,21 +0,0 @@
.listSelector {
min-height: 4em;
border: 1px solid #ccc;
}
.listSelector .message {
display: block;
text-align: center;
margin: 1em;
color: #555;
}
.listSelector .message:empty {
display: none;
}
.listSelector ul {
list-style: none;
margin: 0;
padding-left: 0;
}

View File

@ -1,127 +0,0 @@
Screen.Hall = function(modules) {
var form = modules.ui.connectedForm('room');
var players = listSelector('players');
var games = listSelector('games');
var them = null;
var invitationAnswers = [{
label: 'accept',
action: function() {
modules.messaging.send({tag: "Answer", accept: true});
modules.screen.select("game");
}
}, {
label: 'decline',
action: function() {
modules.messaging.send({tag: "Answer", accept: false});
}
}];
init();
return {};
function listSelector(id) {
var root = document.getElementById(id);
return {
root: root,
message: root.getElementsByClassName('message')[0],
list: root.getElementsByClassName('list')[0]
}
}
function init() {
initDOMEvents();
initMessageHandlers();
}
function initDOMEvents() {
form.addEventListener('submit', function(e) {
e.preventDefault();
modules.messaging.send({tag: "Invitation", to: them});
});
form.them.addEventListener("input", function() {refreshPlayers();});
}
function initMessageHandlers() {
modules.messaging.addEventListener(["Okaeri"], function(o) {
modules.room.populate(o);
refreshPlayers();
refreshGames();
});
modules.messaging.addEventListener(["Welcome"], function(o) {
modules.room.populate(o);
refreshPlayers();
refreshGames();
});
modules.messaging.addEventListener(["LogIn"], function(o) {
modules.room.enter(o.from, o.as);
refreshPlayers();
});
modules.messaging.addEventListener(["LogOut"], function(o) {
modules.room.leave(o.from);
refreshPlayers();
});
modules.messaging.addEventListener(["Relay", "Invitation"], function(o) {
console.log("Received an invitation, should be added to games list");
console.log(o);
/*
var name = modules.room.name(o.from);
// invitations should come only from known players, in doubt say «no»
if(name) {
modules.statusHandler.set("🎴");
modules.screen.dialog({
text: modules.i18n.get('invited')(name),
answers: invitationAnswers
});
} else {
modules.messaging.send({tag: "Answer", accept: false});
}
*/
});
modules.messaging.addEventListener(["Relay", "Answer"], function(o) {
if(o.message.accept) {
modules.screen.select("game");
}
});
}
function refreshPlayers() {
modules.dom.clear(players.list);
players.message.textContent = '';
var name = form.them.value;
them = null;
var filtered = modules.room.filter(name);
filtered.forEach(function(player) {
players.list.appendChild(player.dom);
});
var exact = filtered.find(exactMatch(name));
if(exact != undefined) {
them = exact.key;
} else if(filtered.length == 1) {
them = filtered[0].key;
} else if(filtered.length == 0) {
players.message.textContent = modules.i18n.get(
name.length > 0 ? "notFound" : "alone"
);
}
modules.ui.enableForm('room', them != undefined);
}
function refreshGames() {
modules.dom.clear(games.list);
games.message.textContent = modules.i18n.get('noGames');
}
function exactMatch(name) {
return function(player) {
return player.name === name;
};
}
}

View File

@ -1,38 +0,0 @@
Screen.Login = function(modules) {
var form = modules.ui.connectedForm('login');
init();
return {};
function init() {
initDOM();
initMessageHandlers();
var name = modules.save.get('player.name');
if(name != undefined && name.length > 0) {
form.you.value = name;
modules.ui.enableForm('login', true);
}
}
function initDOM() {
form.getElementsByTagName('label')[0].textContent = modules.i18n.get('pickName');
form.join.value = modules.i18n.get('join');
form.addEventListener('submit', function(e) {
e.preventDefault();
modules.session.start(form.you.value);
});
form.you.addEventListener("input", validate);
}
function initMessageHandlers() {
modules.messaging.addEventListener(["LogIn"], function(o) {
if(modules.session.is(o.from)) {
modules.screen.select('hall');
}
});
}
function validate(e) {
modules.ui.enableForm('login', e.target.value != "")
}
}

View File

@ -1,48 +1,32 @@
function Session(modules) {
var key = null;
var playerKey = null;
var name = null;
var loggedIn = false;
modules.messaging.addEventListener(["Welcome"], function(o) {
playerKey = o.key;
modules.save.set('player.id', o.key);
key = o.key;
});
modules.messaging.addEventListener(["LogIn"], function(o) {
modules.messaging.addEventListener(["Relay", "LogIn"], function(o) {
if(is(o.from)) {
name = o.as;
loggedIn = true;
name = o.message.name;
}
});
return {
is: is,
getKey: getKey,
isLoggedIn: isLoggedIn,
start: start
loggedIn: loggedIn
};
function is(somePlayerKey) {
return playerKey == somePlayerKey;
function is(sessionKey) {
return key == sessionKey;
}
function getKey() {
return key;
}
function isLoggedIn() {
return loggedIn;
}
function start(name) {
var myID = modules.save.get('player.id');
if(myID != undefined) {
modules.messaging.send({tag: 'Tadaima', myID: myID, name: name});
playerKey = myID;
} else {
modules.messaging.send({tag: 'Hello', name: name});
}
modules.save.set('player.name', name);
function loggedIn() {
return name != undefined;
}
}

View File

@ -29,7 +29,6 @@ function Translations() {
monthFlower: function(flower) {
return "This month's flower is the " + flower;
},
noGames: "No games being played",
notFound: "No one goes by that name",
pickName: "Pick a name you like",
playing: function(name) {
@ -73,7 +72,6 @@ function Translations() {
monthFlower: function(flower) {
return "C'est le mois des " + flower;
},
noGames: "Aucune partie en cours",
notFound: "Personne ne s'appelle comme ça",
pickName: "Choisissez votre nom",
playing: function(name) {

View File

@ -1,38 +0,0 @@
function Ui(modules) {
var connectedForms = {};
modules.messaging.addEventListener('open', refreshForms);
modules.messaging.addEventListener('close', refreshForms);
return {
connectedForm: connectedForm,
enableForm: enableForm
};
function connectedForm(formId) {
var root = document.getElementById(formId);
connectedForms[formId] = {
root: root,
submits: root.querySelectorAll('[type=submit]'),
enabled: false
};
return root;
}
function enableForm(formId, enabled) {
connectedForms[formId].enabled = enabled || undefined == enabled;
refreshForm(connectedForms[formId]);
}
function refreshForms() {
for(var key in connectedForms) {
refreshForm(connectedForms[key]);
}
}
function refreshForm(form) {
form.submits.forEach(function(button) {
button.disabled = !(modules.messaging.isOn() && form.enabled);
});
}
}