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

View File

@ -2,17 +2,17 @@ module EloTracker exposing (main)
import Api.Elo as Elo import Api.Elo as Elo
import Element exposing (Element) import Element exposing (Element)
import Element.Background
import Element.Font
import Http import Http
import Json.Decode as D import Json.Decode as D
import Layout
import Pixels
import Program import Program
import Screen.Leaderboard
import Theme
import Time import Time
import Widget.Icon import Widget.Icon
import Layout
import Theme
import Screen.Leaderboard
import Element.Font
import Pixels
import Element.Background
main = main =
@ -35,12 +35,14 @@ type alias Model =
{ baseUrl : String { baseUrl : String
, leaderboard : Result Int Elo.Leaderboard , leaderboard : Result Int Elo.Leaderboard
, matches : Result Int (List Elo.Match) , matches : Result Int (List Elo.Match)
, selectedName : String
} }
type Msg type Msg
= OnLeaderboard (Result Http.Error Elo.Leaderboard) = OnLeaderboard (Result Http.Error Elo.Leaderboard)
| OnMatches (Result Http.Error (List Elo.Match)) | OnMatches (Result Http.Error (List Elo.Match))
| OnSelectLeaderboardName String
| RefreshLeaderboard | RefreshLeaderboard
| RefreshMatches | RefreshMatches
@ -60,6 +62,7 @@ init flag =
{ baseUrl = baseUrl { baseUrl = baseUrl
, leaderboard = Err 0 , leaderboard = Err 0
, matches = Err 0 , matches = Err 0
, selectedName = "tic-tac-toe"
} }
in in
( model ( model
@ -99,6 +102,9 @@ update msg model =
OnMatches (Ok v) -> OnMatches (Ok v) ->
( { model | matches = Ok v }, Cmd.none ) ( { model | matches = Ok v }, Cmd.none )
OnSelectLeaderboardName name ->
( { model | selectedName = name }, Cmd.none )
RefreshLeaderboard -> RefreshLeaderboard ->
( model, getLeaderboard model ) ( model, getLeaderboard model )
@ -181,7 +187,7 @@ view data =
, Element.spacing 10 , Element.spacing 10
] ]
[ Element.text "Loading leaderboard..." [ 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 } , Layout.loadingIndicator { color = Theme.lavender data.flavor }
|> Element.el [ Element.centerX ] |> Element.el [ Element.centerX ]
] ]
@ -189,5 +195,11 @@ view data =
Ok leaderboard -> Ok leaderboard ->
Screen.Leaderboard.view 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,21 +3,83 @@ module Screen.Leaderboard exposing (..)
{-| Widget module for a leaderboard page. {-| 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 exposing (Element)
import Element.Background
import Element.Border
import Element.Events
import Element.Font import Element.Font
import Layout import Layout
import Pixels exposing (Pixels) import Pixels exposing (Pixels)
import Program exposing (ViewBox) import Program exposing (ViewBox)
import Quantity exposing (Quantity) import Quantity exposing (Quantity)
import Theme 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 = 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 let
boardWidth = boardWidth =
data.size.width data.size.width
@ -33,8 +95,8 @@ view data =
|> List.map (viewEloStat { flavor = data.flavor }) |> List.map (viewEloStat { flavor = data.flavor })
|> Element.column [ Element.centerX, boardWidth ] |> Element.column [ Element.centerX, boardWidth ]
|> List.singleton |> List.singleton
|> (::) ( |> (::)
Element.text data.model.name (Element.text data.model.name
|> Element.el |> Element.el
[ Element.centerX, Element.Font.size 50 ] [ Element.centerX, Element.Font.size 50 ]
) )
@ -62,7 +124,13 @@ viewEloStat data stat =
[ Element.Font.color (Theme.subtext0UI data.flavor) [ Element.Font.color (Theme.subtext0UI data.flavor)
, Element.Font.size 15 , 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 -- 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 = "LOSSES", value = stat.losses, color = Theme.redUI data.flavor }
] ]
field : { text : String, value : Int, color : Element.Color } -> Element msg field : { text : String, value : Int, color : Element.Color } -> Element msg
field { text, value, color } = field { text, value, color } =
Element.column Element.column

View File

@ -292,7 +292,7 @@ class EloTracker:
# Please use a lock while doing CRUD operations on them # Please use a lock while doing CRUD operations on them
self.players: list[agents.RemoteAgent] = [] self.players: list[agents.RemoteAgent] = []
self.__matches: list[Match] = [] self.__matches: list[Match] = []
self.__stats: dict[PlayerIdentifier, EloStat] = {} self.__stats: dict[str, dict[PlayerIdentifier, EloStat]] = {}
# Initialize tracker # Initialize tracker
self.__load_matches() self.__load_matches()
@ -309,25 +309,30 @@ class EloTracker:
with self.__lock: with self.__lock:
print(f"[EloTracker] {message}") 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. Get a player's statistics based on their player identifier.
If the player wasn't known, the function returns a newly If the player wasn't known, the function returns a newly
initialized record in the database for them. 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. :param player_id: Unique player identifier.
:type player_id: PlayerIdentifier :type player_id: PlayerIdentifier
:return: Elo statistics :return: Elo statistics
""" """
with self.__lock: 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: if stat is not None:
return stat return stat
if game_name not in self.__stats:
self.__stats[game_name] = {}
stat = EloStat.new(player_id=player_id) stat = EloStat.new(player_id=player_id)
self.__stats[player_id] = stat self.__stats[game_name][player_id] = stat
return stat return stat
@ -387,13 +392,13 @@ class EloTracker:
# Do not apply them yet, in order to guarantee fair ELO shifts # Do not apply them yet, in order to guarantee fair ELO shifts
for player_id1, result1 in m.participants: for player_id1, result1 in m.participants:
total_k = 0.0 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: for player_id2, result2 in m.participants:
if player_id1 == player_id2: if player_id1 == player_id2:
continue 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)) 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 # Then, apply the ELO score update + count the wins, draws & losses
for player_id, result in m.participants: 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] player.elo += scores[player_id]
match result: match result:
@ -473,7 +478,7 @@ class EloTracker:
:type import_name: str :type import_name: str
""" """
try: try:
from flask import Flask, Response, jsonify from flask import Flask, Response, jsonify, request
except ImportError as exc: except ImportError as exc:
raise ImportError( raise ImportError(
"Flask is required to host the EloTracker server. " "Flask is required to host the EloTracker server. "
@ -526,8 +531,15 @@ class EloTracker:
return jsonify(self.get_json_matches()) return jsonify(self.get_json_matches())
@app.get("/players") @app.get("/players")
def players() -> Response: def players() -> tuple[Response, int]:
return jsonify(self.get_json_players()) 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 return app
@ -576,7 +588,11 @@ class EloTracker:
self.players = players self.players = players
for agent in 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: def play_match(self, players: list[str], game: Game) -> pyclient.GameReplay:
""" """
@ -677,12 +693,15 @@ class EloTracker:
if self.__scheduler_thread is thread: if self.__scheduler_thread is thread:
self.__scheduler_thread = None 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. Return known currently available players as notebook-friendly dicts.
""" """
with self.__lock: 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( players.sort(
key=lambda player: (-int(player.elo), player.player_id.name) key=lambda player: (-int(player.elo), player.player_id.name)
@ -709,14 +728,17 @@ class EloTracker:
return [ m.to_json() for m in matches ] 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 aggregate player statistics for local use or JSON APIs.
""" """
return { return [
"name": self.name, {
"players": self.get_json_players(), "name": game_name,
"players": self.get_json_players(game_name=game_name),
} }
for game_name in self.__stats
]
def start_server( def start_server(
self, self,