Create MVP ELO tracker with front-end app & Containerfile

sam/sneaky
Bram van den Heuvel 2026-06-27 17:21:16 +02:00
parent d8c20fd4cc
commit 2ace9688ff
10 changed files with 564 additions and 18 deletions

View File

@ -10,6 +10,9 @@ spec/
.venv-pyserver
.venv-pyclient
# Compiled elm files
elo_tracker/static/elo_tracker.js
# ---> Elm
# elm-package generated files
elm-stuff

3
.gitignore vendored
View File

@ -2,6 +2,9 @@
.venv-pyserver
.venv-pyclient
# Compiled elm files
elo_tracker/static/elo_tracker.js
# ---> Elm
# elm-package generated files
elm-stuff

156
elm/Api/Elo.elm Normal file
View File

@ -0,0 +1,156 @@
module Api.Elo exposing
( EloStat
, HealthCheck
, Leaderboard
, Match
, Participant
, Players
, getHealth
, getLeaderboard
, getMatches
, getPlayers
)
{-|
# ELO API
This module contains functions to address the API of the ELO tracker.
-}
import Http
import Json.Decode as D
type alias EloStat =
{ draws : Int
, elo : Int
, losses : Int
, name : String
, url : String
, wins : Int
}
type alias HealthCheck =
{ ok : Bool, periodicMatches : Bool }
type alias Leaderboard =
{ name : String, players : Players }
type alias Match =
{ name : String
, participants : List Participant
, timestamp : String
}
type alias Participant =
{ name : String
, result : String
, url : String
, version : Maybe String
}
type alias Players =
List EloStat
eloStatDecoder : D.Decoder EloStat
eloStatDecoder =
D.map6 EloStat
(D.field "draws" D.int)
(D.field "elo" D.int)
(D.field "losses" D.int)
(D.field "name" D.string)
(D.field "url" D.string)
(D.field "wins" D.int)
getHealth :
{ baseUrl : String
, toMsg : Result Http.Error HealthCheck -> msg
}
-> Cmd msg
getHealth data =
Http.get
{ url = data.baseUrl ++ "/health"
, expect = Http.expectJson data.toMsg healthCheckDecoder
}
getLeaderboard :
{ baseUrl : String
, toMsg : Result Http.Error Leaderboard -> msg
}
-> Cmd msg
getLeaderboard data =
Http.get
{ url = data.baseUrl ++ "/leaderboard"
, expect = Http.expectJson data.toMsg leaderboardDecoder
}
getMatches :
{ baseUrl : String
, toMsg : Result Http.Error (List Match) -> msg
}
-> Cmd msg
getMatches data =
Http.get
{ url = data.baseUrl ++ "/matches"
, expect = Http.expectJson data.toMsg (D.list matchDecoder)
}
getPlayers :
{ baseUrl : String
, toMsg : Result Http.Error Players -> msg
}
-> Cmd msg
getPlayers data =
Http.get
{ url = data.baseUrl ++ "/players"
, expect = Http.expectJson data.toMsg playersDecoder
}
healthCheckDecoder : D.Decoder HealthCheck
healthCheckDecoder =
D.map2 HealthCheck
(D.field "ok" D.bool)
(D.field "periodic_matches" D.bool)
leaderboardDecoder : D.Decoder Leaderboard
leaderboardDecoder =
D.map2 Leaderboard
(D.field "name" D.string)
(D.field "players" playersDecoder)
matchDecoder : D.Decoder Match
matchDecoder =
D.map3 Match
(D.field "name" D.string)
(D.field "participants" <| D.list participantDecoder)
(D.field "timestamp" D.string)
participantDecoder : D.Decoder Participant
participantDecoder =
D.map4 Participant
(D.field "name" D.string)
(D.field "result" D.string)
(D.field "url" D.string)
(D.maybe <| D.field "version" D.string)
playersDecoder : D.Decoder Players
playersDecoder =
D.list eloStatDecoder

193
elm/EloTracker.elm Normal file
View File

@ -0,0 +1,193 @@
module EloTracker exposing (main)
import Api.Elo as Elo
import Element exposing (Element)
import Http
import Json.Decode as D
import Program
import Time
import Widget.Icon
import Layout
import Theme
import Screen.Leaderboard
import Element.Font
import Pixels
import Element.Background
main =
Program.document
{ flagsDecoder = D.field "base_url" D.string
, headers = headers
, init = init
, subscriptions = subscriptions
, title = title
, update = update
, view = view
}
-- MODEL
type alias Model =
{ baseUrl : String
, leaderboard : Result Int Elo.Leaderboard
, matches : Result Int (List Elo.Match)
}
type Msg
= OnLeaderboard (Result Http.Error Elo.Leaderboard)
| OnMatches (Result Http.Error (List Elo.Match))
| RefreshLeaderboard
| RefreshMatches
init : Result D.Error String -> ( Model, Cmd Msg )
init flag =
let
baseUrl =
case flag of
Err _ ->
"http://localhost:5000"
Ok v ->
v
model =
{ baseUrl = baseUrl
, leaderboard = Err 0
, matches = Err 0
}
in
( model
, Cmd.batch
[ getLeaderboard model
, getMatches model
]
)
-- UPDATE
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
OnLeaderboard (Err _) ->
case model.leaderboard of
Err n ->
( { model | leaderboard = Err (n + 1) }, Cmd.none )
Ok _ ->
( model, Cmd.none )
OnLeaderboard (Ok v) ->
( { model | leaderboard = Ok v }, Cmd.none )
OnMatches (Err _) ->
case model.matches of
Err n ->
( { model | matches = Err (n + 1) }, Cmd.none )
Ok _ ->
( model, Cmd.none )
OnMatches (Ok v) ->
( { model | matches = Ok v }, Cmd.none )
RefreshLeaderboard ->
( model, getLeaderboard model )
RefreshMatches ->
( model, getMatches model )
getLeaderboard : Model -> Cmd Msg
getLeaderboard model =
Elo.getLeaderboard { baseUrl = model.baseUrl, toMsg = OnLeaderboard }
getMatches : Model -> Cmd Msg
getMatches model =
Elo.getMatches { baseUrl = model.baseUrl, toMsg = OnMatches }
-- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg
subscriptions _ =
Sub.batch
[ Time.every (5 * 1000) (always RefreshLeaderboard)
, Time.every (1 * 60 * 1000) (always RefreshMatches)
]
-- VIEW
headers : Model -> List { icon : Widget.Icon.Icon msg, onPress : msg }
headers _ =
[]
title : Model -> String
title model =
case model.leaderboard of
Err _ ->
"EloTracker | Loading..."
Ok _ ->
"EloTracker | Leaderboard"
view : Program.ViewBox Model -> Element Msg
view data =
case data.model.leaderboard of
Err 0 ->
Element.column
[ Element.width <| Element.px <| Pixels.inPixels data.size.width
, Element.height <| Element.px <| Pixels.inPixels data.size.height
]
[ Element.column
[ Element.centerX
, Element.centerY
, Element.Background.color (Theme.mantleUI data.flavor)
, Element.padding 15
, Element.spacing 10
]
[ Element.text "Loading leaderboard..."
, Layout.loadingIndicator { color = Theme.lavender data.flavor }
|> Element.el [ Element.centerX ]
]
]
Err n ->
Element.column
[ Element.width <| Element.px <| Pixels.inPixels data.size.width
, Element.height <| Element.px <| Pixels.inPixels data.size.height
]
[ Element.column
[ Element.centerX
, Element.centerY
, Element.Background.color (Theme.mantleUI data.flavor)
, Element.padding 15
, Element.spacing 10
]
[ Element.text "Loading leaderboard..."
, Element.el [ Element.centerX, Element.Font.color (Theme.redUI data.flavor) ] (Element.text ("Failed " ++ (String.fromInt n) ++ " times"))
, Layout.loadingIndicator { color = Theme.lavender data.flavor }
|> Element.el [ Element.centerX ]
]
]
Ok leaderboard ->
Screen.Leaderboard.view
{ flavor = data.flavor, model = leaderboard, size = data.size }

View File

@ -156,32 +156,43 @@ view :
-> Html.Html (Msg msg)
view data model =
let
preferredNavBarHeight =
navBarIconHeight =
Pixels.pixels 40
navBarHeight =
Quantity.twice navBarIconHeight
showNavBar =
preferredNavBarHeight
navBarHeight
|> Quantity.multiplyBy 6
|> Quantity.lessThanOrEqualTo model.size.height
contentHeight =
if showNavBar then
model.size.height |> Quantity.minus preferredNavBarHeight
model.size.height |> Quantity.minus navBarHeight
else
model.size.height
in
[ viewNavBar
{ headers = data.headers model.content
, iconHeight = preferredNavBarHeight
, model = model
}
[ if showNavBar then
viewNavBar
{ headers = data.headers model.content
, iconHeight = navBarIconHeight
, model = model
}
else
Element.none
, data.body
{ flavor = model.flavor
, model = model.content
, size = { height = contentHeight, width = model.size.width }
, size =
{ height = contentHeight --|> Quantity.minus (Pixels.pixels 25)
, width = model.size.width --|> Quantity.minus (Pixels.pixels 25)
}
}
|> Element.map OnContent
|> Element.el []
]
|> Element.column [ Element.width Element.fill ]
|> Element.layout

View File

@ -2,52 +2,60 @@ 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
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 )
( { model | players = newPlayers }, Cmd.none )
RemovePlayer n ->
( { model
| players =
| players =
model.players
|> List.indexedMap
(\i player ->
if n == i then
Nothing
else
Just player
)
@ -56,10 +64,12 @@ update msg model =
, Cmd.none
)
-- SUBSCRIPTIONS
-- SUBSCRIPTIONS
-- VIEW
mayCreateNewPlayer : List String -> Bool
mayCreateNewPlayer =
List.all (not << String.isEmpty)

View File

@ -0,0 +1,90 @@
module Screen.Leaderboard exposing (..)
{-| Widget module for a leaderboard page.
-}
import Api.Elo exposing (EloStat, Leaderboard)
import Element exposing (Element)
import Element.Font
import Layout
import Pixels exposing (Pixels)
import Program exposing (ViewBox)
import Quantity exposing (Quantity)
import Theme
import Element.Background
import Element.Border
import Color
view : ViewBox Leaderboard -> Element msg
view data =
let
boardWidth =
data.size.width
|> Quantity.toFloatQuantity
|> Quantity.multiplyBy (2/3)
|> Quantity.floor
|> Quantity.clamp (Pixels.pixels 600) (Pixels.pixels 1800)
|> Pixels.inPixels
|> Element.px
|> Element.width
in
data.model.players
|> List.map (viewEloStat { flavor = data.flavor })
|> Element.column [ Element.centerX, boardWidth ]
|> List.singleton
|> (::) (
Element.text data.model.name
|> Element.el
[ Element.centerX, Element.Font.size 50 ]
)
|> Element.column
[ Element.padding 15
, Element.spacing 30
, Element.width <| Element.px <| Pixels.inPixels data.size.width
]
viewEloStat : { flavor : Theme.Flavor } -> EloStat -> Element msg
viewEloStat data stat =
Element.row
[ Element.Background.color (Theme.mantleUI data.flavor)
, Element.Border.rounded 5
, Element.padding 10
, Element.spacing 5
, Element.width Element.fill
]
[ Element.column
[ Element.centerY, Element.spacing 5, Element.width Element.fill ]
[ Element.text stat.name
|> Element.el [ Element.Font.size 30 ]
, Element.el
[ Element.Font.color (Theme.subtext0UI data.flavor)
, Element.Font.size 15
]
(Element.text stat.url)
]
-- Bar to push the rest to the right
, Element.column [ Element.width Element.fill ] []
, field { text = "ELO", value = stat.elo, color = Theme.lavenderUI data.flavor }
|> Element.el [ Element.paddingEach { top = 0, bottom = 0, left = 0, right = 15 } ]
, field { text = "WINS", value = stat.wins, color = Theme.greenUI data.flavor }
, field { text = "DRAWS", value = stat.draws, color = Theme.subtext0UI data.flavor }
, field { text = "LOSSES", value = stat.losses, color = Theme.redUI data.flavor }
]
field : { text : String, value : Int, color : Element.Color } -> Element msg
field { text, value, color } =
Element.column
[ Element.Font.color color
, Element.spacing 3
, Element.width (Element.px 70)
]
[ String.fromInt value
|> Element.text
|> Element.el [ Element.centerX, Element.Font.size 25 ]
, text
|> Element.text
|> Element.el [ Element.centerX, Element.Font.size 15 ]
]

60
elo_tracker/Containerfile Normal file
View File

@ -0,0 +1,60 @@
FROM alpine:3.10 AS elm-builder
ARG ELM_VERSION=0.19.1
ARG ELM_URL=https://github.com/elm/compiler/releases/download/${ELM_VERSION}/binary-for-linux-64-bit.gz
# Download internet packages + Node & npm
RUN apk add --no-cache ca-certificates curl gzip bash nodejs npm
# Download & install Elm
RUN curl -L ${ELM_URL} \
| gunzip > /usr/local/bin/elm \
&& chmod +x /usr/local/bin/elm
# Install uglify-js globally
RUN npm install -g uglify-js
WORKDIR /app
# Copy Elm code
COPY elm.json .
COPY elm/ elm/
# Compile module
RUN elm make --output=/app/elm.js --optimize elm/EloTracker.elm
# Optimize & Minify compiled JS
RUN uglifyjs elm.js \
--compress "pure_funcs=[F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9],pure_getters,keep_fargs=false,unsafe_comps,unsafe" \
| uglifyjs --mangle --output elo_tracker.js
FROM python:3.10-alpine AS python-builder
WORKDIR /app
# Install build dependencies
RUN apk add --no-cache gcc musl-dev python3-dev
COPY requirements-elo.txt .
# Create wheels for faster installation
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements-elo.txt
FROM python:3.10-alpine
WORKDIR /app
# Install from pre-built wheels
COPY --from=python-builder /wheels /wheels
COPY requirements-elo.txt .
RUN pip install --no-cache-dir --no-index --find-links=/wheels \
-r requirements-elo.txt && rm -rf /wheels
# Install Elm front-end JS
COPY --from=elm-builder /app/elo_tracker.js /app/elo_tracker/static/elo_tracker.js
# Install ELO tracker code
COPY agents/ agents/
COPY elo_tracker/ elo_tracker/
COPY pyclient/ pyclient/
COPY pyserver/ pyserver/
COPY elo.py .
CMD ["python", "elo.py"]

View File

@ -491,13 +491,20 @@ class EloTracker:
<head>
<meta charset="utf-8">
<title>Bot-Man-Toe Elo Tracker</title>
<script src="/static/elo_tracker.js"></script>
</head>
<body>
<main>
<main id="main-block">
<h1>Bot-Man-Toe Elo Tracker</h1>
<p>The JSON API is available at /leaderboard, /matches, /players, and /health.</p>
</main>
</body>
<script>
var app = Elm.EloTracker.init({
node: document.body,
flag: { baseUrl: "http://127.0.0.1:5000" }
});
</script>
</html>
""".strip(),
mimetype="text/html",
@ -713,7 +720,7 @@ class EloTracker:
def start_server(
self,
host: str = "127.0.0.1",
host: str = "0.0.0.0",
import_name : str = __name__,
port: int = 5000,
debug: bool = False,

13
requirements-elo.txt Normal file
View File

@ -0,0 +1,13 @@
blinker==1.9.0
certifi==2026.6.17
charset-normalizer==3.4.7
click==8.4.1
colorama==0.4.6
Flask==3.1.3
idna==3.18
itsdangerous==2.2.0
Jinja2==3.1.6
MarkupSafe==3.0.3
requests==2.34.2
urllib3==2.7.0
Werkzeug==3.1.8