Compare commits

..

7 Commits

24 changed files with 3812 additions and 1 deletions

39
elm.json Normal file
View File

@ -0,0 +1,39 @@
{
"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 Normal file
View File

@ -0,0 +1,167 @@
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 Normal file
View File

@ -0,0 +1,238 @@
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
]

201
elm/Games/TicTacToe.elm Normal file
View File

@ -0,0 +1,201 @@
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 Normal file
View File

@ -0,0 +1,515 @@
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 Normal file
View File

@ -0,0 +1,134 @@
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 Normal file
View File

@ -0,0 +1,255 @@
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 Normal file
View File

@ -0,0 +1,300 @@
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

65
elm/Screen/CreateGame.elm Normal file
View File

@ -0,0 +1,65 @@
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)

41
elm/ScreenSize.elm Normal file
View File

@ -0,0 +1,41 @@
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 Normal file
View File

@ -0,0 +1,606 @@
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 Normal file
View File

@ -0,0 +1,143 @@
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 Normal file
View File

@ -0,0 +1,33 @@
"""
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())

5
elo_tracker/__init__.py Normal file
View File

@ -0,0 +1,5 @@
from .app import EloTracker
__all__ = [
"EloTracker",
]

737
elo_tracker/app.py Normal file
View File

@ -0,0 +1,737 @@
"""
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)
)

0
games.jsonl Normal file
View File

6
known_players.json Normal file
View File

@ -0,0 +1,6 @@
{
"players": [
"https://bmt001.noordstar.me",
"https://bmt002.noordstar.me"
]
}

View File

@ -34,8 +34,16 @@ 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:
@ -154,3 +162,17 @@ 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,
)

View File

@ -66,6 +66,7 @@ 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)),
) )

View File

@ -15,6 +15,40 @@ 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.

View File

@ -16,6 +16,7 @@ class GameReplay:
played game. played game.
""" """
game_name : str
start : Game start : Game
turns : list[Turn] turns : list[Turn]

16
web.py Normal file
View File

@ -0,0 +1,16 @@
"""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())

5
webclient/__init__.py Normal file
View File

@ -0,0 +1,5 @@
"""Public server entry points."""
from .app import WebClient
__all__ = ["WebClient"]

247
webclient/app.py Normal file
View File

@ -0,0 +1,247 @@
"""
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)