diff --git a/assets/html/base.html b/assets/html/base.html index 5fa04e8..ed9f9b3 100644 --- a/assets/html/base.html +++ b/assets/html/base.html @@ -25,6 +25,54 @@ > +{% macro alive_time_chart(entity_id, chart_data) -%} + + new Chart("time_in_game_{{entity_id}}", { + type: "pie", + data: { + labels: ["In game", "Eliminated"], + datasets: [{ + backgroundColor: ["#00ff00", "#000000"], + data: {{ chart_data }} + }] + }, + options: { + legend: { + display: false, + fullSize: false + }, + title: { + display: false, + } + } + }); + +{%- endmacro %} + +{% macro uptime_chart(entity_id, state_distribution) -%} + + new Chart("uptime_{{entity_id}}", { + type: "pie", + data: { + labels: {{ state_distribution.labels }}, + datasets: [{ + backgroundColor: {{ state_distribution.colors }}, + data: {{ state_distribution.data }} + }] + }, + options: { + legend: { + display: false, + fullSize: false + }, + title: { + display: false, + } + } + }); + + {%- endmacro %} + diff --git a/db/game.py b/db/game.py index 1d278df..22276ca 100644 --- a/db/game.py +++ b/db/game.py @@ -3,6 +3,9 @@ from __future__ import annotations +from dataclasses import dataclass +from typing import Optional + from db.types import Team, IntRole, EventType, PlayerStateType from tortoise import Model, fields from db.sm5 import SM5Stats @@ -190,4 +193,12 @@ def __str__(self) -> str: return f"" def __repr__(self) -> str: - return str(self) \ No newline at end of file + return str(self) + + +@dataclass +class PlayerInfo: + """Information about a player in one particular game.""" + entity_start: EntityStarts + entity_end: Optional[EntityEnds] + display_name: str diff --git a/db/types.py b/db/types.py index 06bc59f..b7b4429 100644 --- a/db/types.py +++ b/db/types.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Optional +from typing import Optional, List from enum import Enum, IntEnum @@ -201,4 +201,12 @@ class PlayerStateDetailType(IntEnum): class PlayerStateEvent: """This denotes a time at which the player state changed.""" timestamp_millis: int - state: Optional[PlayerStateDetailType] \ No newline at end of file + state: Optional[PlayerStateDetailType] + + +@dataclass +class PieChartData: + """Data sent to a frontend template to display a pie chart.""" + labels: List[str] + colors: List[str] + data: List[int] diff --git a/handlers/game/game.py b/handlers/game/game.py index 1d30b86..47861ba 100644 --- a/handlers/game/game.py +++ b/handlers/game/game.py @@ -1,13 +1,18 @@ +from itertools import chain + from sanic import Request + +from helpers.gamehelper import SM5_STATE_LABEL_MAP, SM5_STATE_COLORS from shared import app from utils import render_template, is_admin from db.game import EntityEnds, EntityStarts from db.sm5 import SM5Game, SM5Stats from db.laserball import LaserballGame, LaserballStats -from helpers.gamehelper import get_team_rosters, get_matchmaking_teams, get_player_display_names +from helpers.gamehelper import get_team_rosters, get_matchmaking_teams from db.types import Team from sanic import exceptions -from helpers.statshelper import sentry_trace, get_sm5_team_score_graph_data +from helpers.statshelper import sentry_trace, get_sm5_team_score_graph_data, get_sm5_player_alive_times, \ + get_player_state_distribution, get_player_state_distribution_pie_chart from numpy import arange from typing import List, Optional @@ -30,12 +35,30 @@ async def game_index(request: Request, type: str, id: int) -> str: if not game: raise exceptions.NotFound("Not found: Invalid game ID") + game_duration = await game.get_actual_game_duration() team_rosters = await get_team_rosters(await game.entity_starts.all(), await game.entity_ends.all()) + # Get a flat list of all players across all teams. + all_players = list(chain.from_iterable(team_rosters.values())) + scores = { team: await game.get_team_score(team) for team in team_rosters.keys() } + time_in_game_values = { + player.entity_end.id: get_sm5_player_alive_times(game_duration, player.entity_end) for player in + all_players if player + } + + uptime_values = { + player.entity_end.id: get_player_state_distribution_pie_chart( + await get_player_state_distribution(player.entity_start, player.entity_end, + game.player_states, game.events, SM5_STATE_LABEL_MAP), + SM5_STATE_COLORS) + for player in + all_players if player + } + score_chart_data = await get_sm5_team_score_graph_data(game, list(team_rosters.keys())) players_matchmake_team1, players_matchmake_team2 = get_matchmaking_teams(team_rosters) @@ -56,8 +79,10 @@ async def game_index(request: Request, type: str, id: int) -> str: team_rosters=team_rosters, scores=scores, game=game, + time_in_game_values=time_in_game_values, + uptime_values=uptime_values, get_sm5stats=get_sm5stats, - score_chart_labels=[t for t in arange(0, 900000//1000//60+0.5, 0.5)], + score_chart_labels=[t for t in arange(0, 900000 // 1000 // 60 + 0.5, 0.5)], score_chart_data=score_chart_data, win_chance_before_game=win_chance_before_game, win_chance_after_game=win_chance_after_game, @@ -84,7 +109,7 @@ async def game_index(request: Request, type: str, id: int) -> str: # Sort the teams in order of their score. team_ranking = sorted(scores.keys(), key=lambda team: scores[team], reverse=True) - + if game.ranked: win_chance_after_game = await game.get_win_chance_after_game() win_chance_before_game = await game.get_win_chance_before_game() diff --git a/handlers/game/scorecard.py b/handlers/game/scorecard.py index d209993..0c99f9c 100644 --- a/handlers/game/scorecard.py +++ b/handlers/game/scorecard.py @@ -10,7 +10,8 @@ from db.game import EntityEnds, EntityStarts from db.laserball import LaserballGame, LaserballStats from helpers.statshelper import sentry_trace, _millis_to_time, count_zaps, count_missiles, count_blocks, \ - get_player_state_distribution, get_sm5_score_components, get_sm5_kd_ratio + get_player_state_distribution, get_sm5_score_components, get_sm5_kd_ratio, get_sm5_single_player_score_graph_data, \ + get_sm5_player_alive_times, get_player_state_distribution_pie_chart from sanic import exceptions @@ -114,15 +115,15 @@ async def scorecard(request: Request, type: str, id: int, entity_end_id: int) -> " eliminated_player" if player_sm5_stats[player.id] or player_sm5_stats[player.id].lives_left == 0 else ""), "score": player_entity_ends[player.id].score, "lives_left": player_sm5_stats[player.id].lives_left if player.id in player_sm5_stats else "", - "time_in_game_values": [player_entity_ends[player.id].time, game_duration - player_entity_ends[player.id].time], + "time_in_game_values": get_sm5_player_alive_times(game_duration, player_entity_ends[player.id]), "kd_ratio": ("%.2f" % get_sm5_kd_ratio(player_sm5_stats[player.id])) if player.id in player_sm5_stats else "", "mvp_points": "%.2f" % await player_sm5_stats[player.id].mvp_points(), "you_zapped": await count_zaps(game, entity_start.entity_id, player.entity_id), "zapped_you": await count_zaps(game, player.entity_id, entity_start.entity_id), "you_missiled": await count_missiles(game, entity_start.entity_id, player.entity_id), "missiled_you": await count_missiles(game, player.entity_id, entity_start.entity_id), - "state_distribution_values": list((await get_player_state_distribution(player, player_entity_ends[player.id], - game.player_states, game.events, SM5_STATE_LABEL_MAP)).values()) + "state_distribution": get_player_state_distribution_pie_chart(await get_player_state_distribution(player, player_entity_ends[player.id], + game.player_states, game.events, SM5_STATE_LABEL_MAP), SM5_STATE_COLORS) } for player in player_entities ]) all_players.sort(key=lambda x: x["score"], reverse=True) @@ -142,7 +143,7 @@ async def scorecard(request: Request, type: str, id: int, entity_end_id: int) -> }, ] - score_chart_data = [await game.get_entity_score_at_time(entity_start.id, t) for t in range(0, 900000+30000, 30000)] + score_chart_data = await get_sm5_single_player_score_graph_data(game, entity_start.id) return await render_template( request, diff --git a/helpers/gamehelper.py b/helpers/gamehelper.py index 99213f2..70faa5a 100644 --- a/helpers/gamehelper.py +++ b/helpers/gamehelper.py @@ -4,12 +4,11 @@ only deals with getting and extracting and visualizing data. """ from collections import defaultdict -from dataclasses import dataclass -from typing import List, Optional, Dict +from typing import List, Dict from sanic import exceptions -from db.game import EntityStarts, EntityEnds +from db.game import EntityStarts, EntityEnds, PlayerInfo from db.types import PlayerStateDetailType, Team """Map of every possible player state and the display name for it in SM5 games. @@ -39,14 +38,6 @@ } -@dataclass -class PlayerInfo: - """Information about a player in one particular game.""" - entity_start: EntityStarts - entity_end: Optional[EntityEnds] - display_name: str - - def get_players_from_team(all_players: List[dict], team_index: int) -> List[dict]: """Returns subset of the list of players - only those in the given team. diff --git a/helpers/statshelper.py b/helpers/statshelper.py index c363c11..0e1d97c 100644 --- a/helpers/statshelper.py +++ b/helpers/statshelper.py @@ -5,7 +5,7 @@ from db.sm5 import SM5Game, SM5Stats from db.laserball import LaserballGame, LaserballStats -from db.types import IntRole, EventType, PlayerStateDetailType, PlayerStateType, PlayerStateEvent, Team +from db.types import IntRole, EventType, PlayerStateDetailType, PlayerStateType, PlayerStateEvent, Team, PieChartData from db.game import EntityEnds, EntityStarts from tortoise.functions import Sum @@ -63,7 +63,19 @@ def get_sm5_kd_ratio(stats: SM5Stats) -> float: return stats.shot_opponent / stats.times_zapped if stats.times_zapped > 0 else 1.0 -async def get_sm5_single_team_score_graph_data(game: SM5Game, team:Team) -> List[int]: +def get_sm5_player_alive_times(game_duration_millis: int, player: EntityEnds) -> List[int]: + return [player.time, game_duration_millis - player.time] + + +async def get_sm5_single_player_score_graph_data(game: SM5Game, entity_id: int) -> List[int]: + """Returns data for a score graph for one player. + + Returns a list with data points containing the current score at the given time, one for every 30 seconds. + """ + return [await game.get_entity_score_at_time(entity_id, time) for time in range(0, 900000 + 30000, 30000)] + + +async def get_sm5_single_team_score_graph_data(game: SM5Game, team: Team) -> List[int]: """Returns data for a score graph for one team. Returns a list with data points containing the current score at the given time, one for every 30 seconds. @@ -597,3 +609,13 @@ def _add_player_state(state: PlayerStateDetailType, duration_millis: int): last_timestamp = state.timestamp_millis return result + + +def get_player_state_distribution_pie_chart(distribution: dict[str, int], + state_color_map: dict[str, str]) -> PieChartData: + """Takes state distribution data from get_player_state_distribution() and turns it into pie chart data.""" + return PieChartData( + labels=list(distribution.keys()), + colors=[state_color_map[state] for state in distribution.keys()], + data=list(distribution.values()) + ) diff --git a/tests/helpers/environment.py b/tests/helpers/environment.py index 13d2f39..fa28580 100644 --- a/tests/helpers/environment.py +++ b/tests/helpers/environment.py @@ -12,7 +12,7 @@ from tortoise import Tortoise -from db.game import Events, EntityStarts, Teams, EntityEnds +from db.game import Events, EntityStarts, Teams, EntityEnds, Scores from db.sm5 import SM5Game from db.types import EventType, Team from helpers.tdfhelper import create_event_from_data @@ -190,3 +190,9 @@ async def create_entity_ends( entity=entity_start, type=type, score=score) + + +async def add_sm5_score(game: SM5Game, entity: EntityStarts, time_millis: int, old_score: int, score: int): + score = await Scores.create(entity=entity, time=time_millis, old=old_score, delta=score-old_score, new=score) + + await game.scores.add(score) diff --git a/tests/helpers/statshelper_test.py b/tests/helpers/statshelper_test.py index b76db92..06eac0b 100644 --- a/tests/helpers/statshelper_test.py +++ b/tests/helpers/statshelper_test.py @@ -1,9 +1,12 @@ import unittest +from db.game import EntityStarts from db.sm5 import SM5Game, SM5Stats -from helpers.statshelper import count_zaps, get_sm5_kd_ratio, get_sm5_score_components +from db.types import Team +from helpers.statshelper import count_zaps, get_sm5_kd_ratio, get_sm5_score_components, \ + get_sm5_single_player_score_graph_data, get_sm5_single_team_score_graph_data, get_sm5_team_score_graph_data from tests.helpers.environment import setup_test_database, ENTITY_ID_1, ENTITY_ID_2, get_sm5_game_id, \ - teardown_test_database, create_destroy_base_event, add_entity, get_red_team + teardown_test_database, create_destroy_base_event, add_entity, get_red_team, get_green_team, add_sm5_score class TestStatsHelper(unittest.IsolatedAsyncioTestCase): @@ -65,6 +68,77 @@ async def test_get_kd_ratio_never_zapped(self): self.assertEqual(1.0, get_sm5_kd_ratio(stats)) + async def test_get_sm5_single_player_score_graph_data(self): + entity1 = await self.create_score_test_scenario() + + game = await SM5Game.filter(id=get_sm5_game_id()).first() + scores = await get_sm5_single_player_score_graph_data(game, entity1.id) + + self.assertEqual([ + # 00:00 + 0, 0, 0, 0, 100, 100, 100, 100, 100, 100, + # 05:00 + 100, 100, 100, 100, 500, 500, 500, 500, 500, 500, + # 10:00 + 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 500], scores) + + async def test_get_sm5_single_team_score_graph_data(self): + await self.create_score_test_scenario() + + game = await SM5Game.filter(id=get_sm5_game_id()).first() + scores = await get_sm5_single_team_score_graph_data(game, Team.RED) + + self.assertEqual([ + # 00:00 + 0, 0, 0, 0, 100, 100, 100, 100, 100, 100, + # 05:00 + 400, 400, 400, 400, 800, 800, 800, 800, 800, 800, + # 10:00 + 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800], scores) + + async def test_get_sm5_team_score_graph_data(self): + await self.create_score_test_scenario() + + game = await SM5Game.filter(id=get_sm5_game_id()).first() + scores = await get_sm5_team_score_graph_data(game, [Team.RED, Team.GREEN]) + + self.assertEqual({ + Team.RED: [ + # 00:00 + 0, 0, 0, 0, 100, 100, 100, 100, 100, 100, + # 05:00 + 400, 400, 400, 400, 800, 800, 800, 800, 800, 800, + # 10:00 + 800, 800, 800, 800, 800, 800, 800, 800, 800, 800, 800], + Team.GREEN: [ + # 00:00 + 0, 0, 0, 0, 0, 0, 0, 200, 200, 200, + # 05:00 + 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, + # 10:00 + 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200]}, + scores) + + async def create_score_test_scenario(self) -> EntityStarts: + game = await SM5Game.filter(id=get_sm5_game_id()).first() + + entity1, entity_end1 = await add_entity(entity_id="@LoggedIn", team=get_red_team()) + entity2, entity_end2 = await add_entity(entity_id="@Member", team=get_green_team()) + entity3, entity_end3 = await add_entity(entity_id="NotLoggedIn", team=get_red_team()) + + # At 01:40: 100 points. + await add_sm5_score(game, time_millis=100000, entity=entity1, old_score=0, score=100) + # At 03:20: 200 points for green team. + await add_sm5_score(game, time_millis=200000, entity=entity2, old_score=0, score=200) + # At 05:00: 300 points for entity3. + await add_sm5_score(game, time_millis=300000, entity=entity3, old_score=0, score=300) + # At 06:40: 500 points. + await add_sm5_score(game, time_millis=400000, entity=entity1, old_score=100, score=500) + # At 16:40 (Irrelevant, past end of the game) + await add_sm5_score(game, time_millis=1000000, entity=entity1, old_score=500, score=600) + + return entity1 + if __name__ == '__main__': unittest.main()