update to handle new structure from spectra, no more gobs (#1266)
This commit is contained in:
+12
-42
@@ -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
@@ -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
@@ -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 = {
|
||||||
|
|||||||
@@ -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 {
|
return {
|
||||||
(_decode_gob_bytes(k) if isinstance(k, bytes) else k): _gob_to_dict(v)
|
"Time": float(sample.get("Time", 0.0)),
|
||||||
for k, v in obj.items()
|
"X": float(sample.get("X", 0.0)),
|
||||||
|
"Y": float(sample.get("Y", 0.0)),
|
||||||
|
"Z": float(sample.get("Z", 0.0)),
|
||||||
}
|
}
|
||||||
elif isinstance(obj, bytes):
|
if {"t", "x", "z"}.issubset(sample.keys()):
|
||||||
return _decode_gob_bytes(obj)
|
return {
|
||||||
return obj
|
"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 {
|
||||||
|
"Time": float(sample[0]),
|
||||||
|
"X": float(sample[1]),
|
||||||
|
"Y": float(sample[2]),
|
||||||
|
"Z": float(sample[3]),
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
@@ -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.",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
+35
-10
@@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1403,8 +1407,22 @@ def transform_to_local_format(api_data: Dict[str, Any]) -> Optional[Dict[str, An
|
|||||||
)
|
)
|
||||||
|
|
||||||
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", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user