163 lines
5.5 KiB
Python
163 lines
5.5 KiB
Python
"""Compatibility normalizers for Spectra replay payload schema changes."""
|
|
from __future__ import annotations
|
|
|
|
from copy import deepcopy
|
|
from typing import Any
|
|
|
|
|
|
POSITION_SCALE = 16.0
|
|
MODEL_PREFIXES = ("tankModels/", "airModels/", "aircrafts/")
|
|
|
|
|
|
def strip_model_prefix(unit: Any) -> str:
|
|
"""Return a bare unit CDK from either ``foo`` or ``tankModels/foo``."""
|
|
value = str(unit or "").strip()
|
|
for prefix in MODEL_PREFIXES:
|
|
if value.startswith(prefix):
|
|
return value[len(prefix):]
|
|
return value
|
|
|
|
|
|
def _model_path(unit: Any) -> str:
|
|
value = str(unit or "").strip()
|
|
return value if any(value.startswith(prefix) for prefix in MODEL_PREFIXES) else ""
|
|
|
|
|
|
def _as_number(value: Any) -> float | int | None:
|
|
if isinstance(value, bool):
|
|
return None
|
|
if isinstance(value, (int, float)):
|
|
return value
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _cumulative(values: Any, *, scale: float = 1.0) -> list[float | int]:
|
|
if not isinstance(values, list):
|
|
return []
|
|
total: float | int = 0
|
|
out: list[float | int] = []
|
|
for value in values:
|
|
num = _as_number(value)
|
|
if num is None:
|
|
continue
|
|
total += num
|
|
out.append(total / scale if scale != 1.0 else total)
|
|
return out
|
|
|
|
|
|
def _entity_path_from_columns(entity: dict[str, Any]) -> list[list[float | int]]:
|
|
ts = _cumulative(entity.get("t"))
|
|
xs = _cumulative(entity.get("x"), scale=POSITION_SCALE)
|
|
ys = _cumulative(entity.get("y"), scale=POSITION_SCALE)
|
|
zs = _cumulative(entity.get("z"), scale=POSITION_SCALE)
|
|
count = min(len(ts), len(xs), len(ys), len(zs))
|
|
return [[ts[i], xs[i], ys[i], zs[i]] for i in range(count)]
|
|
|
|
|
|
def _normalize_entities(entities: Any) -> Any:
|
|
if not isinstance(entities, list):
|
|
return entities
|
|
|
|
out: list[Any] = []
|
|
for entity in entities:
|
|
if not isinstance(entity, dict):
|
|
out.append(entity)
|
|
continue
|
|
normalized = dict(entity)
|
|
model = _model_path(normalized.get("unit"))
|
|
if model:
|
|
normalized["model_path"] = model
|
|
normalized["unit"] = strip_model_prefix(model)
|
|
if not isinstance(normalized.get("path"), list):
|
|
path = _entity_path_from_columns(normalized)
|
|
if path:
|
|
normalized["path"] = path
|
|
out.append(normalized)
|
|
return out
|
|
|
|
|
|
def _looks_like_ticket_delta_columns(series: Any) -> bool:
|
|
if not (
|
|
isinstance(series, list)
|
|
and len(series) == 2
|
|
and isinstance(series[0], list)
|
|
and isinstance(series[1], list)
|
|
and len(series[0]) == len(series[1])
|
|
):
|
|
return False
|
|
# Avoid transposing an ambiguous two-row legacy timeline.
|
|
return len(series[0]) > 2
|
|
|
|
|
|
def _normalize_ticket_series(series: Any) -> Any:
|
|
if not _looks_like_ticket_delta_columns(series):
|
|
return series
|
|
ts = _cumulative(series[0])
|
|
vs = _cumulative(series[1])
|
|
return [[ts[i], vs[i]] for i in range(min(len(ts), len(vs)))]
|
|
|
|
|
|
def _normalize_tickets(tickets: Any) -> Any:
|
|
if not isinstance(tickets, dict):
|
|
return tickets
|
|
return {str(slot): _normalize_ticket_series(series) for slot, series in tickets.items()}
|
|
|
|
|
|
def _normalize_event_units(events: Any) -> Any:
|
|
if not isinstance(events, dict):
|
|
return events
|
|
normalized = dict(events)
|
|
for bucket in ("kills", "damage"):
|
|
rows = normalized.get(bucket)
|
|
if not isinstance(rows, list):
|
|
continue
|
|
out_rows: list[Any] = []
|
|
for event in rows:
|
|
if not isinstance(event, dict):
|
|
out_rows.append(event)
|
|
continue
|
|
item = dict(event)
|
|
for field in ("offender_unit", "offended_unit"):
|
|
model = _model_path(item.get(field))
|
|
if model:
|
|
item[f"{field}_model_path"] = model
|
|
item[field] = strip_model_prefix(model)
|
|
for field in ("offender_pos", "offended_pos"):
|
|
pos = item.get(field)
|
|
if isinstance(pos, list) and len(pos) >= 3:
|
|
nums = [_as_number(v) for v in pos[:3]]
|
|
if all(v is not None for v in nums):
|
|
item[field] = [v / POSITION_SCALE for v in nums] # type: ignore[operator]
|
|
out_rows.append(item)
|
|
normalized[bucket] = out_rows
|
|
return normalized
|
|
|
|
|
|
def normalize_spectra_replay(replay: dict[str, Any]) -> dict[str, Any]:
|
|
"""Return a replay compatible with legacy bot consumers.
|
|
|
|
Spectra v3 sends entity paths and tickets as delta columns. The bots still
|
|
consume absolute row timelines, so this expands the compact representation
|
|
while leaving already-legacy payloads unchanged.
|
|
"""
|
|
normalized = dict(replay)
|
|
if "entities" in normalized:
|
|
normalized["entities"] = _normalize_entities(normalized.get("entities"))
|
|
if "tickets" in normalized:
|
|
normalized["tickets"] = _normalize_tickets(normalized.get("tickets"))
|
|
if isinstance(normalized.get("events"), dict):
|
|
normalized["events"] = _normalize_event_units(normalized.get("events"))
|
|
if isinstance(normalized.get("players"), dict):
|
|
players = deepcopy(normalized["players"])
|
|
for player in players.values():
|
|
if not isinstance(player, dict):
|
|
continue
|
|
for unit in player.get("units") or []:
|
|
if isinstance(unit, dict) and unit.get("unit"):
|
|
unit["unit"] = strip_model_prefix(unit.get("unit"))
|
|
normalized["players"] = players
|
|
return normalized
|