Update EloTracker API to support multiple games
parent
da26f5c016
commit
e2daa9a205
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue