Bot-Man-Toe Elo Tracker
The JSON API is available at /leaderboard, /matches, /players, and /health.
diff --git a/.dockerignore b/.dockerignore index e77097f..8a3c7ad 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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 diff --git a/.gitignore b/.gitignore index 0b72478..df967e0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ .venv-pyserver .venv-pyclient +# Compiled elm files +elo_tracker/static/elo_tracker.js + # ---> Elm # elm-package generated files elm-stuff diff --git a/elm/Api/Elo.elm b/elm/Api/Elo.elm new file mode 100644 index 0000000..1e25f9a --- /dev/null +++ b/elm/Api/Elo.elm @@ -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 diff --git a/elm/EloTracker.elm b/elm/EloTracker.elm new file mode 100644 index 0000000..6ab7f48 --- /dev/null +++ b/elm/EloTracker.elm @@ -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 } + diff --git a/elm/Program.elm b/elm/Program.elm index 8e66f7d..e362a55 100644 --- a/elm/Program.elm +++ b/elm/Program.elm @@ -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 diff --git a/elm/Screen/CreateGame.elm b/elm/Screen/CreateGame.elm index de0e9ad..33d14ec 100644 --- a/elm/Screen/CreateGame.elm +++ b/elm/Screen/CreateGame.elm @@ -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) diff --git a/elm/Screen/Leaderboard.elm b/elm/Screen/Leaderboard.elm new file mode 100644 index 0000000..9a49674 --- /dev/null +++ b/elm/Screen/Leaderboard.elm @@ -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 ] + ] diff --git a/elo_tracker/Containerfile b/elo_tracker/Containerfile new file mode 100644 index 0000000..bd56076 --- /dev/null +++ b/elo_tracker/Containerfile @@ -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"] \ No newline at end of file diff --git a/elo_tracker/app.py b/elo_tracker/app.py index 533dc09..c6ac648 100644 --- a/elo_tracker/app.py +++ b/elo_tracker/app.py @@ -491,13 +491,20 @@ class EloTracker:
The JSON API is available at /leaderboard, /matches, /players, and /health.