update to handle new structure from spectra, no more gobs (#1266)

This commit is contained in:
NotSoToothless
2026-05-23 17:16:53 -07:00
committed by GitHub
parent 2d5adfcbe0
commit f6f4e33a65
11 changed files with 459 additions and 313 deletions
+12 -42
View File
@@ -23,17 +23,15 @@ import aiofiles
import aiohttp import aiohttp
import aiosqlite import aiosqlite
import discord import discord
import pygob
# Local Module Imports # Local Module Imports
from . import utils from . import utils
from data_parser import LangTableReader from data_parser import LangTableReader
from .game_api import get_point_diff from .game_api import get_point_diff
from .gob import load_gob_file, render_gob from .render_replay import load_gob_file, render_gob
from .health import record_game_processed, record_ws_message from .health import record_game_processed, record_ws_message
from .receiver_bridge import publish_gob_payload, publish_replay_batch from .receiver_bridge import publish_replay_batch
from .utils import t, lang_from_features from .utils import t, lang_from_features
from .lux_apis import _gob_to_dict
from .scoreboard import create_scoreboard from .scoreboard import create_scoreboard
from .utils import ( from .utils import (
STORAGE_DIR, STORAGE_DIR,
@@ -452,7 +450,7 @@ async def process_ws_replays(replays: list[dict]):
if not hex_id: if not hex_id:
continue continue
# Skip if already processed (check for replay_data.json, not just dir — GOB ws may create the dir first) # Skip if already processed
replay_dir = replay_session_dir(hex_id) replay_dir = replay_session_dir(hex_id)
if (replay_dir / "replay_data.json").exists(): if (replay_dir / "replay_data.json").exists():
continue continue
@@ -494,14 +492,14 @@ async def process_ws_replays(replays: list[dict]):
hex_id, hex_id,
local_data, local_data,
received_time=now_ts, received_time=now_ts,
end_time=replay.get('end_ts', now_ts), end_time=int(replay.get("end_ts") or now_ts),
) )
local_data["scoreboard_context"] = scoreboard_context local_data["scoreboard_context"] = scoreboard_context
forwarded_replays.append(local_data) forwarded_replays.append(local_data)
validated_games.append({ validated_games.append({
"sessionIdHex": hex_id, "sessionIdHex": hex_id,
"endTime": replay.get('end_ts', now_ts), "endTime": int(replay.get("end_ts") or now_ts),
"missionName": local_data.get("map", ""), "missionName": local_data.get("map", ""),
"receivedTime": now_ts, "receivedTime": now_ts,
"scoreboard_context": scoreboard_context, "scoreboard_context": scoreboard_context,
@@ -1296,36 +1294,8 @@ def build_scoreboard_view(guild_id: int, session_id: str, lang: str = "en") -> d
return view return view
async def handle_gob_message(compressed: bytes, decompressed: bytes) -> None:
"""Save a received GOB replay (zstd-compressed) to disk for on-demand video generation."""
try:
replay = pygob.load(decompressed)
d = _gob_to_dict(replay)
session_id = d.get("SessionID")
if not session_id:
return
hex_id = format(session_id, 'x')
replay_dir = replay_session_dir(hex_id)
replay_dir.mkdir(parents=True, exist_ok=True)
gob_path = replay_dir / "replay.gob"
if not gob_path.exists():
gob_path.write_bytes(compressed)
logging.info(f"[GOB] Saved {hex_id} ({len(compressed)} bytes compressed)")
await record_ws_message("sqb_gob")
try:
await publish_gob_payload({
"session_id": hex_id,
"payload": d,
"compressed_size": len(compressed),
})
except Exception as bridge_error:
logging.warning(f"[BRIDGE] Failed to forward GOB payload for {hex_id}: {bridge_error}")
except Exception as e:
logging.error(f"[GOB] Save error: {e}")
async def handle_view_video(interaction: discord.Interaction, session_id: str): async def handle_view_video(interaction: discord.Interaction, session_id: str):
"""Callback for 'View Video' - renders GOB replay to MP4, sends ephemerally.""" """Callback for 'View Video' - renders replay JSON to MP4, sends ephemerally."""
try: try:
try: try:
await interaction.response.defer(thinking=True, ephemeral=True) await interaction.response.defer(thinking=True, ephemeral=True)
@@ -1336,10 +1306,10 @@ async def handle_view_video(interaction: discord.Interaction, session_id: str):
_lang = lang_from_features(_gf) _lang = lang_from_features(_gf)
replay_dir = replay_session_dir(session_id) replay_dir = replay_session_dir(session_id)
gob_path = replay_dir / "replay.gob" replay_json_path = replay_dir / "replay_data.json"
video_path = replay_dir / "replay_video.mp4" video_path = replay_dir / "replay_video.mp4"
if not gob_path.exists(): if not replay_json_path.exists():
await interaction.followup.send( await interaction.followup.send(
t(_lang, "autolog.replay_not_available"), t(_lang, "autolog.replay_not_available"),
ephemeral=True ephemeral=True
@@ -1356,15 +1326,15 @@ async def handle_view_video(interaction: discord.Interaction, session_id: str):
return return
try: try:
def _generate(): def _generate():
d = load_gob_file(gob_path) d = load_gob_file(replay_json_path)
render_gob(d, video_path) render_gob(d, video_path)
logging.info(f"GOB ({session_id}) RENDER START") logging.info(f"REPLAY ({session_id}) RENDER START")
async with _video_render_sem: async with _video_render_sem:
await asyncio.get_event_loop().run_in_executor(None, _generate) await asyncio.get_event_loop().run_in_executor(None, _generate)
logging.info(f"GOB ({session_id}) RENDER END (Success)") logging.info(f"REPLAY ({session_id}) RENDER END (Success)")
except Exception as e: except Exception as e:
logging.info(f"GOB ({session_id}) RENDER END (Fail)") logging.info(f"REPLAY ({session_id}) RENDER END (Fail)")
# Clean up broken/partial mp4 so it doesn't get cached # Clean up broken/partial mp4 so it doesn't get cached
if video_path.exists(): if video_path.exists():
video_path.unlink(missing_ok=True) video_path.unlink(missing_ok=True)
+16 -129
View File
@@ -15,20 +15,10 @@ from typing import Any, Awaitable, Callable, Dict, List, Optional
# Third-Party Library Imports # Third-Party Library Imports
import aiohttp import aiohttp
import pygob
import zstandard as zstd import zstandard as zstd
from dotenv import load_dotenv from dotenv import load_dotenv
from websockets.asyncio.client import connect as wsconnect from websockets.asyncio.client import connect as wsconnect
# Local Module Imports
try:
from data_parser import LangTableReader
from .utils import REPLAYS_DIR
except ImportError:
LangTableReader = None # Running directly, not as module
REPLAYS_DIR = None
# Load environment variables # Load environment variables
load_dotenv() load_dotenv()
@@ -41,31 +31,9 @@ _replay_queue: asyncio.Queue = asyncio.Queue()
WS_URL = os.getenv("SPECTRA_WS_SQB_URL", "") WS_URL = os.getenv("SPECTRA_WS_SQB_URL", "")
API_KEY = os.getenv("SPECTRA_API_KEY", "") API_KEY = os.getenv("SPECTRA_API_KEY", "")
SPECTRA_API_URL = os.getenv("SPECTRA_API_URL", "") SPECTRA_API_URL = os.getenv("SPECTRA_API_URL", "")
WS_GOB_URL = os.getenv("SPECTRA_WS_GOB_URL", "")
LEADERBOARD_PATH = "/v1/game/leaderboard" LEADERBOARD_PATH = "/v1/game/leaderboard"
REPLAY_SORT_PATH = "/v1/replays/sort" REPLAY_SORT_PATH = "/v1/replays/sort"
# Initialize translation reader for vehicle names
translate = LangTableReader("English") if LangTableReader else None
def _gob_to_dict(obj: object) -> Any:
"""Recursively convert pygob namedtuples to plain dicts for JSON serialization."""
fields = getattr(obj, '_fields', None)
if isinstance(obj, tuple) and fields is not None:
return {f: _gob_to_dict(getattr(obj, f)) for f in fields}
elif isinstance(obj, list):
return [_gob_to_dict(i) for i in obj]
elif isinstance(obj, dict):
return {
(k.decode('utf-8', errors='replace') if isinstance(k, bytes) else k): _gob_to_dict(v)
for k, v in obj.items()
}
elif isinstance(obj, bytes):
return obj.decode('utf-8', errors='replace')
return obj
def normalize_ws_message(data: Any) -> Optional[List[Dict[str, Any]]]: def normalize_ws_message(data: Any) -> Optional[List[Dict[str, Any]]]:
""" """
Normalize WebSocket message to list of replay dicts. Normalize WebSocket message to list of replay dicts.
@@ -104,7 +72,9 @@ async def ws_replay_listener(callback: Callable[[List[Dict[str, Any]]], Awaitabl
Args: Args:
callback: Async function to call with normalized replay data callback: Async function to call with normalized replay data
""" """
headers = {'Authorization': API_KEY} auth_value = API_KEY if API_KEY.startswith("Bearer ") else f"Bearer {API_KEY}"
headers = {"Authorization": auth_value}
decompressor = zstd.ZstdDecompressor()
reconnect_delay = 1 reconnect_delay = 1
# Start queue processor as background task # Start queue processor as background task
@@ -116,12 +86,23 @@ async def ws_replay_listener(callback: Callable[[List[Dict[str, Any]]], Awaitabl
logger.info(f"WebSocket connected to {label}") logger.info(f"WebSocket connected to {label}")
async for message in ws: async for message in ws:
try: try:
data = json.loads(message) if isinstance(message, str):
raw = message.encode("utf-8")
else:
raw = bytes(message)
text: str
try:
text = decompressor.decompress(raw, max_output_size=64 * 1024 * 1024).decode("utf-8")
except zstd.ZstdError:
text = raw.decode("utf-8")
data = json.loads(text)
replays = normalize_ws_message(data) replays = normalize_ws_message(data)
if replays: if replays:
await _replay_queue.put(replays) await _replay_queue.put(replays)
except json.JSONDecodeError: except json.JSONDecodeError:
logger.warning(f"Invalid JSON from WS: {message[:100]}") logger.warning("Invalid JSON from WS frame")
except Exception as e: except Exception as e:
logger.error(f"Error processing WS message: {e}") logger.error(f"Error processing WS message: {e}")
@@ -326,98 +307,6 @@ async def test_fetch_replay_by_id():
print("No data returned.") print("No data returned.")
async def ws_gob_listener(callback: Callable[[bytes, bytes], Awaitable[None]]) -> None:
"""
Maintain persistent WebSocket connection to the Spectra SQB .gob endpoint.
Server pushes raw zstd-compressed .gob binary after each SQB replay is parsed.
Client does not send messages.
Args:
callback: Async function called with (compressed_bytes, decompressed_bytes)
"""
auth_value = API_KEY if API_KEY.startswith("Bearer ") else f"Bearer {API_KEY}"
headers = {"Authorization": auth_value}
decompressor = zstd.ZstdDecompressor()
reconnect_delay = 1
async def _connect_gob(url: str, label: str):
logger.info(f"GOB WS attempting connect → {url}")
async with wsconnect(url, additional_headers=headers) as ws:
logger.info(f"WebSocket connected to {label}")
reconnect_delay_ref = 1 # noqa: F841 — reset handled by caller
async for message in ws:
try:
raw = bytes(message) if isinstance(message, (bytes, bytearray, memoryview)) else message.encode()
data = decompressor.decompress(raw)
await callback(raw, data)
except zstd.ZstdError as e:
logger.error(f"zstd decompression failed: {e}")
except Exception as e:
logger.error(f"Error processing GOB message: {e}")
while True:
primary_url = WS_GOB_URL
primary_label = "Spectra GOB endpoint"
try:
await _connect_gob(primary_url, primary_label)
except Exception as e:
logger.error(f"GOB WebSocket error ({primary_label}): {e}")
logger.info(f"GOB WS reconnecting in {reconnect_delay}s...")
await asyncio.sleep(reconnect_delay)
reconnect_delay = min(reconnect_delay * 2, 30)
async def test_gob_ws():
"""
Connect to the SQB GOB WebSocket and dump received messages to files.
Each decompressed .gob blob is written to STORAGE/REPLAYS/<session_id>.gob for inspection.
"""
from pathlib import Path
if REPLAYS_DIR is None:
raise RuntimeError("REPLAYS_DIR is not configured")
replays_dir = REPLAYS_DIR
replays_dir.mkdir(parents=True, exist_ok=True)
auth_value = API_KEY if API_KEY.startswith("Bearer ") else f"Bearer {API_KEY}"
print(f"Connecting to {WS_GOB_URL}")
print(f"API Key configured: {'Yes' if API_KEY else 'No'}")
print(f"Saving to {replays_dir}")
print("Waiting for messages (Ctrl+C to stop)...\n")
decompressor = zstd.ZstdDecompressor()
count = 0
async with wsconnect(WS_GOB_URL, additional_headers={"Authorization": auth_value}) as ws:
print("Connected.")
async for message in ws:
raw = bytes(message) if isinstance(message, (bytes, bytearray, memoryview)) else message.encode()
print(f"[{count}] Received {len(raw)} bytes (compressed)")
data = b""
try:
data = decompressor.decompress(raw)
print(f"[{count}] Decompressed to {len(data)} bytes")
replay = pygob.load(data)
d = _gob_to_dict(replay)
session_id = d.get("SessionID", count)
out = replays_dir / f"{session_id}.json"
out.write_text(json.dumps(d, indent=2, default=str), encoding="utf-8")
print(f"[{count}] Decoded and written to {out}\n")
except zstd.ZstdError as e:
print(f"[{count}] zstd decompression failed: {e}")
out = replays_dir / f"gob_{count}_raw.bin"
out.write_bytes(raw)
print(f"[{count}] Raw bytes written to {out}\n")
except Exception as e:
print(f"[{count}] gob decode failed: {e}")
if data:
out = replays_dir / f"gob_{count}.gob"
out.write_bytes(data)
print(f"[{count}] Raw gob written to {out}\n")
count += 1
if __name__ == "__main__": if __name__ == "__main__":
# Setup for direct execution # Setup for direct execution
import sys import sys
@@ -434,5 +323,3 @@ if __name__ == "__main__":
mode = sys.argv[1] if len(sys.argv) > 1 else "replay" mode = sys.argv[1] if len(sys.argv) > 1 else "replay"
if mode == "replay": if mode == "replay":
asyncio.run(test_fetch_replay_by_id()) asyncio.run(test_fetch_replay_by_id())
elif mode == "gob":
asyncio.run(test_gob_ws())
+1 -12
View File
@@ -6,7 +6,7 @@ Bridge helpers for external SREBOT transfer.
This module provides two pieces: This module provides two pieces:
1. A formal SREBOT API client that external consumers can use to query the 1. A formal SREBOT API client that external consumers can use to query the
SREBOT HTTP API. SREBOT HTTP API.
2. A persistent outbox for replay and GOB payloads so the external bridge 2. A persistent outbox for replay payloads so the external bridge
service can fan them out over websocket. service can fan them out over websocket.
""" """
@@ -210,17 +210,6 @@ async def publish_replay_batch(replays: list[dict[str, Any]]) -> None:
await _append_external_envelope(envelope) await _append_external_envelope(envelope)
async def publish_gob_payload(payload: dict[str, Any]) -> None:
"""Queue a GOB payload for websocket delivery by the external bridge."""
envelope = {
"type": "spectra.gob",
"version": 1,
"source": "srebot",
"payload": payload,
}
await _append_external_envelope(envelope)
async def publish_event(event_type: str, payload: dict[str, Any]) -> None: async def publish_event(event_type: str, payload: dict[str, Any]) -> None:
"""Generic queue helper for future bridge events.""" """Generic queue helper for future bridge events."""
envelope = { envelope = {
+376 -54
View File
@@ -1,12 +1,12 @@
""" """
gob.py render_replay.py
Handles GOB replay files: renders MP4 videos and exports slim JSON for the Handles replay JSON files: renders MP4 videos and exports slim JSON for the
web canvas replay viewer. Output mode is picked from the output file extension. web canvas replay viewer. Output mode is picked from the output file extension.
Usage: Usage:
python -m BOT.gob <replay.gob|.json> <out.mp4> # render video python -m BOT.render_replay <replay_data.json> <out.mp4> # render video
python -m BOT.gob <replay.gob|.json> <out.json> # export json python -m BOT.render_replay <replay_data.json> <out.json> # export json
Public API: Public API:
render_gob(d, out_path, fps, speed, n_workers, progress_cb) render_gob(d, out_path, fps, speed, n_workers, progress_cb)
@@ -29,8 +29,6 @@ from typing import Any, Callable, Optional
# Third-Party Library Imports # Third-Party Library Imports
import numpy as np import numpy as np
import pygob
import zstandard as zstd
from . import SHARED_DIR from . import SHARED_DIR
from .utils import REPLAYS_DIR from .utils import REPLAYS_DIR
@@ -439,7 +437,7 @@ def blit_batch(buf: np.ndarray, items: list[tuple[Sprite, int, int]]) -> None:
@dataclass @dataclass
class VideoCtx: class VideoCtx:
"""Pre-computed rendering context for one GOB replay video. """Pre-computed rendering context for one replay video.
Holds all interpolated positions, colors, death states, kill/damage events, Holds all interpolated positions, colors, death states, kill/damage events,
label sprites, and the baked background for every frame so that label sprites, and the baked background for every frame so that
@@ -1506,7 +1504,7 @@ def precompute_kills(kills: list[dict], xfm: CoordTransform,
offset_x/y: crop origin to shift coordinates into crop-space. offset_x/y: crop origin to shift coordinates into crop-space.
pid_pos: optional {PlayerID: (px_arr, py_arr)} for tracking moving entities. pid_pos: optional {PlayerID: (px_arr, py_arr)} for tracking moving entities.
When provided, kill lines follow the entity's interpolated position When provided, kill lines follow the entity's interpolated position
each frame instead of using the static GOB snapshot position. each frame instead of using the static kill snapshot position.
""" """
out: list[list[tuple]] = [[] for _ in range(n_frames)] out: list[list[tuple]] = [[] for _ in range(n_frames)]
kill_f = int(math.ceil(KILL_TTL * fps / 1000.0)) kill_f = int(math.ceil(KILL_TTL * fps / 1000.0))
@@ -1565,7 +1563,7 @@ def precompute_damages(damages: list[dict], active: list[dict],
up from px_all/py_all at the damage time. up from px_all/py_all at the damage time.
Args: Args:
damages: Raw damage report dicts from the GOB replay. damages: Raw damage report dicts from the replay.
active: Active player dicts (used for PlayerID-to-index mapping). active: Active player dicts (used for PlayerID-to-index mapping).
px_all: Precomputed pixel X positions, shape (n_players, n_frames). px_all: Precomputed pixel X positions, shape (n_players, n_frames).
py_all: Precomputed pixel Y positions, shape (n_players, n_frames). py_all: Precomputed pixel Y positions, shape (n_players, n_frames).
@@ -2548,10 +2546,10 @@ def render_gob(
progress_cb: Optional[Callable[[int], None]] = None, progress_cb: Optional[Callable[[int], None]] = None,
) -> None: ) -> None:
""" """
Render a GOB replay dict to an MP4 file. Render a replay dict to an MP4 file.
Args: Args:
d: Parsed GOB replay dict (from _gob_to_dict or json.load) d: Parsed replay dict (normalized by load_gob_file)
out_path: Output MP4 path out_path: Output MP4 path
fps: Frames per second fps: Frames per second
speed: Playback speed multiplier speed: Playback speed multiplier
@@ -2931,41 +2929,365 @@ def render_gob(
print(f"\nDone → {out_path} ({sz:.1f} MB)") print(f"\nDone → {out_path} ({sz:.1f} MB)")
# ── GOB loading helpers ─────────────────────────────────────────────────────── # ── Replay loading helpers ────────────────────────────────────────────────────
def _decode_gob_bytes(raw: bytes) -> str: def _to_int(value: Any, default: int = 0) -> int:
"""Decode replay byte strings while trimming fixed-width padding bytes.""" try:
core = raw.split(b"\x00", 1)[0] return int(value)
text = core.decode("utf-8", errors="replace") except (TypeError, ValueError):
return text.rstrip("".join(chr(i) for i in range(0x00, 0x20)) + "\x7f") return default
def _gob_to_dict(obj: object) -> Any: def _unit_to_model_name(unit_name: str) -> str:
"""Recursively convert pygob namedtuples to plain dicts.""" internal = (unit_name or "").strip()
if isinstance(obj, tuple) and hasattr(obj, '_fields'): # type: ignore[union-attr] if not internal:
return {f: _gob_to_dict(getattr(obj, f)) for f in obj._fields} # type: ignore[union-attr] return "tankModels/unknown"
elif isinstance(obj, list): tags = _get_unit_tags(internal) or []
return [_gob_to_dict(i) for i in obj] tag_set = set(tags)
elif isinstance(obj, dict): if "type_strike_ucav" in tag_set or "ucav" in internal.lower():
return f"airModels/{internal}_ucav"
if tag_set & {
"air",
"aircraft",
"helicopter",
"type_jet_bomber",
"type_bomber",
"type_strike_aircraft",
"type_jet_fighter",
"type_fighter",
"type_helicopter",
}:
return f"airModels/{internal}"
return f"tankModels/{internal}"
def _path_sample_to_dict(sample: Any) -> dict[str, float] | None:
if isinstance(sample, dict):
if {"Time", "X", "Z"}.issubset(sample.keys()):
return {
"Time": float(sample.get("Time", 0.0)),
"X": float(sample.get("X", 0.0)),
"Y": float(sample.get("Y", 0.0)),
"Z": float(sample.get("Z", 0.0)),
}
if {"t", "x", "z"}.issubset(sample.keys()):
return {
"Time": float(sample.get("t", 0.0)),
"X": float(sample.get("x", 0.0)),
"Y": float(sample.get("y", 0.0)),
"Z": float(sample.get("z", 0.0)),
}
return None
if isinstance(sample, (list, tuple)) and len(sample) >= 4:
return { return {
(_decode_gob_bytes(k) if isinstance(k, bytes) else k): _gob_to_dict(v) "Time": float(sample[0]),
for k, v in obj.items() "X": float(sample[1]),
"Y": float(sample[2]),
"Z": float(sample[3]),
} }
elif isinstance(obj, bytes): return None
return _decode_gob_bytes(obj)
return obj
def load_gob_file(gob_path: Path) -> dict[str, Any]: def _position_at_time(path: list[dict[str, float]], time_ms: float) -> dict[str, float] | None:
"""Load a .gob (zstd-compressed) or .json replay file and return the dict.""" if not path:
raw = gob_path.read_bytes() return None
if gob_path.suffix == ".json": if time_ms <= path[0]["Time"]:
return json.loads(raw) return path[0]
# zstd-compressed gob binary prev = path[0]
decompressor = zstd.ZstdDecompressor() for pt in path[1:]:
data = decompressor.decompress(raw, max_output_size=200 * 1024 * 1024) if pt["Time"] >= time_ms:
replay = pygob.load(data) return pt
return _gob_to_dict(replay) # type: ignore[return-value] prev = pt
return prev
def _convert_ws_replay_to_render_dict(replay: dict[str, Any]) -> dict[str, Any]:
players_src = replay.get("players") or {}
if not isinstance(players_src, dict):
players_src = {}
players_out: list[dict[str, Any]] = []
winner_tag = str(replay.get("winner") or "")
loser_tag = str(replay.get("loser") or "")
winner_team = 0
loser_team = 0
for uid_str, pdata in players_src.items():
if not isinstance(pdata, dict):
continue
pid = _to_int(pdata.get("uid") or uid_str, 0)
team = _to_int(pdata.get("team"), 0)
tag = str(pdata.get("tag") or "")
if winner_team == 0 and tag == winner_tag:
winner_team = team
if loser_team == 0 and tag == loser_tag:
loser_team = team
players_out.append({
"PlayerID": pid,
"Name": str(pdata.get("name") or f"Player#{pid}"),
"Team": team,
"Clan": tag,
})
if winner_team == 0:
winner_team = 1 if loser_team != 1 else 2
entities_src = replay.get("entities") or []
if not isinstance(entities_src, list):
entities_src = []
entities_out: list[dict[str, Any]] = []
uid_to_entity_index: dict[int, int] = {}
for idx, ent in enumerate(entities_src, start=1):
if not isinstance(ent, dict):
continue
uid = _to_int(ent.get("uid"), 0)
unit = str(ent.get("unit") or "")
path_raw = ent.get("path") or []
if not isinstance(path_raw, list):
continue
path: list[dict[str, float]] = []
for sample in path_raw:
parsed = _path_sample_to_dict(sample)
if parsed is not None:
path.append(parsed)
if not path:
continue
entity_index = _to_int(ent.get("entity_index") or ent.get("entityIndex"), idx)
uid_to_entity_index.setdefault(uid, entity_index)
entities_out.append({
"EntityIndex": entity_index,
"PlayerID": uid,
"ModelName": _unit_to_model_name(unit),
"Path": path,
})
entity_paths_by_uid: dict[int, list[dict[str, float]]] = {
e["PlayerID"]: e["Path"] for e in entities_out if e.get("PlayerID")
}
events = replay.get("events") or {}
if isinstance(events, str):
try:
events = json.loads(events)
except json.JSONDecodeError:
events = {}
if not isinstance(events, dict):
events = {}
kills_out: list[dict[str, Any]] = []
for kill in (events.get("kills") or []):
if not isinstance(kill, dict):
continue
victim_id = _to_int(kill.get("offended_uid"), 0)
killer_id = _to_int(kill.get("offender_uid"), 0)
kill_time = float(kill.get("time") or 0.0)
victim_path = entity_paths_by_uid.get(victim_id, [])
killer_path = entity_paths_by_uid.get(killer_id, [])
victim_pos = _position_at_time(victim_path, kill_time)
killer_pos = _position_at_time(killer_path, kill_time)
payload: dict[str, Any] = {
"Time": kill_time,
"VictimID": victim_id,
"KillerID": killer_id,
"VictimEntityIndex": uid_to_entity_index.get(victim_id, 0),
"Weapon": str(kill.get("used_weapon") or kill.get("weapon") or ""),
"VictimModel": _unit_to_model_name(str(kill.get("offended_unit") or "")),
"KillerModel": _unit_to_model_name(str(kill.get("offender_unit") or "")),
"crashed": bool(kill.get("crashed", False)),
}
if victim_pos:
payload["VictimPosition"] = {
"X": float(victim_pos["X"]),
"Y": float(victim_pos["Y"]),
"Z": float(victim_pos["Z"]),
}
if killer_pos:
payload["KillerPosition"] = {
"X": float(killer_pos["X"]),
"Y": float(killer_pos["Y"]),
"Z": float(killer_pos["Z"]),
}
kills_out.append(payload)
damages_out: list[dict[str, Any]] = []
for dmg in (events.get("damage") or []):
if not isinstance(dmg, dict):
continue
damages_out.append({
"Time": float(dmg.get("time") or 0.0),
"OffenderID": _to_int(dmg.get("offender_uid"), 0),
"OffendedID": _to_int(dmg.get("offended_uid"), 0),
"OffenderModel": _unit_to_model_name(str(dmg.get("offender_unit") or "")),
"OffendedModel": _unit_to_model_name(str(dmg.get("offended_unit") or "")),
"Afire": bool(dmg.get("afire", False)),
})
mission_mode = str(replay.get("mission_mode") or "")
difficulty = str(replay.get("difficulty") or "")
battle_type = mission_mode or difficulty
return {
"SessionID": _to_int(replay.get("_id") or replay.get("id"), 0),
"TeamWon": winner_team,
"Mission": {
"Level": str(replay.get("level_path") or ""),
"LevelSettings": str(replay.get("mission_path") or ""),
"BattleType": battle_type,
},
"Players": players_out,
"Entities": entities_out,
"Kills": kills_out,
"DamageReports": damages_out,
}
def _convert_local_replay_to_render_dict(replay: dict[str, Any]) -> dict[str, Any]:
teams_src = replay.get("teams") or []
if not isinstance(teams_src, list):
teams_src = []
players_out: list[dict[str, Any]] = []
winner_sq = str(replay.get("winning_team_squadron") or "")
winner_team = 1
for idx, team in enumerate(teams_src[:2], start=1):
if not isinstance(team, dict):
continue
team_sq = str(team.get("squadron") or "")
if team_sq and team_sq == winner_sq:
winner_team = idx
for p in (team.get("players") or []):
if not isinstance(p, dict):
continue
pid = _to_int(p.get("uid"), 0)
if pid <= 0:
continue
players_out.append({
"PlayerID": pid,
"Name": str(p.get("nick") or f"Player#{pid}"),
"Team": idx,
"Clan": str(team.get("squadron_tagged") or team_sq),
})
entities_src = replay.get("entities") or []
if not isinstance(entities_src, list):
entities_src = []
entities_out: list[dict[str, Any]] = []
uid_to_entity_index: dict[int, int] = {}
for idx, ent in enumerate(entities_src, start=1):
if not isinstance(ent, dict):
continue
uid = _to_int(ent.get("uid"), 0)
unit = str(ent.get("unit") or "")
path_raw = ent.get("path") or []
if not isinstance(path_raw, list):
continue
path: list[dict[str, float]] = []
for sample in path_raw:
parsed = _path_sample_to_dict(sample)
if parsed is not None:
path.append(parsed)
if not path:
continue
entity_index = _to_int(ent.get("entity_index") or ent.get("entityIndex"), idx)
uid_to_entity_index.setdefault(uid, entity_index)
entities_out.append({
"EntityIndex": entity_index,
"PlayerID": uid,
"ModelName": _unit_to_model_name(unit),
"Path": path,
})
entity_paths_by_uid: dict[int, list[dict[str, float]]] = {
e["PlayerID"]: e["Path"] for e in entities_out if e.get("PlayerID")
}
events = replay.get("events") or {}
if isinstance(events, str):
try:
events = json.loads(events)
except json.JSONDecodeError:
events = {}
if not isinstance(events, dict):
events = {}
kills_out: list[dict[str, Any]] = []
for kill in (events.get("kills") or []):
if not isinstance(kill, dict):
continue
victim_id = _to_int(kill.get("offended_uid"), 0)
killer_id = _to_int(kill.get("offender_uid"), 0)
kill_time = float(kill.get("time") or 0.0)
victim_path = entity_paths_by_uid.get(victim_id, [])
killer_path = entity_paths_by_uid.get(killer_id, [])
victim_pos = _position_at_time(victim_path, kill_time)
killer_pos = _position_at_time(killer_path, kill_time)
payload: dict[str, Any] = {
"Time": kill_time,
"VictimID": victim_id,
"KillerID": killer_id,
"VictimEntityIndex": uid_to_entity_index.get(victim_id, 0),
"Weapon": str(kill.get("used_weapon") or kill.get("weapon") or ""),
"VictimModel": _unit_to_model_name(str(kill.get("offended_unit") or "")),
"KillerModel": _unit_to_model_name(str(kill.get("offender_unit") or "")),
"crashed": bool(kill.get("crashed", False)),
}
if victim_pos:
payload["VictimPosition"] = {
"X": float(victim_pos["X"]),
"Y": float(victim_pos["Y"]),
"Z": float(victim_pos["Z"]),
}
if killer_pos:
payload["KillerPosition"] = {
"X": float(killer_pos["X"]),
"Y": float(killer_pos["Y"]),
"Z": float(killer_pos["Z"]),
}
kills_out.append(payload)
damages_out: list[dict[str, Any]] = []
for dmg in (events.get("damage") or []):
if not isinstance(dmg, dict):
continue
damages_out.append({
"Time": float(dmg.get("time") or 0.0),
"OffenderID": _to_int(dmg.get("offender_uid"), 0),
"OffendedID": _to_int(dmg.get("offended_uid"), 0),
"OffenderModel": _unit_to_model_name(str(dmg.get("offender_unit") or "")),
"OffendedModel": _unit_to_model_name(str(dmg.get("offended_unit") or "")),
"Afire": bool(dmg.get("afire", False)),
})
return {
"SessionID": _to_int(replay.get("session_id_dec") or replay.get("session_id_hex"), 0),
"TeamWon": winner_team,
"Mission": {
"Level": str(replay.get("level_path") or ""),
"LevelSettings": str(replay.get("mission_path") or ""),
"BattleType": str(replay.get("mode") or replay.get("difficulty") or ""),
},
"Players": players_out,
"Entities": entities_out,
"Kills": kills_out,
"DamageReports": damages_out,
}
def load_gob_file(replay_path: Path) -> dict[str, Any]:
"""Load a replay .json file and normalize it for render/export routines."""
data = json.loads(replay_path.read_text(encoding="utf-8"))
if isinstance(data, dict) and {"Players", "Entities", "Mission"}.issubset(data.keys()):
return data
if isinstance(data, dict):
if {"teams", "events", "entities"}.issubset(data.keys()):
return _convert_local_replay_to_render_dict(data)
return _convert_ws_replay_to_render_dict(data)
raise ValueError(f"Unsupported replay payload in {replay_path}")
# ── JSON export (slim dict for the web canvas replay viewer) ────────────────── # ── JSON export (slim dict for the web canvas replay viewer) ──────────────────
@@ -3067,9 +3389,9 @@ def _resolve_drone_team(drone_entity: dict, ground_entities: list[dict],
return best_team return best_team
def export_replay_json(gob_path: Path) -> dict: def export_replay_json(replay_path: Path) -> dict:
"""Load a GOB file and produce a slim dict for the web viewer.""" """Load a replay file and produce a slim dict for the web viewer."""
d = load_gob_file(gob_path) d = load_gob_file(replay_path)
players_by_id = {p["PlayerID"]: p for p in d.get("Players", [])} players_by_id = {p["PlayerID"]: p for p in d.get("Players", [])}
team_won = d.get("TeamWon", 0) team_won = d.get("TeamWon", 0)
@@ -3244,14 +3566,14 @@ def export_replay_json(gob_path: Path) -> dict:
# ── Main (CLI wrapper) ───────────────────────────────────────────────────────── # ── Main (CLI wrapper) ─────────────────────────────────────────────────────────
def main(): def main():
"""CLI entry point: render a GOB replay to MP4, or export a slim JSON for the web viewer. """CLI entry point: render a replay JSON to MP4, or export a slim viewer JSON.
Output mode is selected by the output file extension: `.json` json export, Output mode is selected by the output file extension: `.json` json export,
anything else mp4 render. Supports --profile for cProfile hotspot analysis. anything else mp4 render. Supports --profile for cProfile hotspot analysis.
""" """
import argparse import argparse
parser = argparse.ArgumentParser(description="Render GOB replay to MP4") parser = argparse.ArgumentParser(description="Render replay_data JSON to MP4")
parser.add_argument("gob", nargs="?", help="Path to .gob or .json replay") parser.add_argument("replay", nargs="?", help="Path to replay_data .json")
parser.add_argument("out", nargs="?", help="Output .mp4 path") parser.add_argument("out", nargs="?", help="Output .mp4 path")
parser.add_argument("--fps", type=int, default=FPS) parser.add_argument("--fps", type=int, default=FPS)
parser.add_argument("--speed", type=float, default=SPEED) parser.add_argument("--speed", type=float, default=SPEED)
@@ -3260,30 +3582,30 @@ def main():
help="Run with cProfile and print top 40 hotspots") help="Run with cProfile and print top 40 hotspots")
args = parser.parse_args() args = parser.parse_args()
if args.gob: if args.replay:
gob_path = Path(args.gob) replay_path = Path(args.replay)
else: else:
candidates = sorted(REPLAYS_DIR.glob("*/replay.gob")) candidates = sorted(REPLAYS_DIR.glob("*/replay_data.json"))
if not candidates: if not candidates:
candidates = sorted(REPLAYS_DIR.glob("*.json")) candidates = sorted(REPLAYS_DIR.glob("*.json"))
if not candidates: if not candidates:
sys.exit(f"No .gob or .json files in {REPLAYS_DIR}") sys.exit(f"No replay .json files in {REPLAYS_DIR}")
gob_path = candidates[0] replay_path = candidates[0]
out_path = Path(args.out) if args.out else gob_path.parent / "replay_video.mp4" out_path = Path(args.out) if args.out else replay_path.parent / "replay_video.mp4"
if out_path.suffix.lower() == ".json": if out_path.suffix.lower() == ".json":
data = export_replay_json(gob_path) data = export_replay_json(replay_path)
raw = json.dumps(data, separators=(",", ":")) raw = json.dumps(data, separators=(",", ":"))
out_path.write_text(raw, encoding="utf-8") out_path.write_text(raw, encoding="utf-8")
print(f"Exported {len(raw):,} bytes to {out_path}") print(f"Exported {len(raw):,} bytes to {out_path}")
return return
print(f"Input : {gob_path}") print(f"Input : {replay_path}")
print(f"Output : {out_path}") print(f"Output : {out_path}")
print(f"Settings : {args.fps}fps {args.speed:.0f}× {args.workers} threads") print(f"Settings : {args.fps}fps {args.speed:.0f}× {args.workers} threads")
d = load_gob_file(gob_path) d = load_gob_file(replay_path)
if args.profile: if args.profile:
import cProfile import cProfile
+2 -2
View File
@@ -3,7 +3,7 @@
This PM2-managed process does two things: This PM2-managed process does two things:
1. Proxies read-only SREBOT queries on the external port. 1. Proxies read-only SREBOT queries on the external port.
2. Broadcasts SREBOT replay/GOB envelopes over websocket to any connected 2. Broadcasts SREBOT replay envelopes over websocket to any connected
client. client.
""" """
@@ -165,7 +165,7 @@ async def root(_: web.Request) -> web.Response:
return web.json_response( return web.json_response(
{ {
"service": "srebot-external", "service": "srebot-external",
"message": "Use /api/* for queries and /ws/srebot for replay/gob events.", "message": "Use /api/* for queries and /ws/srebot for replay events.",
} }
) )
+4 -7
View File
@@ -285,14 +285,14 @@ async def cleanup_replays():
""" """
Cleans up replay directories in STORAGE/REPLAYS/: Cleans up replay directories in STORAGE/REPLAYS/:
- After 12 hours: deletes regenerable files (PNGs, MP4s) - After 12 hours: deletes regenerable files (PNGs, MP4s)
- After 48 hours: deletes entire directory (GOB + JSON included) - After 48 hours: deletes entire directory (replay JSON included)
Age is determined from the mtime of replay.gob / replay_data.json (written Age is determined from the mtime of replay_data.json (written
once at capture time), not the directory mtime — directory mtime is bumped once at capture time), not the directory mtime — directory mtime is bumped
whenever files inside are added or removed (including by this cleanup), which whenever files inside are added or removed (including by this cleanup), which
would otherwise keep dirs perpetually "fresh". would otherwise keep dirs perpetually "fresh".
""" """
KEEP_FILES = {"replay.gob", "replay_data.json"} KEEP_FILES = {"replay_data.json"}
def _sync_cleanup_replays(): def _sync_cleanup_replays():
"""Synchronous helper that walks replay dirs and deletes stale files.""" """Synchronous helper that walks replay dirs and deletes stale files."""
@@ -312,11 +312,8 @@ async def cleanup_replays():
if not entry_path.is_dir(): if not entry_path.is_dir():
continue continue
gob_path = entry_path / "replay.gob"
json_path = entry_path / "replay_data.json" json_path = entry_path / "replay_data.json"
if gob_path.exists(): if json_path.exists():
entry_mtime = gob_path.stat().st_mtime
elif json_path.exists():
entry_mtime = json_path.stat().st_mtime entry_mtime = json_path.stat().st_mtime
else: else:
entry_mtime = entry_path.stat().st_mtime entry_mtime = entry_path.stat().st_mtime
+1 -29
View File
@@ -24,7 +24,7 @@ from discord.ext import tasks
# Local Module Imports # Local Module Imports
from . import lux_apis from . import lux_apis
from .autologging import handle_ws_replays, handle_gob_message from .autologging import handle_ws_replays
from .health import get_recent_ttl_stats, record_task_run, write_heartbeat from .health import get_recent_ttl_stats, record_task_run, write_heartbeat
from .meta_manager import process_all_players, sync_all_guild_metas from .meta_manager import process_all_players, sync_all_guild_metas
from .task_executors import ( from .task_executors import (
@@ -501,32 +501,6 @@ async def after_ws_autolog():
ws_autolog_task.start() ws_autolog_task.start()
# ============================================================================
# WEBSOCKET GOB LISTENER TASK
# ============================================================================
@tasks.loop(count=1)
async def ws_gob_task():
"""
Single-run task that maintains persistent WebSocket connection to the GOB endpoint.
Saves incoming compressed GOB replays to disk for on-demand video generation.
"""
await lux_apis.ws_gob_listener(handle_gob_message)
@ws_gob_task.before_loop
async def before_ws_gob():
await get_bot().wait_until_ready()
@ws_gob_task.after_loop
async def after_ws_gob():
if ws_gob_task.failed():
logging.error("[GOB] ws_gob_task died, restarting in 10s...")
await asyncio.sleep(10)
ws_gob_task.start()
# ============================================================================ # ============================================================================
# SQUADRON POINTS CONTINUOUS UPDATER # SQUADRON POINTS CONTINUOUS UPDATER
# ============================================================================ # ============================================================================
@@ -729,7 +703,6 @@ async def start_all_tasks():
ttl_alert_task.start() ttl_alert_task.start()
# Phase 2: WebSocket listeners # Phase 2: WebSocket listeners
ws_autolog_task.start() ws_autolog_task.start()
ws_gob_task.start()
# Phase 3: Heavy DB tasks (background — doesn't block on_ready) # Phase 3: Heavy DB tasks (background — doesn't block on_ready)
asyncio.create_task(_startup_heavy_init()) asyncio.create_task(_startup_heavy_init())
@@ -747,7 +720,6 @@ def stop_all_tasks():
update_squadrons_db_task.cancel() update_squadrons_db_task.cancel()
update_meta_data_task.cancel() update_meta_data_task.cancel()
ws_autolog_task.cancel() ws_autolog_task.cancel()
ws_gob_task.cancel()
squadron_points_loop_task.cancel() squadron_points_loop_task.cancel()
sync_guild_metas_task.cancel() sync_guild_metas_task.cancel()
health_heartbeat_task.cancel() health_heartbeat_task.cancel()
+36 -11
View File
@@ -1216,11 +1216,15 @@ def transform_to_local_format(api_data: Dict[str, Any]) -> Optional[Dict[str, An
replay = api_data["completed"][0] replay = api_data["completed"][0]
winner_winged = replay.get("winner") winner_winged = str(replay.get("winner") or "")
loser_winged = replay.get("loser") loser_winged = str(replay.get("loser") or "")
winner_squadron = winner_winged[1:-1] if winner_winged else "" def _normalize_squadron_tag(raw: str) -> str:
loser_squadron = loser_winged[1:-1] if loser_winged else "" cleaned = re.sub(r"[^A-Za-z0-9_-]", "", raw or "")
return cleaned or (raw or "").strip()
winner_squadron = _normalize_squadron_tag(winner_winged)
loser_squadron = _normalize_squadron_tag(loser_winged)
is_draw = replay.get("draw", False) is_draw = replay.get("draw", False)
@@ -1310,7 +1314,7 @@ def transform_to_local_format(api_data: Dict[str, Any]) -> Optional[Dict[str, An
"offended_uid": str(kill["offended_uid"]) if kill.get("offended_uid") is not None else None, "offended_uid": str(kill["offended_uid"]) if kill.get("offended_uid") is not None else None,
"offended_unit": kill.get("offended_unit"), "offended_unit": kill.get("offended_unit"),
"crashed": kill.get("crashed", False), "crashed": kill.get("crashed", False),
"weapon": kill.get("weapon", ""), "weapon": kill.get("used_weapon", "") or kill.get("weapon", ""),
"afire": False, "afire": False,
}) })
@@ -1402,9 +1406,23 @@ def transform_to_local_format(api_data: Dict[str, Any]) -> Optional[Dict[str, An
f"{prefix}[{time_str}] {sq_tag:<7} {name} ({vehicle}) damaged {afire}{victim_name} ({victim_vehicle})" f"{prefix}[{time_str}] {sq_tag:<7} {name} ({vehicle}) damaged {afire}{victim_name} ({victim_vehicle})"
) )
raw_id = replay.get("_id") raw_id = replay.get("_id")
start_ts = replay.get("start_ts") or 0 if raw_id is None:
end_ts = replay.get("end_ts") or 0 raw_id = replay.get("id")
start_ts = int(replay.get("start_ts") or 0)
end_ts = int(replay.get("end_ts") or 0)
mission_name = str(replay.get("mission_name") or "").strip()
if not mission_name:
mission_name = str(replay.get("level_path") or "").strip()
mission_mode = str(replay.get("mission_mode") or "").strip()
if not mission_mode:
mission_mode = str(replay.get("difficulty") or "").strip()
duration = replay.get("duration")
if duration is None:
duration = max(0, end_ts - start_ts)
session_id_dec = str(raw_id) if raw_id is not None else "" session_id_dec = str(raw_id) if raw_id is not None else ""
try: try:
@@ -1420,9 +1438,11 @@ def transform_to_local_format(api_data: Dict[str, Any]) -> Optional[Dict[str, An
"session_id_dec": session_id_dec, "session_id_dec": session_id_dec,
"session_id_hex": session_id_hex, "session_id_hex": session_id_hex,
"timestamp": end_ts, "timestamp": end_ts,
"map": replay.get("mission_name", ""), "start_ts": start_ts,
"mode": replay.get("mission_mode", ""), "end_ts": end_ts,
"duration": end_ts - start_ts, "map": mission_name,
"mode": mission_mode,
"duration": duration,
"draw": is_draw, "draw": is_draw,
"teams": [ "teams": [
{ {
@@ -1444,6 +1464,11 @@ def transform_to_local_format(api_data: Dict[str, Any]) -> Optional[Dict[str, An
], ],
"chat_log": chat_log, "chat_log": chat_log,
"battle_log": battle_log, "battle_log": battle_log,
"events": raw_events,
"entities": replay.get("entities", []),
"level_path": replay.get("level_path"),
"mission_path": replay.get("mission_path"),
"difficulty": replay.get("difficulty"),
"type": replay.get("type", ""), "type": replay.get("type", ""),
} }
-1
View File
@@ -3,7 +3,6 @@ requests>=2.32.3,<3.0.0
beautifulsoup4>=4.12.3,<5.0.0 beautifulsoup4>=4.12.3,<5.0.0
lxml>=5.0.0 lxml>=5.0.0
zstandard zstandard
pygob @ git+https://github.com/mgeisler/pygob.git
lz4==4.3.3 lz4==4.3.3
aiofiles aiofiles
aiohttp aiohttp
+1 -16
View File
@@ -10,7 +10,6 @@ load_dotenv()
API_KEY = os.getenv("SPECTRA_API_KEY", "") API_KEY = os.getenv("SPECTRA_API_KEY", "")
BASE_URL = os.getenv("SPECTRA_API_URL", "") BASE_URL = os.getenv("SPECTRA_API_URL", "")
WS_URL = os.getenv("SPECTRA_WS_SQB_URL", "") WS_URL = os.getenv("SPECTRA_WS_SQB_URL", "")
WS_GOB_URL = os.getenv("SPECTRA_WS_GOB_URL", "")
async def test_http(): async def test_http():
@@ -42,27 +41,13 @@ async def test_ws_sqb():
print(f"[WS-SQB] Failed: {type(e).__name__}: {e}") print(f"[WS-SQB] Failed: {type(e).__name__}: {e}")
async def test_ws_gob():
"""Test GOB WebSocket connection."""
url = WS_GOB_URL
print(f"\n[WS-GOB] Connecting to {url}")
try:
async with connect(url, additional_headers={"Authorization": API_KEY}, open_timeout=15) as ws:
print("[WS-GOB] Connected! Waiting for message (10s)...")
msg = await asyncio.wait_for(ws.recv(), timeout=10)
print(f"[WS-GOB] Got message ({len(msg)} bytes)")
except Exception as e:
print(f"[WS-GOB] Failed: {type(e).__name__}: {e}")
async def main(): async def main():
print(f"API Key configured: {'Yes' if API_KEY else 'No'}") print(f"API Key configured: {'Yes' if API_KEY else 'No'}")
print(f"Base URL: {BASE_URL}") print(f"Base URL: {BASE_URL}")
print(f"WS URL: {WS_URL}") print(f"WS URL: {WS_URL}")
print(f"WS GOB URL: {WS_GOB_URL}\n") print()
await test_http() await test_http()
await test_ws_sqb() await test_ws_sqb()
await test_ws_gob()
if __name__ == "__main__": if __name__ == "__main__":
+10 -10
View File
@@ -1828,7 +1828,7 @@ app.get('/api/match/:sessionId/video', async (req, res) => {
const sessionDir = resolveReplaySessionDir(sessionId); const sessionDir = resolveReplaySessionDir(sessionId);
const videoPath = path.join(sessionDir, 'replay_video.mp4'); const videoPath = path.join(sessionDir, 'replay_video.mp4');
const gobPath = path.join(sessionDir, 'replay.gob'); const replayPath = path.join(sessionDir, 'replay_data.json');
// 1. Serve from disk if cached // 1. Serve from disk if cached
if (fs.existsSync(videoPath)) { if (fs.existsSync(videoPath)) {
@@ -1837,9 +1837,9 @@ app.get('/api/match/:sessionId/video', async (req, res) => {
}); });
} }
// 2. Check if compressed gob exists for generation // 2. Check if replay JSON exists for generation
if (!fs.existsSync(gobPath)) { if (!fs.existsSync(replayPath)) {
return res.status(404).json({ available: false, reason: 'No GOB replay data available for this session' }); return res.status(404).json({ available: false, reason: 'No replay data available for this session' });
} }
// 3. Generate video on demand (decompress + render) // 3. Generate video on demand (decompress + render)
@@ -1850,7 +1850,7 @@ app.get('/api/match/:sessionId/video', async (req, res) => {
try { try {
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const pythonBin = path.join(__dirname, '..', '.venv', 'bin', 'python'); const pythonBin = path.join(__dirname, '..', '.venv', 'bin', 'python');
execFile(pythonBin, ['-m', 'BOT.gob', gobPath, videoPath], { execFile(pythonBin, ['-m', 'BOT.render_replay', replayPath, videoPath], {
timeout: 120000, timeout: 120000,
cwd: path.join(__dirname, '..') cwd: path.join(__dirname, '..')
}, (error, stdout, stderr) => { }, (error, stdout, stderr) => {
@@ -1891,7 +1891,7 @@ app.get('/api/match/:sessionId/replay-canvas', async (req, res) => {
const sessionDir = resolveReplaySessionDir(sessionId); const sessionDir = resolveReplaySessionDir(sessionId);
const jsonPath = path.join(sessionDir, 'replay_canvas.json'); const jsonPath = path.join(sessionDir, 'replay_canvas.json');
const gobPath = path.join(sessionDir, 'replay.gob'); const replayPath = path.join(sessionDir, 'replay_data.json');
// 1. Serve from disk if cached // 1. Serve from disk if cached
if (fs.existsSync(jsonPath)) { if (fs.existsSync(jsonPath)) {
@@ -1900,9 +1900,9 @@ app.get('/api/match/:sessionId/replay-canvas', async (req, res) => {
}); });
} }
// 2. Check if GOB exists // 2. Check if replay JSON exists
if (!fs.existsSync(gobPath)) { if (!fs.existsSync(replayPath)) {
return res.status(404).json({ available: false, reason: 'No GOB replay data available' }); return res.status(404).json({ available: false, reason: 'No replay data available' });
} }
// 3. Generate on demand // 3. Generate on demand
@@ -1913,7 +1913,7 @@ app.get('/api/match/:sessionId/replay-canvas', async (req, res) => {
try { try {
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const pythonBin = path.join(__dirname, '..', '.venv', 'bin', 'python'); const pythonBin = path.join(__dirname, '..', '.venv', 'bin', 'python');
execFile(pythonBin, ['-m', 'BOT.gob', gobPath, jsonPath], { execFile(pythonBin, ['-m', 'BOT.render_replay', replayPath, jsonPath], {
timeout: 30000, timeout: 30000,
cwd: path.join(__dirname, '..') cwd: path.join(__dirname, '..')
}, (error, stdout, stderr) => { }, (error, stdout, stderr) => {