diff --git a/elm/Api/Elo.elm b/elm/Api/Elo.elm index 1e25f9a..68dd00f 100644 --- a/elm/Api/Elo.elm +++ b/elm/Api/Elo.elm @@ -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 diff --git a/elm/EloTracker.elm b/elm/EloTracker.elm index 82e2c75..04c5786 100644 --- a/elm/EloTracker.elm +++ b/elm/EloTracker.elm @@ -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 + } diff --git a/elm/Screen/Leaderboard.elm b/elm/Screen/Leaderboard.elm index 9a49674..df2ca17 100644 --- a/elm/Screen/Leaderboard.elm +++ b/elm/Screen/Leaderboard.elm @@ -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 diff --git a/elo_tracker/app.py b/elo_tracker/app.py index bdcd9f2..92efb57 100644 --- a/elo_tracker/app.py +++ b/elo_tracker/app.py @@ -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,