diff --git a/assets/html/base.html b/assets/html/base.html index b1f5234..ef76089 100644 --- a/assets/html/base.html +++ b/assets/html/base.html @@ -205,6 +205,22 @@ color: greenyellow; } + .ice-team { + color: #0096FF; + } + + .fire-team-dim { + color: #441100; + } + + .earth-team-dim { + color: #213C0C; + } + + .ice-team-dim { + color: #002534; + } + .active_player { font-weight: bold; } diff --git a/assets/html/game/replay.html b/assets/html/game/replay.html new file mode 100644 index 0000000..e348c77 --- /dev/null +++ b/assets/html/game/replay.html @@ -0,0 +1,221 @@ +{% extends "base.html" %} +{% block title %}Replay{% endblock %} + + +{% block html_head %} + + +{% endblock %} + +{% block content %} + +
+

Loading Replay...

+
+
+
+
+
+
+ +
+
+ + + +
+
+ +
+ +
+ + +
+ + + + +{% endblock %} \ No newline at end of file diff --git a/assets/js/replay.js b/assets/js/replay.js new file mode 100644 index 0000000..8e8ee82 --- /dev/null +++ b/assets/js/replay.js @@ -0,0 +1,379 @@ + + + +function playAudio(audio) { + audio.volume = 0.5; + if (!recalculating) { + audio.play(); + } + return audio; +} + +function playDownedAudio() { + // audio is random between Scream.0.wav Scream.1.wav Scream.2.wav Shot.0.wav Shot.1.wav + sfx = Math.floor(Math.random() * 5); + return playAudio(downed_audio[sfx]); +} + + +start_audio = [new Audio("/assets/sm5/audio/Start.0.wav"), new Audio("/assets/sm5/audio/Start.1.wav"), new Audio("/assets/sm5/audio/Start.2.wav"), new Audio("/assets/sm5/audio/Start.3.wav")]; +alarm_start_audio = new Audio("/assets/sm5/audio/Effect/General Quarters.wav"); +resupply_audio = [new Audio("/assets/sm5/audio/Effect/Resupply.0.wav"), new Audio("/assets/sm5/audio/Effect/Resupply.1.wav"), new Audio("/assets/sm5/audio/Effect/Resupply.2.wav"), new Audio("/assets/sm5/audio/Effect/Resupply.3.wav"), new Audio("/assets/sm5/audio/Effect/Resupply.4.wav")]; +downed_audio = [new Audio("/assets/sm5/audio/Effect/Scream.0.wav"), new Audio("/assets/sm5/audio/Effect/Scream.1.wav"), new Audio("/assets/sm5/audio/Effect/Scream.2.wav"), new Audio("/assets/sm5/audio/Effect/Shot.0.wav"), new Audio("/assets/sm5/audio/Effect/Shot.1.wav")]; +base_destroyed_audio = new Audio("/assets/sm5/audio/Effect/Boom.wav"); + +current_starting_sound_playing = undefined; + +started = false; +cancelled_starting_sound = false; +restarted = false; +play = false; +scrub = false; // for when going back in time +recalculating = false; // for when we're mass recalculating and don't want to play audio +playback_speed = 1.0; + +columns = []; +team_names = []; +team_ids = []; + +// The timestamp (wall clock) when we last started to play back or change the playback settings. +base_timestamp = 0; + +// The game time in milliseconds at the time of base_timestamp. +base_game_time_millis = 0; + +// The current time being shown in the time label, in seconds. +time_label_seconds = 0; + +// Sound IDs and all Audio objects for them. +sounds = {}; + +/* Returns the time in the game (in milliseconds) that's currently active. */ +function getCurrentGameTimeMillis() { + // If we're not currently playing back, the game time remains unchanged. + if (!play) { + return base_game_time_millis; + } + + const now = new Date().getTime(); + + return base_game_time_millis + (now - base_timestamp) * get_playback_speed(); +} + +function finishedPlayingIntro() { + if (current_starting_sound_playing != audio || restarted || cancelled_starting_sound) { + return; + } + play = true; + restarted = false; + playButton.innerHTML = "Pause"; + started = true; + base_timestamp = new Date().getTime(); + + // play the game start sfx + playAudio(alarm_start_audio); + + playEvents(); +} + +function playPause() { + if (play) { // pause the game + // Lock the game time at what it currently is. + base_game_time_millis = getCurrentGameTimeMillis(); + base_timestamp = new Date().getTime(); + + play = false; + playButton.innerHTML = "Play"; + } else { // play the game + base_timestamp = new Date().getTime(); + + restarted = false; + if (current_starting_sound_playing != undefined) { + restartReplay(); + restarted = false; + finishedPlayingIntro(); + cancelled_starting_sound = true; // cancel the callback for the starting sound + } + else if (!started) { + base_game_time_millis = 0 + // starting the game for the first time + + // choose a random sfx 0-3 + + sfx = Math.floor(Math.random() * 4); + + audio = new Audio(`/assets/sm5/audio/Start.${sfx}.wav`); + audio.volume = 0.5; + current_starting_sound_playing = audio; + audio.play(); + + audio.addEventListener("loadeddata", () => { + // wait for the sfx to finish + setTimeout(function() { + finishedPlayingIntro(); + }, audio.duration*1000); + }); + return; + } + + play = true; + playButton.innerHTML = "Pause"; + started = true; + restarted = false; + playEvents(); + } +} + +function add_column(column_name) { + columns.push(column_name); +} + +function add_team(team_name, team_id, team_css_class) { + + let team_div = document.createElement("div"); + team_div.id = team_id; + team_div.className = "team"; + + let team_score = document.createElement("h2"); + team_score.style = "font-size: 20px;"; + team_score.className = `team_score ${team_css_class}`; + team_score.id = `${team_id}_score`; + team_score.innerHTML = `${team_name}: 0`; + team_div.appendChild(team_score); + + team_names.push(team_name); + team_ids.push(team_id); + + let team_table = document.createElement("table"); + team_table.id = `${team_id}_table`; + + let header_row = document.createElement("tr"); + + columns.forEach((column) => { + let header = document.createElement("th"); + header.innerHTML += `

${column}

`; + header_row.appendChild(header); + }); + + team_table.appendChild(header_row); + team_div.appendChild(team_table); + teams.appendChild(team_div); +} + +function register_sound(sound_id, asset_urls) { + sound_objects = []; + + asset_urls.forEach((asset_url) => { + sound_objects.push(new Audio(asset_url)); + }); + + sounds[sound_id] = sound_objects; +} + +function play_sound(sound_id) { + sound_assets = sounds[sound_id]; + index = Math.floor(Math.random() * sound_assets.length); + audio = sound_assets[index]; + + audio.volume = 0.5; + if (!recalculating) { + audio.play(); + } +} + +function add_player(team_id, row_id, cells) { + let row = document.createElement("tr"); + row.id = row_id; + + cells.forEach((column, index) => { + let cell = document.createElement("td"); + cell.id = `${row_id}_${index}`; + cell.innerHTML = column; + row.appendChild(cell); + }); + + document.getElementById(`${team_id}_table`).appendChild(row); +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +event_iteration = 0; + +function get_playback_speed() { + return playback_speed; +} + +function onSpeedChange() { + const new_playback_speed = parseFloat(speedText.value); + + if (playback_speed != new_playback_speed) { + if (play) { + base_game_time_millis = getCurrentGameTimeMillis(); + base_timestamp = new Date().getTime(); + } + + playback_speed = new_playback_speed; + } +} + +function playEvents() { + setTimeSlider(getCurrentGameTimeMillis() / 1000); + + for (let i = event_iteration; i < events.length; i++) { + event_iteration = i; + + if (!play && !scrub) { + return; + } + + const event = events[i]; + const timestamp = event[0]; + + // check if the event way behind the current time so we don't play audio + if (timestamp < getCurrentGameTimeMillis() - 1000) { + recalculating = true; + } else { + recalculating = false; + } + + // check if event is in the future while accounting for speed + if (timestamp > getCurrentGameTimeMillis()) { + event_iteration = i; + + if (play) { + setTimeout(playEvents, 100); + } + return; + } + + oldScrollTop = eventBox.scrollTop + eventBox.clientHeight; + oldScrollHeight = eventBox.scrollHeight; + + const message = event[1]; + + if (message.length > 0) { + eventBox.innerHTML += `
${message}
\n`; + } + + // Handle all cell changes. + event[2].forEach((cell_change) => { + row_id = cell_change[0]; + column = cell_change[1]; + new_value = cell_change[2]; + + document.getElementById(`${row_id}_${column}`).innerHTML = new_value; + }); + + // Handle all row changes. + event[3].forEach((row_change) => { + row_id = row_change[0]; + css_class = row_change[1]; + + document.getElementById(row_id).className = css_class; + }); + + event[4].forEach((team_score, index) => { + document.getElementById(`${team_ids[index]}_score`).innerHTML = `${team_names[index]} ${team_score}`; + }); + + event[5].forEach((sound_id) => { + play_sound(sound_id); + }); + + eventBox.scrollTop = eventBox.scrollHeight; + } +} + +function startReplay() { + teamsLoadingPlaceholder.style.display = "none"; + timeSlider.style.display = "block"; + replayViewer.style.display = "flex"; +} + +function restartReplay() { + console.log("Restarting replay"); + + if (current_starting_sound_playing != undefined) { + current_starting_sound_playing.pause(); + } + + play = false; + started = false; + restarted = true; + + // If true, we're not playing back, but we're paused and just want to evaluate the game at a different time. + scrub = false; + + eventBox.innerHTML = ""; + event_iteration = 0; + teams.innerHTML = ""; + playButton.innerHTML = "Play"; + startReplay(); +} + +function resetGame() { + eventBox.innerHTML = ""; + event_iteration = 0; + reset_players(); +} + +function onLoad() { + teams = document.getElementById("teams"); + replayViewer = document.getElementById("replay_viewer"); + teamsLoadingPlaceholder = document.getElementById("teams_loading_placeholder"); + timeSlider = document.getElementById("time_slider"); + eventBox = document.getElementById("events"); + speedText = document.getElementById("speed"); + playButton = document.getElementById("play"); + restartButton = document.getElementById("restart"); + + playButton.addEventListener("click", playPause); + restartButton.addEventListener("click", restartReplay); +} + +function onTimeChange(seconds) { + // If the new time is earlier than before, we need to reevaluate everything. + if (seconds * 1000 < base_game_time_millis) { + resetGame(); + } + + base_game_time_millis = seconds * 1000 + base_timestamp = new Date().getTime(); + + setTimeLabel(seconds); + + // If we're not currently playing back, we need to manually update. + if (!play) { + scrub = true; + playEvents(); + scrub = false; + } +} + +function setTimeSlider(seconds) { + document.getElementById("time-slider").value = seconds; + setTimeLabel(seconds); +} + +function setTimeLabel(seconds) { + const totalSeconds = Math.floor(seconds); + + if (totalSeconds == time_label_seconds) { + return; + } + + const minutes = Math.floor(seconds / 60); + const remainingSeconds = Math.floor(seconds % 60); + + const formattedMinutes = minutes.toString().padStart(2, "0"); + const formattedSeconds = remainingSeconds.toString().padStart(2, "0"); + + document.getElementById("timestamp").innerHTML = `${formattedMinutes}:${formattedSeconds}`; + time_label_seconds = totalSeconds; +} + +document.addEventListener("DOMContentLoaded", function() { + onLoad(); +}); diff --git a/db/types.py b/db/types.py index 23791c7..8e131a8 100644 --- a/db/types.py +++ b/db/types.py @@ -57,6 +57,7 @@ class _TeamDefinition: css_class: str css_color_name: str dim_color: RgbColor + dim_css_class: str def __eq__(self, color: str) -> bool: return self.color == color @@ -80,11 +81,11 @@ def __hash__(self): class Team(Enum): RED = _TeamDefinition(color="red", element="Fire", css_class="fire-team", css_color_name="orangered", - dim_color=RgbColor(red=68, green=17, blue=0)) + dim_color=RgbColor(red=68, green=17, blue=0), dim_css_class="fire-team-dim") GREEN = _TeamDefinition(color="green", element="Earth", css_class="earth-team", css_color_name="greenyellow", - dim_color=RgbColor(red=43, green=60, blue=12)) + dim_color=RgbColor(red=43, green=60, blue=12), dim_css_class="earth-team-dim") BLUE = _TeamDefinition(color="blue", element="Ice", css_class="ice-team", css_color_name="#0096FF", - dim_color=RgbColor(red=0, green=37, blue=68)) + dim_color=RgbColor(red=0, green=37, blue=68), dim_css_class="ice-team-dim") def __call__(cls, value, *args, **kw): # Tortoise looks up values by the lower-case color name. @@ -108,6 +109,11 @@ def css_class(self) -> str: """CSS class to use to show text using the color of this team.""" return self.value.css_class + @property + def dim_css_class(self) -> str: + """CSS class to use to show text using the color of this team but dimmed (used when a player is down).""" + return self.value.dim_css_class + @property def css_color_name(self) -> str: """CSS color to use for this team, could be a RGB HEX value or a CSS color value.""" @@ -118,6 +124,11 @@ def dim_color(self) -> RgbColor: """Color to use for this team at a darker brightness, good for line graphs showing peripheral data.""" return self.value.dim_color + @property + def name(self) -> str: + """The display name, like "Earth Team".""" + return f"{self.element} Team" + # Mapping of opposing teams in SM5 games. SM5_ENEMY_TEAM = { diff --git a/handlers/api/replay_data.py b/handlers/api/replay_data.py new file mode 100644 index 0000000..b58dc6e --- /dev/null +++ b/handlers/api/replay_data.py @@ -0,0 +1,17 @@ +from db.sm5 import SM5Game +from helpers.replay_sm5 import create_sm5_replay +from shared import app +from helpers.statshelper import sentry_trace +from sanic import Request, response, HTTPResponse + + +@app.get("/api/game///replay_data") +@sentry_trace +async def api_game_tdf(request: Request, type: str, id: int) -> HTTPResponse: + if type.lower() == "sm5": + game = await SM5Game.filter(id=id).first() + replay = await create_sm5_replay(game) + else: + raise ValueError("Invalid game type") + + return response.html(replay.export_to_js(), headers={"Content-Type": "text/javascript"}) diff --git a/handlers/game/replay.py b/handlers/game/replay.py index c12f3b4..3b4fc47 100644 --- a/handlers/game/replay.py +++ b/handlers/game/replay.py @@ -8,7 +8,9 @@ @sentry_trace async def game_replay(request: Request, type: str, id: int) -> str: if type == "sm5": - return await render_template(request, "game/replay_sm5.html", game_id=id) + # Uncomment this line to use the old-style replay. + # return await render_template(request, "game/replay_sm5.html", game_id=id) + return await render_template(request, "game/replay.html", game_type=type, game_id=id) elif type in "laserball": return await render_template(request, "game/replay_laserball.html", game_id=id) - raise exceptions.BadRequest("Invalid game type") \ No newline at end of file + raise exceptions.BadRequest("Invalid game type") diff --git a/helpers/replay.py b/helpers/replay.py new file mode 100644 index 0000000..c000f53 --- /dev/null +++ b/helpers/replay.py @@ -0,0 +1,144 @@ +from dataclasses import dataclass +from typing import List + + +def _escape_string(text: str) -> str: + return text.replace("'", "\\'") + + +@dataclass +class ReplayCellChange: + # Name of the row this update is for. + row_id: str + + column: int + + # The new value for this particular cell. + new_value: str + + def to_js_string(self): + return f'["{self.row_id}",{self.column},"{self.new_value}"]' + + +@dataclass +class ReplayRowChange: + # Name of the row this update is for. + row_id: str + + # The new CSS class for this row. + new_css_class: str + + def to_js_string(self): + return f'["{self.row_id}","{self.new_css_class}"]' + + +@dataclass +class ReplaySound: + # List of possible assets to use for this sound. + asset_urls: List[str] + + # ID to identify this sound. + id: int = 0 + + +@dataclass +class ReplayEvent: + # The time at which this event happened. + timestamp_millis: int + + # The message to show in the scrolling event box. With HTML formatting. + message: str + + # Scores for all teams, or empty if nothing has changed. + team_scores: List[int] + + cell_changes: List[ReplayCellChange] + + row_changes: List[ReplayRowChange] + + sounds: List[ReplaySound] + + +@dataclass +class ReplayPlayer: + # The innerHTML for each column. + cells: List[str] + + # The row identifier, will be used in the ID of each row and cell. + row_id: str + + +@dataclass +class ReplayTeam: + # Display name for the team. + name: str + + # CSS class to use for the team. + css_class: str + + # ID for the table. + id: str + + # A list of all players. + players: List[ReplayPlayer] + + +@dataclass +class Replay: + events: List[ReplayEvent] + + teams: List[ReplayTeam] + + sounds: List[ReplaySound] + + # Names of the columns in each team table. + column_headers: List[str] + + # Export this entire replay to JavaScript code. + def export_to_js(self) -> str: + result = "" + + for column in self.column_headers: + result += f"add_column('{column}');\n" + + for sound in self.sounds: + result += f"register_sound({sound.id}, {sound.asset_urls});\n" + + for team in self.teams: + result += f"add_team('{team.name}', '{team.id}', '{team.css_class}');\n" + + for player in team.players: + cells = [_escape_string(cell) for cell in player.cells] + result += f"add_player('{team.id}', '{player.row_id}', {cells});\n" + + result += "function reset_players() {\n" + result += " player_values = {\n" + + for team in self.teams: + for player in team.players: + for index, cell in enumerate(player.cells): + result += f" '{player.row_id}_{index}': '{_escape_string(cell)}',\n" + result += """ + }; + Object.entries(player_values).forEach(([cell, value]) => { + document.getElementById(cell).innerHTML = value; + }); + } + """ + + result += "events = [\n" + for event in self.events: + cell_changes = [cell_change.to_js_string() for cell_change in event.cell_changes] + row_changes = [row_change.to_js_string() for row_change in event.row_changes] + sound_ids = [sound.id for sound in event.sounds] + result += f" [{event.timestamp_millis},'{_escape_string(event.message)}',[{','.join(cell_changes)}],[{','.join(row_changes)}],{event.team_scores},{sound_ids}],\n" + + result += "];\n\n" + + result += """ + document.addEventListener("DOMContentLoaded", function() { + startReplay(); + }); + """ + + return result diff --git a/helpers/replay_sm5.py b/helpers/replay_sm5.py new file mode 100644 index 0000000..75f7ac4 --- /dev/null +++ b/helpers/replay_sm5.py @@ -0,0 +1,420 @@ +from dataclasses import dataclass +from typing import List, Dict + +from db.sm5 import SM5Game +from db.types import IntRole, EventType, Team +from helpers.gamehelper import get_team_rosters +from helpers.replay import Replay, ReplayTeam, ReplayPlayer, ReplayEvent, ReplayCellChange, \ + ReplayRowChange, ReplaySound +from helpers.sm5helper import SM5_ROLE_DETAILS, Sm5RoleDetails + + +@dataclass +class _Player: + lives: int + shots: int + row_index: int + row_id: str + missiles: int + role: IntRole + role_details: Sm5RoleDetails + team: Team + name: str + downed: bool = False + times_got_shot: int = 0 + times_shot_others: int = 0 + rapid_fire: bool = False + score: int = 0 + special_points: int = 0 + total_shots_fired: int = 0 + total_shots_hit: int = 0 + + def __hash__(self): + return self.row_index + + +_SCORE_COLUMN = 2 +_LIVES_COLUMN = 3 +_SHOTS_COLUMN = 4 +_MISSILES_COLUMN = 5 +_SPEC_COLUMN = 6 +_ACCURACY_COLUMN = 7 +_KD_COLUMN = 8 + +_EVENTS_COSTING_SHOTS = { + EventType.MISS, + EventType.MISS_BASE, + EventType.HIT_BASE, + EventType.DESTROY_BASE, + EventType.DAMAGED_OPPONENT, + EventType.DOWNED_OPPONENT, + EventType.DAMANGED_TEAM, + EventType.DOWNED_TEAM, +} + +_EVENTS_COSTING_MISSILES = { + EventType.MISSILE_MISS, + EventType.MISSILE_DAMAGE_TEAM, + EventType.MISSILE_DOWN_TEAM, + EventType.MISSILE_DAMAGE_OPPONENT, + EventType.MISSILE_DOWN_OPPONENT, + EventType.MISSILE_BASE_MISS, + EventType.MISSILE_BASE_DAMAGE, + EventType.MISISLE_BASE_DESTROY, +} + +_EVENTS_SUCCESSFUL_HITS = { + EventType.HIT_BASE, + EventType.DESTROY_BASE, + EventType.DAMAGED_OPPONENT, + EventType.DOWNED_OPPONENT, + EventType.DAMANGED_TEAM, + EventType.DOWNED_TEAM, +} + +_EVENTS_COSTING_LIVES = { + EventType.DOWNED_OPPONENT: 1, + EventType.DOWNED_TEAM: 1, + EventType.MISSILE_DOWN_OPPONENT: 3, + EventType.MISSILE_DOWN_TEAM: 3, +} + +_EVENTS_DOWNING_PLAYER = { + EventType.DOWNED_OPPONENT, + EventType.DOWNED_TEAM, + EventType.MISSILE_DOWN_OPPONENT, + EventType.MISSILE_DOWN_TEAM, + EventType.RESUPPLY_LIVES, + EventType.RESUPPLY_AMMO, +} + +_START_AUDIO = 0 +_ALARM_START_AUDIO = 1 +_RESUPPLY_AUDIO = 2 +_DOWNED_AUDIO = 3 +_BASE_DESTROYED_AUDIO = 4 + +_AUDIO_PREFIX = "/assets/sm5/audio/" + + +@dataclass +class _Team: + players: List[_Player] + + +async def create_sm5_replay(game: SM5Game) -> Replay: + game_duration = await game.get_actual_game_duration() + entity_starts = await game.entity_starts.all() + team_rosters = await get_team_rosters(entity_starts, + await game.entity_ends.all()) + + # Set up the teams and players. + column_headers = ["Role", "Codename", "Score", "Lives", "Shots", "Missiles", "Spec", "Accuracy", "K/D"] + replay_teams = [] + entity_id_to_player = {} + row_index = 1 + + teams = {} + team_scores = {} + + entity_id_to_nonplayer_name = { + entity.entity_id: entity.name for entity in entity_starts if entity.entity_id[0] == "@" + } + + for team, players in team_rosters.items(): + replay_player_list = [] + players_in_team = [] + team_scores[team] = 0 + + for player_info in players: + if player_info.entity_start.role not in SM5_ROLE_DETAILS: + # Shouldn't happen - a player with an unknown SM5 role? + continue + + role = player_info.entity_start.role + role_details = SM5_ROLE_DETAILS[role] + + cells = [_create_role_image(player_info.entity_start.role), player_info.display_name, "0", + str(role_details.initial_lives), str(role_details.missiles), "0", "0", "", ""] + row_id = f"r{row_index}" + + player = _Player(lives=role_details.initial_lives, shots=role_details.shots, row_index=row_index, + role=role, row_id=row_id, missiles=role_details.missiles, + role_details=role_details, team=team, name=player_info.display_name) + + replay_player_list.append(ReplayPlayer(cells=cells, row_id=row_id)) + row_index += 1 + + entity_id_to_player[player_info.entity_start.entity_id] = player + players_in_team.append(player) + + replay_team = ReplayTeam(name=team.name, css_class=team.css_class, id=f"{team.element.lower()}_team", + players=replay_player_list) + replay_teams.append(replay_team) + teams[team] = players_in_team + + events = [] + + # Map from a player and the timestamp at which the player will be back up. The key is the _Player object. + player_reup_times = {} + + start_audio = ReplaySound( + [f"{_AUDIO_PREFIX}Start.0.wav", f"{_AUDIO_PREFIX}Start.1.wav", f"{_AUDIO_PREFIX}Start.2.wav", + f"{_AUDIO_PREFIX}Start.3.wav"]) + alarm_start_audio = ReplaySound([f"{_AUDIO_PREFIX}Effect/General Quarters.wav"]) + resupply_audio = ReplaySound([f"{_AUDIO_PREFIX}Effect/Resupply.0.wav", f"{_AUDIO_PREFIX}Effect/Resupply.1.wav", + f"{_AUDIO_PREFIX}Effect/Resupply.2.wav", f"{_AUDIO_PREFIX}Effect/Resupply.3.wav", + f"{_AUDIO_PREFIX}Effect/Resupply.4.wav"]) + downed_audio = ReplaySound([f"{_AUDIO_PREFIX}Effect/Scream.0.wav", f"{_AUDIO_PREFIX}Effect/Scream.1.wav", + f"{_AUDIO_PREFIX}Effect/Scream.2.wav", f"{_AUDIO_PREFIX}Effect/Shot.0.wav", + f"{_AUDIO_PREFIX}Effect/Shot.1.wav"]) + base_destroyed_audio = ReplaySound([f"{_AUDIO_PREFIX}Effect/Boom.wav"]) + + sound_assets = [ + start_audio, + alarm_start_audio, + resupply_audio, + downed_audio, + base_destroyed_audio, + ] + + # Now let's walk through the events one by one and translate them into UI events. + for event in await game.events.all(): + + timestamp = event.time + old_team_scores = team_scores.copy() + sounds = [] + + # Before we process the event, let's see if there's a player coming back up. Look up all the timestamps when + # someone is coming back up. + players_reup_timestamps = [ + reup_timestamp for reup_timestamp in player_reup_times.values() if reup_timestamp < timestamp + ] + + if players_reup_timestamps: + # Create events for all the players coming back up one by one. + players_reup_timestamps.sort() + + row_changes = [] + + # Walk through them in order. It's likely that there are multiple players with identical timestamps (usually + # after a nuke), so we can't use a dict here. + for reup_timestamp in players_reup_timestamps: + # Find the first player with this particular timestamp. There may be multiple after a nuke. But who + # cares. We'll eventually get them one by one. + for player, player_reup_timestamp in player_reup_times.items(): + if player_reup_timestamp == reup_timestamp: + player_reup_times.pop(player) + row_changes.append(ReplayRowChange(player.row_id, player.team.css_class)) + break + + # Create a dummy event to update the UI. + events.append(ReplayEvent(reup_timestamp, "", cell_changes=[], row_changes=row_changes, team_scores=[], + sounds=[])) + + # Translate the arguments of the event into HTML if they reference entities. + message = "" + + # Note that we don't care about players missing. + if event.type != EventType.MISS: + for argument in event.arguments: + if argument[0] == "@": + message += _create_entity_reference(argument, entity_id_to_player, entity_id_to_nonplayer_name) + elif argument[0] == '#': + message += _create_entity_reference(argument, entity_id_to_player, entity_id_to_nonplayer_name) + else: + message += argument + + cell_changes = [] + row_changes = [] + player1 = None + player2 = None + + if event.arguments[0] in entity_id_to_player: + player1 = entity_id_to_player[event.arguments[0]] + + if len(event.arguments) > 2 and event.arguments[2] in entity_id_to_player: + player2 = entity_id_to_player[event.arguments[2]] + + # Count successful hits. Must be done before removing the shot so the accuracy is correct. + if event.type in _EVENTS_SUCCESSFUL_HITS: + player1.total_shots_hit += 1 + + # Remove a shot if the player just zapped, and update accuracy. + if event.type in _EVENTS_COSTING_SHOTS: + if player1.role != IntRole.AMMO: + _add_shots(player1, -1, cell_changes) + + player1.total_shots_fired += 1 + + # Recompute accuracy. + cell_changes.append(ReplayCellChange(row_id=player1.row_id, column=_ACCURACY_COLUMN, + new_value="%.2f" % ( + player1.total_shots_hit * 100 / player1.total_shots_fired))) + + # Handle losing lives. + if event.type in _EVENTS_COSTING_LIVES: + _add_lives(player2, -_EVENTS_COSTING_LIVES[event.type], cell_changes, row_changes, player_reup_times) + + if event.type in _EVENTS_COSTING_MISSILES: + player1.missiles -= 1 + cell_changes.append( + ReplayCellChange(row_id=player1.row_id, column=_MISSILES_COLUMN, new_value=str(player1.missiles))) + + # Handle each event. + match event.type: + case EventType.RESUPPLY_LIVES: + _add_lives(player2, player2.role_details.lives_resupply, cell_changes, row_changes, player_reup_times) + sounds.append(resupply_audio) + + case EventType.RESUPPLY_AMMO: + _add_shots(player2, player2.role_details.shots_resupply, cell_changes) + sounds.append(resupply_audio) + + case EventType.ACTIVATE_RAPID_FIRE: + _add_special_points(player1, -10, cell_changes) + + case EventType.DESTROY_BASE | EventType.MISISLE_BASE_DESTROY: + _add_score(player1, 1001, cell_changes, team_scores) + + if player1.role != IntRole.HEAVY and not player1.rapid_fire: + _add_special_points(player1, 5, cell_changes) + sounds.append(base_destroyed_audio) + + case EventType.DAMAGED_OPPONENT | EventType.DOWNED_OPPONENT: + if player1.role != IntRole.HEAVY and not player1.rapid_fire: + _add_special_points(player1, 1, cell_changes) + + _add_score(player1, 100, cell_changes, team_scores) + _add_score(player2, -20, cell_changes, team_scores) + _increase_times_shot_others(player1, cell_changes) + _increase_times_got_shot(player2, cell_changes) + sounds.append(downed_audio) + + case EventType.DAMANGED_TEAM | EventType.DOWNED_TEAM: + _add_score(player1, -100, cell_changes, team_scores) + _add_score(player2, -20, cell_changes, team_scores) + _increase_times_shot_others(player1, cell_changes) + _increase_times_got_shot(player2, cell_changes) + sounds.append(downed_audio) + + case EventType.ACTIVATE_RAPID_FIRE: + player1.rapid_fire = True + _add_special_points(player1, -10, cell_changes) + + case EventType.DEACTIVATE_RAPID_FIRE: + player1.rapid_fire = False + + case EventType.ACTIVATE_NUKE: + _add_special_points(player1, -20, cell_changes) + + case EventType.AMMO_BOOST: + for player in teams[player1.team]: + if not player.downed: + _add_shots(player, player.role_details.shots_resupply, cell_changes) + sounds.append(resupply_audio) + + case EventType.LIFE_BOOST: + for player in teams[player1.team]: + if not player.downed: + _add_lives(player, player.role_details.lives_resupply, cell_changes, row_changes, + player_reup_times) + sounds.append(resupply_audio) + + case EventType.PENALTY: + _add_score(player1, -1000, cell_changes, team_scores) + + # Handle a player being down. + if event.type in _EVENTS_DOWNING_PLAYER: + _down_player(player2, row_changes, event.time, player_reup_times) + + if team_scores == old_team_scores: + new_team_scores = [] + else: + new_team_scores = [ + team_scores[team] for team in team_rosters.keys() + ] + + events.append(ReplayEvent(timestamp_millis=timestamp, message=message, cell_changes=cell_changes, + row_changes=row_changes, team_scores=new_team_scores, sounds=sounds)) + + return Replay( + events=events, + teams=replay_teams, + column_headers=column_headers, + sounds=sound_assets, + ) + + +def _down_player(player: _Player, row_changes: List[ReplayRowChange], timestamp_millis: int, + player_reup_times: Dict[_Player, int]): + if player.lives == 0: + return + + row_changes.append(ReplayRowChange(row_id=player.row_id, new_css_class=player.team.dim_css_class)) + + # The player will be back up 8 seconds from now. + player_reup_times[player] = timestamp_millis + 8000 + + +def _add_lives(player: _Player, lives_to_add: int, cell_changes: List[ReplayCellChange], + row_changes: List[ReplayRowChange], player_reup_times: Dict[_Player, int]): + player.lives = max(min(player.lives + lives_to_add, player.role_details.lives_max), 0) + + cell_changes.append(ReplayCellChange(row_id=player.row_id, column=_LIVES_COLUMN, new_value=str(player.lives))) + + if player.lives == 0: + row_changes.append(ReplayRowChange(row_id=player.row_id, new_css_class='eliminated_player')) + + # This player ain't coming back up. + if player in player_reup_times: + player_reup_times.pop(player) + return + + +def _add_shots(player: _Player, shots_to_add: int, cell_changes: List[ReplayCellChange]): + player.shots = max(min(player.shots + shots_to_add, player.role_details.shots_max), 0) + + cell_changes.append(ReplayCellChange(row_id=player.row_id, column=_SHOTS_COLUMN, new_value=str(player.shots))) + + +def _increase_times_shot_others(player: _Player, cell_changes: List[ReplayCellChange]): + player.times_shot_others += 1 + _update_kd(player, cell_changes) + + +def _increase_times_got_shot(player: _Player, cell_changes: List[ReplayCellChange]): + player.times_got_shot += 1 + _update_kd(player, cell_changes) + + +def _update_kd(player: _Player, cell_changes: List[ReplayCellChange]): + kd_ratio = player.times_shot_others / player.times_got_shot if player.times_got_shot > 0 else 0.0 + cell_changes.append(ReplayCellChange(row_id=player.row_id, column=_KD_COLUMN, new_value="%.02f" % kd_ratio)) + + +def _add_special_points(player: _Player, points_to_add: int, cell_changes: List[ReplayCellChange]): + player.special_points += points_to_add + cell_changes.append( + ReplayCellChange(row_id=player.row_id, column=_SPEC_COLUMN, new_value=str(player.special_points))) + + +def _add_score(player: _Player, points_to_add: int, cell_changes: List[ReplayCellChange], team_scores: Dict[Team, int]): + player.score += points_to_add + cell_changes.append( + ReplayCellChange(row_id=player.row_id, column=_SCORE_COLUMN, new_value=str(player.score))) + + team_scores[player.team] += points_to_add + + +def _create_role_image(role: IntRole) -> str: + return f'{role}' + + +def _create_entity_reference(argument: str, entity_id_to_player: dict, entity_id_to_nonplayer_name: dict) -> str: + if argument in entity_id_to_nonplayer_name: + return entity_id_to_nonplayer_name[argument] + + player = entity_id_to_player[argument] + css_class = player.team.css_class + return f'{player.name}' diff --git a/helpers/statshelper.py b/helpers/statshelper.py index 1c3c1ce..65e4058 100644 --- a/helpers/statshelper.py +++ b/helpers/statshelper.py @@ -105,7 +105,7 @@ class TeamCoreGameStats: @property def name(self) -> str: - return f"{self.team.element} Team" + return self.team.display_name @property def css_color_name(self) -> str: diff --git a/tests/helpers/environment.py b/tests/helpers/environment.py index 093e0b5..e00c4a1 100644 --- a/tests/helpers/environment.py +++ b/tests/helpers/environment.py @@ -8,6 +8,7 @@ dataset. """ import json +import os from typing import Optional from tortoise import Tortoise @@ -62,7 +63,7 @@ def get_blue_team() -> Teams: return _BLUE_TEAM -async def setup_test_database(): +async def setup_test_database(basic_events: bool = True): """Creates a test in-memory database using SQLite, connects Tortoise to it, and generates the schema. It will also generate the test dataset.""" @@ -78,7 +79,7 @@ async def setup_test_database(): _GREEN_TEAM = await create_team(1, Team.GREEN) _BLUE_TEAM = await create_team(2, Team.BLUE) - _TEST_SM5_GAME = await create_sm5_game_1() + _TEST_SM5_GAME = await create_sm5_game_1(basic_events=basic_events) _TEST_LASERBALL_GAME = await create_laserball_game_1() @@ -86,17 +87,19 @@ async def teardown_test_database(): await Tortoise.close_connections() -async def create_sm5_game_1() -> SM5Game: +async def create_sm5_game_1(basic_events: bool = True) -> SM5Game: events = [] - events.extend([await create_zap_event(100, ENTITY_ID_1, ENTITY_ID_2), - await create_zap_event(200, ENTITY_ID_1, ENTITY_ID_2), - await create_zap_event(400, ENTITY_ID_2, ENTITY_ID_1), - await create_zap_event(500, ENTITY_ID_1, ENTITY_ID_2), - await Events.create(time=600, type=EventType.ACTIVATE_NUKE, - arguments=json.dumps([ENTITY_ID_2, " nukes ", ENTITY_ID_1])), - await create_zap_event(700, ENTITY_ID_3, ENTITY_ID_1), - await create_zap_event(800, ENTITY_ID_1, ENTITY_ID_3), - ]) + + if basic_events: + events.extend([await create_zap_event(100, ENTITY_ID_1, ENTITY_ID_2), + await create_zap_event(200, ENTITY_ID_1, ENTITY_ID_2), + await create_zap_event(400, ENTITY_ID_2, ENTITY_ID_1), + await create_zap_event(500, ENTITY_ID_1, ENTITY_ID_2), + await Events.create(time=600, type=EventType.ACTIVATE_NUKE, + arguments=json.dumps([ENTITY_ID_2, " nukes ", ENTITY_ID_1])), + await create_zap_event(700, ENTITY_ID_3, ENTITY_ID_1), + await create_zap_event(800, ENTITY_ID_1, ENTITY_ID_3), + ]) game = await SM5Game.create(winner_color=Team.RED.value.color, tdf_name="in_memory_test", file_version="0.test.0", software_version="12.34.56", arena="Test Arena", mission_name="Space Marines 5", @@ -155,6 +158,11 @@ async def create_mission_end_event(time_millis) -> Events: return await create_event_from_data(["4", str(time_millis), EventType.MISSION_END, "* Mission End *"]) +async def create_resupply_lives_event(time_millis: int, supplier_entity_id: str, supplyee_entity_id: str) -> Events: + return await create_event_from_data( + ["4", str(time_millis), EventType.RESUPPLY_LIVES, supplier_entity_id, " resupplies ", supplyee_entity_id]) + + async def add_entity( entity_id: str, team: Teams, @@ -241,3 +249,8 @@ async def add_sm5_score(game: SM5Game, entity: EntityStarts, time_millis: int, o score = await Scores.create(entity=entity, time=time_millis, old=old_score, delta=score - old_score, new=score) await game.scores.add(score) + + +def get_test_data_path(filename: str) -> str: + """Returns the full path of a file within the tests/data folder.""" + return os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", filename) diff --git a/tests/helpers/replay_sm5_test.py b/tests/helpers/replay_sm5_test.py new file mode 100644 index 0000000..33c044b --- /dev/null +++ b/tests/helpers/replay_sm5_test.py @@ -0,0 +1,86 @@ +import unittest + +from db.sm5 import SM5Game +from db.types import IntRole +from helpers.replay import Replay, ReplayTeam, ReplayPlayer, ReplayEvent, ReplayCellChange, ReplaySound, ReplayRowChange +from helpers.replay_sm5 import create_sm5_replay +from tests.helpers.environment import setup_test_database, get_sm5_game_id, \ + teardown_test_database, add_entity, get_red_team, get_green_team, create_zap_event, create_resupply_lives_event + + +class TestReplaySm5(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + await setup_test_database(basic_events=False) + + async def asyncTearDown(self): + await teardown_test_database() + + async def test_create_sm5_replay(self): + game = await SM5Game.filter(id=get_sm5_game_id()).first() + + entity1, entity_end1 = await add_entity(entity_id="@NotLoggedIn", name="Indy", team=get_red_team(), + role=IntRole.COMMANDER, type="player", sm5_game=game) + entity2, entity_end2 = await add_entity(entity_id="@NotMember", name="Miles", team=get_green_team(), + role=IntRole.SCOUT, type="player", sm5_game=game) + entity3, entity_end3 = await add_entity(entity_id="LoggedIn", name="Bumblebee", team=get_red_team(), + role=IntRole.MEDIC, type="player", sm5_game=game) + + await game.events.add(await create_zap_event(2000, entity1.entity_id, entity2.entity_id)) + await game.events.add(await create_resupply_lives_event(2500, entity3.entity_id, entity1.entity_id)) + + replay = await create_sm5_replay(game) + + print(replay) + + expected = Replay(events=[ReplayEvent(timestamp_millis=2000, message='Indy zaps Miles', team_scores=[100, -20], + cell_changes=[ReplayCellChange(row_id='r1', column=4, new_value='29'), + ReplayCellChange(row_id='r1', column=7, new_value='100.00'), + ReplayCellChange(row_id='r3', column=3, new_value='14'), + ReplayCellChange(row_id='r1', column=6, new_value='1'), + ReplayCellChange(row_id='r1', column=2, new_value='100'), + ReplayCellChange(row_id='r3', column=2, new_value='-20'), + ReplayCellChange(row_id='r1', column=8, new_value='0.00'), + ReplayCellChange(row_id='r3', column=8, new_value='0.00')], + row_changes=[ + ReplayRowChange(row_id='r3', new_css_class='earth-team-dim')], + sounds=[ReplaySound(asset_urls=['/assets/sm5/audio/Effect/Scream.0.wav', + '/assets/sm5/audio/Effect/Scream.1.wav', + '/assets/sm5/audio/Effect/Scream.2.wav', + '/assets/sm5/audio/Effect/Shot.0.wav', + '/assets/sm5/audio/Effect/Shot.1.wav'], + id=0)]), + ReplayEvent(timestamp_millis=2500, message='LoggedIn resupplies Indy', team_scores=[], + cell_changes=[ReplayCellChange(row_id='r1', column=3, new_value='19')], + row_changes=[ReplayRowChange(row_id='r1', new_css_class='fire-team-dim')], + sounds=[ReplaySound(asset_urls=['/assets/sm5/audio/Effect/Resupply.0.wav', + '/assets/sm5/audio/Effect/Resupply.1.wav', + '/assets/sm5/audio/Effect/Resupply.2.wav', + '/assets/sm5/audio/Effect/Resupply.3.wav', + '/assets/sm5/audio/Effect/Resupply.4.wav'], + id=0)])], teams=[ + ReplayTeam(name='Fire Team', css_class='fire-team', id='fire_team', players=[ReplayPlayer( + cells=['Commander', 'Indy', + '0', '15', '5', '0', '0', '', ''], row_id='r1'), ReplayPlayer( + cells=['Medic', 'Bumblebee', '0', + '20', '0', '0', '0', '', ''], row_id='r2')]), + ReplayTeam(name='Earth Team', css_class='earth-team', id='earth_team', players=[ReplayPlayer( + cells=['Scout', 'Miles', '0', '15', + '0', '0', '0', '', ''], row_id='r3')])], sounds=[ReplaySound( + asset_urls=['/assets/sm5/audio/Start.0.wav', '/assets/sm5/audio/Start.1.wav', + '/assets/sm5/audio/Start.2.wav', '/assets/sm5/audio/Start.3.wav'], id=0), ReplaySound( + asset_urls=['/assets/sm5/audio/Effect/General Quarters.wav'], id=0), ReplaySound( + asset_urls=['/assets/sm5/audio/Effect/Resupply.0.wav', '/assets/sm5/audio/Effect/Resupply.1.wav', + '/assets/sm5/audio/Effect/Resupply.2.wav', '/assets/sm5/audio/Effect/Resupply.3.wav', + '/assets/sm5/audio/Effect/Resupply.4.wav'], id=0), ReplaySound( + asset_urls=['/assets/sm5/audio/Effect/Scream.0.wav', '/assets/sm5/audio/Effect/Scream.1.wav', + '/assets/sm5/audio/Effect/Scream.2.wav', '/assets/sm5/audio/Effect/Shot.0.wav', + '/assets/sm5/audio/Effect/Shot.1.wav'], id=0), ReplaySound( + asset_urls=['/assets/sm5/audio/Effect/Boom.wav'], id=0)], + column_headers=['Role', 'Codename', 'Score', 'Lives', 'Shots', 'Missiles', 'Spec', 'Accuracy', + 'K/D']) + + self.assertEqual(expected, replay) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/helpers/replay_test.py b/tests/helpers/replay_test.py new file mode 100644 index 0000000..fe7f21c --- /dev/null +++ b/tests/helpers/replay_test.py @@ -0,0 +1,42 @@ +import unittest + +from db.sm5 import SM5Game +from db.types import IntRole +from helpers.replay import Replay, ReplayTeam, ReplayPlayer, ReplayEvent, ReplayCellChange +from helpers.replay_sm5 import create_sm5_replay +from tests.helpers.environment import setup_test_database, get_sm5_game_id, \ + teardown_test_database, add_entity, get_red_team, get_green_team, create_zap_event, create_resupply_lives_event + + +class TestReplay(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + await setup_test_database(basic_events=False) + + async def asyncTearDown(self): + await teardown_test_database() + + async def test_create_sm5_replay(self): + game = await SM5Game.filter(id=get_sm5_game_id()).first() + + entity1, entity_end1 = await add_entity(entity_id="@NotLoggedIn", name="Indy", team=get_red_team(), + role=IntRole.COMMANDER, type="player", sm5_game=game) + entity2, entity_end2 = await add_entity(entity_id="@NotMember", name="Miles", team=get_green_team(), + role=IntRole.SCOUT, type="player", sm5_game=game) + entity3, entity_end3 = await add_entity(entity_id="LoggedIn", name="Bumblebee", team=get_red_team(), + role=IntRole.MEDIC, type="player", sm5_game=game) + + await game.events.add(await create_zap_event(2000, entity1.entity_id, entity2.entity_id)) + await game.events.add(await create_resupply_lives_event(2500, entity3.entity_id, entity1.entity_id)) + + replay = await create_sm5_replay(game) + + js_replay = replay.export_to_js() + + expected = """ + """ + + #self.assertEqual(expected, js_replay) + + +if __name__ == '__main__': + unittest.main()