Update EloTracker API to support multiple games

bram/mute
Bram van den Heuvel 2026-07-01 17:01:59 +02:00
parent da26f5c016
commit e2daa9a205
4 changed files with 153 additions and 47 deletions

View File

@ -30,6 +30,7 @@ type alias EloStat =
, losses : Int
, name : String
, url : String
, version : Maybe String
, wins : Int
}
@ -39,7 +40,7 @@ type alias HealthCheck =
type alias Leaderboard =
{ name : String, players : Players }
List { name : String, players : Players }
type alias Match =
@ -63,12 +64,13 @@ type alias Players =
eloStatDecoder : D.Decoder EloStat
eloStatDecoder =
D.map6 EloStat
D.map7 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.maybe <| D.field "version" D.string)
(D.field "wins" D.int)
@ -129,9 +131,10 @@ healthCheckDecoder =
leaderboardDecoder : D.Decoder Leaderboard
leaderboardDecoder =
D.map2 Leaderboard
D.map2 (\name players -> { name = name, players = players })
(D.field "name" D.string)
(D.field "players" playersDecoder)
|> D.list
matchDecoder : D.Decoder Match

View File

@ -2,17 +2,17 @@ module EloTracker exposing (main)
import Api.Elo as Elo
import Element exposing (Element)
import Element.Background
import Element.Font
import Http
import Json.Decode as D
import Layout
import Pixels
import Program
import Screen.Leaderboard
import Theme
import Time
import Widget.Icon
import Layout
import Theme
import Screen.Leaderboard
import Element.Font
import Pixels
import Element.Background
main =
@ -35,12 +35,14 @@ type alias Model =
{ baseUrl : String
, leaderboard : Result Int Elo.Leaderboard
, matches : Result Int (List Elo.Match)
, selectedName : String
}
type Msg
= OnLeaderboard (Result Http.Error Elo.Leaderboard)
| OnMatches (Result Http.Error (List Elo.Match))
| OnSelectLeaderboardName String
| RefreshLeaderboard
| RefreshMatches
@ -60,6 +62,7 @@ init flag =
{ baseUrl = baseUrl
, leaderboard = Err 0
, matches = Err 0
, selectedName = "tic-tac-toe"
}
in
( model
@ -98,10 +101,13 @@ update msg model =
OnMatches (Ok v) ->
( { model | matches = Ok v }, Cmd.none )
OnSelectLeaderboardName name ->
( { model | selectedName = name }, Cmd.none )
RefreshLeaderboard ->
( model, getLeaderboard model )
RefreshMatches ->
( model, getMatches model )
@ -142,7 +148,7 @@ title model =
case model.leaderboard of
Err _ ->
"EloTracker | Loading..."
Ok _ ->
"EloTracker | Leaderboard"
@ -167,7 +173,7 @@ view data =
|> Element.el [ Element.centerX ]
]
]
Err n ->
Element.column
[ Element.width <| Element.px <| Pixels.inPixels data.size.width
@ -181,13 +187,19 @@ view data =
, 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"))
, 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 }
{ flavor = data.flavor
, model =
{ board = leaderboard
, onSelect = OnSelectLeaderboardName
, selectedName = data.model.selectedName
}
, size = data.size
}

View File

@ -3,26 +3,88 @@ module Screen.Leaderboard exposing (..)
{-| Widget module for a leaderboard page.
-}
import Api.Elo exposing (EloStat, Leaderboard)
import Api.Elo exposing (EloStat, Leaderboard, Players)
import Color
import Element exposing (Element)
import Element.Background
import Element.Border
import Element.Events
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 : ViewBox { board : Leaderboard, onSelect : String -> msg, selectedName : String } -> Element msg
view data =
Element.column
[]
[ selectBoard
{ flavor = data.flavor
, leaderboard = data.model.board
, onSelect = data.model.onSelect
, selectedName = data.model.selectedName
, width = data.size.width
}
, data.model.board
|> List.map
(\l ->
if l.name == data.model.selectedName then
viewSingleBoard
{ flavor = data.flavor
, model = l
, size = data.size
}
else
Element.none
)
|> Element.column []
]
selectBoard :
{ flavor : Theme.Flavor
, leaderboard : Leaderboard
, onSelect : String -> msg
, selectedName : String
, width : Quantity Int Pixels
}
-> Element msg
selectBoard data =
data.leaderboard
|> List.map .name
|> List.map
(\s ->
Element.el
[ if data.selectedName == s then
Element.Background.color (Theme.subtext0UI data.flavor)
else
Element.Background.color (Theme.surface1UI data.flavor)
, Element.Border.rounded 25
, Element.centerX
, Element.Events.onClick (data.onSelect s)
, Element.padding 10
]
(Element.text s)
)
|> Element.wrappedRow
[ Element.padding 20
, Element.spacing 10
, Element.width <| Element.px <| Pixels.inPixels data.width
]
viewSingleBoard : ViewBox { name : String, players : Players } -> Element msg
viewSingleBoard data =
let
boardWidth =
data.size.width
|> Quantity.toFloatQuantity
|> Quantity.multiplyBy (2/3)
|> Quantity.multiplyBy (2 / 3)
|> Quantity.floor
|> Quantity.clamp (Pixels.pixels 600) (Pixels.pixels 1800)
|> Pixels.inPixels
@ -33,15 +95,15 @@ view data =
|> 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.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
, Element.width <| Element.px <| Pixels.inPixels data.size.width
]
@ -62,7 +124,13 @@ viewEloStat data stat =
[ Element.Font.color (Theme.subtext0UI data.flavor)
, Element.Font.size 15
]
(Element.text stat.url)
(case stat.version of
Nothing ->
Element.text stat.url
Just v ->
Element.text (stat.url ++ " | " ++ v)
)
]
-- Bar to push the rest to the right
@ -74,6 +142,7 @@ viewEloStat data stat =
, 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

View File

@ -292,7 +292,7 @@ class EloTracker:
# Please use a lock while doing CRUD operations on them
self.players: list[agents.RemoteAgent] = []
self.__matches: list[Match] = []
self.__stats: dict[PlayerIdentifier, EloStat] = {}
self.__stats: dict[str, dict[PlayerIdentifier, EloStat]] = {}
# Initialize tracker
self.__load_matches()
@ -309,25 +309,30 @@ class EloTracker:
with self.__lock:
print(f"[EloTracker] {message}")
def __get_stat(self, player_id : PlayerIdentifier) -> EloStat:
def __get_stat(self, game_name : str, 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 game_name: Name of the relevant game
:type game_name: str
:param player_id: Unique player identifier.
:type player_id: PlayerIdentifier
:return: Elo statistics
"""
with self.__lock:
stat = self.__stats.get(player_id, None)
stat = self.__stats.get(game_name, {}).get(player_id, None)
if stat is not None:
return stat
if game_name not in self.__stats:
self.__stats[game_name] = {}
stat = EloStat.new(player_id=player_id)
self.__stats[player_id] = stat
self.__stats[game_name][player_id] = stat
return stat
@ -387,13 +392,13 @@ class EloTracker:
# 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
rating_1 = self.__get_stat(game_name=m.game_name, 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
rating_2 = self.__get_stat(game_name=m.game_name, player_id=player_id2).elo
expected_score = 1 / (1 + 10 ** ((rating_2 - rating_1) / STD_DEV_DIFF))
@ -414,7 +419,7 @@ class EloTracker:
# 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 = self.__get_stat(game_name=m.game_name, player_id=player_id)
player.elo += scores[player_id]
match result:
@ -473,7 +478,7 @@ class EloTracker:
:type import_name: str
"""
try:
from flask import Flask, Response, jsonify
from flask import Flask, Response, jsonify, request
except ImportError as exc:
raise ImportError(
"Flask is required to host the EloTracker server. "
@ -526,8 +531,15 @@ class EloTracker:
return jsonify(self.get_json_matches())
@app.get("/players")
def players() -> Response:
return jsonify(self.get_json_players())
def players() -> tuple[Response, int]:
payload = request.get_json(silent=True) or {}
game = payload.get("game", None)
if isinstance(game, str):
return jsonify(self.get_json_players(game_name=game)), 200
else:
return jsonify({ "error": "No `game` parameter was given" }), 400
return app
@ -576,7 +588,11 @@ class EloTracker:
self.players = players
for agent in players:
self.__get_stat(PlayerIdentifier.from_server_agent(agent))
for game_name in self.__stats:
self.__get_stat(
game_name=game_name,
player_id=PlayerIdentifier.from_server_agent(agent)
)
def play_match(self, players: list[str], game: Game) -> pyclient.GameReplay:
"""
@ -677,12 +693,15 @@ class EloTracker:
if self.__scheduler_thread is thread:
self.__scheduler_thread = None
def get_json_players(self) -> list[dict[str, Any]]:
def get_json_players(self, game_name : str) -> list[dict[str, Any]]:
"""
Return known currently available players as notebook-friendly dicts.
"""
with self.__lock:
players : list[EloStat] = list(self.__stats.values())
players : list[EloStat] = [
player for player in self.__stats.get(game_name, {}).values()
if abs(player.wins + player.draws + player.losses) > 0
]
players.sort(
key=lambda player: (-int(player.elo), player.player_id.name)
@ -709,14 +728,17 @@ class EloTracker:
return [ m.to_json() for m in matches ]
def get_json_leaderboard(self) -> dict[str, Any]:
def get_json_leaderboard(self) -> list[dict[str, Any]]:
"""
Return aggregate player statistics for local use or JSON APIs.
"""
return {
"name": self.name,
"players": self.get_json_players(),
}
return [
{
"name": game_name,
"players": self.get_json_players(game_name=game_name),
}
for game_name in self.__stats
]
def start_server(
self,