Compare commits
No commits in common. "bb3bb366651adfc2d9017a0cb03095e9df6bf4db" and "8aaacfc83c03fdb1a2e285fe74bb829737cc4302" have entirely different histories.
bb3bb36665
...
8aaacfc83c
39
elm.json
39
elm.json
|
|
@ -1,39 +0,0 @@
|
||||||
{
|
|
||||||
"type": "application",
|
|
||||||
"source-directories": [
|
|
||||||
"elm"
|
|
||||||
],
|
|
||||||
"elm-version": "0.19.1",
|
|
||||||
"dependencies": {
|
|
||||||
"direct": {
|
|
||||||
"Orasund/elm-ui-widgets": "3.4.0",
|
|
||||||
"avh4/elm-color": "1.0.0",
|
|
||||||
"elm/browser": "1.0.2",
|
|
||||||
"elm/core": "1.0.5",
|
|
||||||
"elm/html": "1.0.1",
|
|
||||||
"elm/http": "2.0.0",
|
|
||||||
"elm/json": "1.1.4",
|
|
||||||
"elm/svg": "1.0.1",
|
|
||||||
"elm/time": "1.0.0",
|
|
||||||
"ianmackenzie/elm-units": "2.10.0",
|
|
||||||
"icidasset/elm-material-icons": "11.0.0",
|
|
||||||
"mdgriffith/elm-ui": "1.1.8",
|
|
||||||
"noordstar/elm-palette": "1.0.0"
|
|
||||||
},
|
|
||||||
"indirect": {
|
|
||||||
"elm/bytes": "1.0.8",
|
|
||||||
"elm/file": "1.0.5",
|
|
||||||
"elm/regex": "1.0.0",
|
|
||||||
"elm/url": "1.0.0",
|
|
||||||
"elm/virtual-dom": "1.0.5",
|
|
||||||
"elm-community/intdict": "3.1.0",
|
|
||||||
"fredcy/elm-parseint": "2.0.1",
|
|
||||||
"noahzgordon/elm-color-extra": "1.0.2",
|
|
||||||
"turboMaCk/queue": "1.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"test-dependencies": {
|
|
||||||
"direct": {},
|
|
||||||
"indirect": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
167
elm/Api.elm
167
elm/Api.elm
|
|
@ -1,167 +0,0 @@
|
||||||
module Api exposing
|
|
||||||
( GameDetails
|
|
||||||
, Player
|
|
||||||
, gameDetails
|
|
||||||
, profile
|
|
||||||
, startGame
|
|
||||||
)
|
|
||||||
|
|
||||||
import Dict exposing (Dict)
|
|
||||||
import Http
|
|
||||||
import Json.Decode as D
|
|
||||||
import Json.Encode as E
|
|
||||||
|
|
||||||
|
|
||||||
endpointGameDetails =
|
|
||||||
"/game-details"
|
|
||||||
|
|
||||||
|
|
||||||
endpointGetProfile =
|
|
||||||
"/profile"
|
|
||||||
|
|
||||||
|
|
||||||
endpointStartGame =
|
|
||||||
"/start-game"
|
|
||||||
|
|
||||||
|
|
||||||
{-| Full report on how a game is going.
|
|
||||||
-}
|
|
||||||
type alias GameDetails gameState =
|
|
||||||
{ name : String
|
|
||||||
, turns : List { player : Int, action : E.Value, state : gameState }
|
|
||||||
, winner : Maybe Int
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
{-| General format of a player who's allowed to participate.
|
|
||||||
-}
|
|
||||||
type alias Player =
|
|
||||||
{ name : String
|
|
||||||
, games : Dict String (Dict String E.Value)
|
|
||||||
, profile : Dict String E.Value
|
|
||||||
, url : String
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
{-| Builds a generalized API call to the webclient server.
|
|
||||||
-}
|
|
||||||
callWebClient :
|
|
||||||
{ body : Maybe D.Value
|
|
||||||
, decoder : D.Decoder a
|
|
||||||
, method : String
|
|
||||||
, toMsg : Result Http.Error a -> msg
|
|
||||||
, url : String
|
|
||||||
}
|
|
||||||
-> Cmd msg
|
|
||||||
callWebClient data =
|
|
||||||
Http.request
|
|
||||||
{ method = data.method
|
|
||||||
, headers = []
|
|
||||||
, url = data.url
|
|
||||||
, body =
|
|
||||||
data.body
|
|
||||||
|> Maybe.map Http.jsonBody
|
|
||||||
|> Maybe.withDefault Http.emptyBody
|
|
||||||
, expect = Http.expectJson data.toMsg data.decoder
|
|
||||||
, timeout = Nothing
|
|
||||||
, tracker = Nothing
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
{-| Retrieves all the latest details about a game. A game might still be
|
|
||||||
ongoing and therefore might be incomplete.
|
|
||||||
-}
|
|
||||||
gameDetails :
|
|
||||||
{ baseUrl : String
|
|
||||||
, decoder : D.Decoder gameState
|
|
||||||
, gameId : String
|
|
||||||
, toMsg : Result Http.Error (GameDetails gameState) -> msg
|
|
||||||
}
|
|
||||||
-> Cmd msg
|
|
||||||
gameDetails data =
|
|
||||||
callWebClient
|
|
||||||
{ body = Just (E.object [ ( "game_id", E.string data.gameId ) ])
|
|
||||||
, decoder = gameDetailsDecoder data.decoder
|
|
||||||
, method = "GET"
|
|
||||||
, toMsg = data.toMsg
|
|
||||||
, url = data.baseUrl ++ endpointGameDetails
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
{-| Decodes a game's details from JSON.
|
|
||||||
-}
|
|
||||||
gameDetailsDecoder : D.Decoder gameState -> D.Decoder (GameDetails gameState)
|
|
||||||
gameDetailsDecoder decoder =
|
|
||||||
D.map3 GameDetails
|
|
||||||
(D.field "name" D.string)
|
|
||||||
(D.map3
|
|
||||||
(\action player state ->
|
|
||||||
{ player = player, action = action, state = state }
|
|
||||||
)
|
|
||||||
(D.field "action" D.value)
|
|
||||||
(D.field "player" D.int)
|
|
||||||
(D.field "state" decoder)
|
|
||||||
|> D.list
|
|
||||||
|> D.field "turns"
|
|
||||||
)
|
|
||||||
(D.field "winner" <| D.oneOf [ D.map Just D.int, D.null Nothing ])
|
|
||||||
|
|
||||||
|
|
||||||
{-| Decodes a Player from JSON.
|
|
||||||
-}
|
|
||||||
playerDecoder : D.Decoder Player
|
|
||||||
playerDecoder =
|
|
||||||
D.map4 Player
|
|
||||||
(D.field "name" D.string)
|
|
||||||
(D.field "games" <| D.dict <| D.dict D.value)
|
|
||||||
(D.field "profile" <| D.dict D.value)
|
|
||||||
(D.field "url" D.string)
|
|
||||||
|
|
||||||
|
|
||||||
{-| Gets the profile of a given player with a given URL.
|
|
||||||
-}
|
|
||||||
profile :
|
|
||||||
{ baseUrl : String
|
|
||||||
, playerUrl : String
|
|
||||||
, toMsg : Result Http.Error Player -> msg
|
|
||||||
}
|
|
||||||
-> Cmd msg
|
|
||||||
profile data =
|
|
||||||
callWebClient
|
|
||||||
{ body = Just (E.object [ ( "url", E.string data.playerUrl ) ])
|
|
||||||
, decoder = playerDecoder
|
|
||||||
, method = "GET"
|
|
||||||
, toMsg = data.toMsg
|
|
||||||
, url = data.baseUrl ++ endpointGetProfile
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
{-| Instructs the server to start a game with the PyClient. The players list
|
|
||||||
provides a set of URLs that should be considered as its players, even if the
|
|
||||||
players haven't been verified (yet).
|
|
||||||
|
|
||||||
The server responds with a unique identifier for the game. This allows the
|
|
||||||
front-end to query updates about the game while it's still being processed.
|
|
||||||
|
|
||||||
-}
|
|
||||||
startGame :
|
|
||||||
{ baseUrl : String
|
|
||||||
, game : String
|
|
||||||
, players : List String
|
|
||||||
, toMsg : Result Http.Error String -> msg
|
|
||||||
}
|
|
||||||
-> Cmd msg
|
|
||||||
startGame data =
|
|
||||||
callWebClient
|
|
||||||
{ body =
|
|
||||||
Just
|
|
||||||
(E.object
|
|
||||||
[ ( "game", E.string data.game )
|
|
||||||
, ( "players", E.list E.string data.players )
|
|
||||||
]
|
|
||||||
)
|
|
||||||
, decoder = D.string
|
|
||||||
, method = "POST"
|
|
||||||
, toMsg = data.toMsg
|
|
||||||
, url = data.baseUrl ++ endpointStartGame
|
|
||||||
}
|
|
||||||
238
elm/GameList.elm
238
elm/GameList.elm
|
|
@ -1,238 +0,0 @@
|
||||||
module GameList exposing (..)
|
|
||||||
|
|
||||||
import Dict exposing (Dict)
|
|
||||||
import Duration
|
|
||||||
import Element exposing (Element)
|
|
||||||
import Element.Background
|
|
||||||
import Element.Events
|
|
||||||
import Games.TicTacToe as TicTacToe exposing (TicTacToe)
|
|
||||||
import Layout
|
|
||||||
import Match exposing (Match)
|
|
||||||
import Material.Icons as Icons
|
|
||||||
import Pixels exposing (Pixels)
|
|
||||||
import Quantity exposing (Quantity)
|
|
||||||
import Theme
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- MODEL
|
|
||||||
|
|
||||||
|
|
||||||
type CreateGameType
|
|
||||||
= CreateTicTacToe
|
|
||||||
|
|
||||||
|
|
||||||
type Game
|
|
||||||
= GameTicTacToe String (Match TicTacToe)
|
|
||||||
|
|
||||||
|
|
||||||
type GameList
|
|
||||||
= GameList
|
|
||||||
{ ticTacToe : Dict String (Match TicTacToe)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
type Msg
|
|
||||||
= AddTicTacToe { baseUrl : String, matchId : String }
|
|
||||||
| OnTicTacToe String (Match.Msg TicTacToe)
|
|
||||||
|
|
||||||
|
|
||||||
init : {} -> ( GameList, Cmd Msg )
|
|
||||||
init _ =
|
|
||||||
( GameList
|
|
||||||
{ ticTacToe = Dict.empty
|
|
||||||
}
|
|
||||||
, Cmd.none
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- UPDATE
|
|
||||||
|
|
||||||
|
|
||||||
update : Msg -> GameList -> ( GameList, Cmd Msg )
|
|
||||||
update msg ((GameList data) as model) =
|
|
||||||
case msg of
|
|
||||||
AddTicTacToe newGame ->
|
|
||||||
let
|
|
||||||
( newMdl, newM ) =
|
|
||||||
Match.init
|
|
||||||
{ autoScroll = Just (Duration.seconds 0.75)
|
|
||||||
, baseUrl = newGame.baseUrl
|
|
||||||
, decoder = TicTacToe.decoder
|
|
||||||
, empty = TicTacToe.empty
|
|
||||||
, matchId = newGame.matchId
|
|
||||||
}
|
|
||||||
in
|
|
||||||
( GameList { data | ticTacToe = Dict.insert newGame.matchId newMdl data.ticTacToe }
|
|
||||||
, Cmd.map (OnTicTacToe newGame.matchId) newM
|
|
||||||
)
|
|
||||||
|
|
||||||
OnTicTacToe key m ->
|
|
||||||
case Dict.get key data.ticTacToe of
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
Just mdl ->
|
|
||||||
case Match.update m mdl of
|
|
||||||
( newMdl, newM ) ->
|
|
||||||
( GameList { data | ticTacToe = Dict.insert key newMdl data.ticTacToe }
|
|
||||||
, Cmd.map (OnTicTacToe key) newM
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- SUBSCRIPTIONS
|
|
||||||
|
|
||||||
|
|
||||||
subscriptions : GameList -> Sub Msg
|
|
||||||
subscriptions (GameList data) =
|
|
||||||
Sub.batch
|
|
||||||
[ Dict.toList data.ticTacToe
|
|
||||||
|> List.map
|
|
||||||
(\( key, mdl ) ->
|
|
||||||
Sub.map (OnTicTacToe key) (Match.subscriptions mdl)
|
|
||||||
)
|
|
||||||
|> Sub.batch
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- VIEW
|
|
||||||
|
|
||||||
|
|
||||||
createGameToString : CreateGameType -> String
|
|
||||||
createGameToString cg =
|
|
||||||
case cg of
|
|
||||||
CreateTicTacToe ->
|
|
||||||
"tic-tac-toe"
|
|
||||||
|
|
||||||
|
|
||||||
viewCreateGame :
|
|
||||||
{ baseUrl : String
|
|
||||||
, flavor : Theme.Flavor
|
|
||||||
, height : Quantity Int Pixels
|
|
||||||
, onBaseUrl : String -> msg
|
|
||||||
, onPlayers : List String -> msg
|
|
||||||
, players : List String
|
|
||||||
, width : Quantity Int Pixels
|
|
||||||
}
|
|
||||||
-> Element msg
|
|
||||||
viewCreateGame data =
|
|
||||||
Element.none
|
|
||||||
|
|
||||||
|
|
||||||
viewGame :
|
|
||||||
{ flavor : Theme.Flavor
|
|
||||||
, game : Game
|
|
||||||
, height : Quantity Int Pixels
|
|
||||||
, onNavigateBack : msg
|
|
||||||
, toMsg : Msg -> msg
|
|
||||||
, width : Quantity Int Pixels
|
|
||||||
}
|
|
||||||
-> Element msg
|
|
||||||
viewGame data =
|
|
||||||
let
|
|
||||||
navBarHeight =
|
|
||||||
Pixels.pixels 200
|
|
||||||
|
|
||||||
showNavBar =
|
|
||||||
Quantity.ratio (Quantity.toFloatQuantity data.height) navBarHeight <= 3
|
|
||||||
|
|
||||||
gameHeight =
|
|
||||||
if showNavBar then
|
|
||||||
data.height |> Quantity.minus navBarHeight
|
|
||||||
|
|
||||||
else
|
|
||||||
data.height
|
|
||||||
in
|
|
||||||
Element.column
|
|
||||||
[ Element.height <| Element.px <| Pixels.inPixels data.height
|
|
||||||
, Element.width <| Element.px <| Pixels.inPixels data.width
|
|
||||||
]
|
|
||||||
[ if showNavBar then
|
|
||||||
Element.row
|
|
||||||
[ Element.Background.color (Theme.blueUI data.flavor)
|
|
||||||
, Element.height <| Element.px <| Pixels.inPixels navBarHeight
|
|
||||||
, Element.width <| Element.px <| Pixels.inPixels data.width
|
|
||||||
]
|
|
||||||
[ Layout.iconAsElement
|
|
||||||
{ color = Theme.blue data.flavor
|
|
||||||
, height = Pixels.inPixels navBarHeight
|
|
||||||
, icon = Icons.arrow_back
|
|
||||||
, width = Pixels.inPixels data.width
|
|
||||||
}
|
|
||||||
|> Element.el [ Element.Events.onClick data.onNavigateBack ]
|
|
||||||
]
|
|
||||||
|
|
||||||
else
|
|
||||||
Element.none
|
|
||||||
, viewMatch
|
|
||||||
{ flavor = data.flavor
|
|
||||||
, game = data.game
|
|
||||||
, height = gameHeight
|
|
||||||
, width = data.width
|
|
||||||
}
|
|
||||||
|> Element.map data.toMsg
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
viewMatch :
|
|
||||||
{ flavor : Theme.Flavor
|
|
||||||
, game : Game
|
|
||||||
, height : Quantity Int Pixels
|
|
||||||
, width : Quantity Int Pixels
|
|
||||||
}
|
|
||||||
-> Element Msg
|
|
||||||
viewMatch data =
|
|
||||||
case data.game of
|
|
||||||
GameTicTacToe key match ->
|
|
||||||
Match.view
|
|
||||||
{ flavor = data.flavor
|
|
||||||
, height = data.height
|
|
||||||
, match = match
|
|
||||||
, toMsg = OnTicTacToe key
|
|
||||||
, viewGame = TicTacToe.view
|
|
||||||
, width = data.width
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
viewSelection :
|
|
||||||
{ flavor : Theme.Flavor
|
|
||||||
, height : Quantity Int Pixels
|
|
||||||
, model : GameList
|
|
||||||
, onCreateGame : msg
|
|
||||||
, onNavigateToGame : Game -> msg
|
|
||||||
, width : Quantity Int Pixels
|
|
||||||
}
|
|
||||||
-> Element msg
|
|
||||||
viewSelection data =
|
|
||||||
case data.model of
|
|
||||||
GameList model ->
|
|
||||||
[ Layout.itemWithSubtext
|
|
||||||
{ color = Theme.mantle data.flavor
|
|
||||||
, leftIcon = always Element.none
|
|
||||||
, onPress = Just data.onCreateGame
|
|
||||||
, rightIcon = always Element.none
|
|
||||||
, text = "Create new game"
|
|
||||||
, title = "CREATE"
|
|
||||||
}
|
|
||||||
[]
|
|
||||||
|> List.singleton
|
|
||||||
, model.ticTacToe
|
|
||||||
|> Dict.toList
|
|
||||||
|> List.map
|
|
||||||
(\( key, match ) ->
|
|
||||||
Match.viewListItem
|
|
||||||
{ flavor = data.flavor
|
|
||||||
, height = Pixels.pixels 80
|
|
||||||
, match = match
|
|
||||||
, onPress = Just <| data.onNavigateToGame <| GameTicTacToe key match
|
|
||||||
, width = data.width
|
|
||||||
}
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|> List.concat
|
|
||||||
|> Element.column
|
|
||||||
[ Element.centerX
|
|
||||||
]
|
|
||||||
|
|
@ -1,201 +0,0 @@
|
||||||
module Games.TicTacToe exposing (..)
|
|
||||||
|
|
||||||
{-| This module exposes a library for the simple game of tic-tac-toe.
|
|
||||||
-}
|
|
||||||
|
|
||||||
import Color
|
|
||||||
import Element exposing (Element)
|
|
||||||
import Json.Decode as D
|
|
||||||
import Layout
|
|
||||||
import Pixels exposing (Pixels)
|
|
||||||
import Quantity exposing (Quantity)
|
|
||||||
import Svg
|
|
||||||
import Svg.Attributes
|
|
||||||
import Theme
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- MODEL
|
|
||||||
|
|
||||||
|
|
||||||
type Field
|
|
||||||
= X
|
|
||||||
| O
|
|
||||||
| Empty
|
|
||||||
|
|
||||||
|
|
||||||
type alias TicTacToe =
|
|
||||||
{ field_1 : Field
|
|
||||||
, field_2 : Field
|
|
||||||
, field_3 : Field
|
|
||||||
, field_4 : Field
|
|
||||||
, field_5 : Field
|
|
||||||
, field_6 : Field
|
|
||||||
, field_7 : Field
|
|
||||||
, field_8 : Field
|
|
||||||
, field_9 : Field
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
decoder : D.Decoder TicTacToe
|
|
||||||
decoder =
|
|
||||||
D.map3
|
|
||||||
(\( a, b, c ) ( d, e, f ) ( g, h, i ) ->
|
|
||||||
TicTacToe a b c d e f g h i
|
|
||||||
)
|
|
||||||
(D.map3 (\a b c -> ( a, b, c )) fieldDecoder fieldDecoder fieldDecoder)
|
|
||||||
(D.map3 (\a b c -> ( a, b, c )) fieldDecoder fieldDecoder fieldDecoder)
|
|
||||||
(D.map3 (\a b c -> ( a, b, c )) fieldDecoder fieldDecoder fieldDecoder)
|
|
||||||
|
|
||||||
|
|
||||||
empty : TicTacToe
|
|
||||||
empty =
|
|
||||||
{ field_1 = Empty
|
|
||||||
, field_2 = Empty
|
|
||||||
, field_3 = Empty
|
|
||||||
, field_4 = Empty
|
|
||||||
, field_5 = Empty
|
|
||||||
, field_6 = Empty
|
|
||||||
, field_7 = Empty
|
|
||||||
, field_8 = Empty
|
|
||||||
, field_9 = Empty
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fieldDecoder : D.Decoder Field
|
|
||||||
fieldDecoder =
|
|
||||||
D.andThen
|
|
||||||
(\s ->
|
|
||||||
case s of
|
|
||||||
"X" ->
|
|
||||||
D.succeed X
|
|
||||||
|
|
||||||
"O" ->
|
|
||||||
D.succeed O
|
|
||||||
|
|
||||||
"" ->
|
|
||||||
D.succeed Empty
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
D.fail "Unknown field type"
|
|
||||||
)
|
|
||||||
D.string
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- VIEW
|
|
||||||
|
|
||||||
|
|
||||||
view :
|
|
||||||
{ flavor : Theme.Flavor
|
|
||||||
, game : TicTacToe
|
|
||||||
, height : Quantity Int Pixels
|
|
||||||
, width : Quantity Int Pixels
|
|
||||||
}
|
|
||||||
-> Element msg
|
|
||||||
view data =
|
|
||||||
Layout.svg
|
|
||||||
{ aspectRatio = 1 / 1
|
|
||||||
, height = Pixels.inPixels data.height
|
|
||||||
, width = Pixels.inPixels data.width
|
|
||||||
, viewMinX = 0
|
|
||||||
, viewMaxX = 300
|
|
||||||
, viewMinY = 0
|
|
||||||
, viewMaxY = 300
|
|
||||||
, svg =
|
|
||||||
Svg.g
|
|
||||||
[ Svg.Attributes.strokeLinecap "round"
|
|
||||||
]
|
|
||||||
[ svgField { field = data.game.field_1, flavor = data.flavor, offsetX = 0, offsetY = 0 }
|
|
||||||
, svgField { field = data.game.field_2, flavor = data.flavor, offsetX = 1, offsetY = 0 }
|
|
||||||
, svgField { field = data.game.field_3, flavor = data.flavor, offsetX = 2, offsetY = 0 }
|
|
||||||
, svgField { field = data.game.field_4, flavor = data.flavor, offsetX = 0, offsetY = 1 }
|
|
||||||
, svgField { field = data.game.field_5, flavor = data.flavor, offsetX = 1, offsetY = 1 }
|
|
||||||
, svgField { field = data.game.field_6, flavor = data.flavor, offsetX = 2, offsetY = 1 }
|
|
||||||
, svgField { field = data.game.field_7, flavor = data.flavor, offsetX = 0, offsetY = 2 }
|
|
||||||
, svgField { field = data.game.field_8, flavor = data.flavor, offsetX = 1, offsetY = 2 }
|
|
||||||
, svgField { field = data.game.field_9, flavor = data.flavor, offsetX = 2, offsetY = 2 }
|
|
||||||
, Svg.g
|
|
||||||
[ Svg.Attributes.fill <| Color.toCssString <| Theme.text data.flavor
|
|
||||||
, Svg.Attributes.strokeWidth "7.5"
|
|
||||||
]
|
|
||||||
[ Svg.line
|
|
||||||
[ Svg.Attributes.x1 "100"
|
|
||||||
, Svg.Attributes.x1 "100"
|
|
||||||
, Svg.Attributes.y1 "20"
|
|
||||||
, Svg.Attributes.y2 "280"
|
|
||||||
]
|
|
||||||
[]
|
|
||||||
, Svg.line
|
|
||||||
[ Svg.Attributes.x1 "200"
|
|
||||||
, Svg.Attributes.x1 "200"
|
|
||||||
, Svg.Attributes.y1 "20"
|
|
||||||
, Svg.Attributes.y2 "280"
|
|
||||||
]
|
|
||||||
[]
|
|
||||||
, Svg.line
|
|
||||||
[ Svg.Attributes.x1 "20"
|
|
||||||
, Svg.Attributes.x1 "280"
|
|
||||||
, Svg.Attributes.y1 "100"
|
|
||||||
, Svg.Attributes.y2 "100"
|
|
||||||
]
|
|
||||||
[]
|
|
||||||
, Svg.line
|
|
||||||
[ Svg.Attributes.x1 "20"
|
|
||||||
, Svg.Attributes.x1 "280"
|
|
||||||
, Svg.Attributes.y1 "200"
|
|
||||||
, Svg.Attributes.y2 "200"
|
|
||||||
]
|
|
||||||
[]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
svgField :
|
|
||||||
{ field : Field
|
|
||||||
, flavor : Theme.Flavor
|
|
||||||
, offsetX : Int
|
|
||||||
, offsetY : Int
|
|
||||||
}
|
|
||||||
-> Svg.Svg svg
|
|
||||||
svgField data =
|
|
||||||
let
|
|
||||||
radius =
|
|
||||||
35
|
|
||||||
in
|
|
||||||
Svg.g
|
|
||||||
[ Svg.Attributes.fill <| Color.toCssString <| Theme.subtext0 data.flavor
|
|
||||||
, Svg.Attributes.strokeWidth "10"
|
|
||||||
]
|
|
||||||
[ case data.field of
|
|
||||||
Empty ->
|
|
||||||
Svg.g [] []
|
|
||||||
|
|
||||||
O ->
|
|
||||||
Svg.circle
|
|
||||||
[ Svg.Attributes.cx (String.fromInt (50 + 100 * data.offsetX))
|
|
||||||
, Svg.Attributes.cy (String.fromInt (50 + 100 * data.offsetY))
|
|
||||||
, Svg.Attributes.r (String.fromInt radius)
|
|
||||||
]
|
|
||||||
[]
|
|
||||||
|
|
||||||
X ->
|
|
||||||
Svg.g
|
|
||||||
[]
|
|
||||||
[ Svg.line
|
|
||||||
[ Svg.Attributes.x1 (String.fromInt (50 - radius))
|
|
||||||
, Svg.Attributes.x2 (String.fromInt (50 + radius))
|
|
||||||
, Svg.Attributes.y1 (String.fromInt (50 - radius))
|
|
||||||
, Svg.Attributes.y2 (String.fromInt (50 + radius))
|
|
||||||
]
|
|
||||||
[]
|
|
||||||
, Svg.line
|
|
||||||
[ Svg.Attributes.x1 (String.fromInt (50 - radius))
|
|
||||||
, Svg.Attributes.x2 (String.fromInt (50 + radius))
|
|
||||||
, Svg.Attributes.y1 (String.fromInt (50 + radius))
|
|
||||||
, Svg.Attributes.y2 (String.fromInt (50 - radius))
|
|
||||||
]
|
|
||||||
[]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
515
elm/Layout.elm
515
elm/Layout.elm
|
|
@ -1,515 +0,0 @@
|
||||||
module Layout exposing
|
|
||||||
( twoBlocks
|
|
||||||
, tab, sideIconBar
|
|
||||||
, iconAsElement, iconAsIcon
|
|
||||||
, containedButton, outlinedButton, textButton
|
|
||||||
, textInput, passwordInput
|
|
||||||
, header, stdText
|
|
||||||
, itemWithSubtext
|
|
||||||
, sideList, radioButtons
|
|
||||||
, loadingIndicator, svg
|
|
||||||
)
|
|
||||||
|
|
||||||
{-|
|
|
||||||
|
|
||||||
|
|
||||||
# Layout
|
|
||||||
|
|
||||||
The layout module exposes some boilerplate functions that have produce a
|
|
||||||
beautiful Material design Elm webpage.
|
|
||||||
|
|
||||||
|
|
||||||
## Screen layout
|
|
||||||
|
|
||||||
@docs twoBlocks
|
|
||||||
|
|
||||||
|
|
||||||
## Elements
|
|
||||||
|
|
||||||
@docs tab, sideIconBar
|
|
||||||
|
|
||||||
|
|
||||||
## Icons
|
|
||||||
|
|
||||||
@docs iconAsElement, iconAsIcon
|
|
||||||
|
|
||||||
|
|
||||||
## Buttons
|
|
||||||
|
|
||||||
@docs containedButton, outlinedButton, textButton
|
|
||||||
|
|
||||||
|
|
||||||
## Text fields
|
|
||||||
|
|
||||||
@docs textInput, passwordInput
|
|
||||||
|
|
||||||
|
|
||||||
## Text
|
|
||||||
|
|
||||||
@docs header, stdText
|
|
||||||
|
|
||||||
|
|
||||||
## Items in a list
|
|
||||||
|
|
||||||
@docs itemWithSubtext
|
|
||||||
|
|
||||||
|
|
||||||
## Lists
|
|
||||||
|
|
||||||
@docs sideList, radioButtons
|
|
||||||
|
|
||||||
|
|
||||||
## Other elements
|
|
||||||
|
|
||||||
@docs loadingIndicator, svg
|
|
||||||
|
|
||||||
-}
|
|
||||||
|
|
||||||
import Color exposing (Color)
|
|
||||||
import Element exposing (Element)
|
|
||||||
import Element.Background
|
|
||||||
import Element.Events
|
|
||||||
import Element.Font
|
|
||||||
import Element.Input
|
|
||||||
import Html.Attributes
|
|
||||||
import Material.Icons.Types
|
|
||||||
import Svg exposing (Svg)
|
|
||||||
import Svg.Attributes
|
|
||||||
import Theme
|
|
||||||
import Widget
|
|
||||||
import Widget.Customize as Customize
|
|
||||||
import Widget.Icon exposing (Icon)
|
|
||||||
import Widget.Material as Material
|
|
||||||
import Widget.Material.Typography
|
|
||||||
|
|
||||||
|
|
||||||
{-| A contained button representing the most important action of a group.
|
|
||||||
-}
|
|
||||||
containedButton :
|
|
||||||
{ buttonColor : Color
|
|
||||||
, clickColor : Color
|
|
||||||
, icon : Icon msg
|
|
||||||
, onPress : Maybe msg
|
|
||||||
, text : String
|
|
||||||
}
|
|
||||||
-> Element msg
|
|
||||||
containedButton data =
|
|
||||||
Widget.button
|
|
||||||
({ primary = data.buttonColor, onPrimary = data.clickColor }
|
|
||||||
|> singlePalette
|
|
||||||
|> Material.containedButton
|
|
||||||
|> Customize.elementButton [ Element.width Element.fill ]
|
|
||||||
)
|
|
||||||
{ text = data.text, icon = data.icon, onPress = data.onPress }
|
|
||||||
|
|
||||||
|
|
||||||
header : String -> Element msg
|
|
||||||
header =
|
|
||||||
Element.text
|
|
||||||
>> Element.el Widget.Material.Typography.h1
|
|
||||||
>> List.singleton
|
|
||||||
>> Element.paragraph []
|
|
||||||
|
|
||||||
|
|
||||||
iconAsElement :
|
|
||||||
{ color : Color
|
|
||||||
, height : Int
|
|
||||||
, icon : Material.Icons.Types.Icon msg
|
|
||||||
, width : Int
|
|
||||||
}
|
|
||||||
-> Element msg
|
|
||||||
iconAsElement data =
|
|
||||||
data.icon
|
|
||||||
|> iconAsIcon
|
|
||||||
|> (|>) { size = Basics.min data.height data.width, color = data.color }
|
|
||||||
|> Element.el [ Element.centerX, Element.centerY ]
|
|
||||||
|> Element.el
|
|
||||||
[ Element.height (Element.px data.height)
|
|
||||||
, Element.width (Element.px data.width)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
iconAsIcon : Material.Icons.Types.Icon msg -> Widget.Icon.Icon msg
|
|
||||||
iconAsIcon =
|
|
||||||
Widget.Icon.elmMaterialIcons Material.Icons.Types.Color
|
|
||||||
|
|
||||||
|
|
||||||
{-| Multiline item
|
|
||||||
-}
|
|
||||||
itemWithSubtext :
|
|
||||||
{ color : Color
|
|
||||||
, leftIcon : Widget.Icon.Icon msg
|
|
||||||
, onPress : Maybe msg
|
|
||||||
, rightIcon : Widget.Icon.Icon msg
|
|
||||||
, text : String
|
|
||||||
, title : String
|
|
||||||
}
|
|
||||||
-> Widget.Item msg
|
|
||||||
itemWithSubtext data =
|
|
||||||
Widget.multiLineItem
|
|
||||||
({ primary = data.color, onPrimary = data.color }
|
|
||||||
|> singlePalette
|
|
||||||
|> Material.multiLineItem
|
|
||||||
)
|
|
||||||
{ content = data.rightIcon
|
|
||||||
, icon = data.leftIcon
|
|
||||||
, onPress = data.onPress
|
|
||||||
, title = data.title
|
|
||||||
, text = data.text
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
{-| Circular loading bar indicator
|
|
||||||
-}
|
|
||||||
loadingIndicator :
|
|
||||||
{ color : Color
|
|
||||||
}
|
|
||||||
-> Element msg
|
|
||||||
loadingIndicator data =
|
|
||||||
Widget.circularProgressIndicator
|
|
||||||
({ primary = data.color, onPrimary = data.color }
|
|
||||||
|> singlePalette
|
|
||||||
|> Material.progressIndicator
|
|
||||||
)
|
|
||||||
Nothing
|
|
||||||
|
|
||||||
|
|
||||||
{-| An outlined button representing an important action within a group.
|
|
||||||
-}
|
|
||||||
outlinedButton :
|
|
||||||
{ color : Color
|
|
||||||
, icon : Icon msg
|
|
||||||
, onPress : Maybe msg
|
|
||||||
, text : String
|
|
||||||
}
|
|
||||||
-> Element msg
|
|
||||||
outlinedButton data =
|
|
||||||
Widget.button
|
|
||||||
({ primary = data.color, onPrimary = data.color }
|
|
||||||
|> singlePalette
|
|
||||||
|> Material.outlinedButton
|
|
||||||
)
|
|
||||||
{ text = data.text, icon = data.icon, onPress = data.onPress }
|
|
||||||
|
|
||||||
|
|
||||||
{-| Show a password field
|
|
||||||
-}
|
|
||||||
passwordInput :
|
|
||||||
{ color : Color
|
|
||||||
, label : String
|
|
||||||
, onChange : String -> msg
|
|
||||||
, placeholder : Maybe String
|
|
||||||
, show : Bool
|
|
||||||
, text : String
|
|
||||||
}
|
|
||||||
-> Element msg
|
|
||||||
passwordInput data =
|
|
||||||
Widget.currentPasswordInputV2
|
|
||||||
({ primary = data.color, onPrimary = data.color }
|
|
||||||
|> singlePalette
|
|
||||||
|> Material.passwordInput
|
|
||||||
|> Customize.elementRow [ Element.width Element.fill ]
|
|
||||||
)
|
|
||||||
{ label = data.label
|
|
||||||
, onChange = data.onChange
|
|
||||||
, placeholder =
|
|
||||||
data.placeholder
|
|
||||||
|> Maybe.map Element.text
|
|
||||||
|> Maybe.map (Element.Input.placeholder [])
|
|
||||||
, show = data.show
|
|
||||||
, text = data.text
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
{-| Redio buttons are side-by-side buttons that only allowed up to one to be
|
|
||||||
selected.
|
|
||||||
-}
|
|
||||||
radioButtons :
|
|
||||||
{ color : Color
|
|
||||||
, items : List ( Bool, a )
|
|
||||||
, toIcon : a -> Icon msg
|
|
||||||
, toString : a -> String
|
|
||||||
, onChange : a -> msg
|
|
||||||
}
|
|
||||||
-> Element msg
|
|
||||||
radioButtons data =
|
|
||||||
data.items
|
|
||||||
|> List.map
|
|
||||||
(Tuple.mapSecond
|
|
||||||
(\item ->
|
|
||||||
{ text = data.toString item
|
|
||||||
, icon =
|
|
||||||
\{ size, color } ->
|
|
||||||
Element.text (data.toString item)
|
|
||||||
, onPress = Just (data.onChange item)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|> Widget.toggleRow
|
|
||||||
{ elementRow = Material.toggleRow
|
|
||||||
, content =
|
|
||||||
{ primary = data.color, onPrimary = data.color }
|
|
||||||
|> singlePalette
|
|
||||||
|> Material.toggleButton
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
{-| Create a simple palette.
|
|
||||||
-}
|
|
||||||
singlePalette : { primary : Color, onPrimary : Color } -> Material.Palette
|
|
||||||
singlePalette { primary, onPrimary } =
|
|
||||||
{ primary = primary
|
|
||||||
, secondary = primary
|
|
||||||
, background = primary
|
|
||||||
, surface = primary
|
|
||||||
, error = primary
|
|
||||||
, on =
|
|
||||||
{ primary = onPrimary
|
|
||||||
, secondary = onPrimary
|
|
||||||
, background = onPrimary
|
|
||||||
, surface = onPrimary
|
|
||||||
, error = onPrimary
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
sideIconBar :
|
|
||||||
{ colorBackground : Color
|
|
||||||
, colorText : Color
|
|
||||||
, height : Int
|
|
||||||
, items : List { icon : Widget.Icon.Icon msg, onPress : msg, text : String }
|
|
||||||
, width : Int
|
|
||||||
}
|
|
||||||
-> Element msg
|
|
||||||
sideIconBar data =
|
|
||||||
let
|
|
||||||
buttonHeight =
|
|
||||||
round (toFloat data.width * 1.618)
|
|
||||||
|
|
||||||
fontSize =
|
|
||||||
data.width // 6
|
|
||||||
|
|
||||||
iconSize =
|
|
||||||
data.width * 3 // 5
|
|
||||||
in
|
|
||||||
data.items
|
|
||||||
|> List.map
|
|
||||||
(\item ->
|
|
||||||
[ item.icon { size = iconSize, color = data.colorText }
|
|
||||||
|> Element.el [ Element.centerX ]
|
|
||||||
, Element.paragraph [] [ Element.text item.text ]
|
|
||||||
]
|
|
||||||
|> Element.column
|
|
||||||
[ Element.centerX
|
|
||||||
, Element.centerY
|
|
||||||
, Element.Font.bold
|
|
||||||
, Element.Font.center
|
|
||||||
, Element.Font.size fontSize
|
|
||||||
, Element.htmlAttribute (Html.Attributes.style "cursor" "pointer")
|
|
||||||
]
|
|
||||||
|> Element.el
|
|
||||||
[ Element.centerY
|
|
||||||
, Element.Events.onClick item.onPress
|
|
||||||
, Element.height (Element.px data.width)
|
|
||||||
, Element.width (Element.px data.width)
|
|
||||||
]
|
|
||||||
|> Element.el
|
|
||||||
[ Element.height (Element.px buttonHeight)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|> Element.column
|
|
||||||
[ Element.Background.color (Theme.toElmUiColor data.colorBackground)
|
|
||||||
, Element.height (Element.px data.height)
|
|
||||||
, Element.scrollbarY
|
|
||||||
, Element.width (Element.px data.width)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
sideList : { color : Color, items : List (Widget.Item msg), width : Int } -> Element msg
|
|
||||||
sideList data =
|
|
||||||
let
|
|
||||||
width px =
|
|
||||||
Element.width (Element.px px)
|
|
||||||
in
|
|
||||||
Widget.itemList
|
|
||||||
({ primary = data.color, onPrimary = data.color }
|
|
||||||
|> singlePalette
|
|
||||||
|> Material.sideSheet
|
|
||||||
)
|
|
||||||
data.items
|
|
||||||
|> Element.el [ Element.centerX, width (Basics.min 360 data.width) ]
|
|
||||||
|> Element.el [ width data.width ]
|
|
||||||
|
|
||||||
|
|
||||||
stdText : String -> Element msg
|
|
||||||
stdText =
|
|
||||||
Element.text >> List.singleton >> Element.paragraph []
|
|
||||||
|
|
||||||
|
|
||||||
svg :
|
|
||||||
{ aspectRatio : Float
|
|
||||||
, height : Int
|
|
||||||
, svg : Svg msg
|
|
||||||
, width : Int
|
|
||||||
, viewMinX : Float
|
|
||||||
, viewMaxX : Float
|
|
||||||
, viewMinY : Float
|
|
||||||
, viewMaxY : Float
|
|
||||||
}
|
|
||||||
-> Element msg
|
|
||||||
svg data =
|
|
||||||
let
|
|
||||||
givenWidth =
|
|
||||||
toFloat data.width
|
|
||||||
|
|
||||||
givenHeight =
|
|
||||||
toFloat data.height
|
|
||||||
|
|
||||||
scaleFactorWidth =
|
|
||||||
givenHeight / givenWidth
|
|
||||||
|
|
||||||
innerWidth =
|
|
||||||
if scaleFactorWidth > data.aspectRatio then
|
|
||||||
givenWidth
|
|
||||||
|
|
||||||
else
|
|
||||||
givenHeight / data.aspectRatio
|
|
||||||
|
|
||||||
innerHeight =
|
|
||||||
if scaleFactorWidth > data.aspectRatio then
|
|
||||||
givenWidth * data.aspectRatio
|
|
||||||
|
|
||||||
else
|
|
||||||
givenHeight
|
|
||||||
in
|
|
||||||
Svg.svg
|
|
||||||
[ [ data.viewMinX, data.viewMinY, data.viewMaxX - data.viewMinX, data.viewMaxY - data.viewMinY ]
|
|
||||||
|> List.map String.fromFloat
|
|
||||||
|> String.join " "
|
|
||||||
|> Svg.Attributes.viewBox
|
|
||||||
, Svg.Attributes.width (String.fromFloat innerWidth)
|
|
||||||
, Svg.Attributes.height (String.fromFloat innerHeight)
|
|
||||||
]
|
|
||||||
[ data.svg ]
|
|
||||||
|> Element.html
|
|
||||||
|> Element.el [ Element.centerX, Element.centerY ]
|
|
||||||
|> Element.el
|
|
||||||
[ Element.height (Element.px data.height)
|
|
||||||
, Element.width (Element.px data.width)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
{-| A tab selector that always has an item selected.
|
|
||||||
-}
|
|
||||||
tab :
|
|
||||||
{ color : Color
|
|
||||||
, content : Int -> Element msg
|
|
||||||
, items : List { text : String, icon : Icon msg }
|
|
||||||
, onSelect : Int -> msg
|
|
||||||
, selected : Int
|
|
||||||
}
|
|
||||||
-> Element msg
|
|
||||||
tab data =
|
|
||||||
Widget.tab
|
|
||||||
({ primary = data.color, onPrimary = data.color }
|
|
||||||
|> singlePalette
|
|
||||||
|> Material.tab
|
|
||||||
)
|
|
||||||
{ tabs =
|
|
||||||
{ onSelect = data.onSelect >> Just
|
|
||||||
, options = data.items
|
|
||||||
, selected = Just data.selected
|
|
||||||
}
|
|
||||||
, content = \_ -> data.content data.selected
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
{-| A text button representing an important action within a group.
|
|
||||||
-}
|
|
||||||
textButton :
|
|
||||||
{ icon : Icon msg
|
|
||||||
, onPress : Maybe msg
|
|
||||||
, text : String
|
|
||||||
, color : Color
|
|
||||||
}
|
|
||||||
-> Element msg
|
|
||||||
textButton data =
|
|
||||||
Widget.button
|
|
||||||
({ primary = data.color, onPrimary = data.color }
|
|
||||||
|> singlePalette
|
|
||||||
|> Material.textButton
|
|
||||||
)
|
|
||||||
{ text = data.text, icon = data.icon, onPress = data.onPress }
|
|
||||||
|
|
||||||
|
|
||||||
{-| Text input element.
|
|
||||||
-}
|
|
||||||
textInput :
|
|
||||||
{ color : Color
|
|
||||||
, label : String
|
|
||||||
, onChange : String -> msg
|
|
||||||
, placeholder : Maybe String
|
|
||||||
, text : String
|
|
||||||
}
|
|
||||||
-> Element msg
|
|
||||||
textInput data =
|
|
||||||
Widget.textInput
|
|
||||||
({ primary = data.color, onPrimary = data.color }
|
|
||||||
|> singlePalette
|
|
||||||
|> Material.textInput
|
|
||||||
|> Customize.elementRow [ Element.width Element.fill ]
|
|
||||||
)
|
|
||||||
{ chips = []
|
|
||||||
, text = data.text
|
|
||||||
, placeholder =
|
|
||||||
data.placeholder
|
|
||||||
|> Maybe.map Element.text
|
|
||||||
|> Maybe.map (Element.Input.placeholder [])
|
|
||||||
, label = data.label
|
|
||||||
, onChange = data.onChange
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
{-| Two blocks either next to each other or below each other, depending on the
|
|
||||||
screen shape.
|
|
||||||
-}
|
|
||||||
twoBlocks :
|
|
||||||
{ height : Int
|
|
||||||
, el1 : { height : Int, width : Int } -> Element msg
|
|
||||||
, el2 : { height : Int, width : Int } -> Element msg
|
|
||||||
, width : Int
|
|
||||||
}
|
|
||||||
-> Element msg
|
|
||||||
twoBlocks data =
|
|
||||||
let
|
|
||||||
goesVertical =
|
|
||||||
2 * data.width <= 3 * data.height
|
|
||||||
|
|
||||||
direction =
|
|
||||||
if goesVertical then
|
|
||||||
Element.column
|
|
||||||
|
|
||||||
else
|
|
||||||
Element.row
|
|
||||||
|
|
||||||
width =
|
|
||||||
if goesVertical then
|
|
||||||
data.width
|
|
||||||
|
|
||||||
else
|
|
||||||
data.width // 2
|
|
||||||
|
|
||||||
height =
|
|
||||||
if goesVertical then
|
|
||||||
data.height // 2
|
|
||||||
|
|
||||||
else
|
|
||||||
data.height
|
|
||||||
in
|
|
||||||
direction
|
|
||||||
[ Element.height (Element.px data.height)
|
|
||||||
, Element.width (Element.px data.width)
|
|
||||||
]
|
|
||||||
[ data.el1 { height = height, width = width }
|
|
||||||
, data.el2 { height = height, width = width }
|
|
||||||
]
|
|
||||||
134
elm/Main.elm
134
elm/Main.elm
|
|
@ -1,134 +0,0 @@
|
||||||
module Main exposing (main)
|
|
||||||
|
|
||||||
import Element exposing (Element)
|
|
||||||
import GameList exposing (Game, GameList)
|
|
||||||
import Json.Decode as D
|
|
||||||
import Layout
|
|
||||||
import Material.Icons as Icons
|
|
||||||
import Program
|
|
||||||
import Widget.Icon
|
|
||||||
|
|
||||||
|
|
||||||
main =
|
|
||||||
Program.document
|
|
||||||
{ flagsDecoder = D.string
|
|
||||||
, headers = headers
|
|
||||||
, init = init
|
|
||||||
, subscriptions = subscriptions
|
|
||||||
, title = always "Coolio!" -- TODO
|
|
||||||
, update = update
|
|
||||||
, view = view
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- MODEL
|
|
||||||
|
|
||||||
|
|
||||||
type alias Model =
|
|
||||||
{ baseUrl : String
|
|
||||||
, games : GameList
|
|
||||||
, screen : Screen
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
type Screen
|
|
||||||
= ViewCreateGame
|
|
||||||
| ViewGameSelectionMenu
|
|
||||||
| ViewGame Game
|
|
||||||
|
|
||||||
|
|
||||||
type Msg
|
|
||||||
= OnGameList GameList.Msg
|
|
||||||
| OnScreen Screen
|
|
||||||
|
|
||||||
|
|
||||||
init : Result D.Error String -> ( Model, Cmd Msg )
|
|
||||||
init baseUrl =
|
|
||||||
let
|
|
||||||
( gmdl, gmsg ) =
|
|
||||||
GameList.init {}
|
|
||||||
in
|
|
||||||
( { baseUrl =
|
|
||||||
case baseUrl of
|
|
||||||
Ok s ->
|
|
||||||
s
|
|
||||||
|
|
||||||
Err _ ->
|
|
||||||
"http://localhost:5000"
|
|
||||||
, games = gmdl
|
|
||||||
, screen = ViewGameSelectionMenu
|
|
||||||
}
|
|
||||||
, Cmd.map OnGameList gmsg
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- UPDATE
|
|
||||||
|
|
||||||
|
|
||||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
|
||||||
update msg model =
|
|
||||||
case msg of
|
|
||||||
OnGameList m ->
|
|
||||||
case GameList.update m model.games of
|
|
||||||
( newMdl, newM ) ->
|
|
||||||
( { model | games = newMdl }, Cmd.map OnGameList newM )
|
|
||||||
|
|
||||||
OnScreen screen ->
|
|
||||||
( { model | screen = screen }, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- SUBSCRIPTIONS
|
|
||||||
|
|
||||||
|
|
||||||
subscriptions : Model -> Sub Msg
|
|
||||||
subscriptions model =
|
|
||||||
Sub.map OnGameList (GameList.subscriptions model.games)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- VIEW
|
|
||||||
|
|
||||||
|
|
||||||
headers : Model -> List { icon : Widget.Icon.Icon Msg, onPress : Msg }
|
|
||||||
headers model =
|
|
||||||
case model.screen of
|
|
||||||
ViewCreateGame ->
|
|
||||||
[ { icon = Layout.iconAsIcon Icons.arrow_back, onPress = OnScreen ViewGameSelectionMenu }
|
|
||||||
]
|
|
||||||
|
|
||||||
ViewGameSelectionMenu ->
|
|
||||||
[]
|
|
||||||
|
|
||||||
ViewGame _ ->
|
|
||||||
[ { icon = Layout.iconAsIcon Icons.arrow_back, onPress = OnScreen ViewGameSelectionMenu }
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
view : Program.ViewBox Model -> Element Msg
|
|
||||||
view data =
|
|
||||||
case data.model.screen of
|
|
||||||
ViewCreateGame ->
|
|
||||||
Element.text "Create game menu!"
|
|
||||||
|
|
||||||
ViewGameSelectionMenu ->
|
|
||||||
GameList.viewSelection
|
|
||||||
{ flavor = data.flavor
|
|
||||||
, height = data.size.height
|
|
||||||
, model = data.model.games
|
|
||||||
, onCreateGame = OnScreen ViewCreateGame
|
|
||||||
, onNavigateToGame = OnScreen << ViewGame
|
|
||||||
, width = data.size.width
|
|
||||||
}
|
|
||||||
|
|
||||||
ViewGame game ->
|
|
||||||
GameList.viewGame
|
|
||||||
{ flavor = data.flavor
|
|
||||||
, game = game
|
|
||||||
, height = data.size.height
|
|
||||||
, onNavigateBack = OnScreen ViewGameSelectionMenu
|
|
||||||
, toMsg = OnGameList
|
|
||||||
, width = data.size.width
|
|
||||||
}
|
|
||||||
255
elm/Match.elm
255
elm/Match.elm
|
|
@ -1,255 +0,0 @@
|
||||||
module Match exposing (..)
|
|
||||||
|
|
||||||
{-| A match describes a game's history. It shows what took place.
|
|
||||||
-}
|
|
||||||
|
|
||||||
import Api
|
|
||||||
import Duration exposing (Duration)
|
|
||||||
import Element exposing (Element)
|
|
||||||
import Http
|
|
||||||
import Json.Decode as D
|
|
||||||
import Layout
|
|
||||||
import Pixels exposing (Pixels)
|
|
||||||
import Quantity exposing (Quantity)
|
|
||||||
import Theme
|
|
||||||
import Time
|
|
||||||
import Zipper exposing (Zipper)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- MODEL
|
|
||||||
|
|
||||||
|
|
||||||
type Match gameState
|
|
||||||
= Match
|
|
||||||
{ autoScroll : Maybe Duration
|
|
||||||
, baseUrl : String
|
|
||||||
, decoder : D.Decoder gameState
|
|
||||||
, empty : gameState
|
|
||||||
, matchId : String
|
|
||||||
, turns : Zipper gameState
|
|
||||||
, winner : Maybe Int
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
type Msg gameState
|
|
||||||
= AskUpdate
|
|
||||||
| Autoscroll
|
|
||||||
| OnUpdate (Result Http.Error (Api.GameDetails gameState))
|
|
||||||
| PageEnd
|
|
||||||
| PageNext
|
|
||||||
| PagePrev
|
|
||||||
| PageStart
|
|
||||||
|
|
||||||
|
|
||||||
init :
|
|
||||||
{ autoScroll : Maybe Duration
|
|
||||||
, baseUrl : String
|
|
||||||
, decoder : D.Decoder gameState
|
|
||||||
, empty : gameState
|
|
||||||
, matchId : String
|
|
||||||
}
|
|
||||||
-> ( Match gameState, Cmd (Msg gameState) )
|
|
||||||
init data =
|
|
||||||
( Match
|
|
||||||
{ autoScroll = data.autoScroll
|
|
||||||
, baseUrl = data.baseUrl
|
|
||||||
, decoder = data.decoder
|
|
||||||
, empty = data.empty
|
|
||||||
, matchId = data.matchId
|
|
||||||
, turns = Zipper.init data.empty
|
|
||||||
, winner = Nothing
|
|
||||||
}
|
|
||||||
, Cmd.none
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- UPDATE
|
|
||||||
|
|
||||||
|
|
||||||
update : Msg gameState -> Match gameState -> ( Match gameState, Cmd (Msg gameState) )
|
|
||||||
update msg (Match data) =
|
|
||||||
case msg of
|
|
||||||
AskUpdate ->
|
|
||||||
( Match data
|
|
||||||
, Api.gameDetails
|
|
||||||
{ baseUrl = data.baseUrl
|
|
||||||
, decoder = data.decoder
|
|
||||||
, gameId = data.matchId
|
|
||||||
, toMsg = OnUpdate
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
Autoscroll ->
|
|
||||||
( Match { data | turns = Zipper.next data.turns }
|
|
||||||
, Cmd.none
|
|
||||||
)
|
|
||||||
|
|
||||||
OnUpdate (Err _) ->
|
|
||||||
-- For now, do nothing with failed API requests
|
|
||||||
( Match data, Cmd.none )
|
|
||||||
|
|
||||||
OnUpdate (Ok details) ->
|
|
||||||
( Match
|
|
||||||
{ data
|
|
||||||
| turns =
|
|
||||||
details.turns
|
|
||||||
|> List.map .state
|
|
||||||
|> Zipper.fromList data.empty
|
|
||||||
|> Zipper.samePageAs data.turns
|
|
||||||
, winner = details.winner
|
|
||||||
}
|
|
||||||
, Cmd.none
|
|
||||||
)
|
|
||||||
|
|
||||||
PageEnd ->
|
|
||||||
( Match
|
|
||||||
{ data
|
|
||||||
| autoScroll = Nothing
|
|
||||||
, turns = Zipper.toEnd data.turns
|
|
||||||
}
|
|
||||||
, Cmd.none
|
|
||||||
)
|
|
||||||
|
|
||||||
PageNext ->
|
|
||||||
( Match
|
|
||||||
{ data
|
|
||||||
| autoScroll = Nothing
|
|
||||||
, turns = Zipper.next data.turns
|
|
||||||
}
|
|
||||||
, Cmd.none
|
|
||||||
)
|
|
||||||
|
|
||||||
PagePrev ->
|
|
||||||
( Match
|
|
||||||
{ data
|
|
||||||
| autoScroll = Nothing
|
|
||||||
, turns = Zipper.prev data.turns
|
|
||||||
}
|
|
||||||
, Cmd.none
|
|
||||||
)
|
|
||||||
|
|
||||||
PageStart ->
|
|
||||||
( Match
|
|
||||||
{ data
|
|
||||||
| autoScroll = Nothing
|
|
||||||
, turns = Zipper.toStart data.turns
|
|
||||||
}
|
|
||||||
, Cmd.none
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- SUBSCRIPTIONS
|
|
||||||
|
|
||||||
|
|
||||||
subscriptions : Match gameState -> Sub (Msg gameState)
|
|
||||||
subscriptions (Match data) =
|
|
||||||
Sub.batch
|
|
||||||
[ case data.autoScroll of
|
|
||||||
Just duration ->
|
|
||||||
Time.every (Duration.inMilliseconds duration) (always Autoscroll)
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
Sub.none
|
|
||||||
, case data.winner of
|
|
||||||
Just _ ->
|
|
||||||
Sub.none
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
Time.every 550 (always AskUpdate)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- VIEW
|
|
||||||
|
|
||||||
|
|
||||||
view :
|
|
||||||
{ flavor : Theme.Flavor
|
|
||||||
, height : Quantity Int Pixels
|
|
||||||
, match : Match gameState
|
|
||||||
, toMsg : Msg gameState -> msg
|
|
||||||
, width : Quantity Int Pixels
|
|
||||||
, viewGame :
|
|
||||||
{ flavor : Theme.Flavor
|
|
||||||
, game : gameState
|
|
||||||
, height : Quantity Int Pixels
|
|
||||||
, width : Quantity Int Pixels
|
|
||||||
}
|
|
||||||
-> Element msg
|
|
||||||
}
|
|
||||||
-> Element msg
|
|
||||||
view data =
|
|
||||||
let
|
|
||||||
menuHeight =
|
|
||||||
data.height
|
|
||||||
|> Quantity.toFloatQuantity
|
|
||||||
|> Quantity.multiplyBy (1 / 8)
|
|
||||||
|> Quantity.floor
|
|
||||||
|
|
||||||
tinyScreen =
|
|
||||||
data.height |> Quantity.lessThan (Pixels.pixels 300)
|
|
||||||
|
|
||||||
gameHeight =
|
|
||||||
if tinyScreen then
|
|
||||||
data.height
|
|
||||||
|> Quantity.minus menuHeight
|
|
||||||
|
|
||||||
else
|
|
||||||
data.height
|
|
||||||
in
|
|
||||||
Element.column
|
|
||||||
[ Element.height (Element.px (Pixels.inPixels data.height))
|
|
||||||
, Element.width (Element.px (Pixels.inPixels data.width))
|
|
||||||
]
|
|
||||||
[ case data.match of
|
|
||||||
Match { turns } ->
|
|
||||||
data.viewGame
|
|
||||||
{ flavor = data.flavor
|
|
||||||
, game = Zipper.current turns
|
|
||||||
, height = gameHeight
|
|
||||||
, width = data.width
|
|
||||||
}
|
|
||||||
, if tinyScreen then
|
|
||||||
viewMenu
|
|
||||||
{ height = menuHeight
|
|
||||||
, width = data.width
|
|
||||||
}
|
|
||||||
|> Element.map data.toMsg
|
|
||||||
|
|
||||||
else
|
|
||||||
Element.none
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
viewListItem :
|
|
||||||
{ flavor : Theme.Flavor
|
|
||||||
, height : Quantity Int Pixels
|
|
||||||
, match : Match gameState
|
|
||||||
, onPress : Maybe msg
|
|
||||||
, width : Quantity Int Pixels
|
|
||||||
}
|
|
||||||
-> Element msg
|
|
||||||
viewListItem data =
|
|
||||||
case data.match of
|
|
||||||
Match match ->
|
|
||||||
Layout.itemWithSubtext
|
|
||||||
{ color = Theme.mantle data.flavor
|
|
||||||
, leftIcon = always Element.none
|
|
||||||
, onPress = data.onPress
|
|
||||||
, rightIcon = always Element.none
|
|
||||||
, text = "Subtext"
|
|
||||||
, title = match.matchId
|
|
||||||
}
|
|
||||||
[]
|
|
||||||
|
|
||||||
|
|
||||||
viewMenu :
|
|
||||||
{ height : Quantity Int Pixels
|
|
||||||
, width : Quantity Int Pixels
|
|
||||||
}
|
|
||||||
-> Element (Msg gameState)
|
|
||||||
viewMenu _ =
|
|
||||||
Element.none
|
|
||||||
300
elm/Program.elm
300
elm/Program.elm
|
|
@ -1,300 +0,0 @@
|
||||||
module Program exposing (Px, ViewBox, document, element)
|
|
||||||
|
|
||||||
import Browser
|
|
||||||
import Color
|
|
||||||
import Element exposing (Element)
|
|
||||||
import Element.Background
|
|
||||||
import Element.Events
|
|
||||||
import Element.Font
|
|
||||||
import Html
|
|
||||||
import Json.Decode as D
|
|
||||||
import Layout
|
|
||||||
import Pixels exposing (Pixels)
|
|
||||||
import Quantity exposing (Quantity)
|
|
||||||
import ScreenSize exposing (ScreenSize)
|
|
||||||
import Svg
|
|
||||||
import Svg.Attributes
|
|
||||||
import Theme
|
|
||||||
import Widget.Icon
|
|
||||||
|
|
||||||
|
|
||||||
type alias Model model =
|
|
||||||
{ content : model
|
|
||||||
, flavor : Theme.Flavor
|
|
||||||
, size : ScreenSize
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
type Msg msg
|
|
||||||
= OnContent msg
|
|
||||||
| OnFlavor Theme.Flavor
|
|
||||||
| OnScreenSize ScreenSize
|
|
||||||
|
|
||||||
|
|
||||||
type alias Px =
|
|
||||||
Quantity Int Pixels
|
|
||||||
|
|
||||||
|
|
||||||
type alias ViewBox model =
|
|
||||||
{ flavor : Theme.Flavor, model : model, size : ScreenSize }
|
|
||||||
|
|
||||||
|
|
||||||
element :
|
|
||||||
{ flagsDecoder : D.Decoder flags
|
|
||||||
, init : Result D.Error flags -> ( model, Cmd msg )
|
|
||||||
, subscriptions : model -> Sub msg
|
|
||||||
, update : msg -> model -> ( model, Cmd msg )
|
|
||||||
, view : ViewBox model -> Element msg
|
|
||||||
}
|
|
||||||
-> Program D.Value (Model model) (Msg msg)
|
|
||||||
element data =
|
|
||||||
Browser.element
|
|
||||||
{ init = init { f = data.init, d = data.flagsDecoder }
|
|
||||||
, subscriptions = subscriptions data.subscriptions
|
|
||||||
, update = update data.update
|
|
||||||
, view =
|
|
||||||
view
|
|
||||||
{ body = data.view
|
|
||||||
, headers = always []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
document :
|
|
||||||
{ flagsDecoder : D.Decoder flags
|
|
||||||
, headers : model -> List { icon : Widget.Icon.Icon msg, onPress : msg }
|
|
||||||
, init : Result D.Error flags -> ( model, Cmd msg )
|
|
||||||
, subscriptions : model -> Sub msg
|
|
||||||
, title : model -> String
|
|
||||||
, update : msg -> model -> ( model, Cmd msg )
|
|
||||||
, view : ViewBox model -> Element msg
|
|
||||||
}
|
|
||||||
-> Program D.Value (Model model) (Msg msg)
|
|
||||||
document data =
|
|
||||||
Browser.document
|
|
||||||
{ init = init { f = data.init, d = data.flagsDecoder }
|
|
||||||
, subscriptions = subscriptions data.subscriptions
|
|
||||||
, update = update data.update
|
|
||||||
, view =
|
|
||||||
\model ->
|
|
||||||
{ title = data.title model.content
|
|
||||||
, body = [ view { body = data.view, headers = data.headers } model ]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- INIT
|
|
||||||
|
|
||||||
|
|
||||||
init :
|
|
||||||
{ f : Result D.Error flags -> ( model, Cmd msg )
|
|
||||||
, d : D.Decoder flags
|
|
||||||
}
|
|
||||||
-> D.Value
|
|
||||||
-> ( Model model, Cmd (Msg msg) )
|
|
||||||
init data blob =
|
|
||||||
case data.f (D.decodeValue data.d blob) of
|
|
||||||
( mdl, msg ) ->
|
|
||||||
( { content = mdl
|
|
||||||
, flavor = Theme.Frappe
|
|
||||||
, size = ScreenSize.init
|
|
||||||
}
|
|
||||||
, Cmd.batch
|
|
||||||
[ Cmd.map OnContent msg
|
|
||||||
, ScreenSize.updateScreenSize OnScreenSize
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- UPDATE
|
|
||||||
|
|
||||||
|
|
||||||
update :
|
|
||||||
(msg -> model -> ( model, Cmd msg ))
|
|
||||||
-> Msg msg
|
|
||||||
-> Model model
|
|
||||||
-> ( Model model, Cmd (Msg msg) )
|
|
||||||
update f msg model =
|
|
||||||
case msg of
|
|
||||||
OnContent m ->
|
|
||||||
case f m model.content of
|
|
||||||
( newMdl, newMsg ) ->
|
|
||||||
( { model | content = newMdl }
|
|
||||||
, Cmd.map OnContent newMsg
|
|
||||||
)
|
|
||||||
|
|
||||||
OnFlavor flavor ->
|
|
||||||
( { model | flavor = flavor }, Cmd.none )
|
|
||||||
|
|
||||||
OnScreenSize size ->
|
|
||||||
( { model | size = size }, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- SUBSCRIPTIONS
|
|
||||||
|
|
||||||
|
|
||||||
subscriptions : (model -> Sub msg) -> Model model -> Sub (Msg msg)
|
|
||||||
subscriptions f model =
|
|
||||||
Sub.batch
|
|
||||||
[ Sub.map OnContent <| f model.content
|
|
||||||
, ScreenSize.onResize OnScreenSize
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- VIEW
|
|
||||||
|
|
||||||
|
|
||||||
view :
|
|
||||||
{ body : ViewBox model -> Element msg
|
|
||||||
, headers : model -> List { icon : Widget.Icon.Icon msg, onPress : msg }
|
|
||||||
}
|
|
||||||
-> Model model
|
|
||||||
-> Html.Html (Msg msg)
|
|
||||||
view data model =
|
|
||||||
let
|
|
||||||
preferredNavBarHeight =
|
|
||||||
Pixels.pixels 40
|
|
||||||
|
|
||||||
showNavBar =
|
|
||||||
preferredNavBarHeight
|
|
||||||
|> Quantity.multiplyBy 6
|
|
||||||
|> Quantity.lessThanOrEqualTo model.size.height
|
|
||||||
|
|
||||||
contentHeight =
|
|
||||||
if showNavBar then
|
|
||||||
model.size.height |> Quantity.minus preferredNavBarHeight
|
|
||||||
|
|
||||||
else
|
|
||||||
model.size.height
|
|
||||||
in
|
|
||||||
[ viewNavBar
|
|
||||||
{ headers = data.headers model.content
|
|
||||||
, iconHeight = preferredNavBarHeight
|
|
||||||
, model = model
|
|
||||||
}
|
|
||||||
, data.body
|
|
||||||
{ flavor = model.flavor
|
|
||||||
, model = model.content
|
|
||||||
, size = { height = contentHeight, width = model.size.width }
|
|
||||||
}
|
|
||||||
|> Element.map OnContent
|
|
||||||
]
|
|
||||||
|> Element.column [ Element.width Element.fill ]
|
|
||||||
|> Element.layout
|
|
||||||
[ Element.Background.color (Theme.baseUI model.flavor)
|
|
||||||
, Element.Font.color (Theme.textUI model.flavor)
|
|
||||||
, Element.width <| Element.px <| Pixels.inPixels model.size.width
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
viewFlavorPicker :
|
|
||||||
{ currentFlavor : Theme.Flavor
|
|
||||||
, flavorToPick : Theme.Flavor
|
|
||||||
, onClick : Theme.Flavor -> msg
|
|
||||||
, size : Px
|
|
||||||
}
|
|
||||||
-> Element msg
|
|
||||||
viewFlavorPicker data =
|
|
||||||
Layout.svg
|
|
||||||
{ aspectRatio = 1 / 1
|
|
||||||
, height = Pixels.inPixels data.size
|
|
||||||
, svg =
|
|
||||||
Svg.circle
|
|
||||||
[ Svg.Attributes.cx "5"
|
|
||||||
, Svg.Attributes.cy "5"
|
|
||||||
, Svg.Attributes.r "4"
|
|
||||||
, Svg.Attributes.strokeWidth "1"
|
|
||||||
, Svg.Attributes.fill (Color.toCssString <| Theme.base data.flavorToPick)
|
|
||||||
, Svg.Attributes.stroke (Color.toCssString <| Theme.crust data.currentFlavor)
|
|
||||||
]
|
|
||||||
[]
|
|
||||||
, viewMinY = 0
|
|
||||||
, viewMaxY = 10
|
|
||||||
, viewMinX = 0
|
|
||||||
, viewMaxX = 10
|
|
||||||
, width = Pixels.inPixels data.size
|
|
||||||
}
|
|
||||||
|> (if data.currentFlavor /= data.flavorToPick then
|
|
||||||
Element.el [ Element.Events.onClick (data.onClick data.flavorToPick) ]
|
|
||||||
|
|
||||||
else
|
|
||||||
identity
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
viewNavBar :
|
|
||||||
{ headers : List { icon : Widget.Icon.Icon msg, onPress : msg }
|
|
||||||
, iconHeight : Px
|
|
||||||
, model : Model model
|
|
||||||
}
|
|
||||||
-> Element (Msg msg)
|
|
||||||
viewNavBar data =
|
|
||||||
let
|
|
||||||
heightAttr =
|
|
||||||
Quantity.twice data.iconHeight
|
|
||||||
|> Pixels.inPixels
|
|
||||||
|> Element.px
|
|
||||||
|> Element.height
|
|
||||||
|
|
||||||
widthAttr =
|
|
||||||
Quantity.twice data.iconHeight
|
|
||||||
|> Pixels.inPixels
|
|
||||||
|> Element.px
|
|
||||||
|> Element.width
|
|
||||||
in
|
|
||||||
Element.row
|
|
||||||
[ Element.Background.color <| Theme.mantleUI data.model.flavor
|
|
||||||
, Element.width Element.fill
|
|
||||||
]
|
|
||||||
[ data.headers
|
|
||||||
|> List.map
|
|
||||||
(viewNavBarIcon
|
|
||||||
{ flavor = data.model.flavor
|
|
||||||
, height = Quantity.twice data.iconHeight
|
|
||||||
, heightIcon = data.iconHeight
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|> Element.row []
|
|
||||||
, Element.el [ heightAttr, Element.width Element.fill ] Element.none
|
|
||||||
, [ Theme.Latte, Theme.Frappe, Theme.Macchiato, Theme.Mocha ]
|
|
||||||
|> List.map
|
|
||||||
(\flavor ->
|
|
||||||
viewFlavorPicker
|
|
||||||
{ currentFlavor = data.model.flavor
|
|
||||||
, flavorToPick = flavor
|
|
||||||
, onClick = OnFlavor
|
|
||||||
, size = data.iconHeight
|
|
||||||
}
|
|
||||||
|> Element.el [ Element.centerX, Element.centerY ]
|
|
||||||
|> Element.el [ heightAttr, widthAttr ]
|
|
||||||
)
|
|
||||||
|> Element.row []
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
viewNavBarIcon :
|
|
||||||
{ flavor : Theme.Flavor
|
|
||||||
, height : Px
|
|
||||||
, heightIcon : Px
|
|
||||||
}
|
|
||||||
-> { icon : Widget.Icon.Icon msg, onPress : msg }
|
|
||||||
-> Element (Msg msg)
|
|
||||||
viewNavBarIcon { flavor, height, heightIcon } { icon, onPress } =
|
|
||||||
-- TODO: Implement coloring for hover + onclick
|
|
||||||
icon { color = Theme.text flavor, size = Pixels.inPixels heightIcon }
|
|
||||||
|> Element.el
|
|
||||||
[ Element.centerX
|
|
||||||
, Element.centerY
|
|
||||||
, Element.height <| Element.px <| Pixels.inPixels heightIcon
|
|
||||||
, Element.width <| Element.px <| Pixels.inPixels heightIcon
|
|
||||||
]
|
|
||||||
|> Element.el
|
|
||||||
[ Element.Events.onClick onPress
|
|
||||||
, Element.height <| Element.px <| Pixels.inPixels height
|
|
||||||
, Element.width <| Element.px <| Pixels.inPixels height
|
|
||||||
]
|
|
||||||
|> Element.map OnContent
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
module Screen.CreateGame exposing (..)
|
|
||||||
|
|
||||||
-- MODEL
|
|
||||||
|
|
||||||
type alias Model =
|
|
||||||
{ baseUrl : String
|
|
||||||
, players : List String
|
|
||||||
}
|
|
||||||
|
|
||||||
type Msg
|
|
||||||
= OnBaseUrl String
|
|
||||||
| OnPlayer Int String
|
|
||||||
| RemovePlayer Int
|
|
||||||
|
|
||||||
-- UPDATE
|
|
||||||
|
|
||||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
|
||||||
update msg model =
|
|
||||||
case msg of
|
|
||||||
OnBaseUrl url ->
|
|
||||||
( { model | baseUrl = url }, Cmd.none )
|
|
||||||
|
|
||||||
OnPlayer n p ->
|
|
||||||
let
|
|
||||||
newIndex = List.length model.players == n
|
|
||||||
|
|
||||||
newPlayers =
|
|
||||||
if newIndex && mayCreateNewPlayer model.players then
|
|
||||||
List.append model.players [ p ]
|
|
||||||
else
|
|
||||||
List.indexedMap
|
|
||||||
(\i player ->
|
|
||||||
if n == i then
|
|
||||||
p
|
|
||||||
else
|
|
||||||
player
|
|
||||||
)
|
|
||||||
model.players
|
|
||||||
|
|
||||||
in
|
|
||||||
( { model | players = newPlayers }, Cmd.none )
|
|
||||||
|
|
||||||
RemovePlayer n ->
|
|
||||||
( { model
|
|
||||||
| players =
|
|
||||||
model.players
|
|
||||||
|> List.indexedMap
|
|
||||||
(\i player ->
|
|
||||||
if n == i then
|
|
||||||
Nothing
|
|
||||||
else
|
|
||||||
Just player
|
|
||||||
)
|
|
||||||
|> List.filterMap identity
|
|
||||||
}
|
|
||||||
, Cmd.none
|
|
||||||
)
|
|
||||||
|
|
||||||
-- SUBSCRIPTIONS
|
|
||||||
|
|
||||||
-- VIEW
|
|
||||||
|
|
||||||
mayCreateNewPlayer : List String -> Bool
|
|
||||||
mayCreateNewPlayer =
|
|
||||||
List.all (not << String.isEmpty)
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
module ScreenSize exposing (..)
|
|
||||||
|
|
||||||
import Browser.Dom
|
|
||||||
import Browser.Events
|
|
||||||
import Pixels exposing (Pixels)
|
|
||||||
import Quantity exposing (Quantity)
|
|
||||||
import Task
|
|
||||||
|
|
||||||
|
|
||||||
type alias ScreenSize =
|
|
||||||
{ height : Quantity Int Pixels
|
|
||||||
, width : Quantity Int Pixels
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
init : ScreenSize
|
|
||||||
init =
|
|
||||||
{ width = Pixels.pixels 960, height = Pixels.pixels 480 }
|
|
||||||
|
|
||||||
|
|
||||||
onResize : (ScreenSize -> msg) -> Sub msg
|
|
||||||
onResize toMsg =
|
|
||||||
Browser.Events.onResize
|
|
||||||
(\w h ->
|
|
||||||
toMsg
|
|
||||||
{ height = Pixels.pixels h
|
|
||||||
, width = Pixels.pixels w
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
updateScreenSize : (ScreenSize -> msg) -> Cmd msg
|
|
||||||
updateScreenSize toMsg =
|
|
||||||
Browser.Dom.getViewport
|
|
||||||
|> Task.map
|
|
||||||
(\viewport ->
|
|
||||||
{ height = Pixels.pixels (floor viewport.viewport.height)
|
|
||||||
, width = Pixels.pixels (floor viewport.viewport.width)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|> Task.perform toMsg
|
|
||||||
606
elm/Theme.elm
606
elm/Theme.elm
|
|
@ -1,606 +0,0 @@
|
||||||
module Theme exposing (..)
|
|
||||||
|
|
||||||
{-|
|
|
||||||
|
|
||||||
|
|
||||||
# Theme
|
|
||||||
|
|
||||||
The Theme helps pick colors from different color schemes.
|
|
||||||
|
|
||||||
-}
|
|
||||||
|
|
||||||
import Catppuccin.Frappe as CF
|
|
||||||
import Catppuccin.Latte as CL
|
|
||||||
import Catppuccin.Macchiato as CA
|
|
||||||
import Catppuccin.Mocha as CO
|
|
||||||
import Color
|
|
||||||
import Element
|
|
||||||
import Element.Background
|
|
||||||
|
|
||||||
|
|
||||||
{-| Catppuccin flavor used for display.
|
|
||||||
-}
|
|
||||||
type Flavor
|
|
||||||
= Frappe
|
|
||||||
| Latte
|
|
||||||
| Macchiato
|
|
||||||
| Mocha
|
|
||||||
|
|
||||||
|
|
||||||
background : (Flavor -> Color.Color) -> Flavor -> Element.Attribute msg
|
|
||||||
background toColor =
|
|
||||||
toColor >> toElmUiColor >> Element.Background.color
|
|
||||||
|
|
||||||
|
|
||||||
toElmUiColor : Color.Color -> Element.Color
|
|
||||||
toElmUiColor =
|
|
||||||
Color.toRgba >> Element.fromRgb
|
|
||||||
|
|
||||||
|
|
||||||
rosewater : Flavor -> Color.Color
|
|
||||||
rosewater flavor =
|
|
||||||
case flavor of
|
|
||||||
Frappe ->
|
|
||||||
CF.rosewater
|
|
||||||
|
|
||||||
Latte ->
|
|
||||||
CL.rosewater
|
|
||||||
|
|
||||||
Macchiato ->
|
|
||||||
CA.rosewater
|
|
||||||
|
|
||||||
Mocha ->
|
|
||||||
CO.rosewater
|
|
||||||
|
|
||||||
|
|
||||||
rosewaterUI : Flavor -> Element.Color
|
|
||||||
rosewaterUI =
|
|
||||||
rosewater >> toElmUiColor
|
|
||||||
|
|
||||||
|
|
||||||
flamingo : Flavor -> Color.Color
|
|
||||||
flamingo flavor =
|
|
||||||
case flavor of
|
|
||||||
Frappe ->
|
|
||||||
CF.flamingo
|
|
||||||
|
|
||||||
Latte ->
|
|
||||||
CL.flamingo
|
|
||||||
|
|
||||||
Macchiato ->
|
|
||||||
CA.flamingo
|
|
||||||
|
|
||||||
Mocha ->
|
|
||||||
CO.flamingo
|
|
||||||
|
|
||||||
|
|
||||||
flamingoUI : Flavor -> Element.Color
|
|
||||||
flamingoUI =
|
|
||||||
flamingo >> toElmUiColor
|
|
||||||
|
|
||||||
|
|
||||||
pink : Flavor -> Color.Color
|
|
||||||
pink flavor =
|
|
||||||
case flavor of
|
|
||||||
Frappe ->
|
|
||||||
CF.pink
|
|
||||||
|
|
||||||
Latte ->
|
|
||||||
CL.pink
|
|
||||||
|
|
||||||
Macchiato ->
|
|
||||||
CA.pink
|
|
||||||
|
|
||||||
Mocha ->
|
|
||||||
CO.pink
|
|
||||||
|
|
||||||
|
|
||||||
pinkUI : Flavor -> Element.Color
|
|
||||||
pinkUI =
|
|
||||||
pink >> toElmUiColor
|
|
||||||
|
|
||||||
|
|
||||||
mauve : Flavor -> Color.Color
|
|
||||||
mauve flavor =
|
|
||||||
case flavor of
|
|
||||||
Frappe ->
|
|
||||||
CF.mauve
|
|
||||||
|
|
||||||
Latte ->
|
|
||||||
CL.mauve
|
|
||||||
|
|
||||||
Macchiato ->
|
|
||||||
CA.mauve
|
|
||||||
|
|
||||||
Mocha ->
|
|
||||||
CO.mauve
|
|
||||||
|
|
||||||
|
|
||||||
mauveUI : Flavor -> Element.Color
|
|
||||||
mauveUI =
|
|
||||||
mauve >> toElmUiColor
|
|
||||||
|
|
||||||
|
|
||||||
red : Flavor -> Color.Color
|
|
||||||
red flavor =
|
|
||||||
case flavor of
|
|
||||||
Frappe ->
|
|
||||||
CF.red
|
|
||||||
|
|
||||||
Latte ->
|
|
||||||
CL.red
|
|
||||||
|
|
||||||
Macchiato ->
|
|
||||||
CA.red
|
|
||||||
|
|
||||||
Mocha ->
|
|
||||||
CO.red
|
|
||||||
|
|
||||||
|
|
||||||
redUI : Flavor -> Element.Color
|
|
||||||
redUI =
|
|
||||||
red >> toElmUiColor
|
|
||||||
|
|
||||||
|
|
||||||
maroon : Flavor -> Color.Color
|
|
||||||
maroon flavor =
|
|
||||||
case flavor of
|
|
||||||
Frappe ->
|
|
||||||
CF.maroon
|
|
||||||
|
|
||||||
Latte ->
|
|
||||||
CL.maroon
|
|
||||||
|
|
||||||
Macchiato ->
|
|
||||||
CA.maroon
|
|
||||||
|
|
||||||
Mocha ->
|
|
||||||
CO.maroon
|
|
||||||
|
|
||||||
|
|
||||||
maroonUI : Flavor -> Element.Color
|
|
||||||
maroonUI =
|
|
||||||
maroon >> toElmUiColor
|
|
||||||
|
|
||||||
|
|
||||||
peach : Flavor -> Color.Color
|
|
||||||
peach flavor =
|
|
||||||
case flavor of
|
|
||||||
Frappe ->
|
|
||||||
CF.peach
|
|
||||||
|
|
||||||
Latte ->
|
|
||||||
CL.peach
|
|
||||||
|
|
||||||
Macchiato ->
|
|
||||||
CA.peach
|
|
||||||
|
|
||||||
Mocha ->
|
|
||||||
CO.peach
|
|
||||||
|
|
||||||
|
|
||||||
peachUI : Flavor -> Element.Color
|
|
||||||
peachUI =
|
|
||||||
peach >> toElmUiColor
|
|
||||||
|
|
||||||
|
|
||||||
yellow : Flavor -> Color.Color
|
|
||||||
yellow flavor =
|
|
||||||
case flavor of
|
|
||||||
Frappe ->
|
|
||||||
CF.yellow
|
|
||||||
|
|
||||||
Latte ->
|
|
||||||
CL.yellow
|
|
||||||
|
|
||||||
Macchiato ->
|
|
||||||
CA.yellow
|
|
||||||
|
|
||||||
Mocha ->
|
|
||||||
CO.yellow
|
|
||||||
|
|
||||||
|
|
||||||
yellowUI : Flavor -> Element.Color
|
|
||||||
yellowUI =
|
|
||||||
yellow >> toElmUiColor
|
|
||||||
|
|
||||||
|
|
||||||
green : Flavor -> Color.Color
|
|
||||||
green flavor =
|
|
||||||
case flavor of
|
|
||||||
Frappe ->
|
|
||||||
CF.green
|
|
||||||
|
|
||||||
Latte ->
|
|
||||||
CL.green
|
|
||||||
|
|
||||||
Macchiato ->
|
|
||||||
CA.green
|
|
||||||
|
|
||||||
Mocha ->
|
|
||||||
CO.green
|
|
||||||
|
|
||||||
|
|
||||||
greenUI : Flavor -> Element.Color
|
|
||||||
greenUI =
|
|
||||||
green >> toElmUiColor
|
|
||||||
|
|
||||||
|
|
||||||
teal : Flavor -> Color.Color
|
|
||||||
teal flavor =
|
|
||||||
case flavor of
|
|
||||||
Frappe ->
|
|
||||||
CF.teal
|
|
||||||
|
|
||||||
Latte ->
|
|
||||||
CL.teal
|
|
||||||
|
|
||||||
Macchiato ->
|
|
||||||
CA.teal
|
|
||||||
|
|
||||||
Mocha ->
|
|
||||||
CO.teal
|
|
||||||
|
|
||||||
|
|
||||||
tealUI : Flavor -> Element.Color
|
|
||||||
tealUI =
|
|
||||||
teal >> toElmUiColor
|
|
||||||
|
|
||||||
|
|
||||||
sky : Flavor -> Color.Color
|
|
||||||
sky flavor =
|
|
||||||
case flavor of
|
|
||||||
Frappe ->
|
|
||||||
CF.sky
|
|
||||||
|
|
||||||
Latte ->
|
|
||||||
CL.sky
|
|
||||||
|
|
||||||
Macchiato ->
|
|
||||||
CA.sky
|
|
||||||
|
|
||||||
Mocha ->
|
|
||||||
CO.sky
|
|
||||||
|
|
||||||
|
|
||||||
skyUI : Flavor -> Element.Color
|
|
||||||
skyUI =
|
|
||||||
sky >> toElmUiColor
|
|
||||||
|
|
||||||
|
|
||||||
sapphire : Flavor -> Color.Color
|
|
||||||
sapphire flavor =
|
|
||||||
case flavor of
|
|
||||||
Frappe ->
|
|
||||||
CF.sapphire
|
|
||||||
|
|
||||||
Latte ->
|
|
||||||
CL.sapphire
|
|
||||||
|
|
||||||
Macchiato ->
|
|
||||||
CA.sapphire
|
|
||||||
|
|
||||||
Mocha ->
|
|
||||||
CO.sapphire
|
|
||||||
|
|
||||||
|
|
||||||
sapphireUI : Flavor -> Element.Color
|
|
||||||
sapphireUI =
|
|
||||||
sapphire >> toElmUiColor
|
|
||||||
|
|
||||||
|
|
||||||
blue : Flavor -> Color.Color
|
|
||||||
blue flavor =
|
|
||||||
case flavor of
|
|
||||||
Frappe ->
|
|
||||||
CF.blue
|
|
||||||
|
|
||||||
Latte ->
|
|
||||||
CL.blue
|
|
||||||
|
|
||||||
Macchiato ->
|
|
||||||
CA.blue
|
|
||||||
|
|
||||||
Mocha ->
|
|
||||||
CO.blue
|
|
||||||
|
|
||||||
|
|
||||||
blueUI : Flavor -> Element.Color
|
|
||||||
blueUI =
|
|
||||||
blue >> toElmUiColor
|
|
||||||
|
|
||||||
|
|
||||||
lavender : Flavor -> Color.Color
|
|
||||||
lavender flavor =
|
|
||||||
case flavor of
|
|
||||||
Frappe ->
|
|
||||||
CF.lavender
|
|
||||||
|
|
||||||
Latte ->
|
|
||||||
CL.lavender
|
|
||||||
|
|
||||||
Macchiato ->
|
|
||||||
CA.lavender
|
|
||||||
|
|
||||||
Mocha ->
|
|
||||||
CO.lavender
|
|
||||||
|
|
||||||
|
|
||||||
lavenderUI : Flavor -> Element.Color
|
|
||||||
lavenderUI =
|
|
||||||
lavender >> toElmUiColor
|
|
||||||
|
|
||||||
|
|
||||||
text : Flavor -> Color.Color
|
|
||||||
text flavor =
|
|
||||||
case flavor of
|
|
||||||
Frappe ->
|
|
||||||
CF.text
|
|
||||||
|
|
||||||
Latte ->
|
|
||||||
CL.text
|
|
||||||
|
|
||||||
Macchiato ->
|
|
||||||
CA.text
|
|
||||||
|
|
||||||
Mocha ->
|
|
||||||
CO.text
|
|
||||||
|
|
||||||
|
|
||||||
textUI : Flavor -> Element.Color
|
|
||||||
textUI =
|
|
||||||
text >> toElmUiColor
|
|
||||||
|
|
||||||
|
|
||||||
subtext1 : Flavor -> Color.Color
|
|
||||||
subtext1 flavor =
|
|
||||||
case flavor of
|
|
||||||
Frappe ->
|
|
||||||
CF.subtext1
|
|
||||||
|
|
||||||
Latte ->
|
|
||||||
CL.subtext1
|
|
||||||
|
|
||||||
Macchiato ->
|
|
||||||
CA.subtext1
|
|
||||||
|
|
||||||
Mocha ->
|
|
||||||
CO.subtext1
|
|
||||||
|
|
||||||
|
|
||||||
subtext1UI : Flavor -> Element.Color
|
|
||||||
subtext1UI =
|
|
||||||
subtext1 >> toElmUiColor
|
|
||||||
|
|
||||||
|
|
||||||
subtext0 : Flavor -> Color.Color
|
|
||||||
subtext0 flavor =
|
|
||||||
case flavor of
|
|
||||||
Frappe ->
|
|
||||||
CF.subtext0
|
|
||||||
|
|
||||||
Latte ->
|
|
||||||
CL.subtext0
|
|
||||||
|
|
||||||
Macchiato ->
|
|
||||||
CA.subtext0
|
|
||||||
|
|
||||||
Mocha ->
|
|
||||||
CO.subtext0
|
|
||||||
|
|
||||||
|
|
||||||
subtext0UI : Flavor -> Element.Color
|
|
||||||
subtext0UI =
|
|
||||||
subtext0 >> toElmUiColor
|
|
||||||
|
|
||||||
|
|
||||||
overlay2 : Flavor -> Color.Color
|
|
||||||
overlay2 flavor =
|
|
||||||
case flavor of
|
|
||||||
Frappe ->
|
|
||||||
CF.overlay2
|
|
||||||
|
|
||||||
Latte ->
|
|
||||||
CL.overlay2
|
|
||||||
|
|
||||||
Macchiato ->
|
|
||||||
CA.overlay2
|
|
||||||
|
|
||||||
Mocha ->
|
|
||||||
CO.overlay2
|
|
||||||
|
|
||||||
|
|
||||||
overlay2UI : Flavor -> Element.Color
|
|
||||||
overlay2UI =
|
|
||||||
overlay2 >> toElmUiColor
|
|
||||||
|
|
||||||
|
|
||||||
overlay1 : Flavor -> Color.Color
|
|
||||||
overlay1 flavor =
|
|
||||||
case flavor of
|
|
||||||
Frappe ->
|
|
||||||
CF.overlay1
|
|
||||||
|
|
||||||
Latte ->
|
|
||||||
CL.overlay1
|
|
||||||
|
|
||||||
Macchiato ->
|
|
||||||
CA.overlay1
|
|
||||||
|
|
||||||
Mocha ->
|
|
||||||
CO.overlay1
|
|
||||||
|
|
||||||
|
|
||||||
overlay1UI : Flavor -> Element.Color
|
|
||||||
overlay1UI =
|
|
||||||
overlay1 >> toElmUiColor
|
|
||||||
|
|
||||||
|
|
||||||
overlay0 : Flavor -> Color.Color
|
|
||||||
overlay0 flavor =
|
|
||||||
case flavor of
|
|
||||||
Frappe ->
|
|
||||||
CF.overlay0
|
|
||||||
|
|
||||||
Latte ->
|
|
||||||
CL.overlay0
|
|
||||||
|
|
||||||
Macchiato ->
|
|
||||||
CA.overlay0
|
|
||||||
|
|
||||||
Mocha ->
|
|
||||||
CO.overlay0
|
|
||||||
|
|
||||||
|
|
||||||
overlay0UI : Flavor -> Element.Color
|
|
||||||
overlay0UI =
|
|
||||||
overlay0 >> toElmUiColor
|
|
||||||
|
|
||||||
|
|
||||||
surface2 : Flavor -> Color.Color
|
|
||||||
surface2 flavor =
|
|
||||||
case flavor of
|
|
||||||
Frappe ->
|
|
||||||
CF.surface2
|
|
||||||
|
|
||||||
Latte ->
|
|
||||||
CL.surface2
|
|
||||||
|
|
||||||
Macchiato ->
|
|
||||||
CA.surface2
|
|
||||||
|
|
||||||
Mocha ->
|
|
||||||
CO.surface2
|
|
||||||
|
|
||||||
|
|
||||||
surface2UI : Flavor -> Element.Color
|
|
||||||
surface2UI =
|
|
||||||
surface2 >> toElmUiColor
|
|
||||||
|
|
||||||
|
|
||||||
surface1 : Flavor -> Color.Color
|
|
||||||
surface1 flavor =
|
|
||||||
case flavor of
|
|
||||||
Frappe ->
|
|
||||||
CF.surface1
|
|
||||||
|
|
||||||
Latte ->
|
|
||||||
CL.surface1
|
|
||||||
|
|
||||||
Macchiato ->
|
|
||||||
CA.surface1
|
|
||||||
|
|
||||||
Mocha ->
|
|
||||||
CO.surface1
|
|
||||||
|
|
||||||
|
|
||||||
surface1UI : Flavor -> Element.Color
|
|
||||||
surface1UI =
|
|
||||||
surface1 >> toElmUiColor
|
|
||||||
|
|
||||||
|
|
||||||
surface0 : Flavor -> Color.Color
|
|
||||||
surface0 flavor =
|
|
||||||
case flavor of
|
|
||||||
Frappe ->
|
|
||||||
CF.surface0
|
|
||||||
|
|
||||||
Latte ->
|
|
||||||
CL.surface0
|
|
||||||
|
|
||||||
Macchiato ->
|
|
||||||
CA.surface0
|
|
||||||
|
|
||||||
Mocha ->
|
|
||||||
CO.surface0
|
|
||||||
|
|
||||||
|
|
||||||
surface0UI : Flavor -> Element.Color
|
|
||||||
surface0UI =
|
|
||||||
surface0 >> toElmUiColor
|
|
||||||
|
|
||||||
|
|
||||||
base : Flavor -> Color.Color
|
|
||||||
base flavor =
|
|
||||||
case flavor of
|
|
||||||
Frappe ->
|
|
||||||
CF.base
|
|
||||||
|
|
||||||
Latte ->
|
|
||||||
CL.base
|
|
||||||
|
|
||||||
Macchiato ->
|
|
||||||
CA.base
|
|
||||||
|
|
||||||
Mocha ->
|
|
||||||
CO.base
|
|
||||||
|
|
||||||
|
|
||||||
baseUI : Flavor -> Element.Color
|
|
||||||
baseUI =
|
|
||||||
base >> toElmUiColor
|
|
||||||
|
|
||||||
|
|
||||||
mantle : Flavor -> Color.Color
|
|
||||||
mantle flavor =
|
|
||||||
case flavor of
|
|
||||||
Frappe ->
|
|
||||||
CF.mantle
|
|
||||||
|
|
||||||
Latte ->
|
|
||||||
CL.mantle
|
|
||||||
|
|
||||||
Macchiato ->
|
|
||||||
CA.mantle
|
|
||||||
|
|
||||||
Mocha ->
|
|
||||||
CO.mantle
|
|
||||||
|
|
||||||
|
|
||||||
mantleUI : Flavor -> Element.Color
|
|
||||||
mantleUI =
|
|
||||||
mantle >> toElmUiColor
|
|
||||||
|
|
||||||
|
|
||||||
crust : Flavor -> Color.Color
|
|
||||||
crust flavor =
|
|
||||||
case flavor of
|
|
||||||
Frappe ->
|
|
||||||
CF.crust
|
|
||||||
|
|
||||||
Latte ->
|
|
||||||
CL.crust
|
|
||||||
|
|
||||||
Macchiato ->
|
|
||||||
CA.crust
|
|
||||||
|
|
||||||
Mocha ->
|
|
||||||
CO.crust
|
|
||||||
|
|
||||||
|
|
||||||
crustUI : Flavor -> Element.Color
|
|
||||||
crustUI =
|
|
||||||
crust >> toElmUiColor
|
|
||||||
|
|
||||||
|
|
||||||
brown : Flavor -> Color.Color
|
|
||||||
brown flavor =
|
|
||||||
case flavor of
|
|
||||||
Frappe ->
|
|
||||||
Color.rgb 165 42 42
|
|
||||||
|
|
||||||
-- Example RGB for a brown color
|
|
||||||
Latte ->
|
|
||||||
Color.rgb 139 69 19
|
|
||||||
|
|
||||||
-- Example RGB for another shade of brown
|
|
||||||
Macchiato ->
|
|
||||||
Color.rgb 160 82 45
|
|
||||||
|
|
||||||
Mocha ->
|
|
||||||
Color.rgb 101 67 33
|
|
||||||
|
|
||||||
|
|
||||||
brownUI : Flavor -> Element.Color
|
|
||||||
brownUI =
|
|
||||||
brown >> toElmUiColor
|
|
||||||
143
elm/Zipper.elm
143
elm/Zipper.elm
|
|
@ -1,143 +0,0 @@
|
||||||
module Zipper exposing (Zipper, current, currentPage, fromList, init, isAtEnd, isAtStart, length, next, prev, samePageAs, toEnd, toStart)
|
|
||||||
|
|
||||||
{-| The Zipper allows to dynamically paginate between items.
|
|
||||||
-}
|
|
||||||
|
|
||||||
|
|
||||||
type Zipper a
|
|
||||||
= Zipper
|
|
||||||
{ prev : List a
|
|
||||||
, current : a
|
|
||||||
, next : List a
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
{-| Gets the current item in the zipper.
|
|
||||||
-}
|
|
||||||
current : Zipper a -> a
|
|
||||||
current (Zipper data) =
|
|
||||||
data.current
|
|
||||||
|
|
||||||
|
|
||||||
{-| If counting from 1, determines the number corresponding to the currently selected item.
|
|
||||||
-}
|
|
||||||
currentPage : Zipper a -> Int
|
|
||||||
currentPage (Zipper data) =
|
|
||||||
List.length data.prev + 1
|
|
||||||
|
|
||||||
|
|
||||||
{-| Builds a zipper from a list of items.
|
|
||||||
-}
|
|
||||||
fromList : a -> List a -> Zipper a
|
|
||||||
fromList head tail =
|
|
||||||
Zipper { prev = [], current = head, next = tail }
|
|
||||||
|
|
||||||
|
|
||||||
{-| Create a new zipper from nothing.
|
|
||||||
-}
|
|
||||||
init : a -> Zipper a
|
|
||||||
init x =
|
|
||||||
Zipper { prev = [], current = x, next = [] }
|
|
||||||
|
|
||||||
|
|
||||||
{-| Determines whether the zipper is at the end.
|
|
||||||
-}
|
|
||||||
isAtEnd : Zipper a -> Bool
|
|
||||||
isAtEnd (Zipper data) =
|
|
||||||
List.isEmpty data.next
|
|
||||||
|
|
||||||
|
|
||||||
{-| Determines whether the zipper is at the start.
|
|
||||||
-}
|
|
||||||
isAtStart : Zipper a -> Bool
|
|
||||||
isAtStart (Zipper data) =
|
|
||||||
List.isEmpty data.prev
|
|
||||||
|
|
||||||
|
|
||||||
{-| Determine the total number of items in the zipper.
|
|
||||||
-}
|
|
||||||
length : Zipper a -> Int
|
|
||||||
length (Zipper data) =
|
|
||||||
List.length data.prev + List.length data.next + 1
|
|
||||||
|
|
||||||
|
|
||||||
{-| Paginates one further in the zipper.
|
|
||||||
-}
|
|
||||||
next : Zipper a -> Zipper a
|
|
||||||
next (Zipper data) =
|
|
||||||
case data.next of
|
|
||||||
[] ->
|
|
||||||
Zipper data
|
|
||||||
|
|
||||||
head :: tail ->
|
|
||||||
Zipper
|
|
||||||
{ prev = data.current :: data.prev
|
|
||||||
, current = head
|
|
||||||
, next = tail
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
{-| Paginates one back in the zipper.
|
|
||||||
-}
|
|
||||||
prev : Zipper a -> Zipper a
|
|
||||||
prev (Zipper data) =
|
|
||||||
case data.prev of
|
|
||||||
[] ->
|
|
||||||
Zipper data
|
|
||||||
|
|
||||||
head :: tail ->
|
|
||||||
Zipper
|
|
||||||
{ prev = tail
|
|
||||||
, current = head
|
|
||||||
, next = data.current :: data.next
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
{-| Synchronize a zipper to be at the same page as another, if possible.
|
|
||||||
-}
|
|
||||||
samePageAs : Zipper a -> Zipper a -> Zipper a
|
|
||||||
samePageAs goal z =
|
|
||||||
let
|
|
||||||
cp =
|
|
||||||
currentPage z
|
|
||||||
|
|
||||||
tp =
|
|
||||||
currentPage goal
|
|
||||||
in
|
|
||||||
if cp == tp then
|
|
||||||
z
|
|
||||||
|
|
||||||
else if cp < tp then
|
|
||||||
if isAtEnd z then
|
|
||||||
z
|
|
||||||
|
|
||||||
else
|
|
||||||
samePageAs goal (next z)
|
|
||||||
|
|
||||||
else if isAtStart z then
|
|
||||||
z
|
|
||||||
|
|
||||||
else
|
|
||||||
samePageAs goal (prev z)
|
|
||||||
|
|
||||||
|
|
||||||
{-| Navigate all the way to the end of the zipper.
|
|
||||||
-}
|
|
||||||
toEnd : Zipper a -> Zipper a
|
|
||||||
toEnd z =
|
|
||||||
if isAtEnd z then
|
|
||||||
z
|
|
||||||
|
|
||||||
else
|
|
||||||
toEnd (next z)
|
|
||||||
|
|
||||||
|
|
||||||
{-| Navigate all the way to the start of the zipper.
|
|
||||||
-}
|
|
||||||
toStart : Zipper a -> Zipper a
|
|
||||||
toStart z =
|
|
||||||
if isAtStart z then
|
|
||||||
z
|
|
||||||
|
|
||||||
else
|
|
||||||
toStart (prev z)
|
|
||||||
33
elo.py
33
elo.py
|
|
@ -1,33 +0,0 @@
|
||||||
"""
|
|
||||||
Create an ELO tracker that compares various server agents out there.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from elo_tracker import EloTracker
|
|
||||||
from pyclient.games import TicTacToe
|
|
||||||
|
|
||||||
GAME_FILE = "games.jsonl"
|
|
||||||
PLAYER_FILE = "known_players.json"
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
tracker = EloTracker(
|
|
||||||
game_file_name=GAME_FILE,
|
|
||||||
player_file_name=PLAYER_FILE,
|
|
||||||
debug=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
tracker.start_periodic_matches(
|
|
||||||
game=TicTacToe.empty(),
|
|
||||||
interval_seconds=10 * 60,
|
|
||||||
player_count=2,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
tracker.start_server(import_name=__name__)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("Noticed KeyboardInterrupt, stopping match daemon...")
|
|
||||||
tracker.stop_periodic_matches()
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
from .app import EloTracker
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"EloTracker",
|
|
||||||
]
|
|
||||||
|
|
@ -1,737 +0,0 @@
|
||||||
"""
|
|
||||||
This app hosts the client that'll perform the ELO tracking.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import copy
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import random
|
|
||||||
import threading
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any, Sequence
|
|
||||||
|
|
||||||
import pyclient
|
|
||||||
|
|
||||||
from pyclient.games import FinishState, Game
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_ELO = 1000
|
|
||||||
STD_DEV_DIFF = 400
|
|
||||||
ELO_K_FACTOR = 32
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PlayerIdentifier:
|
|
||||||
"""
|
|
||||||
The Player Identifier uniquely identifies each player for the ELO
|
|
||||||
tracker.
|
|
||||||
"""
|
|
||||||
name : str
|
|
||||||
url : str
|
|
||||||
version : str | None
|
|
||||||
|
|
||||||
def __key__(self) -> tuple[str, str, str | None]:
|
|
||||||
return (self.name, self.url, self.version)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_server_agent(cls, agent : pyclient.ServerAgent) -> "PlayerIdentifier":
|
|
||||||
"""
|
|
||||||
Gain a player identifier from an agent.
|
|
||||||
"""
|
|
||||||
return cls(
|
|
||||||
name=agent.name,
|
|
||||||
url=agent.url,
|
|
||||||
version=agent.profile.get("version",
|
|
||||||
agent.profile.get("me.noordstar.peanuts.agent.version", None)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
@dataclass()
|
|
||||||
class EloStat:
|
|
||||||
"""
|
|
||||||
The EloStat records the ELO statistics of a single player.
|
|
||||||
What's their score, and how much did they win?
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Identity
|
|
||||||
player_id : PlayerIdentifier
|
|
||||||
|
|
||||||
# Statistics
|
|
||||||
losses : int
|
|
||||||
draws : int
|
|
||||||
wins : int
|
|
||||||
elo : float
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def new(cls, player_id : PlayerIdentifier) -> "EloStat":
|
|
||||||
"""
|
|
||||||
Create a new ELO type based on a player.
|
|
||||||
|
|
||||||
:param player_id: Unique player identifier
|
|
||||||
:type player_id: PlayerIdentifier
|
|
||||||
:return: New empty Elo statistics for the player
|
|
||||||
:rtype: EloStat
|
|
||||||
"""
|
|
||||||
return cls(
|
|
||||||
player_id=player_id, losses=0, draws=0, wins=0, elo=DEFAULT_ELO,
|
|
||||||
)
|
|
||||||
|
|
||||||
def to_json(self) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Convert EloStat to a JSON-formatted dictionary
|
|
||||||
|
|
||||||
:return: The EloStat in JSON format
|
|
||||||
:rtype: dict[str, Any]
|
|
||||||
"""
|
|
||||||
d = dict(
|
|
||||||
name=self.player_id.name,
|
|
||||||
url=self.player_id.url,
|
|
||||||
losses=self.losses,
|
|
||||||
draws=self.draws,
|
|
||||||
wins=self.wins,
|
|
||||||
elo=int(self.elo),
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.player_id.version is not None:
|
|
||||||
d["version"] = self.player_id.version
|
|
||||||
|
|
||||||
return d
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Match:
|
|
||||||
"""
|
|
||||||
A Match represents a game written to disk in JSONL format.
|
|
||||||
"""
|
|
||||||
game_name : str
|
|
||||||
participants : list[tuple[PlayerIdentifier, FinishState]]
|
|
||||||
timestamp : str
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def now() -> str:
|
|
||||||
"""
|
|
||||||
Get a timestamp of now.
|
|
||||||
|
|
||||||
:return: Timestamp in ISO format
|
|
||||||
:rtype: str
|
|
||||||
"""
|
|
||||||
return datetime.now(tz=timezone.utc).isoformat()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_json_record(cls, record : dict[str, Any]) -> "Match":
|
|
||||||
"""
|
|
||||||
Create a new match from a decoded JSON object.
|
|
||||||
|
|
||||||
:param participants: Decoded JSON object
|
|
||||||
:type participants: dict[str, Any]
|
|
||||||
:return: An initialized match
|
|
||||||
:rtype: Match
|
|
||||||
:raises KeyError: The JSON is missing required keys.
|
|
||||||
:raises ValueError: The JSON is formatted improperly.
|
|
||||||
"""
|
|
||||||
participants : list[dict[str, Any]] = record["participants"]
|
|
||||||
if not isinstance(participants, list):
|
|
||||||
raise ValueError(
|
|
||||||
"Key `participants` must be list of objects"
|
|
||||||
)
|
|
||||||
|
|
||||||
game_name : str = str(record["name"])
|
|
||||||
timestamp : str = str(record["timestamp"]) # TODO: Perhaps verify ISO format?
|
|
||||||
new_participants : list[tuple[PlayerIdentifier, FinishState]] = []
|
|
||||||
|
|
||||||
for i, participant in enumerate(participants):
|
|
||||||
# Sanity assertions
|
|
||||||
if not isinstance(participant, dict):
|
|
||||||
raise ValueError(
|
|
||||||
f"Participant #{i+1} must be dictionary"
|
|
||||||
)
|
|
||||||
|
|
||||||
for key in ["name", "url", "result"]:
|
|
||||||
if not isinstance(participant[key], str):
|
|
||||||
raise ValueError(
|
|
||||||
f"Participant #{i+1} must have the `{key}` key as string"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Initialize participant
|
|
||||||
name : str = participant["name"]
|
|
||||||
url : str = participant["url"]
|
|
||||||
result = FinishState.from_str(participant["result"])
|
|
||||||
version : str | None = participant.get("version", None)
|
|
||||||
if version is not None:
|
|
||||||
version = str(version)
|
|
||||||
|
|
||||||
new_participants.append((
|
|
||||||
PlayerIdentifier(name=name, url=url, version=version),
|
|
||||||
result,
|
|
||||||
))
|
|
||||||
|
|
||||||
if len(new_participants) < 2:
|
|
||||||
raise ValueError(
|
|
||||||
"Expected at least 2 participants in a game for which ELO can be tracked"
|
|
||||||
)
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
game_name=game_name,
|
|
||||||
participants=new_participants,
|
|
||||||
timestamp=timestamp,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_replay(
|
|
||||||
cls,
|
|
||||||
players : list[pyclient.ServerAgent],
|
|
||||||
replay : pyclient.GameReplay,
|
|
||||||
timestamp : str | None,
|
|
||||||
) -> "Match":
|
|
||||||
"""
|
|
||||||
Convert a GameReplay into a match.
|
|
||||||
|
|
||||||
:param players: The participants of the match.
|
|
||||||
:type players: list[pyclient.ServerAgent]
|
|
||||||
:param replay: Game summary.
|
|
||||||
:type replay: pyclient.GameReplay
|
|
||||||
:param timestamp: ISO formatted timestamp of when the game was planned.
|
|
||||||
:type timestamp: str
|
|
||||||
:return: An initialized match.
|
|
||||||
:rtype: Match
|
|
||||||
:raises ValueError: The replay shows an unfinished match.
|
|
||||||
"""
|
|
||||||
results = replay.turns[-1].state.winner()
|
|
||||||
if results is None:
|
|
||||||
raise ValueError(
|
|
||||||
"Game hasn't finished yet."
|
|
||||||
)
|
|
||||||
|
|
||||||
participants : list[tuple[PlayerIdentifier, FinishState]] = []
|
|
||||||
for i, agent in enumerate(players):
|
|
||||||
finish_state = results.get(i + 1, None)
|
|
||||||
if finish_state is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
participants.append((
|
|
||||||
PlayerIdentifier.from_server_agent(agent=agent),
|
|
||||||
finish_state,
|
|
||||||
))
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
game_name=replay.game_name,
|
|
||||||
participants=participants,
|
|
||||||
timestamp=timestamp or cls.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
def log(self, file_name : str) -> None:
|
|
||||||
"""
|
|
||||||
Log the current match to disk.
|
|
||||||
|
|
||||||
:param file_name: File name to write the match to.
|
|
||||||
:type file_name: str
|
|
||||||
"""
|
|
||||||
with open(file_name, "a", encoding="utf-8") as wp:
|
|
||||||
wp.write(json.dumps(self.to_json(), sort_keys=True) + "\n")
|
|
||||||
|
|
||||||
def to_json(self) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Convert the Match back to JSON.
|
|
||||||
|
|
||||||
:return: The Match in a dictionary that's can be converted to JSON.
|
|
||||||
:rtype: dict[str, Any]
|
|
||||||
"""
|
|
||||||
participants : list[dict[str, str]] = []
|
|
||||||
|
|
||||||
for player_id, result in self.participants:
|
|
||||||
d : dict[str, str]= dict(
|
|
||||||
name=player_id.name,
|
|
||||||
url=player_id.url,
|
|
||||||
result=result.name,
|
|
||||||
)
|
|
||||||
|
|
||||||
if player_id.version is not None:
|
|
||||||
d["version"] = player_id.version
|
|
||||||
|
|
||||||
participants.append(d)
|
|
||||||
|
|
||||||
return dict(
|
|
||||||
name=self.game_name,
|
|
||||||
participants=participants,
|
|
||||||
timestamp=self.timestamp,
|
|
||||||
)
|
|
||||||
|
|
||||||
class EloTracker:
|
|
||||||
"""
|
|
||||||
The Elo tracker tracks matches between URLs that it is familiar with.
|
|
||||||
"""
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
game_file_name: str,
|
|
||||||
player_file_name: str,
|
|
||||||
debug: bool = False,
|
|
||||||
name: str = "Bot-Man-Toe Elo Tracker",
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Create an EloTracker.
|
|
||||||
|
|
||||||
:param game_file_name: The file name to write game results to.
|
|
||||||
:type game_file_name: str
|
|
||||||
:param player_file_name: The file name to read player URLs from.
|
|
||||||
:type player_file_name: str
|
|
||||||
:param debug: Whether to print scheduler errors.
|
|
||||||
:type debug: bool
|
|
||||||
:param name: Display name for the leaderboard.
|
|
||||||
:type name: str
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Threading variables
|
|
||||||
self.__lock = threading.RLock()
|
|
||||||
self.__scheduler_stop = threading.Event()
|
|
||||||
self.__scheduler_thread: threading.Thread | None = None
|
|
||||||
|
|
||||||
# Immutable values
|
|
||||||
self.debug: bool = debug
|
|
||||||
self.game_file_name: str = game_file_name
|
|
||||||
self.player_file_name: str = player_file_name
|
|
||||||
self.name: str = name
|
|
||||||
|
|
||||||
# Thread-unsafe variables
|
|
||||||
# Please use a lock while doing CRUD operations on them
|
|
||||||
self.players: list[pyclient.ServerAgent] = []
|
|
||||||
self.__matches: list[Match] = []
|
|
||||||
self.__stats: dict[PlayerIdentifier, EloStat] = {}
|
|
||||||
|
|
||||||
# Initialize tracker
|
|
||||||
self.__load_matches()
|
|
||||||
self.load_players()
|
|
||||||
|
|
||||||
def __debug(self, message: str) -> None:
|
|
||||||
"""
|
|
||||||
Send a debug message to stdout. Ignored when not in debug mode.
|
|
||||||
|
|
||||||
:param message: The message to debug log
|
|
||||||
:type message: str
|
|
||||||
"""
|
|
||||||
if self.debug:
|
|
||||||
with self.__lock:
|
|
||||||
print(f"[EloTracker] {message}")
|
|
||||||
|
|
||||||
def __get_stat(self, player_id : PlayerIdentifier) -> EloStat:
|
|
||||||
"""
|
|
||||||
Get a player's statistics based on their player identifier.
|
|
||||||
|
|
||||||
If the player wasn't known, the function returns a newly
|
|
||||||
initialized record in the database for them.
|
|
||||||
|
|
||||||
:param player_id: Unique player identifier.
|
|
||||||
:type player_id: PlayerIdentifier
|
|
||||||
:return: Elo statistics
|
|
||||||
"""
|
|
||||||
with self.__lock:
|
|
||||||
stat = self.__stats.get(player_id, None)
|
|
||||||
|
|
||||||
if stat is not None:
|
|
||||||
return stat
|
|
||||||
|
|
||||||
stat = EloStat.new(player_id=player_id)
|
|
||||||
self.__stats[player_id] = stat
|
|
||||||
|
|
||||||
return stat
|
|
||||||
|
|
||||||
def __load_matches(self) -> None:
|
|
||||||
"""
|
|
||||||
Load persisted JSONL records and rebuild in-memory statistics.
|
|
||||||
"""
|
|
||||||
if not os.path.exists(self.game_file_name):
|
|
||||||
return
|
|
||||||
|
|
||||||
with self.__lock:
|
|
||||||
self.__matches = []
|
|
||||||
self.__stats = {}
|
|
||||||
|
|
||||||
with open(self.game_file_name, encoding="utf-8") as fp:
|
|
||||||
for line_no, line in enumerate(fp, start=1):
|
|
||||||
line = line.strip()
|
|
||||||
if line == "":
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
record = json.loads(line)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
self.__debug(
|
|
||||||
f"Skipping malformed match record on line {line_no}."
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not isinstance(record, dict):
|
|
||||||
self.__debug(
|
|
||||||
f"Skipping non-object match record on line {line_no}."
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
m = Match.from_json_record(record=record)
|
|
||||||
except ( KeyError, ValueError ):
|
|
||||||
self.__debug(
|
|
||||||
f"Skipping malformed JSON object on line {line_no}."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.__matches.append(m)
|
|
||||||
self.__register_match(m)
|
|
||||||
|
|
||||||
def __register_match(self, m : Match) -> None:
|
|
||||||
"""
|
|
||||||
Apply a newly registered match to the aggregate statistics.
|
|
||||||
|
|
||||||
:param m: Newly created match with results & outcomes.
|
|
||||||
:type m: Match
|
|
||||||
"""
|
|
||||||
|
|
||||||
effective_k = ELO_K_FACTOR / (len(m.participants) - 1)
|
|
||||||
scores : dict[PlayerIdentifier, float] = {}
|
|
||||||
|
|
||||||
# First, calculate the pairwise ELO results
|
|
||||||
# Do not apply them yet, in order to guarantee fair ELO shifts
|
|
||||||
for player_id1, result1 in m.participants:
|
|
||||||
total_k = 0.0
|
|
||||||
rating_1 = self.__get_stat(player_id=player_id1).elo
|
|
||||||
|
|
||||||
for player_id2, result2 in m.participants:
|
|
||||||
if player_id1 == player_id2:
|
|
||||||
continue
|
|
||||||
|
|
||||||
rating_2 = self.__get_stat(player_id=player_id2).elo
|
|
||||||
|
|
||||||
expected_score = 1 / (1 + 10 ** ((rating_2 - rating_1) / STD_DEV_DIFF))
|
|
||||||
|
|
||||||
actual_score = 0
|
|
||||||
if result1.score() > 0.0:
|
|
||||||
actual_score = result1.score() / (result1.score() + result2.score())
|
|
||||||
|
|
||||||
total_k += effective_k * (actual_score - expected_score)
|
|
||||||
|
|
||||||
scores[player_id1] = total_k
|
|
||||||
|
|
||||||
all_scores = sum(scores.values())
|
|
||||||
|
|
||||||
if 0.001 <= abs(all_scores):
|
|
||||||
self.__debug(
|
|
||||||
f"In total, all ELO score changes added together are {all_scores} (should be 0.0)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Then, apply the ELO score update + count the wins, draws & losses
|
|
||||||
for player_id, result in m.participants:
|
|
||||||
player = self.__get_stat(player_id=player_id)
|
|
||||||
player.elo += scores[player_id]
|
|
||||||
|
|
||||||
match result:
|
|
||||||
case FinishState.draw:
|
|
||||||
player.draws += 1
|
|
||||||
case FinishState.loss:
|
|
||||||
player.losses += 1
|
|
||||||
case FinishState.win:
|
|
||||||
player.wins += 1
|
|
||||||
|
|
||||||
def __scheduler_loop(
|
|
||||||
self,
|
|
||||||
game: Game,
|
|
||||||
interval_seconds: float,
|
|
||||||
player_count: int,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Perform a schedule in which you play a random game.
|
|
||||||
|
|
||||||
:param game: Game to play.
|
|
||||||
:type game: pyclient.Game
|
|
||||||
:param interval_seconds: Number of seconds to sleep between games
|
|
||||||
:type interval_seconds: float
|
|
||||||
:param player_count: The number of players that are supposed to participate
|
|
||||||
:type player_count: int
|
|
||||||
"""
|
|
||||||
while not self.__scheduler_stop.is_set():
|
|
||||||
try:
|
|
||||||
self.load_players()
|
|
||||||
|
|
||||||
with self.__lock:
|
|
||||||
available = len(self.players)
|
|
||||||
|
|
||||||
if available < player_count:
|
|
||||||
self.__debug(
|
|
||||||
f"Skipping scheduled match: {available} players available, "
|
|
||||||
f"{player_count} required."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.__debug(
|
|
||||||
"Playing a new scheduled match"
|
|
||||||
)
|
|
||||||
self.play_random_match(game=game, player_count=player_count)
|
|
||||||
except Exception as exc:
|
|
||||||
self.__debug(f"Scheduled match failed: {exc}")
|
|
||||||
raise exc
|
|
||||||
|
|
||||||
if self.__scheduler_stop.wait(interval_seconds):
|
|
||||||
break
|
|
||||||
|
|
||||||
def create_flask_app(self, import_name : str) -> Any:
|
|
||||||
"""
|
|
||||||
Create a Flask app that exposes tracker statistics.
|
|
||||||
|
|
||||||
:param import_name: The name of the application package.
|
|
||||||
:type import_name: str
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from flask import Flask, Response, jsonify
|
|
||||||
except ImportError as exc:
|
|
||||||
raise ImportError(
|
|
||||||
"Flask is required to host the EloTracker server. "
|
|
||||||
"Install the project requirements before calling create_app()."
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
|
||||||
|
|
||||||
@app.get("/")
|
|
||||||
def index() -> Response:
|
|
||||||
return Response(
|
|
||||||
"""
|
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Bot-Man-Toe Elo Tracker</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main>
|
|
||||||
<h1>Bot-Man-Toe Elo Tracker</h1>
|
|
||||||
<p>The JSON API is available at /leaderboard, /matches, /players, and /health.</p>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
""".strip(),
|
|
||||||
mimetype="text/html",
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
def health() -> Response:
|
|
||||||
return jsonify({
|
|
||||||
"ok": True,
|
|
||||||
"periodic_matches": self.is_running_periodic_matches(),
|
|
||||||
})
|
|
||||||
|
|
||||||
@app.get("/leaderboard")
|
|
||||||
def leaderboard() -> Response:
|
|
||||||
return jsonify(self.get_json_leaderboard())
|
|
||||||
|
|
||||||
@app.get("/matches")
|
|
||||||
def matches() -> Response:
|
|
||||||
return jsonify(self.get_json_matches())
|
|
||||||
|
|
||||||
@app.get("/players")
|
|
||||||
def players() -> Response:
|
|
||||||
return jsonify(self.get_json_players())
|
|
||||||
|
|
||||||
return app
|
|
||||||
|
|
||||||
def is_running_periodic_matches(self) -> bool:
|
|
||||||
"""
|
|
||||||
Return whether the scheduler thread is currently alive.
|
|
||||||
|
|
||||||
:return: Whether the scheduler thread is currently alive.
|
|
||||||
:rtype: bool
|
|
||||||
"""
|
|
||||||
with self.__lock:
|
|
||||||
return self.__scheduler_thread is not None and self.__scheduler_thread.is_alive()
|
|
||||||
|
|
||||||
def load_players(self) -> None:
|
|
||||||
"""
|
|
||||||
Load the known players from disk.
|
|
||||||
|
|
||||||
:raises ValueError: File was not properly JSON-formatted.
|
|
||||||
"""
|
|
||||||
with open(self.player_file_name, encoding="utf-8") as fp:
|
|
||||||
obj = json.load(fp)
|
|
||||||
if not isinstance(obj, dict):
|
|
||||||
raise ValueError(
|
|
||||||
"Expected list of URLs in player file."
|
|
||||||
)
|
|
||||||
|
|
||||||
urls = obj.get("players", [])
|
|
||||||
if not isinstance(urls, list):
|
|
||||||
raise ValueError(
|
|
||||||
"Expected `players` field to be a list of strings."
|
|
||||||
)
|
|
||||||
|
|
||||||
players : list[pyclient.ServerAgent] = []
|
|
||||||
for url in urls:
|
|
||||||
if not isinstance(url, str):
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
agent = pyclient.Agent.from_url(url, debug=self.debug)
|
|
||||||
except ValueError:
|
|
||||||
pass # Not an available player right now
|
|
||||||
else:
|
|
||||||
players.append(agent)
|
|
||||||
|
|
||||||
with self.__lock:
|
|
||||||
self.players = players
|
|
||||||
|
|
||||||
for agent in players:
|
|
||||||
self.__get_stat(PlayerIdentifier.from_server_agent(agent))
|
|
||||||
|
|
||||||
def play_match(self, players: list[str], game: Game) -> pyclient.GameReplay:
|
|
||||||
"""
|
|
||||||
Play a single match with appointed players.
|
|
||||||
|
|
||||||
:param players: List of URLs that participate.
|
|
||||||
:type players: list[str]
|
|
||||||
:return: A summary of the game.
|
|
||||||
:rtype: pyclient.GameReplay
|
|
||||||
:raises ValueError: One of the URLs could not be accessed.
|
|
||||||
"""
|
|
||||||
agents : list[Any] = [
|
|
||||||
pyclient.Agent.from_url(url, debug=self.debug)
|
|
||||||
for url in players
|
|
||||||
]
|
|
||||||
|
|
||||||
replay = pyclient.PyClient(debug=self.debug).play_game(
|
|
||||||
players=agents,
|
|
||||||
start=game,
|
|
||||||
)
|
|
||||||
|
|
||||||
m = Match.from_replay(players=agents, replay=replay, timestamp=Match.now())
|
|
||||||
|
|
||||||
# Record match
|
|
||||||
m.log(self.game_file_name)
|
|
||||||
self.__register_match(m)
|
|
||||||
|
|
||||||
return replay
|
|
||||||
|
|
||||||
def play_random_match(
|
|
||||||
self,
|
|
||||||
game: Game,
|
|
||||||
player_count: int | None = None,
|
|
||||||
) -> pyclient.GameReplay:
|
|
||||||
"""
|
|
||||||
Play a game with any known players.
|
|
||||||
|
|
||||||
:param game: The game to start playing
|
|
||||||
:type game: Game
|
|
||||||
:param player_count: Optional number of players to select.
|
|
||||||
:type player_count: int | None
|
|
||||||
:raises ValueError: One of the randomly chosen URLs could not be accessed.
|
|
||||||
"""
|
|
||||||
with self.__lock:
|
|
||||||
players = [agent.url for agent in self.players]
|
|
||||||
|
|
||||||
random.shuffle(players)
|
|
||||||
|
|
||||||
if player_count is not None:
|
|
||||||
players = players[:player_count]
|
|
||||||
|
|
||||||
return self.play_match(players=players, game=game)
|
|
||||||
|
|
||||||
def start_periodic_matches(
|
|
||||||
self,
|
|
||||||
game: Game,
|
|
||||||
interval_seconds: float = 300,
|
|
||||||
player_count: int = 2,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Start running matches periodically in a daemon thread.
|
|
||||||
|
|
||||||
:param game: Game to play.
|
|
||||||
:type game: pyclient.Game
|
|
||||||
:param interval_seconds: Number of seconds to sleep between games
|
|
||||||
:type interval_seconds: float
|
|
||||||
:param player_count: The number of players that are supposed to participate
|
|
||||||
:type player_count: int
|
|
||||||
"""
|
|
||||||
if interval_seconds <= 0:
|
|
||||||
raise ValueError("interval_seconds must be greater than zero.")
|
|
||||||
if player_count <= 1:
|
|
||||||
raise ValueError("player_count must be greater than one.")
|
|
||||||
|
|
||||||
with self.__lock:
|
|
||||||
if self.is_running_periodic_matches():
|
|
||||||
self.stop_periodic_matches()
|
|
||||||
|
|
||||||
self.__scheduler_stop.clear()
|
|
||||||
self.__scheduler_thread = threading.Thread(
|
|
||||||
target=self.__scheduler_loop,
|
|
||||||
args=(game, interval_seconds, player_count),
|
|
||||||
daemon=True,
|
|
||||||
)
|
|
||||||
self.__scheduler_thread.start()
|
|
||||||
|
|
||||||
def stop_periodic_matches(self) -> None:
|
|
||||||
"""
|
|
||||||
Stop the periodic match scheduler and wait briefly for it to exit.
|
|
||||||
"""
|
|
||||||
thread: threading.Thread | None
|
|
||||||
|
|
||||||
with self.__lock:
|
|
||||||
self.__scheduler_stop.set()
|
|
||||||
thread = self.__scheduler_thread
|
|
||||||
|
|
||||||
if thread is not None:
|
|
||||||
thread.join(timeout=5)
|
|
||||||
|
|
||||||
with self.__lock:
|
|
||||||
if self.__scheduler_thread is thread:
|
|
||||||
self.__scheduler_thread = None
|
|
||||||
|
|
||||||
def get_json_players(self) -> list[dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Return known currently available players as notebook-friendly dicts.
|
|
||||||
"""
|
|
||||||
with self.__lock:
|
|
||||||
players : list[EloStat] = list(self.__stats.values())
|
|
||||||
|
|
||||||
players.sort(
|
|
||||||
key=lambda player: (-int(player.elo), player.player_id.name)
|
|
||||||
)
|
|
||||||
|
|
||||||
return [ stat.to_json() for stat in players ]
|
|
||||||
|
|
||||||
def get_json_matches(self, limit: int | None = None) -> list[dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Return persisted match records, newest last unless limited.
|
|
||||||
|
|
||||||
:param limit: Maximum number of most recent matches
|
|
||||||
:type limit: int | None
|
|
||||||
:return: A list of most recent matches
|
|
||||||
:rtype: list[dict[str, Any]]
|
|
||||||
"""
|
|
||||||
with self.__lock:
|
|
||||||
if limit is None:
|
|
||||||
matches = self.__matches
|
|
||||||
elif limit <= 0:
|
|
||||||
matches = []
|
|
||||||
else:
|
|
||||||
matches = self.__matches[-limit:]
|
|
||||||
|
|
||||||
return [ m.to_json() for m in matches ]
|
|
||||||
|
|
||||||
def get_json_leaderboard(self) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Return aggregate player statistics for local use or JSON APIs.
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
"name": self.name,
|
|
||||||
"players": self.get_json_players(),
|
|
||||||
}
|
|
||||||
|
|
||||||
def start_server(
|
|
||||||
self,
|
|
||||||
host: str = "127.0.0.1",
|
|
||||||
import_name : str = __name__,
|
|
||||||
port: int = 5000,
|
|
||||||
debug: bool = False,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Start a Flask development server from which the ELO scores can be
|
|
||||||
viewed interactively.
|
|
||||||
"""
|
|
||||||
return (
|
|
||||||
self.create_flask_app(import_name=import_name)
|
|
||||||
.run(host=host, port=port, debug=debug, **kwargs)
|
|
||||||
)
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
{
|
|
||||||
"players": [
|
|
||||||
"https://bmt001.noordstar.me",
|
|
||||||
"https://bmt002.noordstar.me"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -34,16 +34,8 @@ class Agent:
|
||||||
:type url: str
|
:type url: str
|
||||||
:return: An agent that contacts a server when polled.
|
:return: An agent that contacts a server when polled.
|
||||||
:rtype: ServerAgent
|
:rtype: ServerAgent
|
||||||
:raises ValueError: The server fails to reach out one of the URLs.
|
|
||||||
"""
|
"""
|
||||||
try:
|
|
||||||
return ServerAgent.from_server_url(url=url, **kwargs)
|
return ServerAgent.from_server_url(url=url, **kwargs)
|
||||||
except (ValueError, requests.RequestException, requests.HTTPError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
raise ValueError(
|
|
||||||
"URL did not lead to a willing agent"
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
|
|
@ -162,17 +154,3 @@ class ServerAgent(Agent):
|
||||||
print(content)
|
print(content)
|
||||||
|
|
||||||
return content if isinstance(content, dict) else None
|
return content if isinstance(content, dict) else None
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Represent the agent in the form of a dict.
|
|
||||||
|
|
||||||
:return: Dictionary representation of the ServerAgent
|
|
||||||
:rtype: dict[str, Any]
|
|
||||||
"""
|
|
||||||
return dict(
|
|
||||||
name=self.name,
|
|
||||||
games=self.games,
|
|
||||||
url=self.url,
|
|
||||||
profile=self.profile,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,6 @@ class PyClient:
|
||||||
:rtype: GameReplay
|
:rtype: GameReplay
|
||||||
"""
|
"""
|
||||||
return GameReplay(
|
return GameReplay(
|
||||||
game_name=start.game_name(),
|
|
||||||
start=start,
|
start=start,
|
||||||
turns=list(self.gen_game(players=players, start=start)),
|
turns=list(self.gen_game(players=players, start=start)),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -15,40 +15,6 @@ class FinishState(Enum):
|
||||||
loss = auto()
|
loss = auto()
|
||||||
win = auto()
|
win = auto()
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_str(cls, s : str) -> "FinishState":
|
|
||||||
"""
|
|
||||||
Convert the finish state from a string.
|
|
||||||
|
|
||||||
:param s: String to convert.
|
|
||||||
:type s: str
|
|
||||||
:return: Finish state
|
|
||||||
:rtype: FinishState
|
|
||||||
:raises ValueError: Invalid string value.
|
|
||||||
"""
|
|
||||||
for option in FinishState:
|
|
||||||
if s == option.name:
|
|
||||||
return option
|
|
||||||
else:
|
|
||||||
raise ValueError(
|
|
||||||
f"Unknown finish state `{s}`"
|
|
||||||
)
|
|
||||||
|
|
||||||
def score(self) -> float:
|
|
||||||
"""
|
|
||||||
As a score between 0 and 1, convert how "good" an outcome is.
|
|
||||||
|
|
||||||
:return: A score determining how beneficial a finish state is.
|
|
||||||
:rtype: float
|
|
||||||
"""
|
|
||||||
match self:
|
|
||||||
case FinishState.draw:
|
|
||||||
return 0.5
|
|
||||||
case FinishState.loss:
|
|
||||||
return 0.0
|
|
||||||
case FinishState.win:
|
|
||||||
return 1.0
|
|
||||||
|
|
||||||
class Game:
|
class Game:
|
||||||
"""
|
"""
|
||||||
Base class for all games.
|
Base class for all games.
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ class GameReplay:
|
||||||
played game.
|
played game.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
game_name : str
|
|
||||||
start : Game
|
start : Game
|
||||||
turns : list[Turn]
|
turns : list[Turn]
|
||||||
|
|
||||||
|
|
|
||||||
16
web.py
16
web.py
|
|
@ -1,16 +0,0 @@
|
||||||
"""Entry point for the web client Flask server."""
|
|
||||||
|
|
||||||
from webclient import WebClient
|
|
||||||
|
|
||||||
|
|
||||||
web_client = WebClient(import_name=__name__)
|
|
||||||
app = web_client.app
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
web_client.start()
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
"""Public server entry points."""
|
|
||||||
|
|
||||||
from .app import WebClient
|
|
||||||
|
|
||||||
__all__ = ["WebClient"]
|
|
||||||
247
webclient/app.py
247
webclient/app.py
|
|
@ -1,247 +0,0 @@
|
||||||
"""
|
|
||||||
Flask server that enables a user to run Bot-Man-Toe games as a client from
|
|
||||||
a web browser interface.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import threading
|
|
||||||
import uuid
|
|
||||||
from typing import Any, Optional
|
|
||||||
from urllib.parse import urlparse, urlunparse
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from flask import Flask, jsonify, request
|
|
||||||
|
|
||||||
from pyclient.games.tic_tac_toe import TicTacToe
|
|
||||||
from pyclient import PyClient, ServerAgent
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Match:
|
|
||||||
name : str
|
|
||||||
turns : list[tuple[int, Any, Any]]
|
|
||||||
winner : int | None
|
|
||||||
|
|
||||||
def append_turn(self, player : int, action : Any, state : Any) -> "Match":
|
|
||||||
"""
|
|
||||||
Create a new match value that contains a new turn.
|
|
||||||
|
|
||||||
:param player: The player taking the action
|
|
||||||
:type player: int
|
|
||||||
:param action: The action taken by the player
|
|
||||||
:type action: Any
|
|
||||||
:param state: The new game state based on the action
|
|
||||||
:type state: Any
|
|
||||||
:return: A new match type
|
|
||||||
:rtype: Match
|
|
||||||
"""
|
|
||||||
return Match(
|
|
||||||
name=self.name,
|
|
||||||
turns=self.turns + [( player, action, state )],
|
|
||||||
winner=state.winner(),
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def new(cls, name : str) -> "Match":
|
|
||||||
"""
|
|
||||||
Initialize a new match.
|
|
||||||
|
|
||||||
:param name: The name of the game being played.
|
|
||||||
:type name: str
|
|
||||||
:return: Initialized match
|
|
||||||
:rtype: Match
|
|
||||||
"""
|
|
||||||
return cls(name=name, turns=[], winner=None)
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Create a dictionary representation of the match.
|
|
||||||
"""
|
|
||||||
return dict(
|
|
||||||
name=self.name,
|
|
||||||
turns=[
|
|
||||||
dict(player=player, action=action, state=state.to_dict())
|
|
||||||
for player, action, state in self.turns
|
|
||||||
],
|
|
||||||
winner=self.winner,
|
|
||||||
)
|
|
||||||
|
|
||||||
class WebClient:
|
|
||||||
"""
|
|
||||||
A small Flask application aimed at running Bot-Man-Toe games.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
debug : bool = False,
|
|
||||||
import_name: str = __name__,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Create a WebClient.
|
|
||||||
|
|
||||||
:param debug: Debug mode
|
|
||||||
:type debug: bool
|
|
||||||
:param import_name: Flask import name
|
|
||||||
:type import_name: str
|
|
||||||
"""
|
|
||||||
self.app = Flask(import_name)
|
|
||||||
self.debug = debug
|
|
||||||
self.__lock = threading.RLock()
|
|
||||||
self.__matches : dict[str, Match] = {}
|
|
||||||
self.__games : dict[str, Any] = {}
|
|
||||||
|
|
||||||
@self.app.route("/game-details", methods=["GET"])
|
|
||||||
def game_details():
|
|
||||||
payload = request.get_json(silent=True) or {}
|
|
||||||
game_id : str | None = payload.get("game_id")
|
|
||||||
|
|
||||||
if not isinstance(game_id, str):
|
|
||||||
return jsonify({
|
|
||||||
"error": "Expected field `game` as string"
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
match : Match | None = self.__find_match(match_id=game_id)
|
|
||||||
|
|
||||||
if not isinstance(match, Match):
|
|
||||||
return jsonify({
|
|
||||||
"error": f"Could not find match with id `{game_id}`",
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
return jsonify(match.to_dict())
|
|
||||||
|
|
||||||
@self.app.route("/profile", methods=["GET"])
|
|
||||||
def profile():
|
|
||||||
payload = request.get_json(silent=True) or {}
|
|
||||||
url = payload.get("url")
|
|
||||||
|
|
||||||
if not isinstance(url, str):
|
|
||||||
return jsonify({"error": "A string 'url' is required."}), 400
|
|
||||||
|
|
||||||
try:
|
|
||||||
agent = ServerAgent.from_url(url, debug=self.debug)
|
|
||||||
except (requests.HTTPError, requests.RequestException, ValueError) as exc:
|
|
||||||
return jsonify({"error": str(exc)}), 400
|
|
||||||
|
|
||||||
return jsonify(agent.to_dict())
|
|
||||||
|
|
||||||
@self.app.route("/start-game", methods=["GET"])
|
|
||||||
def start_game():
|
|
||||||
payload = request.get_json(silent=True) or {}
|
|
||||||
|
|
||||||
game : str | None = payload.get("game", None)
|
|
||||||
ps : list | None = payload.get("players", None)
|
|
||||||
|
|
||||||
if not isinstance(game, str):
|
|
||||||
return jsonify({
|
|
||||||
"error": "Field `game` must be a string",
|
|
||||||
}), 400
|
|
||||||
if not isinstance(ps, list):
|
|
||||||
return jsonify({
|
|
||||||
"error": "Field `players` must be a list of URLs"
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
players : list[str] = [ str(player) for player in ps ]
|
|
||||||
game_cls : Any | None = self.__games.get(game, None)
|
|
||||||
|
|
||||||
if game_cls is None:
|
|
||||||
return jsonify({
|
|
||||||
"error": "Unknown game type"
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
match_id = self.__register_match(game)
|
|
||||||
|
|
||||||
runner = threading.Thread(
|
|
||||||
target=self.__run_match,
|
|
||||||
args=(match_id, game, players),
|
|
||||||
daemon=True,
|
|
||||||
)
|
|
||||||
runner.start()
|
|
||||||
|
|
||||||
return match_id
|
|
||||||
|
|
||||||
def __find_match(self, match_id : str) -> Match | None:
|
|
||||||
"""
|
|
||||||
Find a match. Uses a threading lock for a safe read.
|
|
||||||
"""
|
|
||||||
with self.__lock:
|
|
||||||
return self.__matches.get(match_id, None)
|
|
||||||
|
|
||||||
def __register_match(self, name : str) -> str:
|
|
||||||
"""
|
|
||||||
Create a new match in a given game.
|
|
||||||
|
|
||||||
:param name: The name of the game to be played.
|
|
||||||
:type name: str
|
|
||||||
:param game: Game class
|
|
||||||
:type game: Any
|
|
||||||
:return: Unique match identifier
|
|
||||||
:rtype: str
|
|
||||||
"""
|
|
||||||
match_id = uuid.uuid4().hex
|
|
||||||
|
|
||||||
with self.__lock:
|
|
||||||
# Ensure uuid uniqueness
|
|
||||||
while match_id in self.__matches:
|
|
||||||
print("WARNING: Found duplicate uuid `{match_id}` in existing matches")
|
|
||||||
match_id = uuid.uuid4().hex
|
|
||||||
|
|
||||||
self.__matches[match_id] = Match.new(name)
|
|
||||||
|
|
||||||
return match_id
|
|
||||||
|
|
||||||
def __run_match(self, match_id : str, game : str, players : list[str]) -> None:
|
|
||||||
"""
|
|
||||||
Run a match. This function is usually run in a separate thread.
|
|
||||||
|
|
||||||
:param match_id: The match to process
|
|
||||||
:type match_id: str
|
|
||||||
:param game: The name of the game type to play
|
|
||||||
:type game: str
|
|
||||||
:param players: List of URLs to use for processing
|
|
||||||
:type players: list[str]
|
|
||||||
:raises KeyError: The game is not recognized or the match isn't found
|
|
||||||
:raises ValueError: None of the players were accessible
|
|
||||||
"""
|
|
||||||
game_cls = self.__games[game]
|
|
||||||
match = self.__find_match(match_id=match_id)
|
|
||||||
|
|
||||||
if match is None:
|
|
||||||
raise KeyError(
|
|
||||||
f"Could not find match with id `{match_id}`"
|
|
||||||
)
|
|
||||||
|
|
||||||
c = PyClient(hosts=players, debug=self.debug)
|
|
||||||
|
|
||||||
for player, action, state in c.gen_game(name=game, game=game_cls, urls=players, move_default_if_nonexistent=True):
|
|
||||||
match = match.append_turn(player=player, action=action, state=state)
|
|
||||||
|
|
||||||
with self.__lock:
|
|
||||||
self.__matches[match_id] = match
|
|
||||||
|
|
||||||
def register_game(self, game_name: str, game_type: type[Any]) -> None:
|
|
||||||
"""
|
|
||||||
Register a supported game.
|
|
||||||
|
|
||||||
:param game_name: The string name of the game
|
|
||||||
:type game_name: str
|
|
||||||
:param game_type: The game's object
|
|
||||||
:type game_type: Any
|
|
||||||
:raises ValueError:
|
|
||||||
"""
|
|
||||||
name = game_name.strip().strip("/")
|
|
||||||
|
|
||||||
if name == "":
|
|
||||||
raise ValueError("Game name cannot be empty.")
|
|
||||||
|
|
||||||
self.__games[name] = game_type
|
|
||||||
|
|
||||||
def start(
|
|
||||||
self,
|
|
||||||
host: str = "127.0.0.1",
|
|
||||||
port: int = 5000,
|
|
||||||
debug: bool = False,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> None:
|
|
||||||
"""Start the Flask development server."""
|
|
||||||
self.app.run(host=host, port=port, debug=debug, **kwargs)
|
|
||||||
Loading…
Reference in New Issue