From d35079d5f3ce926547900b9aa2aaa4c1c398c311 Mon Sep 17 00:00:00 2001 From: Ebo Date: Tue, 23 Apr 2024 22:54:13 -0700 Subject: [PATCH] Rewrote Laserball game page to use the LB helper. Same system we're already using in SM5. That way, we only have to write the logic once. This also adds totals at the bottom of every team. --- assets/html/game/laserball.html | 136 ++++--------- handlers/game/game.py | 36 +--- helpers/laserballhelper.py | 345 ++++++++++++++++++++++++++++++++ helpers/statshelper.py | 12 ++ 4 files changed, 404 insertions(+), 125 deletions(-) create mode 100644 helpers/laserballhelper.py diff --git a/assets/html/game/laserball.html b/assets/html/game/laserball.html index 836c3be..17bad4d 100644 --- a/assets/html/game/laserball.html +++ b/assets/html/game/laserball.html @@ -266,8 +266,9 @@
-
-

Fire Team: {{ fire_score }}

+ {% for team in teams %} +
+

{{ team.element }} Team: {{ team.score }}

@@ -288,94 +289,38 @@

Fire Team: {{

MVP Points

Accuracy

K/D

- {% for entity in game.entity_starts %} - {% if entity.type == "player" and entity.team.index == 0 %} - {% set entity_end = get_entity_end(entity) %} - {% set laserball_stats = get_laserballstats(entity) %} - - {% if entity.entity_id.startswith("@") %} -

{{ entity.name }}

-

{{ laserball_stats.score }}

- {% else %} -

{{ entity.name }}

- {{ laserball_stats.score }} - {% endif %} - {% if entity_end.current_rating_mu and game.ranked %} - {{ (entity_end.current_rating_mu - 3 * entity_end.current_rating_sigma)|round(2) }} - {% endif %} -

{{ laserball_stats.goals }}

-

{{ laserball_stats.assists }}

-

{{ laserball_stats.steals }}

-

{{ laserball_stats.clears }}

-

{{ laserball_stats.passes }}

-

{{ laserball_stats.blocks }}

-

{{ laserball_stats.times_stolen }}

-

{{ laserball_stats.times_blocked }}

-

{{ "%.2f" % (laserball_stats.mvp_points|round(2)) }}

-

{{ laserball_stats.shots_fired and "%.2f" % (((laserball_stats.shots_hit/laserball_stats.shots_fired)*100)|round(2)) }}%

-

{{ laserball_stats.times_blocked and "%.2f" % ((laserball_stats.blocks/laserball_stats.times_blocked) | round(2)) }}

- - {% endif %} - {% endfor %} - -

-
-
-
-

Ice Team: {{ ice_score }}

- -
-
- - - - {% if game.ranked %} - - {% endif %} - - - - - - - - - - - - {% for entity in game.entity_starts %} - {% if entity.type == "player" and entity.team.index == 1 %} - {% set entity_end = get_entity_end(entity) %} - {% set laserball_stats = get_laserballstats(entity) %} - - {% if entity.entity_id.startswith("@") %} - - - {% else %} - - - {% endif %} - {% if entity_end.current_rating_mu and game.ranked %} - - {% endif %} - - - - - - - - - - - - - {% endif %} + {% for player in team.players_with_sum %} + {% set entity_end = player.entity_end %} + + + {% if player.player_info.is_member %} + + {% else %} + + {% endif %} + {% if entity_end.current_rating_mu and game.ranked %} + + {% else %} + + {% endif %} + + + + + + + + + + + + {% endfor %}

Codename

Score

Current Rating

Goals

Assists

Steals

Clears

Passes

Blocks

Stolen

Blocked

MVP Points

Accuracy

K/D

{{ entity.name }}

{{ laserball_stats.score }}

{{ entity.name }}

{{ laserball_stats.score }}{{ (entity_end.current_rating_mu - 3 * entity_end.current_rating_sigma)|round(2) }}

{{ laserball_stats.goals }}

{{ laserball_stats.assists }}

{{ laserball_stats.steals }}

{{ laserball_stats.clears }}

{{ laserball_stats.passes }}

{{ laserball_stats.blocks }}

{{ laserball_stats.times_stolen }}

{{ laserball_stats.times_blocked }}

{{ "%.2f" % (laserball_stats.mvp_points|round(2)) }}

{{ laserball_stats.shots_fired and "%.2f" % (((laserball_stats.shots_hit/laserball_stats.shots_fired)*100)|round(2)) }}%

{{ laserball_stats.times_blocked and (laserball_stats.blocks/laserball_stats.times_blocked) | round(2) }}

{{ player_reference(player) }}{{ player.score }}

{{ player.score }}

{{ (entity_end.current_rating_mu - 3 * entity_end.current_rating_sigma)|round(2) }}

 

{{ player.goals }}

{{ player.assists }}

{{ player.steals }}

{{ player.clears }}

{{ player.passes }}

{{ player.blocks }}

{{ player.times_stolen }}

{{ player.times_blocked }}

{{ "%.2f" % (player.mvp_points|round(2)) }}

{{ player.accuracy_str }}

{{ player.kd_ratio_str }}

+ {% endfor %}
@@ -386,8 +331,6 @@

Ice Team: {{ ice

- -

For more detailed stats, please view the website on desktop.

@@ -399,24 +342,17 @@

For more detailed stats, please view the website on de data: { labels: {{score_chart_labels}}, datasets: [ + {% for team in teams %} { - label: "Fire Team Goals", - data: {{score_chart_data_red}}, - borderColor: "orangered", + label: "{{ team.element }} Team Goals", + data: {{score_chart_data[team.team]}}, + borderColor: "{{ team.css_color_name }}", fill: false, tension: 0.2, pointRadius: 0, pointHitRadius: 10 - }, - { - label: "Ice Team Goals", - data: {{score_chart_data_blue}}, - borderColor: "#0096FF", - fill: false, - tension: 0.2, - pointRadius: 0, - pointHitRadius: 10 - } + }{{ "," if not loop.last }} + {% endfor %} ] }, options: { diff --git a/handlers/game/game.py b/handlers/game/game.py index 7e042a9..2e4a9e9 100644 --- a/handlers/game/game.py +++ b/handlers/game/game.py @@ -1,5 +1,6 @@ from sanic import Request +from helpers.laserballhelper import get_laserball_player_stats from helpers.sm5helper import get_sm5_player_stats from helpers.tooltips import TOOLTIP_INFO from shared import app @@ -11,7 +12,7 @@ from db.types import Team from sanic import exceptions from helpers.statshelper import sentry_trace, get_sm5_team_score_graph_data, \ - millis_to_time + millis_to_time, get_ticks_for_time_graph from numpy import arange from typing import Optional from sanic.log import logger @@ -76,7 +77,8 @@ async def game_index(request: Request, type: str, id: int) -> str: is_admin=is_admin(request) ) elif type == "laserball": - game = await LaserballGame.filter(id=id).prefetch_related("entity_starts").first() + game = await LaserballGame.filter(id=id).prefetch_related("entity_starts", "entity_ends").first() + game_duration = game.mission_duration logger.debug(f"Fetching laserball game with ID {id}") @@ -87,27 +89,15 @@ async def game_index(request: Request, type: str, id: int) -> str: logger.debug("Fetching team rosters") - team_rosters = await get_team_rosters(await game.entity_starts.all(), await game.entity_ends.all()) + full_stats = await get_laserball_player_stats(game) - logger.debug("Fetching team scores") - - scores = { - team: await game.get_team_score(team) for team in team_rosters.keys() - } - - logger.debug("Fetching team score graph data") - - score_chart_data = await get_sm5_team_score_graph_data(game, list(team_rosters.keys())) logger.debug("Fetching matchmaking teams") - players_matchmake_team1, players_matchmake_team2 = get_matchmaking_teams(team_rosters) + players_matchmake_team1, players_matchmake_team2 = get_matchmaking_teams(full_stats.get_team_rosters()) logger.debug("Fetching win chances") - # 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() @@ -120,14 +110,10 @@ async def game_index(request: Request, type: str, id: int) -> str: return await render_cached_template( request, "game/laserball.html", game=game, - get_entity_end=get_entity_end, - get_laserballstats=get_laserballstats, - fire_score=await game.get_team_score(Team.RED), - ice_score=await game.get_team_score(Team.BLUE), - score_chart_labels=[{"x": t, "y": await game.get_rounds_at_time(t*60*1000)} for t in arange(0, 900000//1000//60+0.5, 0.5)], - score_chart_data_red=[await game.get_team_score_at_time(Team.RED, t) for t in range(0, 900000+30000, 30000)], - score_chart_data_blue=[await game.get_team_score_at_time(Team.BLUE, t) for t in range(0, 900000+30000, 30000)], - score_chart_data_rounds=[await game.get_rounds_at_time(t) for t in range(0, 900000+30000, 30000)], + teams=full_stats.teams, + score_chart_labels=[{"x": t, "y": await game.get_rounds_at_time(t*60*1000)} for t in arange(0, game_duration//1000//60+0.5, 0.5)], + score_chart_data=full_stats.score_chart_data, + score_chart_data_rounds=full_stats.score_chart_data_rounds, win_chance_before_game=win_chance_before_game, win_chance_after_game=win_chance_after_game, players_matchmake_team1=players_matchmake_team1, @@ -135,4 +121,4 @@ async def game_index(request: Request, type: str, id: int) -> str: is_admin=is_admin(request) ) else: - raise exceptions.BadRequest("Invalid game type") \ No newline at end of file + raise exceptions.BadRequest("Invalid game type") diff --git a/helpers/laserballhelper.py b/helpers/laserballhelper.py new file mode 100644 index 0000000..d308e04 --- /dev/null +++ b/helpers/laserballhelper.py @@ -0,0 +1,345 @@ +"""Various helpers specifically for Laserball games. +""" +from dataclasses import dataclass +from typing import Optional, List, Dict + +from db.game import EntityStarts, PlayerInfo +from db.laserball import LaserballStats, LaserballGame +from db.types import Team +from helpers.cachehelper import cache +from helpers.gamehelper import get_team_rosters, SM5_STATE_LABEL_MAP +from helpers.statshelper import PlayerCoreGameStats, get_player_state_distribution, TeamCoreGameStats, count_blocks, \ + get_ticks_for_time_graph + + +# TODO: A lot of stuff from statshelper.py should be moved here. But let's do that separately to keep the commit size +# reasonable. + +@dataclass +class PlayerLaserballGameStats(PlayerCoreGameStats): + """The stats for a player in one LB game.""" + stats: LaserballStats + + # How many times this player blocked the main player (for score cards). None if this wasn't computed. + blocked_main_player: Optional[int] + + # How many times this player got blocked by the main player (for score cards). None if this wasn't computed. + blocked_by_main_player: Optional[int] + + # String expressing the hit ratio between this player and the main player. None if this wasn't computed. + main_player_hit_ratio: Optional[str] + + @property + def goals(self) -> int: + return self.stats.goals + + @property + def assists(self) -> int: + return self.stats.assists + + @property + def passes(self) -> int: + return self.stats.passes + + @property + def steals(self) -> int: + return self.stats.steals + + @property + def times_stolen(self) -> int: + return self.stats.times_stolen + + @property + def clears(self) -> int: + return self.stats.clears + + @property + def blocks(self) -> int: + return self.stats.blocks + + @property + def times_blocked(self) -> int: + return self.stats.times_blocked + + @property + def score(self) -> int: + """The score, as used in Laserforce player stats. + + The formula: Score = (Goals + Assists) * 10000 + Steals * 100 + Blocks + See also: https://www.iplaylaserforce.com/games/laserball/. + """ + return (self.goals + self.assists) * 10000 + self.steals * 100 + self.blocks + + +@dataclass +class LaserballPlayerGameStatsSum(PlayerLaserballGameStats): + """Fake Laserball player game stats for the sum of all players in a team.""" + total_score: int + + average_points_per_minute: int + + total_goals: int + + total_assists: int + + total_passes: int + + total_steals: int + + total_times_stolen: int + + total_clears: int + + total_blocks: int + + total_times_blocked: int + + @property + def name(self) -> str: + return "Total" + + @property + def goals(self) -> int: + return self.total_goals + + @property + def assists(self) -> int: + return self.total_assists + + @property + def passes(self) -> int: + return self.total_passes + + @property + def steals(self) -> int: + return self.total_steals + + @property + def times_stolen(self) -> int: + return self.total_times_stolen + + @property + def clears(self) -> int: + return self.total_clears + + @property + def blocks(self) -> int: + return self.total_blocks + + @property + def times_blocked(self) -> int: + return self.total_times_blocked + + @property + def score(self) -> int: + return self.total_score + + +@dataclass +class TeamLaserballGameStats(TeamCoreGameStats): + """The stats for a team for one Laserball game.""" + players: List[PlayerLaserballGameStats] + + # Score over time, one data point every 30 seconds. + team_score_graph: List[int] + + # The stats for every player in the game, plus a fake stat at the end with the sum (or average) of all players. + @property + def players_with_sum(self): + return self.players + [self.sum_player] + + # A fake player that has the sum (or average where appropriate) of all players in the team. + sum_player: LaserballPlayerGameStatsSum + + def get_player_infos(self) -> List[PlayerInfo]: + return [player.player_info for player in self.players] + + +@dataclass +class FullLaserballStats: + # All teams, sorted by team score (highest to lowest). + teams: List[TeamLaserballGameStats] + + # Dict with all players from all teams. Key is the entity end ID. + all_players: dict[int, PlayerLaserballGameStats] + + # Score graph (one data point every 30 seconds) for every team. + score_chart_data: dict[Team, List[int]] + + # Round graph (current round for every 30 seconds during the game). + score_chart_data_rounds: List[int] + + def get_teams(self) -> List[Team]: + return [team.team for team in self.teams] + + def get_team_rosters(self) -> Dict[Team, List[PlayerInfo]]: + return { + team.team: team.get_player_infos() for team in self.teams + } + + +@cache() +async def get_laserball_player_stats(game: LaserballGame, + main_player: Optional[EntityStarts] = None) -> FullLaserballStats: + """Returns all teams with all player stats for an LB game. + + Returns: + A list of teams and all the players within them. The teams will be sorted by highest score. + """ + teams = [] + all_players = {} + game_duration = game.mission_duration + + team_rosters = await get_team_rosters(game.entity_starts, game.entity_ends) + + for team in team_rosters.keys(): + players = [] + avg_state_distribution = {} + avg_score_components = {} + sum_shots_fired = 0 + sum_shots_hit = 0 + sum_shot_opponent = 0 + sum_blocked_main_player = 0 + sum_blocked_by_main_player = 0 + sum_score = 0 + sum_mvp_points = 0.0 + sum_points_per_minute = 0 + sum_goals = 0 + sum_assists = 0 + sum_steals = 0 + sum_times_stolen = 0 + sum_blocks = 0 + sum_clears = 0 + sum_passes = 0 + sum_times_blocked = 0 + + for player in team_rosters[team]: + stats = await LaserballStats.filter(entity_id=player.entity_start.id).first() + + if not stats: + # This player might have been kicked before the game was over. Don't include in the actual result. + continue + + state_distribution = await get_player_state_distribution(player.entity_start, player.entity_end, + game.player_states, + game.events, + SM5_STATE_LABEL_MAP) + + blocked_main_player = None + blocked_by_main_player = None + main_player_hit_ratio = None + is_main_player = False + + if main_player: + is_main_player = player.entity_start.id == main_player.id + blocked_main_player = await count_blocks(game, player.entity_start.entity_id, main_player.entity_id) + blocked_by_main_player = await count_blocks(game, main_player.entity_id, player.entity_start.entity_id) + + main_player_hit_ratio = "%.2f" % _calc_ratio(blocked_by_main_player, blocked_main_player) + + player = PlayerLaserballGameStats( + team=team, + player_info=player, + css_class="player%s" % (" active_player" if is_main_player else ""), + state_distribution=state_distribution, + shots_fired=stats.shots_fired, + shots_hit=stats.shots_hit, + stats=stats, + blocked_main_player=blocked_main_player, + blocked_by_main_player=blocked_by_main_player, + main_player_hit_ratio=main_player_hit_ratio, + shot_opponent=stats.blocks + stats.steals, + times_zapped=stats.times_blocked + stats.times_stolen, + score_components={}, + mvp_points=stats.mvp_points, + ) + + players.append(player) + all_players[player.entity_end.id] = player + + # Run a tally so we can compute the sum/average for the team. + sum_score += player.score + sum_points_per_minute += player.points_per_minute + sum_mvp_points += player.mvp_points + sum_shots_fired += player.shots_fired + sum_shots_hit += player.shots_hit + sum_shot_opponent += player.shot_opponent + sum_times_blocked += player.times_zapped + sum_blocked_main_player += player.blocked_main_player if player.blocked_main_player else 0 + sum_blocked_by_main_player += player.blocked_by_main_player if player.blocked_by_main_player else 0 + sum_goals += player.goals + sum_assists += player.assists + sum_steals += player.steals + sum_blocks += player.blocks + sum_clears += player.clears + sum_passes += player.passes + sum_times_stolen += player.times_stolen + sum_times_blocked += player.times_blocked + + player_count = len(players) + + # Fake the player count to 1 if there aren't any so we don't divide by 0. All the numbers will be 0 anyway so + # it won't make a difference. + average_divider = player_count if player_count else 1 + + # Create the sum of all players in the game. + sum_player = LaserballPlayerGameStatsSum( + team=team, + player_info=None, + css_class="team_totals", + total_score=sum_score, + average_points_per_minute=int(sum_points_per_minute / average_divider), + state_distribution=avg_state_distribution, + score_components=avg_score_components, + mvp_points=sum_mvp_points / average_divider, + shots_fired=sum_shots_fired, + shots_hit=sum_shots_hit, + shot_opponent=sum_shot_opponent, + times_zapped=sum_times_blocked, + stats=None, + blocked_main_player=sum_blocked_main_player, + blocked_by_main_player=sum_blocked_by_main_player, + main_player_hit_ratio="%.2f" % _calc_ratio(sum_blocked_by_main_player, sum_blocked_main_player), + total_blocks=sum_blocks, + total_goals=sum_goals, + total_assists=sum_assists, + total_clears=sum_clears, + total_steals=sum_steals, + total_passes=sum_passes, + total_times_blocked=sum_times_blocked, + total_times_stolen=sum_times_stolen, + ) + + team_score_graph = [await game.get_team_score_at_time(team, time) for time in + get_ticks_for_time_graph(game_duration)] + + # Sort the roster by score. + players.sort(key=lambda x: x.score, reverse=True) + teams.append( + TeamLaserballGameStats( + team=team, + score=await game.get_team_score(team), + players=players, + sum_player=sum_player, + team_score_graph=team_score_graph, + )) + + # Sort teams by score. + teams.sort(key=lambda x: x.score, reverse=True) + + score_chart_data = { + team.team: team.team_score_graph for team in teams + } + + score_chart_data_rounds = [await game.get_rounds_at_time(t) for t in get_ticks_for_time_graph(game_duration)] + + return FullLaserballStats( + teams=teams, + all_players=all_players, + score_chart_data=score_chart_data, + score_chart_data_rounds=score_chart_data_rounds, + ) + + +def _calc_ratio(numerator: int, denominator: int) -> float: + return float(numerator) / float(denominator) if denominator else 0.0 diff --git a/helpers/statshelper.py b/helpers/statshelper.py index 65e4058..39057de 100644 --- a/helpers/statshelper.py +++ b/helpers/statshelper.py @@ -14,6 +14,9 @@ # stats helpers +# The frequency for ticks in time series graphs, in milliseconds. +_DEFAULT_TICKS_DURATION_MILLIS = 30000 + @dataclass class PlayerCoreGameStats: """The stats for a player for one game that apply to most game formats (at least both SM5 and LB).""" @@ -147,6 +150,15 @@ def millis_to_time(milliseconds: Optional[int]) -> str: return "%02d:%02d" % (milliseconds / 60000, milliseconds % 60000 / 1000) +def get_ticks_for_time_graph(game_duration_millis: int) -> range: + """Returns a list of timestamps for ticks for a time series graph. + + The list will be one tick every 30 seconds, until 30 seconds after the duration + of the game. + """ + return range(0, game_duration_millis + _DEFAULT_TICKS_DURATION_MILLIS, _DEFAULT_TICKS_DURATION_MILLIS) + + async def get_sm5_score_components(game: SM5Game, stats: SM5Stats, entity_start: EntityStarts) -> dict[str, int]: """Returns a dict with individual components that make up a player's total score.