This commit is contained in:
NotSoToothless
2026-06-17 00:32:30 -07:00
committed by GitHub
parent d07afdee21
commit 7bd8a1b72c
10 changed files with 808 additions and 32 deletions
+156
View File
@@ -0,0 +1,156 @@
"""Raw TSS game → scoreboard model adapter.
``process_game`` receives the raw Spectra TSS payload (also persisted to disk by
``tss_ws._write_game``). Its shape is awkward for rendering: players live in a dict
keyed by uid, team identity and pvp_ratio live in a parallel ``tss`` block, and each
player carries a full ``units[]`` lineup rather than a single vehicle.
``build_scoreboard_model`` flattens all of that into a stable, renderer-friendly dict
so ``scoreboard.py`` never has to understand the raw feed. Pure (no I/O); unit-tested
against sample replays.
"""
from __future__ import annotations
import logging
from typing import Any, Optional
log = logging.getLogger("tssbot.transform")
# Imported lazily/defensively so the module is importable (and unit-testable for the
# non-translation paths) even if SHARED isn't on sys.path yet.
try:
from data_parser import LangTableReader, apply_vehicle_name_filters # type: ignore
except Exception: # pragma: no cover - exercised only when SHARED is missing
LangTableReader = None # type: ignore
def apply_vehicle_name_filters(name, strip_decorations=True): # type: ignore
return name
def _translator(lang_column: str):
"""Return a translate(internal)->human callable for the given lang column.
Falls back to identity (internal name) when data_parser is unavailable.
"""
if LangTableReader is None:
return lambda v: v
try:
reader = LangTableReader(lang_column)
except Exception as exc: # pragma: no cover
log.warning("LangTableReader(%r) failed: %s", lang_column, exc)
return lambda v: v
def _t(internal: str) -> str:
try:
translated = reader.get_translate(internal) or internal
except Exception:
translated = internal
# Strip country-leak / premium / tree decoration glyphs the PNG font can't
# render (e.g. the leading ␗ on cn_t_34_85_d_5t), matching SREBOT's renderer.
return apply_vehicle_name_filters(translated)
return _t
def _build_units(units: list[dict[str, Any]], translate) -> list[dict[str, Any]]:
"""Normalize a player's lineup: icon key (internal), translated name, used flag."""
out: list[dict[str, Any]] = []
for u in units or []:
internal = str(u.get("unit") or "").strip()
if not internal:
continue
out.append({
"internal": internal,
"name": translate(internal) or u.get("unit_normalized") or internal,
"used": bool(u.get("used")),
})
return out
def _pvp_index(tss: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""Map uid -> {pvp_ratio, role} from the tss per-team roster blocks."""
idx: dict[str, dict[str, Any]] = {}
for slot in ("1", "2"):
team = tss.get(slot)
if not isinstance(team, dict):
continue
for entry in team.get("players") or []:
uid = str(entry.get("uid") or "")
if uid:
idx[uid] = {
"pvp_ratio": entry.get("pvp_ratio"),
"role": entry.get("role"),
}
return idx
def build_scoreboard_model(game: dict[str, Any], lang_column: str = "<English>") -> Optional[dict[str, Any]]:
"""Flatten a raw TSS game into a scoreboard render model.
Returns ``None`` if the game lacks the minimum structure (two teams w/ players).
"""
tss = game.get("tss") or {}
players_raw = game.get("players") or {}
if not isinstance(players_raw, dict):
return None
translate = _translator(lang_column)
pvp = _pvp_index(tss)
teams: dict[str, dict[str, Any]] = {}
for slot in ("1", "2"):
raw_meta = tss.get(slot)
meta: dict[str, Any] = raw_meta if isinstance(raw_meta, dict) else {}
teams[slot] = {
"slot": slot,
"team_id": str(meta.get("team_id")) if meta.get("team_id") is not None else None,
"team_name": str(meta.get("team_name") or f"Team {slot}"),
"players": [],
}
for uid, p in players_raw.items():
if not isinstance(p, dict):
continue
slot = str(p.get("team") or "")
if slot not in teams:
continue
info = pvp.get(str(uid), {})
teams[slot]["players"].append({
"uid": str(uid),
"nick": str(p.get("name") or ""),
# No streamer-mode field in the TSS feed yet; renderer falls back to nick.
"fake_nick": None,
"tag": str(p.get("tag") or ""),
"country_id": p.get("country_id"),
"air_kills": int(p.get("air_kills") or 0),
"ground_kills": int(p.get("ground_kills") or 0),
"assists": int(p.get("assists") or 0),
"deaths": int(p.get("deaths") or 0),
"captures": int(p.get("captures") or 0),
"score": int(p.get("score") or 0),
"pvp_ratio": info.get("pvp_ratio"),
"role": info.get("role"),
"units": _build_units(p.get("units") or [], translate),
})
if not teams["1"]["players"] or not teams["2"]["players"]:
return None
winner_slot = str(game.get("winner") or "")
is_draw = bool(game.get("draw"))
winner_name = teams[winner_slot]["team_name"] if winner_slot in teams and not is_draw else None
return {
"session_id": str(game.get("_id") or ""),
"utc_timestamp": int(game.get("end_ts") or 0),
"start_ts": int(game.get("start_ts") or 0),
"duration": game.get("duration"),
"map": str(game.get("mission_name") or ""),
"mission_mode": str(game.get("mission_mode") or ""),
"tournament_name": str(tss.get("tournament_name") or ""),
"bracket": str(tss.get("bracket") or ""),
"is_draw": is_draw,
"winner": winner_name,
"winner_slot": winner_slot,
"teams": [teams["1"], teams["2"]],
}