Update EloTracker API to support multiple games
parent
da26f5c016
commit
e2daa9a205
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,26 +3,88 @@ 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
|
||||||
|> Quantity.toFloatQuantity
|
|> Quantity.toFloatQuantity
|
||||||
|> Quantity.multiplyBy (2/3)
|
|> Quantity.multiplyBy (2 / 3)
|
||||||
|> Quantity.floor
|
|> Quantity.floor
|
||||||
|> Quantity.clamp (Pixels.pixels 600) (Pixels.pixels 1800)
|
|> Quantity.clamp (Pixels.pixels 600) (Pixels.pixels 1800)
|
||||||
|> Pixels.inPixels
|
|> Pixels.inPixels
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue