Compare commits

..

11 commits

6 changed files with 190 additions and 78 deletions

View file

@ -21,15 +21,19 @@ executable hannah
other-modules: AI other-modules: AI
, Automaton , Automaton
, Config , Config
, Session
-- other-extensions: -- other-extensions:
build-depends: aeson build-depends: aeson
, base >=4.9 && <4.13 , base >=4.9 && <4.13
, bytestring , bytestring
, containers , containers
, directory
, filepath
, hanafuda , hanafuda
, hanafuda-APILanguage , hanafuda-protocol
, mtl , mtl
, text , text
, websockets , websockets
hs-source-dirs: src hs-source-dirs: src
ghc-options: -Wall
default-language: Haskell2010 default-language: Haskell2010

View file

@ -4,34 +4,41 @@ module AI (
) where ) where
import Data.List (sortOn) import Data.List (sortOn)
import Data.Map ((!)) import Data.Map ((!), delete, findMin)
import Data.Ord (Down(..)) import Data.Ord (Down(..))
import Data.Set (Set, member) import Data.Set (member)
import qualified Data.Set as Set (fromList, intersection, unions) import qualified Data.Set as Set (fromList)
import Hanafuda ( import Hanafuda (
Card(..), Flower(..), Pack Card(..), Flower(..), Pack
, cardsOf, cardsOfPack, contains, empty,flower, packOfCards, sameMonth, size, union , cardsOf, cardsOfPack, contains, empty,flower, packOfCards, sameMonth, size, union
) )
import Hanafuda.Player (Player(..), Players(..)) import Hanafuda.KoiKoi (Move(..), PlayerID, Step(..))
import Hanafuda.KoiKoi (GameBlueprint(..), Move(..), PlayerID, Step(..), Score) import Hanafuda.Message (PublicGame(..), PublicState(..), PublicPlayer)
import Hanafuda.Message (PublicGame) import qualified Hanafuda.Message as Message (Coordinates(..))
move :: PlayerID -> PublicGame -> Move move :: PlayerID -> PublicGame -> Move
move me (Game {step = ToPlay, month, players = Players p, river}) = move me (PublicGame {playerHand, public = PublicState {coordinates, step = ToPlay, players, river}}) =
case getAvailableCards myHand (cardsOfPack river) of case getAvailableCards hand (cardsOfPack river) of
[] -> Play $ worstFor month (p ! (nextPlayer $ p ! me)) myHand [] -> Play $ worstFor month opponent hand
available -> available ->
let riverCard = bestFor month (p ! me) available in let riverCard = bestFor month (players ! me) available in
let matchingCards = cardsOfPack . sameMonth riverCard . hand $ p ! me in let matchingCards = cardsOfPack . sameMonth riverCard $ playerHand in
capture (bestFor month (p ! me) matchingCards) riverCard river capture (bestFor month (players ! me) matchingCards) riverCard river
where where
myHand = cardsOfPack . hand $ p ! me month = Message.month $ coordinates
hand = cardsOfPack playerHand
opponent = snd . findMin $ delete me players
move me (Game {step = Turned card, month, players = Players p, river}) = move me (PublicGame {public = PublicState {coordinates, step = Turned card, players, river}}) =
Choose . bestFor month (p ! me) . cardsOfPack $ sameMonth card river Choose . bestFor month (players ! me) . cardsOfPack $ sameMonth card river
where
month = Message.month $ coordinates
move me (Game {players = Players p}) = KoiKoi $ hand (p ! me) /= empty move _ (PublicGame {playerHand, public = PublicState {step = Scored}}) =
KoiKoi $ playerHand /= empty
move _ _ = error "Nothing to play on ended game"
capture :: Card -> Card -> Pack -> Move capture :: Card -> Card -> Pack -> Move
capture card caught river = capture card caught river =
@ -48,13 +55,13 @@ getAvailableCards hand =
choose :: Ord a => (Card -> a) -> [Card] -> Card choose :: Ord a => (Card -> a) -> [Card] -> Card
choose sortCriterion = head . sortOn sortCriterion choose sortCriterion = head . sortOn sortCriterion
bestFor :: Flower -> Player Score -> [Card] -> Card bestFor :: Flower -> PublicPlayer -> [Card] -> Card
bestFor monthFlower player = choose (Down . rank monthFlower player) bestFor monthFlower player = choose (Down . rank monthFlower player)
worstFor :: Flower -> Player Score -> [Card] -> Card worstFor :: Flower -> PublicPlayer -> [Card] -> Card
worstFor monthFlower player = choose (rank monthFlower player) worstFor monthFlower player = choose (rank monthFlower player)
rank :: Flower -> Player Score -> Card -> Int rank :: Flower -> PublicPlayer -> Card -> Int
rank monthFlower _ card rank monthFlower _ card
| isTrueLight card = 5 | isTrueLight card = 5
| card == RainMan = 2 | card == RainMan = 2

View file

@ -4,38 +4,23 @@ module Automaton (
start start
) where ) where
import AI (move) import qualified AI (move)
import Control.Concurrent (forkIO, threadDelay) import Control.Concurrent (forkIO, threadDelay)
import Data.Aeson (encode, eitherDecode') import Data.Aeson (encode, eitherDecode')
import Data.ByteString.Lazy.Char8 (ByteString, append, pack, putStrLn) import Data.ByteString.Lazy.Char8 (ByteString, append, pack, putStrLn)
import qualified Data.ByteString.Lazy.Char8 as ByteString (concat) import qualified Data.ByteString.Lazy.Char8 as ByteString (concat)
import Data.Map (Map, empty) import Data.Map ((!))
import Data.Text (Text) import qualified Data.Map as Map (empty, filter, lookup)
import Control.Concurrent (threadDelay)
import Control.Monad.Reader (ReaderT, ask) import Control.Monad.Reader (ReaderT, ask)
import Control.Monad.State (StateT)
import Control.Monad.Trans (lift) import Control.Monad.Trans (lift)
import Hanafuda.KoiKoi (Game, GameBlueprint(..), PlayerID, Step(..)) import Hanafuda.KoiKoi (Step(..))
import qualified Hanafuda.Message as Message (T(..), FromClient(..), PublicGame) import Hanafuda.Message (FromClient(..), T(..), orderCoordinates)
import qualified Hanafuda.Message as Message (
Coordinates(..), FromClient, PublicGame(..), PublicState(..), T(..)
)
import Network.WebSockets (Connection, receiveData, sendTextData) import Network.WebSockets (Connection, receiveData, sendTextData)
import Prelude hiding (error, putStrLn) import Prelude hiding (error, putStrLn)
import System.Exit (exitSuccess) import Session (State(..), deleteGame, initial, storeID, storeGame)
data State =
Initial
| Connected {
key :: PlayerID
}
| LoggedIn {
key :: PlayerID
, name :: Text
}
| Playing {
key :: PlayerID
, name :: Text
, against :: PlayerID
}
deriving Show
type App a = ReaderT Connection IO a type App a = ReaderT Connection IO a
@ -61,50 +46,76 @@ receive = do
debug :: ByteString -> App () debug :: ByteString -> App ()
debug message = lift $ putStrLn message debug message = lift $ putStrLn message
answer :: Message.T -> State -> App State answer :: State -> Message.T -> App State
answer welcome@(Message.Welcome {}) Initial = do
send $ Message.LogIn {Message.name = "Hannah"}
return $ Connected {key = Message.key welcome}
answer (Message.Relay {Message.from, Message.message = Message.LogIn {Message.name}}) (Connected {key}) answer New (Welcome {key}) = do
| from == key = return $ LoggedIn {key, name} lift $ Session.storeID key
lift $ putStrLn "Stored"
return $ Connected {playerID = key, games = Map.empty}
answer (Message.Relay {Message.from, Message.message = Message.Invitation {}}) (LoggedIn {key, name}) = do answer state@(Connected {}) (Relay {from, message = Invitation {}}) =
send $ Message.Answer {Message.accept = True} send (Answer {accept = True, to = from}) >> return state
return $ Playing {key, name, against = from}
answer (Message.Relay {Message.message = Message.Invitation {}}) state = do answer state@(Connected {playerID}) message@(Game {}) = do
send $ Message.Answer {Message.accept = False} case Message.step $ Message.public game of
return state Over -> deleteGame gameID state
answer (Message.Game {Message.game, Message.logs}) state@(Playing {key, name}) = do
case step game of
Over -> send Message.Quit >> return (LoggedIn {key, name})
_ -> do _ -> do
if playing game == key if Message.playing (Message.public game) == playerID
then send (Message.Play {Message.move = AI.move key game}) >> return state then
else return state send (Play {move = AI.move playerID game, onGame = game})
else return ()
storeGame gameID game state
where
game = Message.state message
gameID = Message.gameID . Message.coordinates $ Message.public game
answer (Message.Relay {Message.from, Message.message = Message.LogOut}) (Playing {key, name, against}) answer state Pong = ping >> return state
| from == against = send Message.Quit >> return (LoggedIn {key, name})
answer (Message.Relay {}) state = return state answer state (Error {error}) = do
answer Message.Pong state = ping >> return state
answer (Message.Error {Message.error}) state = do
debug $ "Received error from server : " `append` pack error debug $ "Received error from server : " `append` pack error
return state return state
answer message state = do answer state@(Connected {playerID, games}) (LogIn {from}) =
(mapM_ sync . Map.filter isAgainst $ Message.public <$> games) >> return state
where
isAgainst publicState = Message.nextPlayer publicState ! playerID == from
sync publicState =
send $ Sync {latestKnown = Message.coordinates $ publicState, to = from}
answer state@(Connected {games}) (Relay {from, message = Sync {latestKnown}}) =
case Map.lookup gameID games of
Nothing -> send $ Yield {onGameID = gameID, to = from}
Just game ->
let latestKnownHere = Message.coordinates $ Message.public game in
case orderCoordinates latestKnown latestKnownHere of
Just LT -> send $ Share {gameSave = game}
Just GT -> send $ Yield {onGameID = gameID, to = from}
_ -> return ()
>> return state
where
gameID = Message.gameID latestKnown
answer state@(Connected {games}) (Relay {message = Yield {onGameID}}) =
send (Share {gameSave = games ! onGameID}) >> return state
{-
- Ignore
-}
answer state@(Connected {}) (Okaeri {}) = return state
answer state (LogIn {}) = return state
answer state (LogOut {}) = return state
answer state (Relay {}) = return state
answer state message = do
debug $ ByteString.concat [ debug $ ByteString.concat [
"Unexpected message : ", encode message, " in state ", pack $ show state "Unexpected message : ", encode message, " in state ", pack $ show state
] ]
return state return state
ping :: App ()
ping = do ping = do
connection <- ask connection <- ask
setTimeout (20*s) (sendIO Message.Ping connection) setTimeout (20*s) (sendIO Ping connection)
where where
setTimeout delay callback = setTimeout delay callback =
const () <$> (lift $ forkIO (threadDelay delay >> callback)) const () <$> (lift $ forkIO (threadDelay delay >> callback))
@ -112,8 +123,12 @@ ping = do
s = 1000 * ms s = 1000 * ms
start :: App () start :: App ()
start = ping >> loop Initial start = do
ping
initialState <- lift Session.initial
case initialState of
New -> send $ Hello {name = "Hannah"}
Connected {playerID} -> send $ Tadaima {myID = playerID, name = "Hannah"}
loop initialState
where where
loop state = do loop state = receive >>= answer state >>= loop
newMessage <- receive
answer newMessage state >>= loop

View file

@ -1,11 +1,15 @@
module Config ( module Config (
host host
, libDir
, path , path
, port , port
) where ) where
host :: String host :: String
host = "koikoi.menf.in" host = "koikoi.local"
libDir :: FilePath
libDir = "/home/alice/Documents/Atelier/hanafuda/hannah/lib"
path :: String path :: String
path = "/play/" path = "/play/"

View file

@ -2,7 +2,6 @@ module Main where
import Network.WebSockets (ClientApp, runClient) import Network.WebSockets (ClientApp, runClient)
import Control.Monad.Reader (runReaderT) import Control.Monad.Reader (runReaderT)
import Control.Monad.State (runStateT)
import Config (host, port, path) import Config (host, port, path)
import Automaton (start) import Automaton (start)

83
src/Session.hs Normal file
View file

@ -0,0 +1,83 @@
{-# LANGUAGE NamedFieldPuns #-}
module Session (
State(..)
, deleteGame
, initial
, storeID
, storeGame
) where
import Config (libDir)
import Control.Monad (foldM)
import Control.Monad.Except (MonadError(..), ExceptT(..), runExceptT)
import Control.Monad.IO.Class (MonadIO(..))
import Data.Aeson (encodeFile, eitherDecodeFileStrict')
import Data.Map (Map)
import qualified Data.Map as Map (delete, empty, insert)
import Hanafuda.KoiKoi (GameID, PlayerID)
import qualified Hanafuda.Message as Message (PublicGame)
import System.Directory (
createDirectoryIfMissing, doesFileExist, listDirectory, removeFile
)
import System.FilePath ((</>))
import System.IO (stderr, hPutStrLn)
import Text.Read (readEither)
type Games = Map GameID Message.PublicGame
data State =
New
| Connected {
playerID :: PlayerID
, games :: Games
}
deriving Show
stateFile :: FilePath
stateFile = libDir </> "state"
gamesDir :: FilePath
gamesDir = libDir </> "games"
warn :: a -> String -> IO a
warn defaultValue errorMessage =
hPutStrLn stderr errorMessage >> return defaultValue
initial :: IO State
initial = do
createDirectoryIfMissing True libDir
fileExists <- doesFileExist stateFile
runExceptT (if fileExists then loadFile else stateFileMissing)
>>= either (warn New) return
where
stateFileMissing = throwError $ "State file missing : " ++ stateFile
loadFile = do
playerID <- ExceptT $ readEither <$> readFile stateFile
Connected playerID <$> liftIO loadGames
storeID :: Show a => a -> IO ()
storeID = writeFile stateFile . show
loadGames :: IO Games
loadGames = do
createDirectoryIfMissing True gamesDir
listDirectory gamesDir >>= foldM loadGame Map.empty
loadGame :: Games -> FilePath -> IO Games
loadGame tmpGames fileName =
runExceptT exceptLoad >>= either (warn tmpGames) return
where
exceptLoad = do
gameID <- ExceptT . return $ readEither fileName
publicGame <- ExceptT . eitherDecodeFileStrict' $ gamesDir </> fileName
return $ Map.insert gameID publicGame tmpGames
storeGame :: MonadIO m => GameID -> Message.PublicGame -> State -> m State
storeGame gameID publicGame state = do
liftIO $ createDirectoryIfMissing True gamesDir
>> encodeFile (gamesDir </> show gameID) publicGame
return $ state {games = Map.insert gameID publicGame $ games state}
deleteGame :: MonadIO m => GameID -> State -> m State
deleteGame gameID state = do
liftIO $ removeFile (gamesDir </> show gameID)
return $ state {games = Map.delete gameID $ games state}