Compare commits

...

3 Commits

16 changed files with 2706 additions and 25 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 }
]

147
elm/Main.elm Normal file
View File

@ -0,0 +1,147 @@
module Main exposing (main)
import Api
import Browser
import Element exposing (Element)
import Element.Background
import GameList exposing (Game, GameList)
import Http
import ScreenSize exposing (ScreenSize)
import Theme
main : Program () Model Msg
main =
Browser.document
{ init = init
, view = view
, update = update
, subscriptions = subscriptions
}
-- MODEL
type alias Model =
{ baseUrl : String
, flavor : Theme.Flavor
, games : GameList
, screen : Screen
, size : ScreenSize
}
type Screen
= ViewCreateGame
| ViewGameSelectionMenu
| ViewGame Game
type Msg
= OnGameList GameList.Msg
| OnScreen Screen
| OnScreenSize ScreenSize
init : () -> ( Model, Cmd Msg )
init () =
let
( gmdl, gmsg ) =
GameList.init {}
in
( { baseUrl = "http://localhost:5000"
, flavor = Theme.Latte
, games = gmdl
, screen = ViewGameSelectionMenu
, size = ScreenSize.init
}
, Cmd.batch
[ ScreenSize.updateScreenSize OnScreenSize
, 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 )
OnScreenSize size ->
( { model | size = size }, Cmd.none )
-- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.batch
[ ScreenSize.onResize OnScreenSize
, Sub.map OnGameList (GameList.subscriptions model.games)
]
-- VIEW
view : Model -> Browser.Document Msg
view model =
{ title =
case model.screen of
ViewCreateGame ->
"Create Game | Bot-Man-Toe"
ViewGameSelectionMenu ->
"Menu | Bot-Man-Toe"
ViewGame _ ->
"Replay | Bot-Man-Toe"
, body =
viewScreen model
|> Element.layout
[ Element.Background.color (Theme.baseUI model.flavor)
]
|> List.singleton
}
viewScreen : Model -> Element Msg
viewScreen model =
case model.screen of
ViewCreateGame ->
Element.text "Create game menu!"
ViewGameSelectionMenu ->
GameList.viewSelection
{ flavor = model.flavor
, height = model.size.height
, model = model.games
, onCreateGame = OnScreen ViewCreateGame
, onNavigateToGame = OnScreen << ViewGame
, width = model.size.width
}
ViewGame game ->
GameList.viewGame
{ flavor = model.flavor
, game = game
, height = model.size.height
, onNavigateBack = OnScreen ViewGameSelectionMenu
, toMsg = OnGameList
, width = model.size.width
}

256
elm/Match.elm Normal file
View File

@ -0,0 +1,256 @@
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 Element.Background
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 data =
Element.none

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)

View File

@ -1,6 +1,6 @@
"""Public client entry points.""" """Public client entry points."""
from typing import List, Optional from typing import Any, Generator, List, Optional
import requests import requests
@ -39,12 +39,28 @@ class PyClient:
except (requests.exceptions.RequestException, ValueError): except (requests.exceptions.RequestException, ValueError):
return None return None
def play_game(self, name : str, game, timeout : float = 1.0): def gen_game(self, name : str, game : Any, urls : list[str], timeout : float = 1.0, move_default_if_nonexistent : bool = False) -> Generator[tuple[int, dict[str, Any], Any], None, None]:
""" """
Play a given game. Ask the registered agents to participate. Play game. Return the results as they've been calculated.
:param name: Unique identifier string name for the game
:type name: str
:param game: Game to be played
:type game: Any
:param urls: List of URLs where players are located
:type urls: list[str]
:param timeout: Maximum time in seconds to wait for a player to respond
:type timeout: float
:param move_default_if_nonexistent: If a required player doesn't exist, raise an error. When this is set to true, however, the program will instead assume a player that always takes the default option.
:type move_default_if_nonexistent: bool
:return: Generator of each turn consisting of a player, what action it took and what that resulted in.
:rtype: Generator[tuple[int, dict[str, Any], Any], None, None]
:raises KeyError: The number of URLs provided is too low, the game requires more players.
""" """
agents = [ agent for agent in self.agents if name in agent.games ] agents = [
states = [] ServerAgent(url=url, name="", games={}, debug=self.debug)
for url in urls
]
current_state = game.empty() current_state = game.empty()
@ -52,14 +68,19 @@ class PyClient:
player : int = current_state.player_to_move() player : int = current_state.player_to_move()
if len(agents) < player: if len(agents) < player:
if not move_default_if_nonexistent:
raise KeyError( raise KeyError(
f"{player} players for game {name}, found only {len(agents)}" f"Game requires at least {player} players to exist, found only {len(agents)}"
) )
# Ask for move current_state = current_state.move_default()
yield player, {}, current_state
else:
agent = agents[player-1] agent = agents[player-1]
payload = agent.poll( payload = agent.poll(
game=current_state.action_name(), game=(name + "/" + current_state.action_name()).strip("/"),
payload=current_state.as_seen_by(player), payload=current_state.as_seen_by(player),
timeout=timeout, timeout=timeout,
) )
@ -67,10 +88,18 @@ class PyClient:
# Calculate move # Calculate move
current_state = current_state.move(payload) current_state = current_state.move(payload)
# Save the results yield player, payload or {}, current_state
states.append((player, payload, current_state))
return states def play_game(self, name : str, game, timeout : float = 1.0) -> list[tuple[int, dict[str, Any], Any]]:
"""
Play a given game. Ask the registered agents to participate.
"""
urls = [ agent.url for agent in self.agents if name in agent.games ]
return list(self.gen_game(
name=name, game=game, urls=urls, timeout=timeout,
move_default_if_nonexistent=False,
))
def verify_host(self, url: str) -> bool: def verify_host(self, url: str) -> bool:
""" """

View File

@ -169,8 +169,8 @@ class TicTacToe:
f"Index to write to should be 1-9, not {index}" f"Index to write to should be 1-9, not {index}"
) )
def action_name(self): def action_name(self) -> str:
return "/tic-tac-toe" return ""
def as_seen_by(self, player : int) -> Dict[str, Any]: def as_seen_by(self, player : int) -> Dict[str, Any]:
""" """

View File

@ -8,7 +8,13 @@ import time
class ServerAgent: class ServerAgent:
"""Representation of a server that can host one or more games.""" """Representation of a server that can host one or more games."""
def __init__(self, url: str, name: str, games: Dict[str, Dict[str, Any]], debug : bool = False) -> None: def __init__(self,
url: str,
name: str,
games: Dict[str, Dict[str, Any]],
debug : bool = False,
profile : dict[str, Any] = {}
) -> None:
""" """
Create a server representation. Create a server representation.
@ -18,10 +24,15 @@ class ServerAgent:
:type name: str :type name: str
:param games: Games the server is willing to play. :param games: Games the server is willing to play.
:type games: Dict[str, Dict[str, Any]] :type games: Dict[str, Dict[str, Any]]
:param debug: Whether to enable debug mode.
:type debug: bool
:param profile: Custom user profile containing a user's details.
:type profile: dict[str, Any]
""" """
self.debug = debug self.debug = debug
self.games = games self.games = games
self.name = name self.name = name
self.profile = profile
self.url = url.strip("/") self.url = url.strip("/")
@classmethod @classmethod
@ -70,7 +81,12 @@ class ServerAgent:
if isinstance(profile, dict): if isinstance(profile, dict):
games[str(game_name)] = profile games[str(game_name)] = profile
return cls(url=url, name=name, games=games, debug=debug) profile: dict[str, Any] = {}
for k, v in content.items():
if k not in [ "name", "games" ]:
profile[k] = v
return cls(url=url, name=name, games=games, debug=debug, profile=profile)
def poll(self, game: str, payload: Dict[str, Any], timeout: float = 1.0) -> Optional[Dict[str, Any]]: def poll(self, game: str, payload: Dict[str, Any], timeout: float = 1.0) -> Optional[Dict[str, Any]]:
""" """
@ -100,3 +116,18 @@ class ServerAgent:
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,
)

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)