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...
+
+
+
+
+
+
+
+
+
+ Play
+
+ 0.25x
+ 0.5x
+ 1.0x
+ 1.5x
+ 2.0x
+
+ Restart
+
+
+
+
+
+
+
+ 00:00
+
+
+
+
+
+{% 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' '
+
+
+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=[' ', 'Indy',
+ '0', '15', '5', '0', '0', '', ''], row_id='r1'), ReplayPlayer(
+ cells=[' ', 'Bumblebee', '0',
+ '20', '0', '0', '0', '', ''], row_id='r2')]),
+ ReplayTeam(name='Earth Team', css_class='earth-team', id='earth_team', players=[ReplayPlayer(
+ cells=[' ', '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()