Compare commits
10 Commits
2f4ae54bdb
...
cfd2458ab3
| Author | SHA1 | Date | |
|---|---|---|---|
| cfd2458ab3 | |||
| c417226e9e | |||
| 6d251be97c | |||
| fa203c653b | |||
| a5abecb918 | |||
| 43c2837f6d | |||
| 2b792b4d0b | |||
| 24335a2677 | |||
| f2a1a33c28 | |||
| da66722e03 |
@@ -0,0 +1,60 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiofiles
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _env(name: str, default: str = "") -> str:
|
||||||
|
return os.getenv(name, default).strip()
|
||||||
|
|
||||||
|
|
||||||
|
_storage_root_raw = _env("STORAGE_VOL_PATH")
|
||||||
|
if not _storage_root_raw:
|
||||||
|
raise RuntimeError("STORAGE_VOL_PATH must be set")
|
||||||
|
_STORAGE_ROOT = Path(_storage_root_raw)
|
||||||
|
TSS_EXTERNAL_OUTBOX_PATH = Path(
|
||||||
|
_env("TSS_EXTERNAL_OUTBOX_PATH", str(_STORAGE_ROOT / "tss_bridge_outbox.jsonl"))
|
||||||
|
)
|
||||||
|
TSS_EXTERNAL_OUTBOX_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
_LOCK: asyncio.Lock | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _lock() -> asyncio.Lock:
|
||||||
|
global _LOCK
|
||||||
|
if _LOCK is None:
|
||||||
|
_LOCK = asyncio.Lock()
|
||||||
|
return _LOCK
|
||||||
|
|
||||||
|
|
||||||
|
async def _append(envelope: dict[str, Any]) -> None:
|
||||||
|
line = json.dumps(envelope, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
async with _lock():
|
||||||
|
async with aiofiles.open(TSS_EXTERNAL_OUTBOX_PATH, "a", encoding="utf-8") as handle:
|
||||||
|
await handle.write(line + "\n")
|
||||||
|
logger.info("TSS bridge envelope queued type=%s", envelope.get("type"))
|
||||||
|
|
||||||
|
|
||||||
|
async def publish_replay_batch(replays: list[dict[str, Any]]) -> None:
|
||||||
|
if not replays:
|
||||||
|
return
|
||||||
|
await _append({
|
||||||
|
"type": "tss.replay_batch", "version": 1, "source": "tss",
|
||||||
|
"sent_at": time.time(), "payload": {"replays": replays},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async def publish_event(event_type: str, payload: dict[str, Any]) -> None:
|
||||||
|
await _append({
|
||||||
|
"type": event_type, "version": 1, "source": "tss",
|
||||||
|
"sent_at": time.time(), "payload": payload,
|
||||||
|
})
|
||||||
+39
-14
@@ -3575,6 +3575,10 @@ def _unit_to_model_name(unit_name: str) -> str:
|
|||||||
internal = (unit_name or "").strip()
|
internal = (unit_name or "").strip()
|
||||||
if not internal:
|
if not internal:
|
||||||
return "tankModels/unknown"
|
return "tankModels/unknown"
|
||||||
|
if internal.startswith(("tankModels/", "airModels/")):
|
||||||
|
return internal
|
||||||
|
if internal.startswith("aircrafts/"):
|
||||||
|
return f"airModels/{internal.split('/', 1)[1]}"
|
||||||
tags = _get_unit_tags(internal) or []
|
tags = _get_unit_tags(internal) or []
|
||||||
tag_set = set(tags)
|
tag_set = set(tags)
|
||||||
if "type_strike_ucav" in tag_set or "ucav" in internal.lower():
|
if "type_strike_ucav" in tag_set or "ucav" in internal.lower():
|
||||||
@@ -3645,6 +3649,15 @@ def _position_at_time(path: list[dict[str, float]], time_ms: float) -> dict[str,
|
|||||||
return prev
|
return prev
|
||||||
|
|
||||||
|
|
||||||
|
def _event_position_to_dict(pos: Any) -> dict[str, float] | None:
|
||||||
|
if not isinstance(pos, list) or len(pos) < 3:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return {"X": float(pos[0]), "Y": float(pos[1]), "Z": float(pos[2])}
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _find_render_entity_for_event(entities: list[dict[str, Any]],
|
def _find_render_entity_for_event(entities: list[dict[str, Any]],
|
||||||
player_id: int,
|
player_id: int,
|
||||||
event_model: str | None,
|
event_model: str | None,
|
||||||
@@ -3739,7 +3752,7 @@ def _convert_ws_replay_to_render_dict(replay: dict[str, Any]) -> dict[str, Any]:
|
|||||||
if not isinstance(ent, dict):
|
if not isinstance(ent, dict):
|
||||||
continue
|
continue
|
||||||
uid = _to_int(ent.get("uid"), 0)
|
uid = _to_int(ent.get("uid"), 0)
|
||||||
unit = str(ent.get("unit") or "")
|
unit = str(ent.get("model_path") or ent.get("unit") or "")
|
||||||
path_raw = ent.get("path") or []
|
path_raw = ent.get("path") or []
|
||||||
if not isinstance(path_raw, list):
|
if not isinstance(path_raw, list):
|
||||||
continue
|
continue
|
||||||
@@ -3775,12 +3788,18 @@ def _convert_ws_replay_to_render_dict(replay: dict[str, Any]) -> dict[str, Any]:
|
|||||||
victim_id = _to_int(kill.get("offended_uid"), 0)
|
victim_id = _to_int(kill.get("offended_uid"), 0)
|
||||||
killer_id = _to_int(kill.get("offender_uid"), 0)
|
killer_id = _to_int(kill.get("offender_uid"), 0)
|
||||||
kill_time = float(kill.get("time") or 0.0)
|
kill_time = float(kill.get("time") or 0.0)
|
||||||
victim_model = _unit_to_model_name(str(kill.get("offended_unit") or ""))
|
victim_model = _unit_to_model_name(str(kill.get("offended_unit_model_path") or kill.get("offended_unit") or ""))
|
||||||
killer_model = _unit_to_model_name(str(kill.get("offender_unit") or ""))
|
killer_model = _unit_to_model_name(str(kill.get("offender_unit_model_path") or kill.get("offender_unit") or ""))
|
||||||
victim_entity = _find_render_entity_for_event(entities_out, victim_id, victim_model, kill_time)
|
victim_entity = _find_render_entity_for_event(entities_out, victim_id, victim_model, kill_time)
|
||||||
killer_entity = _find_render_entity_for_event(entities_out, killer_id, killer_model, kill_time)
|
killer_entity = _find_render_entity_for_event(entities_out, killer_id, killer_model, kill_time)
|
||||||
victim_pos = _position_at_time(victim_entity.get("Path", []), kill_time) if victim_entity else None
|
victim_pos = (
|
||||||
killer_pos = _position_at_time(killer_entity.get("Path", []), kill_time) if killer_entity else None
|
_position_at_time(victim_entity.get("Path", []), kill_time)
|
||||||
|
if victim_entity else None
|
||||||
|
) or _event_position_to_dict(kill.get("offended_pos"))
|
||||||
|
killer_pos = (
|
||||||
|
_position_at_time(killer_entity.get("Path", []), kill_time)
|
||||||
|
if killer_entity else None
|
||||||
|
) or _event_position_to_dict(kill.get("offender_pos"))
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"Time": kill_time,
|
"Time": kill_time,
|
||||||
"VictimID": victim_id,
|
"VictimID": victim_id,
|
||||||
@@ -3816,8 +3835,8 @@ def _convert_ws_replay_to_render_dict(replay: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"Time": float(dmg.get("time") or 0.0),
|
"Time": float(dmg.get("time") or 0.0),
|
||||||
"OffenderID": _to_int(dmg.get("offender_uid"), 0),
|
"OffenderID": _to_int(dmg.get("offender_uid"), 0),
|
||||||
"OffendedID": _to_int(dmg.get("offended_uid"), 0),
|
"OffendedID": _to_int(dmg.get("offended_uid"), 0),
|
||||||
"OffenderModel": _unit_to_model_name(str(dmg.get("offender_unit") or "")),
|
"OffenderModel": _unit_to_model_name(str(dmg.get("offender_unit_model_path") or dmg.get("offender_unit") or "")),
|
||||||
"OffendedModel": _unit_to_model_name(str(dmg.get("offended_unit") or "")),
|
"OffendedModel": _unit_to_model_name(str(dmg.get("offended_unit_model_path") or dmg.get("offended_unit") or "")),
|
||||||
"Afire": bool(dmg.get("afire", False)),
|
"Afire": bool(dmg.get("afire", False)),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -3905,7 +3924,7 @@ def _convert_local_replay_to_render_dict(replay: dict[str, Any]) -> dict[str, An
|
|||||||
if not isinstance(ent, dict):
|
if not isinstance(ent, dict):
|
||||||
continue
|
continue
|
||||||
uid = _to_int(ent.get("uid"), 0)
|
uid = _to_int(ent.get("uid"), 0)
|
||||||
unit = str(ent.get("unit") or "")
|
unit = str(ent.get("model_path") or ent.get("unit") or "")
|
||||||
path_raw = ent.get("path") or []
|
path_raw = ent.get("path") or []
|
||||||
if not isinstance(path_raw, list):
|
if not isinstance(path_raw, list):
|
||||||
continue
|
continue
|
||||||
@@ -3941,12 +3960,18 @@ def _convert_local_replay_to_render_dict(replay: dict[str, Any]) -> dict[str, An
|
|||||||
victim_id = _to_int(kill.get("offended_uid"), 0)
|
victim_id = _to_int(kill.get("offended_uid"), 0)
|
||||||
killer_id = _to_int(kill.get("offender_uid"), 0)
|
killer_id = _to_int(kill.get("offender_uid"), 0)
|
||||||
kill_time = float(kill.get("time") or 0.0)
|
kill_time = float(kill.get("time") or 0.0)
|
||||||
victim_model = _unit_to_model_name(str(kill.get("offended_unit") or ""))
|
victim_model = _unit_to_model_name(str(kill.get("offended_unit_model_path") or kill.get("offended_unit") or ""))
|
||||||
killer_model = _unit_to_model_name(str(kill.get("offender_unit") or ""))
|
killer_model = _unit_to_model_name(str(kill.get("offender_unit_model_path") or kill.get("offender_unit") or ""))
|
||||||
victim_entity = _find_render_entity_for_event(entities_out, victim_id, victim_model, kill_time)
|
victim_entity = _find_render_entity_for_event(entities_out, victim_id, victim_model, kill_time)
|
||||||
killer_entity = _find_render_entity_for_event(entities_out, killer_id, killer_model, kill_time)
|
killer_entity = _find_render_entity_for_event(entities_out, killer_id, killer_model, kill_time)
|
||||||
victim_pos = _position_at_time(victim_entity.get("Path", []), kill_time) if victim_entity else None
|
victim_pos = (
|
||||||
killer_pos = _position_at_time(killer_entity.get("Path", []), kill_time) if killer_entity else None
|
_position_at_time(victim_entity.get("Path", []), kill_time)
|
||||||
|
if victim_entity else None
|
||||||
|
) or _event_position_to_dict(kill.get("offended_pos"))
|
||||||
|
killer_pos = (
|
||||||
|
_position_at_time(killer_entity.get("Path", []), kill_time)
|
||||||
|
if killer_entity else None
|
||||||
|
) or _event_position_to_dict(kill.get("offender_pos"))
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"Time": kill_time,
|
"Time": kill_time,
|
||||||
"VictimID": victim_id,
|
"VictimID": victim_id,
|
||||||
@@ -3982,8 +4007,8 @@ def _convert_local_replay_to_render_dict(replay: dict[str, Any]) -> dict[str, An
|
|||||||
"Time": float(dmg.get("time") or 0.0),
|
"Time": float(dmg.get("time") or 0.0),
|
||||||
"OffenderID": _to_int(dmg.get("offender_uid"), 0),
|
"OffenderID": _to_int(dmg.get("offender_uid"), 0),
|
||||||
"OffendedID": _to_int(dmg.get("offended_uid"), 0),
|
"OffendedID": _to_int(dmg.get("offended_uid"), 0),
|
||||||
"OffenderModel": _unit_to_model_name(str(dmg.get("offender_unit") or "")),
|
"OffenderModel": _unit_to_model_name(str(dmg.get("offender_unit_model_path") or dmg.get("offender_unit") or "")),
|
||||||
"OffendedModel": _unit_to_model_name(str(dmg.get("offended_unit") or "")),
|
"OffendedModel": _unit_to_model_name(str(dmg.get("offended_unit_model_path") or dmg.get("offended_unit") or "")),
|
||||||
"Afire": bool(dmg.get("afire", False)),
|
"Afire": bool(dmg.get("afire", False)),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+46
-2
@@ -23,8 +23,48 @@ from typing import Any, Dict, List, Optional
|
|||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
|
||||||
|
from spectra_replay_normalize import strip_model_prefix
|
||||||
|
|
||||||
log = logging.getLogger("tssbot.storage")
|
log = logging.getLogger("tssbot.storage")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from data_parser import LangTableReader, apply_vehicle_name_filters # type: ignore
|
||||||
|
except Exception: # pragma: no cover - only when SHARED is unavailable
|
||||||
|
LangTableReader = None # type: ignore
|
||||||
|
|
||||||
|
def apply_vehicle_name_filters(name, strip_decorations=True): # type: ignore
|
||||||
|
return name
|
||||||
|
|
||||||
|
_EN_VEHICLE_TRANSLATOR = None
|
||||||
|
|
||||||
|
|
||||||
|
def _vehicle_display_name(internal: str) -> str:
|
||||||
|
"""Return parser-translated English vehicle name, falling back to internal ID."""
|
||||||
|
if not internal:
|
||||||
|
return ""
|
||||||
|
if LangTableReader is None:
|
||||||
|
return internal
|
||||||
|
global _EN_VEHICLE_TRANSLATOR
|
||||||
|
if _EN_VEHICLE_TRANSLATOR is None:
|
||||||
|
try:
|
||||||
|
_EN_VEHICLE_TRANSLATOR = LangTableReader("English")
|
||||||
|
except Exception as exc: # pragma: no cover
|
||||||
|
log.warning("LangTableReader('English') failed: %s", exc)
|
||||||
|
return internal
|
||||||
|
try:
|
||||||
|
translated = _EN_VEHICLE_TRANSLATOR.get_translate(internal)
|
||||||
|
except Exception:
|
||||||
|
translated = None
|
||||||
|
return apply_vehicle_name_filters(translated) if translated else internal
|
||||||
|
|
||||||
|
|
||||||
|
def _unit_vehicle_fields(unit: dict[str, Any]) -> tuple[str, str] | None:
|
||||||
|
"""Return (display_name, internal_id) for a Spectra unit without using unit_normalized."""
|
||||||
|
internal = strip_model_prefix(unit.get("unit"))
|
||||||
|
if not internal:
|
||||||
|
return None
|
||||||
|
return _vehicle_display_name(internal), internal
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Paths
|
# Paths
|
||||||
@@ -377,14 +417,18 @@ async def insert_player_games(game: Dict[str, Any]) -> None:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
for unit in used_units:
|
for unit in used_units:
|
||||||
|
vehicle_fields = _unit_vehicle_fields(unit)
|
||||||
|
if vehicle_fields is None:
|
||||||
|
continue
|
||||||
|
vehicle, internal = vehicle_fields
|
||||||
rows.append((
|
rows.append((
|
||||||
str(uid_str),
|
str(uid_str),
|
||||||
str(p.get("name") or ""),
|
str(p.get("name") or ""),
|
||||||
str(tss_team.get("team_name") or tss_team.get("name") or ""),
|
str(tss_team.get("team_name") or tss_team.get("name") or ""),
|
||||||
str(p.get("team") or ""), # team_slot ("1" or "2")
|
str(p.get("team") or ""), # team_slot ("1" or "2")
|
||||||
session_id,
|
session_id,
|
||||||
str(unit.get("unit_normalized") or ""),
|
vehicle,
|
||||||
str(unit.get("unit") or ""),
|
internal,
|
||||||
int(p.get("ground_kills") or 0),
|
int(p.get("ground_kills") or 0),
|
||||||
int(p.get("air_kills") or 0),
|
int(p.get("air_kills") or 0),
|
||||||
int(p.get("assists") or 0),
|
int(p.get("assists") or 0),
|
||||||
|
|||||||
+12
-6
@@ -14,6 +14,8 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from spectra_replay_normalize import strip_model_prefix
|
||||||
|
|
||||||
log = logging.getLogger("tssbot.transform")
|
log = logging.getLogger("tssbot.transform")
|
||||||
|
|
||||||
# Imported lazily/defensively so the module is importable (and unit-testable for the
|
# Imported lazily/defensively so the module is importable (and unit-testable for the
|
||||||
@@ -58,19 +60,22 @@ def _build_units(units: list[dict[str, Any]], translate, dead: set[str] | None =
|
|||||||
Prefer an explicit per-unit ``dead``/``died`` flag if Spectra provides one; otherwise
|
Prefer an explicit per-unit ``dead``/``died`` flag if Spectra provides one; otherwise
|
||||||
fall back to the ``dead`` set cross-referenced from ``events.kills`` (see ``_dead_units_by_uid``).
|
fall back to the ``dead`` set cross-referenced from ``events.kills`` (see ``_dead_units_by_uid``).
|
||||||
"""
|
"""
|
||||||
dead = dead or set()
|
dead_lc = {s.lower() for s in (dead or set())}
|
||||||
out: list[dict[str, Any]] = []
|
out: list[dict[str, Any]] = []
|
||||||
for u in units or []:
|
for u in units or []:
|
||||||
internal = str(u.get("unit") or "").strip()
|
internal = strip_model_prefix(u.get("unit"))
|
||||||
if not internal:
|
if not internal:
|
||||||
continue
|
continue
|
||||||
flag = u.get("dead", u.get("died"))
|
flag = u.get("dead", u.get("died"))
|
||||||
is_dead = bool(flag) if flag is not None else internal in dead
|
is_dead = bool(flag) if flag is not None else internal.lower() in dead_lc
|
||||||
out.append({
|
out.append({
|
||||||
"internal": internal,
|
"internal": internal,
|
||||||
"name": translate(internal) or u.get("unit_normalized") or internal,
|
"name": translate(internal) or internal,
|
||||||
"used": bool(u.get("used")),
|
"used": bool(u.get("used")),
|
||||||
"dead": is_dead,
|
"dead": is_dead,
|
||||||
|
"unit_type": u.get("unit_type"),
|
||||||
|
"unit_class": u.get("unit_class"),
|
||||||
|
"unit_country": u.get("unit_country"),
|
||||||
})
|
})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -81,9 +86,9 @@ def _dead_units_by_uid(game: dict[str, Any]) -> dict[str, set[str]]:
|
|||||||
kills = ((game.get("events") or {}).get("kills")) or []
|
kills = ((game.get("events") or {}).get("kills")) or []
|
||||||
for k in kills:
|
for k in kills:
|
||||||
uid = str(k.get("offended_uid") or "")
|
uid = str(k.get("offended_uid") or "")
|
||||||
unit = str(k.get("offended_unit") or "").strip()
|
unit = strip_model_prefix(k.get("offended_unit"))
|
||||||
if uid and unit:
|
if uid and unit:
|
||||||
out.setdefault(uid, set()).add(unit)
|
out.setdefault(uid, set()).add(unit.lower())
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -143,6 +148,7 @@ def build_scoreboard_model(game: dict[str, Any], lang_column: str = "<English>")
|
|||||||
"fake_nick": None,
|
"fake_nick": None,
|
||||||
"tag": str(p.get("tag") or ""),
|
"tag": str(p.get("tag") or ""),
|
||||||
"country_id": p.get("country_id"),
|
"country_id": p.get("country_id"),
|
||||||
|
"country": p.get("country"),
|
||||||
"air_kills": int(p.get("air_kills") or 0),
|
"air_kills": int(p.get("air_kills") or 0),
|
||||||
"ground_kills": int(p.get("ground_kills") or 0),
|
"ground_kills": int(p.get("ground_kills") or 0),
|
||||||
"assists": int(p.get("assists") or 0),
|
"assists": int(p.get("assists") or 0),
|
||||||
|
|||||||
+432
-108
@@ -17,15 +17,17 @@ the captured sample payloads.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from BOT.storage import STORAGE_DIR
|
from BOT.storage import STORAGE_DIR
|
||||||
|
|
||||||
@@ -36,6 +38,7 @@ TSS_TOURNAMENTS_DB_PATH: Path = STORAGE_DIR / "tss_tournaments.db"
|
|||||||
_API_URL = "https://tss.warthunder.com/functions.php"
|
_API_URL = "https://tss.warthunder.com/functions.php"
|
||||||
_API_HEADERS = {"Content-Type": "application/x-www-form-urlencoded"}
|
_API_HEADERS = {"Content-Type": "application/x-www-form-urlencoded"}
|
||||||
_API_TIMEOUT = 20
|
_API_TIMEOUT = 20
|
||||||
|
_DEFAULT_BATTLE_WORKERS = max(1, int(os.environ.get("TSS_TOURNAMENT_BATTLE_WORKERS", "8")))
|
||||||
|
|
||||||
# Re-scan a live tournament at most this often; stop re-scanning this long after
|
# Re-scan a live tournament at most this often; stop re-scanning this long after
|
||||||
# its end time (battle rows land shortly after a game finishes).
|
# its end time (battle rows land shortly after a game finishes).
|
||||||
@@ -187,6 +190,25 @@ def normalize_side(type_bracket: Optional[str]) -> str:
|
|||||||
return t or "match"
|
return t or "match"
|
||||||
|
|
||||||
|
|
||||||
|
def apply_tournament_side_context(matches: List[Dict[str, Any]]) -> None:
|
||||||
|
"""Fix stage labels whose side depends on tournament format.
|
||||||
|
|
||||||
|
In double-elim data, TSS puts ``Semifinal`` under the loser bracket tree. In
|
||||||
|
single-elim data, ``Semifinal`` is just an elimination stage. Use presence of
|
||||||
|
Looser/Loser rows to disambiguate.
|
||||||
|
"""
|
||||||
|
has_loser_side = any(
|
||||||
|
"looser" in str(m.get("type_bracket") or "").lower()
|
||||||
|
or "loser" in str(m.get("type_bracket") or "").lower()
|
||||||
|
for m in matches
|
||||||
|
)
|
||||||
|
if not has_loser_side:
|
||||||
|
return
|
||||||
|
for match in matches:
|
||||||
|
if "semifinal" in str(match.get("type_bracket") or "").lower():
|
||||||
|
match["side"] = "loser"
|
||||||
|
|
||||||
|
|
||||||
def derive_format(
|
def derive_format(
|
||||||
type_brackets: Any, type_tournament: Optional[str] = None
|
type_brackets: Any, type_tournament: Optional[str] = None
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -426,20 +448,28 @@ def compute_status(
|
|||||||
now: int,
|
now: int,
|
||||||
in_active: Optional[bool] = True,
|
in_active: Optional[bool] = True,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Tournament status from match completion + end date + live-list presence.
|
"""Tournament status from match completion + live-list presence + end date.
|
||||||
|
|
||||||
A tournament absent from ``GetActiveTournaments`` (``in_active`` False) is over
|
Presence in ``GetActiveTournaments`` is the authoritative live/over signal:
|
||||||
— this is the reliable finished signal for old tournaments that carry no
|
absence (``in_active`` False) is over, presence (True) is live. ``date_end``
|
||||||
``date_end``, and it stops ``needs_scan`` from re-scanning them forever.
|
is only TSS's *scheduled* end, which tournaments routinely overrun (finals run
|
||||||
|
hours/days late), so it must NOT finish a tournament that is still listed as
|
||||||
|
active — that froze brackets whose final landed after the scheduled end. The
|
||||||
|
``date_end`` heuristic is therefore a fallback used only when the active-list
|
||||||
|
state is unknown (``in_active`` None).
|
||||||
"""
|
"""
|
||||||
if in_active is False:
|
if in_active is False:
|
||||||
return "finished"
|
return "finished"
|
||||||
if not matches:
|
if not matches:
|
||||||
return "pending"
|
return "pending"
|
||||||
if date_end and now > date_end + RESCAN_END_BUFFER_SECONDS:
|
|
||||||
return "finished"
|
|
||||||
if all(m["status"] in ("played", "bye", "technical") for m in matches):
|
if all(m["status"] in ("played", "bye", "technical") for m in matches):
|
||||||
return "finished"
|
return "finished"
|
||||||
|
# Has unplayed matches.
|
||||||
|
if in_active is True:
|
||||||
|
return "active" # still live/upcoming — overrunning the scheduled end is fine
|
||||||
|
# in_active is None: active list unavailable — fall back to scheduled end + buffer.
|
||||||
|
if date_end and now > date_end + RESCAN_END_BUFFER_SECONDS:
|
||||||
|
return "finished"
|
||||||
return "active"
|
return "active"
|
||||||
|
|
||||||
|
|
||||||
@@ -515,18 +545,15 @@ def _active_meta(tournament_id: int, now: int) -> Tuple[Optional[Dict[str, Any]]
|
|||||||
return meta, meta is not None
|
return meta, meta is not None
|
||||||
|
|
||||||
|
|
||||||
def build_scan_sync(
|
def gather_structure_sync(
|
||||||
tournament_id: int,
|
tournament_id: int,
|
||||||
*,
|
*,
|
||||||
fallback_name: Optional[str] = None,
|
fallback_name: Optional[str] = None,
|
||||||
active_meta: Optional[Dict[str, Any]] = None,
|
active_meta: Optional[Dict[str, Any]] = None,
|
||||||
now: Optional[int] = None,
|
now: Optional[int] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Fetch + assemble the full authoritative structure for one tournament.
|
"""Fetch + assemble the bracket structure (matches, standings, meta) for one
|
||||||
|
tournament. Network-bound; call via ``run_in_thread``. No battles fetched."""
|
||||||
Network-bound; call via ``run_in_thread``. Returns a dict of rows ready for
|
|
||||||
``store_scan``.
|
|
||||||
"""
|
|
||||||
now = now or int(time.time())
|
now = now or int(time.time())
|
||||||
if active_meta is None:
|
if active_meta is None:
|
||||||
active_meta, in_active = _active_meta(tournament_id, now)
|
active_meta, in_active = _active_meta(tournament_id, now)
|
||||||
@@ -542,14 +569,12 @@ def build_scan_sync(
|
|||||||
if raw_matches:
|
if raw_matches:
|
||||||
matches = [parse_schedule_match(r, names) for r in raw_matches if isinstance(r, dict)]
|
matches = [parse_schedule_match(r, names) for r in raw_matches if isinstance(r, dict)]
|
||||||
else:
|
else:
|
||||||
# Empty schedule can mean bracket fallback, swiss/group, or pre-start.
|
|
||||||
bracket = _data_dict(_request("POST", "GetArrayBracketData", tournamentID=tournament_id))
|
bracket = _data_dict(_request("POST", "GetArrayBracketData", tournamentID=tournament_id))
|
||||||
bracket_rows = collect_group_rows(bracket.get("bracket") or bracket)
|
bracket_rows = collect_group_rows(bracket.get("bracket") or bracket)
|
||||||
if bracket_rows:
|
if bracket_rows:
|
||||||
matches = [parse_schedule_match(r, names) for r in bracket_rows if isinstance(r, dict)]
|
matches = [parse_schedule_match(r, names) for r in bracket_rows if isinstance(r, dict)]
|
||||||
|
|
||||||
if not matches:
|
if not matches:
|
||||||
# Empty schedule/bracket → swiss/group. Try swiss, then group.
|
|
||||||
for action, kw in (
|
for action, kw in (
|
||||||
("GetArraySwissData", {"tournamentID": tournament_id, "id_group": 1}),
|
("GetArraySwissData", {"tournamentID": tournament_id, "id_group": 1}),
|
||||||
("GetArrayGroupData", {"tournamentID": tournament_id}),
|
("GetArrayGroupData", {"tournamentID": tournament_id}),
|
||||||
@@ -561,21 +586,7 @@ def build_scan_sync(
|
|||||||
standings = parse_standings(data.get("GroupStage"))
|
standings = parse_standings(data.get("GroupStage"))
|
||||||
break
|
break
|
||||||
|
|
||||||
# Battles per match → session links. Dedupe match_ids (same id can repeat
|
apply_tournament_side_context(matches)
|
||||||
# across sources); fetch once per (match_id, type_bracket).
|
|
||||||
battles: List[Dict[str, Any]] = []
|
|
||||||
seen_battle_keys = set()
|
|
||||||
for match in matches:
|
|
||||||
mid, tb = match["match_id"], match["type_bracket"]
|
|
||||||
if not mid or (mid, tb) in seen_battle_keys:
|
|
||||||
continue
|
|
||||||
seen_battle_keys.add((mid, tb))
|
|
||||||
rows = _request("GET", "getListAllBattles", tournamentID=tournament_id, idMatch=mid, typeBracket=tb)
|
|
||||||
fill_names_from_battles(match, rows)
|
|
||||||
match_battles, technical = parse_battles(rows, tournament_id, mid, tb)
|
|
||||||
if technical and match["status"] in ("pending", "bye"):
|
|
||||||
match["status"] = "technical"
|
|
||||||
battles.extend(match_battles)
|
|
||||||
|
|
||||||
type_set = {m["type_bracket"] for m in matches}
|
type_set = {m["type_bracket"] for m in matches}
|
||||||
meta = active_meta or {}
|
meta = active_meta or {}
|
||||||
@@ -596,73 +607,128 @@ def build_scan_sync(
|
|||||||
"status": compute_status(matches, date_end, now, in_active),
|
"status": compute_status(matches, date_end, now, in_active),
|
||||||
"scanned_unix": now,
|
"scanned_unix": now,
|
||||||
"matches": matches,
|
"matches": matches,
|
||||||
"battles": battles,
|
|
||||||
"standings": standings,
|
"standings": standings,
|
||||||
|
"in_active": in_active,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
def fetch_schedule_sync(
|
||||||
# Storage
|
tournament_id: int,
|
||||||
# ---------------------------------------------------------------------------
|
*,
|
||||||
|
fallback_name: Optional[str] = None,
|
||||||
async def store_scan(scan: Dict[str, Any]) -> None:
|
now: Optional[int] = None,
|
||||||
"""Upsert one assembled scan into ``tss_tournaments.db`` transactionally."""
|
) -> Dict[str, Any]:
|
||||||
_store_scan_sync(scan)
|
"""Schedule-only gather (no battles), ready for ``store_schedule``."""
|
||||||
|
scan = gather_structure_sync(tournament_id, fallback_name=fallback_name, now=now)
|
||||||
|
scan["battles"] = []
|
||||||
|
return scan
|
||||||
|
|
||||||
|
|
||||||
def _store_scan_sync(scan: Dict[str, Any]) -> None:
|
def build_scan_sync(
|
||||||
"""Synchronous implementation for ``store_scan``; run off the event loop."""
|
tournament_id: int,
|
||||||
tid = scan["tournament_id"]
|
*,
|
||||||
now = scan["scanned_unix"]
|
fallback_name: Optional[str] = None,
|
||||||
|
active_meta: Optional[Dict[str, Any]] = None,
|
||||||
|
now: Optional[int] = None,
|
||||||
|
battle_workers: int = _DEFAULT_BATTLE_WORKERS,
|
||||||
|
progress: Optional[Callable[[str], None]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Fetch + assemble the full authoritative structure incl. battles."""
|
||||||
|
now = now or int(time.time())
|
||||||
|
scan = gather_structure_sync(
|
||||||
|
tournament_id, fallback_name=fallback_name, active_meta=active_meta, now=now
|
||||||
|
)
|
||||||
|
matches = scan["matches"]
|
||||||
|
|
||||||
|
battle_targets: List[Dict[str, Any]] = []
|
||||||
|
seen_battle_keys = set()
|
||||||
|
for match in matches:
|
||||||
|
mid, tb = match["match_id"], match["type_bracket"]
|
||||||
|
if not mid or (mid, tb) in seen_battle_keys:
|
||||||
|
continue
|
||||||
|
seen_battle_keys.add((mid, tb))
|
||||||
|
battle_targets.append(match)
|
||||||
|
|
||||||
|
if progress:
|
||||||
|
progress(f"{len(matches)} matches; fetching battles for {len(battle_targets)} match rows")
|
||||||
|
battles = fetch_battles_for_matches(
|
||||||
|
tournament_id, battle_targets, workers=max(1, battle_workers), progress=progress
|
||||||
|
)
|
||||||
|
scan["battles"] = battles
|
||||||
|
# Recompute status: fetch_battles_for_matches may have flipped matches to
|
||||||
|
# 'technical', which changes whether the tournament reads finished.
|
||||||
|
scan["status"] = compute_status(matches, scan["date_end"], now, scan["in_active"])
|
||||||
|
return scan
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_battles_for_matches(
|
||||||
|
tournament_id: int,
|
||||||
|
matches: List[Dict[str, Any]],
|
||||||
|
*,
|
||||||
|
workers: int = _DEFAULT_BATTLE_WORKERS,
|
||||||
|
progress: Optional[Callable[[str], None]] = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Fetch getListAllBattles rows for each match concurrently."""
|
||||||
|
if not matches:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def one(match: Dict[str, Any]) -> Tuple[Dict[str, Any], Any, List[Dict[str, Any]], bool]:
|
||||||
|
mid, tb = match["match_id"], match["type_bracket"]
|
||||||
|
rows = _request(
|
||||||
|
"GET",
|
||||||
|
"getListAllBattles",
|
||||||
|
tournamentID=tournament_id,
|
||||||
|
idMatch=mid,
|
||||||
|
typeBracket=tb,
|
||||||
|
)
|
||||||
|
match_battles, technical = parse_battles(rows, tournament_id, mid, tb)
|
||||||
|
return match, rows, match_battles, technical
|
||||||
|
|
||||||
|
battles: List[Dict[str, Any]] = []
|
||||||
|
done = 0
|
||||||
|
total = len(matches)
|
||||||
|
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
|
||||||
|
futures = [pool.submit(one, match) for match in matches]
|
||||||
|
for future in as_completed(futures):
|
||||||
|
match, rows, match_battles, technical = future.result()
|
||||||
|
fill_names_from_battles(match, rows)
|
||||||
|
if technical and match["status"] in ("pending", "bye"):
|
||||||
|
match["status"] = "technical"
|
||||||
|
battles.extend(match_battles)
|
||||||
|
done += 1
|
||||||
|
if progress and (done == total or done % 25 == 0):
|
||||||
|
progress(f"battle lookups {done}/{total}")
|
||||||
|
return battles
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_targets_sync(tid: int) -> List[Dict[str, str]]:
|
||||||
|
"""Matches that should have a replay link but don't yet: played/technical with
|
||||||
|
zero stored battles (a game we missed, or a not-yet-linked technical victory).
|
||||||
|
Byes (empty team slot) are excluded — no battle is expected."""
|
||||||
with _connect() as conn:
|
with _connect() as conn:
|
||||||
conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO tournaments
|
SELECT m.match_id, m.type_bracket
|
||||||
(tournament_id, name, format, game_mode, cluster, date_start,
|
FROM tournament_matches m
|
||||||
date_end, team_count, match_count, status, first_seen_unix, scanned_unix)
|
WHERE m.tournament_id = ?
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
AND m.status IN ('played', 'technical')
|
||||||
ON CONFLICT(tournament_id) DO UPDATE SET
|
AND NOT EXISTS (
|
||||||
name=COALESCE(excluded.name, tournaments.name),
|
SELECT 1 FROM tournament_battles b
|
||||||
format=excluded.format,
|
WHERE b.tournament_id = m.tournament_id
|
||||||
game_mode=COALESCE(excluded.game_mode, tournaments.game_mode),
|
AND b.match_id = m.match_id
|
||||||
cluster=COALESCE(excluded.cluster, tournaments.cluster),
|
AND b.type_bracket = m.type_bracket
|
||||||
date_start=COALESCE(excluded.date_start, tournaments.date_start),
|
)
|
||||||
date_end=COALESCE(excluded.date_end, tournaments.date_end),
|
|
||||||
team_count=excluded.team_count,
|
|
||||||
match_count=excluded.match_count,
|
|
||||||
status=excluded.status,
|
|
||||||
scanned_unix=excluded.scanned_unix
|
|
||||||
""",
|
""",
|
||||||
(
|
(tid,),
|
||||||
tid, scan.get("name"), scan.get("format"), scan.get("game_mode"),
|
).fetchall()
|
||||||
scan.get("cluster"), scan.get("date_start"), scan.get("date_end"),
|
return [{"match_id": r[0], "type_bracket": r[1]} for r in rows]
|
||||||
scan.get("team_count", 0), scan.get("match_count", 0),
|
|
||||||
scan.get("status", "pending"), now, now,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
# Replace child rows for this tournament (authoritative snapshot).
|
|
||||||
conn.execute("DELETE FROM tournament_matches WHERE tournament_id = ?", (tid,))
|
|
||||||
conn.execute("DELETE FROM tournament_battles WHERE tournament_id = ?", (tid,))
|
|
||||||
conn.execute("DELETE FROM tournament_standings WHERE tournament_id = ?", (tid,))
|
|
||||||
|
|
||||||
conn.executemany(
|
|
||||||
"""
|
def _insert_battles_sync(battles: List[Dict[str, Any]]) -> None:
|
||||||
INSERT OR REPLACE INTO tournament_matches
|
"""Upsert battle rows without deleting anything (preserves fast-path links)."""
|
||||||
(tournament_id, match_id, type_bracket, side, round, position,
|
if not battles:
|
||||||
team_a_uuid, team_a_name, team_b_uuid, team_b_name, winner_name,
|
return
|
||||||
score_a, score_b, status, time_start)
|
with _connect() as conn:
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
||||||
""",
|
|
||||||
[
|
|
||||||
(
|
|
||||||
tid, m["match_id"], m["type_bracket"], m["side"], m["round"],
|
|
||||||
m["position"], m["team_a_uuid"], m["team_a_name"], m["team_b_uuid"],
|
|
||||||
m["team_b_name"], m["winner_name"], m["score_a"], m["score_b"],
|
|
||||||
m["status"], m["time_start"],
|
|
||||||
)
|
|
||||||
for m in scan["matches"]
|
|
||||||
],
|
|
||||||
)
|
|
||||||
conn.executemany(
|
conn.executemany(
|
||||||
"""
|
"""
|
||||||
INSERT OR REPLACE INTO tournament_battles
|
INSERT OR REPLACE INTO tournament_battles
|
||||||
@@ -676,39 +742,187 @@ def _store_scan_sync(scan: Dict[str, Any]) -> None:
|
|||||||
b["match_id"], b["type_bracket"], b["position"],
|
b["match_id"], b["type_bracket"], b["position"],
|
||||||
b["winner_name"], b["status_replay"],
|
b["winner_name"], b["status_replay"],
|
||||||
)
|
)
|
||||||
for b in scan["battles"]
|
for b in battles
|
||||||
],
|
|
||||||
)
|
|
||||||
conn.executemany(
|
|
||||||
"""
|
|
||||||
INSERT OR REPLACE INTO tournament_standings
|
|
||||||
(tournament_id, group_index, team_uuid, team_name, points,
|
|
||||||
wins, draws, losses, buchholz, rank)
|
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?)
|
|
||||||
""",
|
|
||||||
[
|
|
||||||
(
|
|
||||||
tid, s["group_index"], s["team_uuid"], s["team_name"],
|
|
||||||
s["points"], s["wins"], s["draws"], s["losses"],
|
|
||||||
s["buchholz"], s["rank"],
|
|
||||||
)
|
|
||||||
for s in scan["standings"]
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def reconcile_battles(
|
||||||
|
tid: int,
|
||||||
|
*,
|
||||||
|
now: Optional[int] = None,
|
||||||
|
force: bool = False,
|
||||||
|
workers: int = _DEFAULT_BATTLE_WORKERS,
|
||||||
|
) -> int:
|
||||||
|
"""Fill replay links for played matches that have no stored session. Calls
|
||||||
|
getListAllBattles ONLY for those gap matches. Rate-limited unless ``force``."""
|
||||||
|
now = now or int(time.time())
|
||||||
|
if not force and (_reconcile_last.get(tid, 0) + _RECONCILE_INTERVAL_SECONDS > now):
|
||||||
|
return 0
|
||||||
|
targets = await run_in_thread(reconcile_targets_sync, tid)
|
||||||
|
if not targets:
|
||||||
|
_reconcile_last[tid] = now
|
||||||
|
return 0
|
||||||
|
battles = await run_in_thread(fetch_battles_for_matches, tid, targets, workers=workers)
|
||||||
|
await run_in_thread(_insert_battles_sync, battles)
|
||||||
|
_reconcile_last[tid] = now
|
||||||
|
return len(battles)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Storage
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def store_scan(scan: Dict[str, Any]) -> None:
|
||||||
|
"""Upsert one assembled scan into ``tss_tournaments.db`` transactionally."""
|
||||||
|
await run_in_thread(_store_scan_sync, scan)
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_tournament_meta(conn: sqlite3.Connection, scan: Dict[str, Any]) -> None:
|
||||||
|
tid = scan["tournament_id"]
|
||||||
|
now = scan["scanned_unix"]
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO tournaments
|
||||||
|
(tournament_id, name, format, game_mode, cluster, date_start,
|
||||||
|
date_end, team_count, match_count, status, first_seen_unix, scanned_unix)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(tournament_id) DO UPDATE SET
|
||||||
|
name=COALESCE(excluded.name, tournaments.name),
|
||||||
|
format=excluded.format,
|
||||||
|
game_mode=COALESCE(excluded.game_mode, tournaments.game_mode),
|
||||||
|
cluster=COALESCE(excluded.cluster, tournaments.cluster),
|
||||||
|
date_start=COALESCE(excluded.date_start, tournaments.date_start),
|
||||||
|
date_end=COALESCE(excluded.date_end, tournaments.date_end),
|
||||||
|
team_count=excluded.team_count,
|
||||||
|
match_count=excluded.match_count,
|
||||||
|
status=excluded.status,
|
||||||
|
scanned_unix=excluded.scanned_unix
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
tid, scan.get("name"), scan.get("format"), scan.get("game_mode"),
|
||||||
|
scan.get("cluster"), scan.get("date_start"), scan.get("date_end"),
|
||||||
|
scan.get("team_count", 0), scan.get("match_count", 0),
|
||||||
|
scan.get("status", "pending"), now, now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_matches(conn: sqlite3.Connection, tid: int, matches: List[Dict[str, Any]]) -> None:
|
||||||
|
conn.execute("DELETE FROM tournament_matches WHERE tournament_id = ?", (tid,))
|
||||||
|
conn.executemany(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO tournament_matches
|
||||||
|
(tournament_id, match_id, type_bracket, side, round, position,
|
||||||
|
team_a_uuid, team_a_name, team_b_uuid, team_b_name, winner_name,
|
||||||
|
score_a, score_b, status, time_start)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
tid, m["match_id"], m["type_bracket"], m["side"], m["round"],
|
||||||
|
m["position"], m["team_a_uuid"], m["team_a_name"], m["team_b_uuid"],
|
||||||
|
m["team_b_name"], m["winner_name"], m["score_a"], m["score_b"],
|
||||||
|
m["status"], m["time_start"],
|
||||||
|
)
|
||||||
|
for m in matches
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_standings(conn: sqlite3.Connection, tid: int, standings: List[Dict[str, Any]]) -> None:
|
||||||
|
conn.execute("DELETE FROM tournament_standings WHERE tournament_id = ?", (tid,))
|
||||||
|
conn.executemany(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO tournament_standings
|
||||||
|
(tournament_id, group_index, team_uuid, team_name, points,
|
||||||
|
wins, draws, losses, buchholz, rank)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
tid, s["group_index"], s["team_uuid"], s["team_name"],
|
||||||
|
s["points"], s["wins"], s["draws"], s["losses"],
|
||||||
|
s["buchholz"], s["rank"],
|
||||||
|
)
|
||||||
|
for s in standings
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_battles(conn: sqlite3.Connection, tid: int, battles: List[Dict[str, Any]]) -> None:
|
||||||
|
# Authoritative full-scan replace. NOTE: this briefly drops any fast-path
|
||||||
|
# battle link (maybe_link_battle) for a session TSS has not yet listed in
|
||||||
|
# getListAllBattles; such a link is refilled by a later reconcile or scan.
|
||||||
|
# The debounced schedule refresh deliberately does NOT call this.
|
||||||
|
conn.execute("DELETE FROM tournament_battles WHERE tournament_id = ?", (tid,))
|
||||||
|
conn.executemany(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO tournament_battles
|
||||||
|
(session_hex, session_decimal, tournament_id, match_id,
|
||||||
|
type_bracket, position, winner_name, status_replay)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?)
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
b["session_hex"], b["session_decimal"], b["tournament_id"],
|
||||||
|
b["match_id"], b["type_bracket"], b["position"],
|
||||||
|
b["winner_name"], b["status_replay"],
|
||||||
|
)
|
||||||
|
for b in battles
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _store_scan_sync(scan: Dict[str, Any]) -> None:
|
||||||
|
"""Synchronous implementation for ``store_scan``; run off the event loop."""
|
||||||
|
tid = scan["tournament_id"]
|
||||||
|
with _connect() as conn:
|
||||||
|
_upsert_tournament_meta(conn, scan)
|
||||||
|
_replace_matches(conn, tid, scan["matches"])
|
||||||
|
_replace_battles(conn, tid, scan["battles"])
|
||||||
|
_replace_standings(conn, tid, scan["standings"])
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def store_schedule_sync(scan: Dict[str, Any]) -> None:
|
||||||
|
"""Persist a schedule-only refresh: meta + matches + standings.
|
||||||
|
|
||||||
|
Deliberately does NOT touch ``tournament_battles`` — those rows are
|
||||||
|
maintained incrementally by the fast-path linker and the reconcile, and a
|
||||||
|
schedule refresh that wiped them would drop every replay link.
|
||||||
|
"""
|
||||||
|
tid = scan["tournament_id"]
|
||||||
|
with _connect() as conn:
|
||||||
|
_upsert_tournament_meta(conn, scan)
|
||||||
|
_replace_matches(conn, tid, scan["matches"])
|
||||||
|
_replace_standings(conn, tid, scan["standings"])
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def store_schedule(scan: Dict[str, Any]) -> None:
|
||||||
|
"""Async wrapper for ``store_schedule_sync``."""
|
||||||
|
await run_in_thread(store_schedule_sync, scan)
|
||||||
|
|
||||||
|
|
||||||
async def needs_scan(tournament_id: int, *, now: Optional[int] = None) -> bool:
|
async def needs_scan(tournament_id: int, *, now: Optional[int] = None) -> bool:
|
||||||
"""True if we've never scanned this tournament, or it's live and stale."""
|
"""True if we've never scanned this tournament, or it's live and stale.
|
||||||
|
|
||||||
|
The only permanent stop is ``status == 'finished'`` (which ``compute_status``
|
||||||
|
sets reliably once the tournament drops out of ``GetActiveTournaments``). We
|
||||||
|
deliberately do NOT gate on ``date_end``: it is only the *scheduled* end, and
|
||||||
|
scans are game-triggered — a game arriving for a tournament is itself evidence
|
||||||
|
it is still live, even days past that scheduled end. Gating on ``date_end``
|
||||||
|
froze brackets whose final was played late. Re-scans stay interval-limited.
|
||||||
|
"""
|
||||||
now = now or int(time.time())
|
now = now or int(time.time())
|
||||||
row = _scan_state_sync(tournament_id)
|
row = _scan_state_sync(tournament_id)
|
||||||
if row is None:
|
if row is None:
|
||||||
return True
|
return True
|
||||||
status, date_end, scanned_unix = row
|
status, _date_end, scanned_unix = row
|
||||||
if status == "finished":
|
if status == "finished":
|
||||||
return False
|
return False
|
||||||
if date_end and now > date_end + RESCAN_END_BUFFER_SECONDS:
|
|
||||||
return False
|
|
||||||
return (scanned_unix or 0) + RESCAN_INTERVAL_SECONDS <= now
|
return (scanned_unix or 0) + RESCAN_INTERVAL_SECONDS <= now
|
||||||
|
|
||||||
|
|
||||||
@@ -723,6 +937,11 @@ def _scan_state_sync(tournament_id: int) -> Optional[Tuple[str, Optional[int], O
|
|||||||
_db_ready = False
|
_db_ready = False
|
||||||
_inflight: set = set()
|
_inflight: set = set()
|
||||||
_tasks: set = set()
|
_tasks: set = set()
|
||||||
|
_RECONCILE_INTERVAL_SECONDS = 600
|
||||||
|
_reconcile_last: Dict[int, int] = {}
|
||||||
|
_REFRESH_DEBOUNCE_SECONDS = 30
|
||||||
|
_refresh_tasks: Dict[int, "asyncio.Task"] = {}
|
||||||
|
_refresh_names: Dict[int, Optional[str]] = {}
|
||||||
|
|
||||||
|
|
||||||
async def _ensure_db() -> None:
|
async def _ensure_db() -> None:
|
||||||
@@ -732,6 +951,83 @@ async def _ensure_db() -> None:
|
|||||||
_db_ready = True
|
_db_ready = True
|
||||||
|
|
||||||
|
|
||||||
|
def link_battle_sync(
|
||||||
|
tid: int,
|
||||||
|
match_id: str,
|
||||||
|
session_hex: str,
|
||||||
|
*,
|
||||||
|
session_decimal: Optional[str] = None,
|
||||||
|
winner_name: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Link a captured replay to its match using the game's own ``match_id``.
|
||||||
|
|
||||||
|
Returns True if the match exists locally (battle inserted), False otherwise
|
||||||
|
so the caller can fall back to a full scan. No network access.
|
||||||
|
"""
|
||||||
|
with _connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT type_bracket, status "
|
||||||
|
"FROM tournament_matches WHERE tournament_id = ? AND match_id = ?",
|
||||||
|
(tid, match_id),
|
||||||
|
).fetchall()
|
||||||
|
if not rows:
|
||||||
|
return False
|
||||||
|
# Prefer a still-pending row when a match_id spans >1 type_bracket (rare).
|
||||||
|
chosen = next((r for r in rows if r[1] == "pending"), rows[0])
|
||||||
|
type_bracket = chosen[0]
|
||||||
|
if session_decimal is None:
|
||||||
|
try:
|
||||||
|
session_decimal = str(int(session_hex, 16))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
session_decimal = None
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO tournament_battles
|
||||||
|
(session_hex, session_decimal, tournament_id, match_id,
|
||||||
|
type_bracket, position, winner_name, status_replay)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?)
|
||||||
|
""",
|
||||||
|
(session_hex, session_decimal, tid, match_id, type_bracket, None,
|
||||||
|
winner_name, "view replay"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def maybe_link_battle(game: Dict[str, Any]) -> None:
|
||||||
|
"""Trigger entry point for each received game: link its replay to the bracket
|
||||||
|
slot from ``tss.match_id`` (zero TSS calls) and request a debounced schedule
|
||||||
|
refresh. Falls back to a full scan when the match is not yet indexed."""
|
||||||
|
tss = game.get("tss") or {}
|
||||||
|
raw_tid = tss.get("tournament_id")
|
||||||
|
match_id = tss.get("match_id")
|
||||||
|
try:
|
||||||
|
tid = int(raw_tid)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return
|
||||||
|
if tid <= 0:
|
||||||
|
return
|
||||||
|
await _ensure_db()
|
||||||
|
|
||||||
|
session_hex = str(game.get("_id") or "")
|
||||||
|
name = tss.get("tournament_name") or None
|
||||||
|
winner_slot = str(game.get("winner") or "")
|
||||||
|
winner_name = None
|
||||||
|
slot = tss.get(winner_slot) if winner_slot in ("1", "2") else None
|
||||||
|
if isinstance(slot, dict):
|
||||||
|
winner_name = slot.get("name")
|
||||||
|
|
||||||
|
if match_id and session_hex:
|
||||||
|
linked = await run_in_thread(
|
||||||
|
link_battle_sync, tid, str(match_id), session_hex, winner_name=winner_name
|
||||||
|
)
|
||||||
|
if linked:
|
||||||
|
request_schedule_refresh(tid, fallback_name=name)
|
||||||
|
return
|
||||||
|
# Unknown match (or missing match_id): fall back to the existing full scan.
|
||||||
|
await maybe_scan_tournament(game)
|
||||||
|
|
||||||
|
|
||||||
async def maybe_scan_tournament(game: Dict[str, Any]) -> None:
|
async def maybe_scan_tournament(game: Dict[str, Any]) -> None:
|
||||||
"""Trigger entry point: schedule a background scan if this game's tournament
|
"""Trigger entry point: schedule a background scan if this game's tournament
|
||||||
is new or live-and-stale. Non-blocking and deduped per tournament id."""
|
is new or live-and-stale. Non-blocking and deduped per tournament id."""
|
||||||
@@ -777,6 +1073,34 @@ async def scan_and_store(
|
|||||||
log.error("tournament scan failed for %s: %s", tournament_id, exc)
|
log.error("tournament scan failed for %s: %s", tournament_id, exc)
|
||||||
|
|
||||||
|
|
||||||
|
def request_schedule_refresh(tid: int, *, fallback_name: Optional[str] = None) -> None:
|
||||||
|
"""Schedule a debounced schedule-only refresh for ``tid``. Repeated calls
|
||||||
|
within the debounce window collapse into the single pending refresh."""
|
||||||
|
if fallback_name and not _refresh_names.get(tid):
|
||||||
|
_refresh_names[tid] = fallback_name
|
||||||
|
if tid in _refresh_tasks and not _refresh_tasks[tid].done():
|
||||||
|
return # a refresh is already pending; this game folds into it
|
||||||
|
task = asyncio.create_task(_run_schedule_refresh(tid))
|
||||||
|
_refresh_tasks[tid] = task
|
||||||
|
_tasks.add(task)
|
||||||
|
task.add_done_callback(_tasks.discard)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_schedule_refresh(tid: int) -> None:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(_REFRESH_DEBOUNCE_SECONDS)
|
||||||
|
name = _refresh_names.pop(tid, None)
|
||||||
|
scan = await run_in_thread(fetch_schedule_sync, tid, fallback_name=name)
|
||||||
|
await store_schedule(scan)
|
||||||
|
await reconcile_battles(tid, force=(scan["status"] == "finished"))
|
||||||
|
log.info("schedule refresh %s: %d matches (%s)",
|
||||||
|
tid, scan["match_count"], scan["status"])
|
||||||
|
except Exception as exc: # pragma: no cover - network/transient
|
||||||
|
log.error("schedule refresh failed for %s: %s", tid, exc)
|
||||||
|
finally:
|
||||||
|
_refresh_tasks.pop(tid, None)
|
||||||
|
|
||||||
|
|
||||||
async def run_in_thread(fn, *args, **kwargs):
|
async def run_in_thread(fn, *args, **kwargs):
|
||||||
"""Run a blocking callable without relying on asyncio's default executor."""
|
"""Run a blocking callable without relying on asyncio's default executor."""
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
|
|||||||
@@ -10,11 +10,21 @@ const DEPLOY_PATH = __dirname;
|
|||||||
// SHARED/requirements.txt (both bots also share BOTS/SHARED for code).
|
// SHARED/requirements.txt (both bots also share BOTS/SHARED for code).
|
||||||
const PY_INTERPRETER = `${DEPLOY_PATH}/../SHARED/.venv/bin/python`;
|
const PY_INTERPRETER = `${DEPLOY_PATH}/../SHARED/.venv/bin/python`;
|
||||||
|
|
||||||
|
// Crash-loop governor: after max_restarts attempts that each fail to stay up
|
||||||
|
// min_uptime ms, PM2 marks the app `errored` and stops relaunching it, instead
|
||||||
|
// of restarting forever and pegging the CPU. See SREBOT/ecosystem.config.js.
|
||||||
|
const RESTART_POLICY = {
|
||||||
|
max_restarts: 10,
|
||||||
|
min_uptime: 10000,
|
||||||
|
exp_backoff_restart_delay: 200,
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
apps: [
|
apps: [
|
||||||
// Discord Bot
|
// Discord Bot
|
||||||
{
|
{
|
||||||
name: 'tssbot',
|
name: 'tssbot',
|
||||||
|
...RESTART_POLICY,
|
||||||
script: 'start_bot.py',
|
script: 'start_bot.py',
|
||||||
interpreter: PY_INTERPRETER,
|
interpreter: PY_INTERPRETER,
|
||||||
cwd: DEPLOY_PATH,
|
cwd: DEPLOY_PATH,
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
@@ -8,6 +8,7 @@ import asyncio
|
|||||||
import pathlib
|
import pathlib
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
from typing import List, Optional, Tuple
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
@@ -23,7 +24,12 @@ except Exception:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
from BOT.storage import TSS_BATTLES_DB_PATH # noqa: E402
|
from BOT.storage import TSS_BATTLES_DB_PATH # noqa: E402
|
||||||
from BOT.tss_tournaments import init_tss_tournaments_db, scan_and_store # noqa: E402
|
from BOT.tss_tournaments import ( # noqa: E402
|
||||||
|
TSS_TOURNAMENTS_DB_PATH,
|
||||||
|
build_scan_sync,
|
||||||
|
init_tss_tournaments_db,
|
||||||
|
store_scan,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def tournament_ids(limit: Optional[int]) -> List[Tuple[int, Optional[str]]]:
|
async def tournament_ids(limit: Optional[int]) -> List[Tuple[int, Optional[str]]]:
|
||||||
@@ -45,24 +51,59 @@ async def tournament_ids(limit: Optional[int]) -> List[Tuple[int, Optional[str]]
|
|||||||
return [(int(row[0]), row[1]) for row in rows]
|
return [(int(row[0]), row[1]) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def scanned_tournament_ids() -> set[int]:
|
||||||
|
if not TSS_TOURNAMENTS_DB_PATH.exists():
|
||||||
|
return set()
|
||||||
|
with sqlite3.connect(TSS_TOURNAMENTS_DB_PATH) as conn:
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT tournament_id FROM tournaments WHERE scanned_unix IS NOT NULL"
|
||||||
|
).fetchall()
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
return set()
|
||||||
|
return {int(row[0]) for row in rows}
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("--dry-run", action="store_true")
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
parser.add_argument("--limit", type=int, default=None)
|
parser.add_argument("--limit", type=int, default=None)
|
||||||
parser.add_argument("--sleep", type=float, default=1.0)
|
parser.add_argument("--sleep", type=float, default=1.0)
|
||||||
|
parser.add_argument("--battle-workers", type=int, default=8)
|
||||||
|
parser.add_argument("--rescan", action="store_true", help="rescan tournaments already present in tss_tournaments.db")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
rows = await tournament_ids(args.limit)
|
rows = await tournament_ids(args.limit)
|
||||||
print(f"Found {len(rows)} tournament ids in {TSS_BATTLES_DB_PATH}")
|
print(f"Found {len(rows)} tournament ids in {TSS_BATTLES_DB_PATH}")
|
||||||
|
await init_tss_tournaments_db()
|
||||||
|
if not args.rescan:
|
||||||
|
done = scanned_tournament_ids()
|
||||||
|
if done:
|
||||||
|
before = len(rows)
|
||||||
|
rows = [(tid, name) for tid, name in rows if tid not in done]
|
||||||
|
print(f"Skipping {before - len(rows)} already-scanned tournaments ({len(rows)} remaining)")
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
for tid, name in rows:
|
for tid, name in rows:
|
||||||
print(f" {tid}: {name or 'Tournament ' + str(tid)}")
|
print(f" {tid}: {name or 'Tournament ' + str(tid)}")
|
||||||
return
|
return
|
||||||
|
|
||||||
await init_tss_tournaments_db()
|
|
||||||
for index, (tid, name) in enumerate(rows, start=1):
|
for index, (tid, name) in enumerate(rows, start=1):
|
||||||
print(f"[{index}/{len(rows)}] scanning tournament {tid}")
|
started = time.monotonic()
|
||||||
await scan_and_store(tid, fallback_name=name)
|
print(f"[{index}/{len(rows)}] scanning tournament {tid}", flush=True)
|
||||||
|
scan = build_scan_sync(
|
||||||
|
tid,
|
||||||
|
fallback_name=name,
|
||||||
|
battle_workers=args.battle_workers,
|
||||||
|
progress=lambda msg, tid=tid: print(f" {tid}: {msg}", flush=True),
|
||||||
|
)
|
||||||
|
await store_scan(scan)
|
||||||
|
elapsed = time.monotonic() - started
|
||||||
|
print(
|
||||||
|
f" stored {tid}: {scan['match_count']} matches, "
|
||||||
|
f"{len(scan['battles'])} battles, {len(scan['standings'])} standings "
|
||||||
|
f"({scan['status']}) in {elapsed:.1f}s",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
if args.sleep and index < len(rows):
|
if args.sleep and index < len(rows):
|
||||||
await asyncio.sleep(args.sleep)
|
await asyncio.sleep(args.sleep)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
"""Diagnose why a tournament bracket is "stuck" on the website.
|
||||||
|
|
||||||
|
The website reads tss_tournaments.db live, so a bracket that never updates means
|
||||||
|
the *scanner* stopped writing fresh rows for that tournament. This script proves
|
||||||
|
where the freeze is by comparing, for each tournament:
|
||||||
|
|
||||||
|
- what is STORED in tss_tournaments.db (and how stale it is),
|
||||||
|
- whether needs_scan() will ever re-scan it (and why not),
|
||||||
|
- what a FRESH live scan from the TSS API would produce right now,
|
||||||
|
- the diff (new battles / changed match statuses the DB is missing).
|
||||||
|
|
||||||
|
If the live scan has battles/matches the DB lacks AND needs_scan() is False, the
|
||||||
|
freeze is in the scanner (a prematurely-stored "finished" status). If the live
|
||||||
|
scan matches the DB, the data source is current and the staleness is downstream
|
||||||
|
(web cache / frontend not refreshing).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# Auto-detect frozen tournaments: games arrived after the last scan.
|
||||||
|
python TSSBOT/scripts/diagnose_tournament_freeze.py
|
||||||
|
|
||||||
|
# Diagnose specific tournament ids.
|
||||||
|
python TSSBOT/scripts/diagnose_tournament_freeze.py 25003 25034
|
||||||
|
|
||||||
|
# Tune how many auto-detected tournaments to deep-scan (default 5).
|
||||||
|
python TSSBOT/scripts/diagnose_tournament_freeze.py --top 10
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import pathlib
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
sys.path.insert(0, str(ROOT.parent / "SHARED"))
|
||||||
|
|
||||||
|
# Match start_bot.py/tss_ws.py: load TSSBOT/.env before importing storage, which
|
||||||
|
# resolves STORAGE_VOL_PATH at import time.
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv(dotenv_path=ROOT / ".env")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
from BOT.storage import TSS_BATTLES_DB_PATH # noqa: E402
|
||||||
|
from BOT.tss_tournaments import ( # noqa: E402
|
||||||
|
TSS_TOURNAMENTS_DB_PATH,
|
||||||
|
RESCAN_INTERVAL_SECONDS,
|
||||||
|
RESCAN_END_BUFFER_SECONDS,
|
||||||
|
build_scan_sync,
|
||||||
|
needs_scan,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _age(unix: Optional[int], now: int) -> str:
|
||||||
|
if not unix:
|
||||||
|
return "never"
|
||||||
|
secs = max(0, now - int(unix))
|
||||||
|
if secs < 90:
|
||||||
|
return f"{secs}s ago"
|
||||||
|
if secs < 5400:
|
||||||
|
return f"{secs // 60}m ago"
|
||||||
|
if secs < 172800:
|
||||||
|
return f"{secs // 3600}h ago"
|
||||||
|
return f"{secs // 86400}d ago"
|
||||||
|
|
||||||
|
|
||||||
|
def stored_state(tid: int) -> Optional[Dict]:
|
||||||
|
"""Stored tournaments row + child-row counts, or None if never scanned."""
|
||||||
|
if not TSS_TOURNAMENTS_DB_PATH.exists():
|
||||||
|
return None
|
||||||
|
with sqlite3.connect(f"file:{TSS_TOURNAMENTS_DB_PATH}?mode=ro", uri=True) as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT name, status, date_end, scanned_unix, match_count "
|
||||||
|
"FROM tournaments WHERE tournament_id = ?",
|
||||||
|
(tid,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
status_counts = dict(
|
||||||
|
conn.execute(
|
||||||
|
"SELECT status, COUNT(*) FROM tournament_matches "
|
||||||
|
"WHERE tournament_id = ? GROUP BY status",
|
||||||
|
(tid,),
|
||||||
|
).fetchall()
|
||||||
|
)
|
||||||
|
battle_count = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM tournament_battles WHERE tournament_id = ?",
|
||||||
|
(tid,),
|
||||||
|
).fetchone()[0]
|
||||||
|
battle_hexes = {
|
||||||
|
r[0]
|
||||||
|
for r in conn.execute(
|
||||||
|
"SELECT session_hex FROM tournament_battles WHERE tournament_id = ?",
|
||||||
|
(tid,),
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"name": row[0],
|
||||||
|
"status": row[1],
|
||||||
|
"date_end": row[2],
|
||||||
|
"scanned_unix": row[3],
|
||||||
|
"match_count": row[4],
|
||||||
|
"status_counts": status_counts,
|
||||||
|
"battle_count": battle_count,
|
||||||
|
"battle_hexes": battle_hexes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def newest_game_unix(tid: int) -> Optional[int]:
|
||||||
|
"""endtime_unix of the most recent game we ingested for this tournament."""
|
||||||
|
with sqlite3.connect(f"file:{TSS_BATTLES_DB_PATH}?mode=ro", uri=True) as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT MAX(endtime_unix) FROM match_summary WHERE tournament_id = ?",
|
||||||
|
(tid,),
|
||||||
|
).fetchone()
|
||||||
|
return int(row[0]) if row and row[0] else None
|
||||||
|
|
||||||
|
|
||||||
|
def frozen_candidates() -> List[Tuple[int, Optional[str], int, Optional[int]]]:
|
||||||
|
"""Tournaments with a game ingested AFTER their last scan — the freeze tell.
|
||||||
|
|
||||||
|
Returns (tid, name, newest_game_unix, scanned_unix) sorted by newest game.
|
||||||
|
"""
|
||||||
|
games = {}
|
||||||
|
names = {}
|
||||||
|
with sqlite3.connect(f"file:{TSS_BATTLES_DB_PATH}?mode=ro", uri=True) as conn:
|
||||||
|
for tid, newest, name in conn.execute(
|
||||||
|
"SELECT tournament_id, MAX(endtime_unix), NULLIF(MAX(tournament_name), '') "
|
||||||
|
"FROM match_summary WHERE tournament_id IS NOT NULL AND tournament_id > 0 "
|
||||||
|
"GROUP BY tournament_id"
|
||||||
|
).fetchall():
|
||||||
|
games[int(tid)] = int(newest) if newest else 0
|
||||||
|
names[int(tid)] = name
|
||||||
|
|
||||||
|
scanned: Dict[int, int] = {}
|
||||||
|
if TSS_TOURNAMENTS_DB_PATH.exists():
|
||||||
|
with sqlite3.connect(f"file:{TSS_TOURNAMENTS_DB_PATH}?mode=ro", uri=True) as conn:
|
||||||
|
for tid, sunix in conn.execute(
|
||||||
|
"SELECT tournament_id, scanned_unix FROM tournaments"
|
||||||
|
).fetchall():
|
||||||
|
scanned[int(tid)] = int(sunix) if sunix else 0
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for tid, newest in games.items():
|
||||||
|
sunix = scanned.get(tid, 0)
|
||||||
|
# A game landed after the last scan → the bracket on the site is stale.
|
||||||
|
if newest and newest > sunix:
|
||||||
|
out.append((tid, names.get(tid), newest, scanned.get(tid)))
|
||||||
|
out.sort(key=lambda r: r[2], reverse=True)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def diagnose(tid: int, name: Optional[str], now: int) -> None:
|
||||||
|
print(f"\n{'=' * 72}\nTournament {tid}" + (f" ({name})" if name else ""))
|
||||||
|
print("=" * 72)
|
||||||
|
|
||||||
|
stored = stored_state(tid)
|
||||||
|
newest_game = newest_game_unix(tid)
|
||||||
|
|
||||||
|
if stored is None:
|
||||||
|
print(" STORED: never scanned (no row in tss_tournaments.db)")
|
||||||
|
else:
|
||||||
|
print(f" STORED: status={stored['status']!r} "
|
||||||
|
f"last scan {_age(stored['scanned_unix'], now)} "
|
||||||
|
f"matches={stored['match_count']} battles={stored['battle_count']}")
|
||||||
|
print(f" match statuses: {stored['status_counts']}")
|
||||||
|
print(f" date_end {_age(stored['date_end'], now)}")
|
||||||
|
print(f" NEWEST INGESTED GAME: {_age(newest_game, now)}")
|
||||||
|
if stored and newest_game and stored["scanned_unix"]:
|
||||||
|
if newest_game > stored["scanned_unix"]:
|
||||||
|
print(" >> A game arrived AFTER the last scan — the stored bracket is stale.")
|
||||||
|
|
||||||
|
will_rescan = await needs_scan(tid, now=now)
|
||||||
|
reason = ""
|
||||||
|
if stored is None:
|
||||||
|
reason = "never scanned"
|
||||||
|
elif stored["status"] == "finished":
|
||||||
|
reason = "status=='finished' → needs_scan() is permanently False (FROZEN)"
|
||||||
|
elif stored["date_end"] and now > stored["date_end"] + RESCAN_END_BUFFER_SECONDS:
|
||||||
|
reason = "past date_end + buffer → re-scan disabled"
|
||||||
|
else:
|
||||||
|
nxt = (stored["scanned_unix"] or 0) + RESCAN_INTERVAL_SECONDS
|
||||||
|
reason = f"interval-gated; next eligible {_age(nxt, now)} ({'due' if nxt <= now else 'waiting'})"
|
||||||
|
print(f" needs_scan(): {will_rescan} — {reason}")
|
||||||
|
|
||||||
|
print(" LIVE SCAN (fetching from TSS API, not stored)…", flush=True)
|
||||||
|
try:
|
||||||
|
scan = build_scan_sync(tid, fallback_name=name, now=now)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f" LIVE SCAN FAILED: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
live_hexes = {b["session_hex"] for b in scan["battles"]}
|
||||||
|
live_status_counts: Dict[str, int] = {}
|
||||||
|
for m in scan["matches"]:
|
||||||
|
live_status_counts[m["status"]] = live_status_counts.get(m["status"], 0) + 1
|
||||||
|
print(f" LIVE: status={scan['status']!r} matches={scan['match_count']} "
|
||||||
|
f"battles={len(scan['battles'])}")
|
||||||
|
print(f" match statuses: {live_status_counts}")
|
||||||
|
|
||||||
|
stored_hexes = stored["battle_hexes"] if stored else set()
|
||||||
|
new_battles = live_hexes - stored_hexes
|
||||||
|
print(f" DIFF: live has {len(new_battles)} battle(s) the DB is missing; "
|
||||||
|
f"match_count {stored['match_count'] if stored else 0} → {scan['match_count']}")
|
||||||
|
|
||||||
|
if new_battles and not will_rescan:
|
||||||
|
print(" VERDICT: FROZEN SCANNER — live data is newer but needs_scan() is "
|
||||||
|
"False, so the bracket will never update. Root cause is the scanner.")
|
||||||
|
elif new_battles and will_rescan:
|
||||||
|
print(" VERDICT: due to re-scan — data source is fine; staleness is timing "
|
||||||
|
"or downstream (web cache / frontend not refreshing).")
|
||||||
|
elif not new_battles:
|
||||||
|
print(" VERDICT: DB already current with live — if the site still looks "
|
||||||
|
"stale, the freeze is downstream (web cache / frontend polling).")
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("tournament_ids", nargs="*", type=int,
|
||||||
|
help="tournament ids to diagnose (default: auto-detect frozen ones)")
|
||||||
|
parser.add_argument("--top", type=int, default=5,
|
||||||
|
help="how many auto-detected tournaments to deep-scan (default 5)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
now = int(time.time())
|
||||||
|
|
||||||
|
print(f"battles db: {TSS_BATTLES_DB_PATH}")
|
||||||
|
print(f"tournament db: {TSS_TOURNAMENTS_DB_PATH}")
|
||||||
|
|
||||||
|
if args.tournament_ids:
|
||||||
|
targets = [(tid, None) for tid in args.tournament_ids]
|
||||||
|
else:
|
||||||
|
candidates = frozen_candidates()
|
||||||
|
print(f"\nAuto-detected {len(candidates)} tournament(s) with a game ingested "
|
||||||
|
f"after their last scan (stale brackets):")
|
||||||
|
for tid, name, newest, sunix in candidates[: args.top * 4]:
|
||||||
|
print(f" {tid:>8} game {_age(newest, now):>10} "
|
||||||
|
f"scan {_age(sunix, now):>10} {name or ''}")
|
||||||
|
if not candidates:
|
||||||
|
print(" (none — no tournament has games newer than its last scan)")
|
||||||
|
return
|
||||||
|
print(f"\nDeep-scanning the top {min(args.top, len(candidates))}…")
|
||||||
|
targets = [(tid, name) for tid, name, _, _ in candidates[: args.top]]
|
||||||
|
|
||||||
|
for tid, name in targets:
|
||||||
|
await diagnose(tid, name, now)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "SHARED"))
|
||||||
|
|
||||||
|
BATTLES_DDL = """
|
||||||
|
CREATE TABLE match_summary (
|
||||||
|
session_id TEXT PRIMARY KEY, mission_mode TEXT, mission_name TEXT, level_path TEXT,
|
||||||
|
mission_path TEXT, difficulty TEXT, starttime_unix INTEGER, endtime_unix INTEGER,
|
||||||
|
duration REAL, draw INTEGER NOT NULL DEFAULT 0, winning_slot TEXT, losing_slot TEXT,
|
||||||
|
received_unix INTEGER, tournament_id INTEGER, tournament_name TEXT, match_id TEXT, bracket TEXT);
|
||||||
|
CREATE TABLE player_games_hist (
|
||||||
|
UID TEXT NOT NULL, nick TEXT NOT NULL, team_name TEXT, team_slot TEXT, session_id TEXT NOT NULL,
|
||||||
|
vehicle TEXT, vehicle_internal TEXT, ground_kills INTEGER NOT NULL DEFAULT 0,
|
||||||
|
air_kills INTEGER NOT NULL DEFAULT 0, assists INTEGER NOT NULL DEFAULT 0,
|
||||||
|
captures INTEGER NOT NULL DEFAULT 0, deaths INTEGER NOT NULL DEFAULT 0, score INTEGER NOT NULL DEFAULT 0,
|
||||||
|
missile_evades INTEGER NOT NULL DEFAULT 0, shell_interceptions INTEGER NOT NULL DEFAULT 0,
|
||||||
|
team_kills_stat INTEGER NOT NULL DEFAULT 0, country_id INTEGER, victor_bool TEXT NOT NULL DEFAULT 'Loss',
|
||||||
|
endtime_unix INTEGER NOT NULL DEFAULT 0, team_id INTEGER, tss_role TEXT, pvp_ratio REAL,
|
||||||
|
UNIQUE (UID, session_id, vehicle_internal));
|
||||||
|
CREATE TABLE match_logs (
|
||||||
|
session_id TEXT PRIMARY KEY, chat_log_json TEXT, battle_log_json TEXT, built_unix INTEGER, event_log_json TEXT);
|
||||||
|
"""
|
||||||
|
|
||||||
|
TOURN_DDL = """
|
||||||
|
CREATE TABLE tournaments (
|
||||||
|
tournament_id INTEGER PRIMARY KEY, name TEXT, format TEXT, game_mode TEXT, cluster TEXT,
|
||||||
|
date_start INTEGER, date_end INTEGER, team_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
match_count INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
first_seen_unix INTEGER, scanned_unix INTEGER);
|
||||||
|
CREATE TABLE tournament_matches (
|
||||||
|
tournament_id INTEGER NOT NULL, match_id TEXT NOT NULL, type_bracket TEXT NOT NULL, side TEXT,
|
||||||
|
round INTEGER, position INTEGER, team_a_uuid TEXT, team_a_name TEXT, team_b_uuid TEXT, team_b_name TEXT,
|
||||||
|
winner_name TEXT, score_a INTEGER NOT NULL DEFAULT 0, score_b INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending', time_start INTEGER,
|
||||||
|
PRIMARY KEY (tournament_id, match_id, type_bracket));
|
||||||
|
CREATE TABLE tournament_battles (
|
||||||
|
session_hex TEXT PRIMARY KEY, session_decimal TEXT, tournament_id INTEGER NOT NULL, match_id TEXT NOT NULL,
|
||||||
|
type_bracket TEXT NOT NULL, position INTEGER, winner_name TEXT, status_replay TEXT);
|
||||||
|
CREATE TABLE tournament_standings (
|
||||||
|
tournament_id INTEGER NOT NULL, group_index INTEGER NOT NULL DEFAULT 0, team_uuid TEXT NOT NULL,
|
||||||
|
team_name TEXT, points INTEGER NOT NULL DEFAULT 0, wins INTEGER NOT NULL DEFAULT 0,
|
||||||
|
draws INTEGER NOT NULL DEFAULT 0, losses INTEGER NOT NULL DEFAULT 0, buchholz REAL NOT NULL DEFAULT 0,
|
||||||
|
rank INTEGER, PRIMARY KEY (tournament_id, group_index, team_uuid));
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(battles: Path, tourn: Path) -> None:
|
||||||
|
b = sqlite3.connect(battles); b.executescript(BATTLES_DDL)
|
||||||
|
b.execute("INSERT INTO match_summary VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
("sess1", "Tournament", "Gladiators 1x1 - Kursk", "levels/kursk.bin",
|
||||||
|
"gamedata/missions/x.blk", "realistic", 1780937467, 1780937680, 156.84, 0,
|
||||||
|
"1", "2", 1780937817, 24839, "Cadet 1x1 RB Air", "884571", "single-elim"))
|
||||||
|
b.execute("INSERT INTO player_games_hist (UID,nick,team_name,team_slot,session_id,vehicle,vehicle_internal,ground_kills,air_kills,assists,captures,deaths,score,country_id,victor_bool,endtime_unix,team_id,tss_role,pvp_ratio) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
("148919027", "Joe", "SunThunder", "1", "sess1", "I-153 M-62", "i-153_m62",
|
||||||
|
0, 1, 0, 0, 1, 270, 3, "Win", 1780937680, 1211052, "captain", 1.0))
|
||||||
|
b.execute("INSERT INTO player_games_hist (UID,nick,team_name,team_slot,session_id,vehicle,vehicle_internal,deaths,score,victor_bool,endtime_unix,team_id,tss_role) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
("80080809", "Foe", "RedTeam", "2", "sess1", "I-153 M-62", "i-153_m62",
|
||||||
|
2, 90, "Loss", 1780937680, 1211099, "player"))
|
||||||
|
b.execute("INSERT INTO match_logs VALUES (?,?,?,?,?)",
|
||||||
|
("sess1", "[]", '["[01:18] kill"]', 1781776039, '{"kills": []}'))
|
||||||
|
b.commit(); b.close()
|
||||||
|
|
||||||
|
t = sqlite3.connect(tourn); t.executescript(TOURN_DDL)
|
||||||
|
t.execute("INSERT INTO tournaments VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
(24839, "Cadet 1x1 RB Air", "single-elim", "RB", "EU", 1780937400, 1780944668,
|
||||||
|
63, 63, "finished", 1782016814, 1782016814))
|
||||||
|
t.execute("INSERT INTO tournament_matches VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
(24839, "336136", "Swiss", "swiss", "", "", "uuid-a", "BenisPutt", "uuid-b", "roflan",
|
||||||
|
"BenisPutt", 1, 0, "played", 1782014460))
|
||||||
|
t.execute("INSERT INTO tournament_battles VALUES (?,?,?,?,?,?,?,?)",
|
||||||
|
("6cbc20b001de1ee", "489698337002217966", 24839, "336136", "Swiss", 0, "BenisPutt", "view replay"))
|
||||||
|
t.execute("INSERT INTO tournament_standings VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
(24839, 0, "uuid-a", "BenisPutt", 4, 2, 0, 5, 41.0, 1))
|
||||||
|
t.commit(); t.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path, monkeypatch):
|
||||||
|
battles = tmp_path / "tss_battles.db"
|
||||||
|
tourn = tmp_path / "tss_tournaments.db"
|
||||||
|
_seed(battles, tourn)
|
||||||
|
monkeypatch.setenv("TSS_API_BATTLES_DB", str(battles))
|
||||||
|
monkeypatch.setenv("TSS_API_TOURNAMENTS_DB", str(tourn))
|
||||||
|
monkeypatch.setenv("STORAGE_VOL_PATH", str(tmp_path))
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
import importlib
|
||||||
|
import web.api.db as dbmod
|
||||||
|
importlib.reload(dbmod)
|
||||||
|
import web.api.app as appmod
|
||||||
|
importlib.reload(appmod)
|
||||||
|
return TestClient(appmod.app)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import json
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "SHARED"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def bridge(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("STORAGE_VOL_PATH", str(tmp_path))
|
||||||
|
import BOT.receiver_bridge as rb
|
||||||
|
importlib.reload(rb)
|
||||||
|
return rb
|
||||||
|
|
||||||
|
|
||||||
|
async def test_publish_replay_batch_writes_tss_envelope(bridge, tmp_path):
|
||||||
|
await bridge.publish_replay_batch([{"sessionIdHex": "abc", "x": 1}])
|
||||||
|
line = (tmp_path / "tss_bridge_outbox.jsonl").read_text(encoding="utf-8").strip()
|
||||||
|
env = json.loads(line)
|
||||||
|
assert env["type"] == "tss.replay_batch"
|
||||||
|
assert env["source"] == "tss"
|
||||||
|
assert env["version"] == 1
|
||||||
|
assert env["payload"]["replays"][0]["sessionIdHex"] == "abc"
|
||||||
|
assert isinstance(env["sent_at"], float)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_publish_replay_batch_empty_is_noop(bridge, tmp_path):
|
||||||
|
await bridge.publish_replay_batch([])
|
||||||
|
assert not (tmp_path / "tss_bridge_outbox.jsonl").exists()
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "SHARED"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_unit_vehicle_fields_translates_vehicle_and_strips_model_prefix(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("STORAGE_VOL_PATH", str(tmp_path))
|
||||||
|
|
||||||
|
import BOT.storage as storage
|
||||||
|
importlib.reload(storage)
|
||||||
|
|
||||||
|
unit = {
|
||||||
|
"unit": "tankModels/germ_pzkpfw_V_ausf_d_panther",
|
||||||
|
"unit_normalized": "Bad Spectra Name",
|
||||||
|
"used": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
assert storage._unit_vehicle_fields(unit) == ("Panther D", "germ_pzkpfw_V_ausf_d_panther")
|
||||||
@@ -29,3 +29,42 @@ def test_model_carries_tournament_id():
|
|||||||
model = build_scoreboard_model(_game())
|
model = build_scoreboard_model(_game())
|
||||||
assert model is not None
|
assert model is not None
|
||||||
assert model["tournament_id"] == 24965
|
assert model["tournament_id"] == 24965
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_strips_unit_model_prefixes_for_dead_matching():
|
||||||
|
game = _game()
|
||||||
|
game["players"]["2"]["units"][0]["unit"] = "tankModels/pz"
|
||||||
|
game["players"]["2"]["units"][0]["unit_type"] = "tank"
|
||||||
|
game["players"]["2"]["units"][0]["unit_class"] = "medium tank"
|
||||||
|
game["players"]["2"]["country"] = "germany"
|
||||||
|
game["events"] = {
|
||||||
|
"kills": [
|
||||||
|
{
|
||||||
|
"offended_uid": "2",
|
||||||
|
"offended_unit": "tankModels/pz",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
model = build_scoreboard_model(game)
|
||||||
|
|
||||||
|
assert model is not None
|
||||||
|
bob = model["teams"][1]["players"][0]
|
||||||
|
assert bob["country"] == "germany"
|
||||||
|
assert bob["units"][0]["internal"] == "pz"
|
||||||
|
assert bob["units"][0]["dead"] is True
|
||||||
|
assert bob["units"][0]["unit_type"] == "tank"
|
||||||
|
assert bob["units"][0]["unit_class"] == "medium tank"
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_ignores_spectra_unit_normalized_for_vehicle_name():
|
||||||
|
game = _game()
|
||||||
|
game["players"]["1"]["units"][0]["unit"] = "germ_pzkpfw_V_ausf_d_panther"
|
||||||
|
game["players"]["1"]["units"][0]["unit_normalized"] = "Bad Spectra Name"
|
||||||
|
|
||||||
|
model = build_scoreboard_model(game, "<English>")
|
||||||
|
|
||||||
|
assert model is not None
|
||||||
|
alice = model["teams"][0]["players"][0]
|
||||||
|
assert alice["units"][0]["internal"] == "germ_pzkpfw_V_ausf_d_panther"
|
||||||
|
assert alice["units"][0]["name"] == "Panther D"
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
def test_info_reports_counts(client):
|
||||||
|
r = client.get("/api/tss/info")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["service"] == "tss"
|
||||||
|
assert body["counts"]["matches"] == 1
|
||||||
|
assert body["counts"]["player_games"] == 2
|
||||||
|
assert body["counts"]["tournaments"] == 1
|
||||||
|
assert "/api/tss/player/{uid}" in body["endpoints"]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
def test_leaderboard_requires_filter(client):
|
||||||
|
r = client.get("/api/tss/leaderboard/players")
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert r.json()["code"] == "FILTER_REQUIRED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_leaderboard_players_with_window(client):
|
||||||
|
r = client.get("/api/tss/leaderboard/players?start_date=1780000000&end_date=1790000000")
|
||||||
|
assert r.status_code == 200
|
||||||
|
rows = r.json()["players"]
|
||||||
|
assert rows[0]["uid"] == "148919027" # highest score
|
||||||
|
|
||||||
|
|
||||||
|
def test_leaderboard_vehicles_with_tournament(client):
|
||||||
|
r = client.get("/api/tss/leaderboard/vehicles?tournament_id=24839")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["vehicles"][0]["vehicle_internal"] == "i-153_m62"
|
||||||
|
|
||||||
|
|
||||||
|
def test_leaderboard_stats(client):
|
||||||
|
r = client.get("/api/tss/leaderboard/stats?start_date=1780000000&end_date=1790000000")
|
||||||
|
body = r.json()
|
||||||
|
assert body["totals"]["battles"] == 2
|
||||||
|
assert body["top_vehicles"][0]["vehicle_internal"] == "i-153_m62"
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
def test_live_recent_matches(client):
|
||||||
|
r = client.get("/api/tss/live?limit=10")
|
||||||
|
assert r.json()["matches"][0]["session_id"] == "sess1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_match_with_rosters(client):
|
||||||
|
r = client.get("/api/tss/match/sess1")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["match"]["mission_name"].startswith("Gladiators")
|
||||||
|
slots = {t["team_slot"] for t in body["teams"]}
|
||||||
|
assert slots == {"1", "2"}
|
||||||
|
win = next(t for t in body["teams"] if t["team_slot"] == "1")
|
||||||
|
assert win["is_winner"] is True
|
||||||
|
assert win["players"][0]["UID"] == "148919027"
|
||||||
|
|
||||||
|
|
||||||
|
def test_match_unknown_404(client):
|
||||||
|
assert client.get("/api/tss/match/nope").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_scoreboard_includes_logs(client):
|
||||||
|
r = client.get("/api/tss/match/sess1/scoreboard")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["logs"]["available"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_matches_search_by_player(client):
|
||||||
|
r = client.get("/api/tss/matches/search?player=148919027")
|
||||||
|
assert r.json()["matches"][0]["session_id"] == "sess1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_maps_distinct(client):
|
||||||
|
maps = client.get("/api/tss/maps").json()["maps"]
|
||||||
|
assert any(m["mission_name"].startswith("Gladiators") for m in maps)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
def test_player_summary_and_team_history(client):
|
||||||
|
r = client.get("/api/tss/player/148919027")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["uid"] == "148919027"
|
||||||
|
assert body["summary"]["battles"] == 1
|
||||||
|
assert body["summary"]["wins"] == 1
|
||||||
|
assert body["summary"]["air_kills"] == 1
|
||||||
|
assert any(v["vehicle_internal"] == "i-153_m62" for v in body["vehicles"])
|
||||||
|
assert any(t["team_name"] == "SunThunder" and t["tss_role"] == "captain"
|
||||||
|
for t in body["team_history"])
|
||||||
|
assert any(n["nick"] == "Joe" for n in body["nicks"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_unknown_is_404(client):
|
||||||
|
assert client.get("/api/tss/player/000").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_games_rows(client):
|
||||||
|
r = client.get("/api/tss/player/148919027/games")
|
||||||
|
assert r.status_code == 200
|
||||||
|
rows = r.json()["games"]
|
||||||
|
assert rows[0]["session_id"] == "sess1"
|
||||||
|
assert rows[0]["UID"] == "148919027"
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_history_daily(client):
|
||||||
|
r = client.get("/api/tss/player/148919027/history")
|
||||||
|
body = r.json()
|
||||||
|
assert body["days_with_battles_only"] is True
|
||||||
|
assert body["history"][0]["battles"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_by_nick(client):
|
||||||
|
r = client.get("/api/tss/search/Joe")
|
||||||
|
hits = r.json()["players"]
|
||||||
|
assert hits[0]["uid"] == "148919027"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
def test_list_tournaments(client):
|
||||||
|
r = client.get("/api/tss/tournaments")
|
||||||
|
assert r.json()["tournaments"][0]["tournament_id"] == 24839
|
||||||
|
|
||||||
|
|
||||||
|
def test_tournament_detail(client):
|
||||||
|
r = client.get("/api/tss/tournament/24839")
|
||||||
|
body = r.json()
|
||||||
|
assert body["tournament"]["name"] == "Cadet 1x1 RB Air"
|
||||||
|
assert body["standings"][0]["team_name"] == "BenisPutt"
|
||||||
|
assert len(body["matches"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_tournament_unknown_404(client):
|
||||||
|
assert client.get("/api/tss/tournament/999").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_tournament_matches_coerce_empty_to_null(client):
|
||||||
|
r = client.get("/api/tss/tournament/24839/matches")
|
||||||
|
m = r.json()["matches"][0]
|
||||||
|
assert m["round"] is None
|
||||||
|
assert m["position"] is None
|
||||||
|
assert m["session_ids"] == ["6cbc20b001de1ee"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_tournament_standings(client):
|
||||||
|
r = client.get("/api/tss/tournament/24839/standings")
|
||||||
|
assert r.json()["standings"][0]["rank"] == 1
|
||||||
@@ -79,6 +79,22 @@ def test_normalize_side():
|
|||||||
assert tt.normalize_side("Group") == "group"
|
assert tt.normalize_side("Group") == "group"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_tournament_side_context_moves_double_elim_semifinal_to_loser():
|
||||||
|
matches = [
|
||||||
|
{"type_bracket": "Winner", "side": "winner"},
|
||||||
|
{"type_bracket": "Looser", "side": "loser"},
|
||||||
|
{"type_bracket": "Semifinal", "side": "final"},
|
||||||
|
{"type_bracket": "Final", "side": "final"},
|
||||||
|
]
|
||||||
|
tt.apply_tournament_side_context(matches)
|
||||||
|
assert matches[2]["side"] == "loser"
|
||||||
|
assert matches[3]["side"] == "final"
|
||||||
|
|
||||||
|
single_elim = [{"type_bracket": "Semifinal", "side": "final"}]
|
||||||
|
tt.apply_tournament_side_context(single_elim)
|
||||||
|
assert single_elim[0]["side"] == "final"
|
||||||
|
|
||||||
|
|
||||||
def test_derive_format():
|
def test_derive_format():
|
||||||
assert tt.derive_format([], "double-elumination") == "double-elim"
|
assert tt.derive_format([], "double-elumination") == "double-elim"
|
||||||
assert tt.derive_format([], "single-elumination") == "single-elim"
|
assert tt.derive_format([], "single-elumination") == "single-elim"
|
||||||
@@ -172,20 +188,84 @@ def test_fill_names_from_battles_by_uuid():
|
|||||||
assert match["winner_name"] == "NUGOB"
|
assert match["winner_name"] == "NUGOB"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_battles_for_matches_concurrent(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_request(method, action, **params):
|
||||||
|
calls.append((method, action, params["idMatch"]))
|
||||||
|
return [{
|
||||||
|
"url": "224584316650954636",
|
||||||
|
"position": 0,
|
||||||
|
"statusReplay": "view replay",
|
||||||
|
"winner": "NUGOB",
|
||||||
|
"teamA": {"teamName": "uuid-a", "realName": "NUGOB"},
|
||||||
|
"teamB": {"teamName": "uuid-b", "realName": "GRIDAC"},
|
||||||
|
}]
|
||||||
|
|
||||||
|
monkeypatch.setattr(tt, "_request", fake_request)
|
||||||
|
matches = [{
|
||||||
|
"match_id": "m1", "type_bracket": "Winner", "status": "played",
|
||||||
|
"team_a_uuid": "uuid-a", "team_a_name": None,
|
||||||
|
"team_b_uuid": "uuid-b", "team_b_name": None,
|
||||||
|
"winner_name": None, "score_a": 1, "score_b": 0,
|
||||||
|
}]
|
||||||
|
battles = tt.fetch_battles_for_matches(123, matches, workers=2)
|
||||||
|
assert len(calls) == 1
|
||||||
|
assert battles[0]["session_hex"] == "31de23f001a9f8c"
|
||||||
|
assert matches[0]["team_a_name"] == "NUGOB"
|
||||||
|
assert matches[0]["team_b_name"] == "GRIDAC"
|
||||||
|
|
||||||
|
|
||||||
def test_compute_status():
|
def test_compute_status():
|
||||||
played = [{"status": "played"}, {"status": "bye"}]
|
played = [{"status": "played"}, {"status": "bye"}]
|
||||||
mixed = [{"status": "played"}, {"status": "pending"}]
|
mixed = [{"status": "played"}, {"status": "pending"}]
|
||||||
assert tt.compute_status([], None, 1000) == "pending"
|
assert tt.compute_status([], None, 1000) == "pending"
|
||||||
assert tt.compute_status(played, None, 1000) == "finished"
|
assert tt.compute_status(played, None, 1000) == "finished"
|
||||||
assert tt.compute_status(mixed, None, 1000) == "active"
|
assert tt.compute_status(mixed, None, 1000) == "active"
|
||||||
# past end + buffer → finished even with pending matches
|
# A tournament still listed as active is NOT finished just because it overran
|
||||||
assert tt.compute_status(mixed, 100, 100 + tt.RESCAN_END_BUFFER_SECONDS + 1) == "finished"
|
# its *scheduled* dateEndTournament — finals routinely get played late.
|
||||||
|
assert tt.compute_status(mixed, 100, 100 + tt.RESCAN_END_BUFFER_SECONDS + 1) == "active"
|
||||||
|
assert tt.compute_status(mixed, 100, 100 + tt.RESCAN_END_BUFFER_SECONDS + 1,
|
||||||
|
in_active=True) == "active"
|
||||||
# absent from the active list → finished (old tournament, no date_end)
|
# absent from the active list → finished (old tournament, no date_end)
|
||||||
assert tt.compute_status(mixed, None, 1000, in_active=False) == "finished"
|
assert tt.compute_status(mixed, None, 1000, in_active=False) == "finished"
|
||||||
# unknown active-list state should not finish a tournament by absence alone
|
# active-list state unknown → fall back to the scheduled end + buffer
|
||||||
|
assert tt.compute_status(mixed, 100, 100 + tt.RESCAN_END_BUFFER_SECONDS + 1,
|
||||||
|
in_active=None) == "finished"
|
||||||
|
# unknown active-list state without a past end should not finish by absence alone
|
||||||
assert tt.compute_status(mixed, None, 1000, in_active=None) == "active"
|
assert tt.compute_status(mixed, None, 1000, in_active=None) == "active"
|
||||||
|
|
||||||
|
|
||||||
|
def test_needs_scan_overrun_keeps_scanning():
|
||||||
|
# A live tournament past its *scheduled* end must stay eligible for re-scan so
|
||||||
|
# a late final still lands; only status=='finished' permanently stops re-scans.
|
||||||
|
async def run():
|
||||||
|
await tt.init_tss_tournaments_db()
|
||||||
|
now = 1_000_000
|
||||||
|
match = {
|
||||||
|
"match_id": "m1", "type_bracket": "Winner", "side": "winner",
|
||||||
|
"round": 0, "position": 0, "team_a_uuid": None, "team_a_name": "A",
|
||||||
|
"team_b_uuid": None, "team_b_name": "B", "winner_name": None,
|
||||||
|
"score_a": 0, "score_b": 0, "status": "pending", "time_start": None,
|
||||||
|
}
|
||||||
|
scan = {
|
||||||
|
"tournament_id": 77777, "name": "Overrun Cup", "format": "single-elim",
|
||||||
|
"game_mode": "RB", "cluster": "EU", "date_start": 1,
|
||||||
|
"date_end": now - 10 * tt.RESCAN_END_BUFFER_SECONDS, # long past scheduled end
|
||||||
|
"team_count": 2, "match_count": 1, "status": "active",
|
||||||
|
"scanned_unix": now - tt.RESCAN_INTERVAL_SECONDS - 1, # interval elapsed
|
||||||
|
"matches": [match], "battles": [], "standings": [],
|
||||||
|
}
|
||||||
|
await tt.store_scan(scan)
|
||||||
|
assert await tt.needs_scan(77777, now=now) is True
|
||||||
|
# Once truly finished, never re-scan again.
|
||||||
|
scan["status"] = "finished"
|
||||||
|
await tt.store_scan(scan)
|
||||||
|
assert await tt.needs_scan(77777, now=now) is False
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
def test_store_scan_roundtrip():
|
def test_store_scan_roundtrip():
|
||||||
names = tt.team_name_map(SCHEDULE["all_teams"])
|
names = tt.team_name_map(SCHEDULE["all_teams"])
|
||||||
matches = [tt.parse_schedule_match(r, names) for r in SCHEDULE["all_matches"]]
|
matches = [tt.parse_schedule_match(r, names) for r in SCHEDULE["all_matches"]]
|
||||||
@@ -233,3 +313,180 @@ async def _restore_and_count(scan):
|
|||||||
"SELECT COUNT(*) FROM tournament_matches WHERE tournament_id=99999"
|
"SELECT COUNT(*) FROM tournament_matches WHERE tournament_id=99999"
|
||||||
).fetchone()
|
).fetchone()
|
||||||
assert mcount == 2
|
assert mcount == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_schedule_sync_has_no_battles_and_makes_no_battle_calls(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_request(method, action, **params):
|
||||||
|
calls.append(action)
|
||||||
|
if action == "get_shedule_matches":
|
||||||
|
return {"data": SCHEDULE}
|
||||||
|
return {"data": {}}
|
||||||
|
|
||||||
|
monkeypatch.setattr(tt, "_request", fake_request)
|
||||||
|
monkeypatch.setattr(tt, "_active_meta", lambda tid, now: ({}, True))
|
||||||
|
|
||||||
|
scan = tt.fetch_schedule_sync(12345, now=1000)
|
||||||
|
assert scan["battles"] == []
|
||||||
|
assert scan["match_count"] == len(SCHEDULE["all_matches"])
|
||||||
|
assert "getListAllBattles" not in calls # the whole point: no per-match battle loop
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_schedule_preserves_battles():
|
||||||
|
# A schedule refresh updates matches/standings/status but must NOT wipe the
|
||||||
|
# battle links the fast path inserted.
|
||||||
|
names = tt.team_name_map(SCHEDULE["all_teams"])
|
||||||
|
matches = [tt.parse_schedule_match(r, names) for r in SCHEDULE["all_matches"]]
|
||||||
|
battles, _ = tt.parse_battles(BATTLES_PLAYED, 88888, "686556", "Looser")
|
||||||
|
full = {
|
||||||
|
"tournament_id": 88888, "name": "Cup", "format": "double-elim",
|
||||||
|
"game_mode": "RB", "cluster": "EU", "date_start": 1, "date_end": 2,
|
||||||
|
"team_count": 2, "match_count": len(matches), "status": "active",
|
||||||
|
"scanned_unix": 1000, "matches": matches, "battles": battles, "standings": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
await tt.init_tss_tournaments_db()
|
||||||
|
await tt.store_scan(full) # seeds matches + battles
|
||||||
|
# Now a schedule-only refresh with NO battles in the payload.
|
||||||
|
sched = dict(full, status="finished", scanned_unix=2000, battles=[])
|
||||||
|
await tt.store_schedule(sched)
|
||||||
|
import sqlite3
|
||||||
|
with sqlite3.connect(tt.TSS_TOURNAMENTS_DB_PATH) as conn:
|
||||||
|
n_battles = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM tournament_battles WHERE tournament_id = 88888"
|
||||||
|
).fetchone()[0]
|
||||||
|
status = conn.execute(
|
||||||
|
"SELECT status FROM tournaments WHERE tournament_id = 88888"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert n_battles == len(battles) # battles preserved
|
||||||
|
assert status == "finished" # meta updated
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_schedule_refresh_coalesces_burst(monkeypatch):
|
||||||
|
fetched = []
|
||||||
|
|
||||||
|
def fake_fetch(tid, **kwargs):
|
||||||
|
fetched.append(tid)
|
||||||
|
return {
|
||||||
|
"tournament_id": tid, "name": "T", "format": "single-elim",
|
||||||
|
"game_mode": "RB", "cluster": "EU", "date_start": 1, "date_end": 2,
|
||||||
|
"team_count": 0, "match_count": 0, "status": "active",
|
||||||
|
"scanned_unix": 1000, "matches": [], "standings": [], "battles": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(tt, "fetch_schedule_sync", fake_fetch)
|
||||||
|
monkeypatch.setattr(tt, "_REFRESH_DEBOUNCE_SECONDS", 0.05)
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
await tt.init_tss_tournaments_db()
|
||||||
|
for _ in range(8): # burst of 8 games for the same tournament
|
||||||
|
tt.request_schedule_refresh(60002)
|
||||||
|
await asyncio.sleep(0.2) # let the single debounced refresh fire
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
assert fetched == [60002] # 8 requests collapsed into one fetch
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_battle_sync_links_known_match():
|
||||||
|
async def run():
|
||||||
|
await tt.init_tss_tournaments_db()
|
||||||
|
match = {
|
||||||
|
"match_id": "330669", "type_bracket": "Swiss", "side": "swiss", "round": None,
|
||||||
|
"position": None, "team_a_uuid": None, "team_a_name": "NEXOOOS",
|
||||||
|
"team_b_uuid": None, "team_b_name": "GFRIS2", "winner_name": None,
|
||||||
|
"score_a": 0, "score_b": 0, "status": "pending", "time_start": None,
|
||||||
|
}
|
||||||
|
scan = {
|
||||||
|
"tournament_id": 24836, "name": "ACL", "format": "swiss", "game_mode": "RB",
|
||||||
|
"cluster": "EU", "date_start": 1, "date_end": 2, "team_count": 2,
|
||||||
|
"match_count": 1, "status": "active", "scanned_unix": 1000,
|
||||||
|
"matches": [match], "battles": [], "standings": [],
|
||||||
|
}
|
||||||
|
await tt.store_scan(scan)
|
||||||
|
linked = tt.link_battle_sync(24836, "330669", "1abcd", winner_name="NEXOOOS")
|
||||||
|
assert linked is True
|
||||||
|
import sqlite3
|
||||||
|
with sqlite3.connect(tt.TSS_TOURNAMENTS_DB_PATH) as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT type_bracket FROM tournament_battles WHERE session_hex = '1abcd'"
|
||||||
|
).fetchone()
|
||||||
|
assert row == ("Swiss",) # type_bracket pulled from the stored match
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_battle_sync_returns_false_for_unknown_match():
|
||||||
|
async def run():
|
||||||
|
await tt.init_tss_tournaments_db()
|
||||||
|
assert tt.link_battle_sync(999999, "nope", "deadbeef") is False
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_maybe_link_battle_known_match_makes_no_network_call(monkeypatch):
|
||||||
|
def boom(*a, **k):
|
||||||
|
raise AssertionError("fast path must not call the TSS API")
|
||||||
|
|
||||||
|
monkeypatch.setattr(tt, "_request", boom)
|
||||||
|
refreshed = []
|
||||||
|
monkeypatch.setattr(tt, "request_schedule_refresh",
|
||||||
|
lambda tid, **k: refreshed.append(tid))
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
await tt.init_tss_tournaments_db()
|
||||||
|
match = {
|
||||||
|
"match_id": "330669", "type_bracket": "Swiss", "side": "swiss", "round": None,
|
||||||
|
"position": None, "team_a_uuid": None, "team_a_name": "NEXOOOS",
|
||||||
|
"team_b_uuid": None, "team_b_name": "GFRIS2", "winner_name": None,
|
||||||
|
"score_a": 0, "score_b": 0, "status": "pending", "time_start": None,
|
||||||
|
}
|
||||||
|
scan = {
|
||||||
|
"tournament_id": 24836, "name": "ACL", "format": "swiss", "game_mode": "RB",
|
||||||
|
"cluster": "EU", "date_start": 1, "date_end": 2, "team_count": 2,
|
||||||
|
"match_count": 1, "status": "active", "scanned_unix": 1000,
|
||||||
|
"matches": [match], "battles": [], "standings": [],
|
||||||
|
}
|
||||||
|
await tt.store_scan(scan)
|
||||||
|
game = {
|
||||||
|
"_id": "1abcd", "winner": "1",
|
||||||
|
"tss": {"tournament_id": 24836, "match_id": "330669", "bracket": "swiss",
|
||||||
|
"1": {"name": "NEXOOOS"}, "2": {"name": "GFRIS2"}},
|
||||||
|
}
|
||||||
|
await tt.maybe_link_battle(game)
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
assert refreshed == [24836] # linked + scheduled a refresh, no network
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_targets_only_played_without_battles():
|
||||||
|
async def run():
|
||||||
|
await tt.init_tss_tournaments_db()
|
||||||
|
m_played = {
|
||||||
|
"match_id": "p1", "type_bracket": "Winner", "side": "winner", "round": 0,
|
||||||
|
"position": 0, "team_a_uuid": None, "team_a_name": "A", "team_b_uuid": None,
|
||||||
|
"team_b_name": "B", "winner_name": "A", "score_a": 1, "score_b": 0,
|
||||||
|
"status": "played", "time_start": None,
|
||||||
|
}
|
||||||
|
m_pending = dict(m_played, match_id="p2", status="pending", winner_name=None)
|
||||||
|
m_linked = dict(m_played, match_id="p3")
|
||||||
|
battle = {
|
||||||
|
"session_hex": "abc", "session_decimal": "2748", "tournament_id": 70001,
|
||||||
|
"match_id": "p3", "type_bracket": "Winner", "position": 0,
|
||||||
|
"winner_name": "A", "status_replay": "view replay",
|
||||||
|
}
|
||||||
|
scan = {
|
||||||
|
"tournament_id": 70001, "name": "T", "format": "single-elim",
|
||||||
|
"game_mode": "RB", "cluster": "EU", "date_start": 1, "date_end": 2,
|
||||||
|
"team_count": 2, "match_count": 3, "status": "active", "scanned_unix": 1000,
|
||||||
|
"matches": [m_played, m_pending, m_linked], "battles": [battle], "standings": [],
|
||||||
|
}
|
||||||
|
await tt.store_scan(scan)
|
||||||
|
targets = tt.reconcile_targets_sync(70001)
|
||||||
|
ids = {t["match_id"] for t in targets}
|
||||||
|
assert ids == {"p1"} # played + no battle; p2 pending excluded, p3 already linked
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ from websockets.asyncio.client import connect as wsconnect
|
|||||||
|
|
||||||
from BOT.storage import insert_match, insert_player_games, upsert_tss_teams
|
from BOT.storage import insert_match, insert_player_games, upsert_tss_teams
|
||||||
from BOT.autologging import process_game as autolog_process_game
|
from BOT.autologging import process_game as autolog_process_game
|
||||||
|
from BOT.receiver_bridge import publish_replay_batch
|
||||||
|
from spectra_replay_normalize import normalize_spectra_replay
|
||||||
from spectra_ws_payload import SpectraPayloadError, decode_spectra_ws_payload
|
from spectra_ws_payload import SpectraPayloadError, decode_spectra_ws_payload
|
||||||
|
|
||||||
_HERE = Path(__file__).resolve().parent
|
_HERE = Path(__file__).resolve().parent
|
||||||
@@ -72,6 +74,7 @@ def _session_id(game: Dict[str, Any]) -> str:
|
|||||||
|
|
||||||
def _write_game(game: Dict[str, Any]) -> Path:
|
def _write_game(game: Dict[str, Any]) -> Path:
|
||||||
"""Normalize _id to hex, then write to REPLAYS/TSS/<session_id>/replay_data.json.gz."""
|
"""Normalize _id to hex, then write to REPLAYS/TSS/<session_id>/replay_data.json.gz."""
|
||||||
|
game.update(normalize_spectra_replay(game))
|
||||||
sid = _session_id(game)
|
sid = _session_id(game)
|
||||||
game["_id"] = sid # hex from this point forward
|
game["_id"] = sid # hex from this point forward
|
||||||
session_dir = REPLAYS_DIR / sid
|
session_dir = REPLAYS_DIR / sid
|
||||||
@@ -90,6 +93,10 @@ def normalize(data: Any) -> Optional[List[Dict[str, Any]]]:
|
|||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
if "_id" in data or "id" in data:
|
if "_id" in data or "id" in data:
|
||||||
return [data]
|
return [data]
|
||||||
|
if isinstance(data.get("data"), dict):
|
||||||
|
return [data["data"]]
|
||||||
|
if isinstance(data.get("data"), list):
|
||||||
|
return data["data"]
|
||||||
if "completed" in data:
|
if "completed" in data:
|
||||||
return data["completed"]
|
return data["completed"]
|
||||||
log.warning("Unknown WS frame shape: %s", type(data))
|
log.warning("Unknown WS frame shape: %s", type(data))
|
||||||
@@ -171,13 +178,20 @@ async def _handle_game(game: Dict[str, Any]) -> None:
|
|||||||
log.info("Stored game %s in DB", sid)
|
log.info("Stored game %s in DB", sid)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("DB insert failed for %s: %s", sid, exc)
|
log.error("DB insert failed for %s: %s", sid, exc)
|
||||||
# If this game belongs to a tournament we haven't indexed (or one that's live
|
# Forward the processed game to the relay gateway (tss channel). Best-effort:
|
||||||
# and stale), scan its authoritative bracket in the background. Never blocks.
|
# a bridge failure must never break ingest.
|
||||||
try:
|
try:
|
||||||
from BOT.tss_tournaments import maybe_scan_tournament
|
await publish_replay_batch([game])
|
||||||
await maybe_scan_tournament(game)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("tournament scan trigger failed for %s: %s", sid, exc)
|
log.warning("[TSS-BRIDGE] Failed to forward replay batch for %s: %s", sid, exc)
|
||||||
|
# Link this game's replay to its bracket slot from tss.match_id (zero TSS
|
||||||
|
# calls) and request a debounced schedule refresh; unknown matches fall back
|
||||||
|
# to a full scan inside maybe_link_battle. Never blocks ingest.
|
||||||
|
try:
|
||||||
|
from BOT.tss_tournaments import maybe_link_battle
|
||||||
|
await maybe_link_battle(game)
|
||||||
|
except Exception as exc:
|
||||||
|
log.error("tournament link trigger failed for %s: %s", sid, exc)
|
||||||
# Autolog match/dispatch (no-ops in standalone mode where no bot is set).
|
# Autolog match/dispatch (no-ops in standalone mode where no bot is set).
|
||||||
try:
|
try:
|
||||||
await autolog_process_game(game)
|
await autolog_process_game(game)
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, FastAPI, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||||
|
|
||||||
|
from web.api import db
|
||||||
|
|
||||||
|
ENDPOINTS = [
|
||||||
|
"/api/tss/info", "/api/tss/live", "/api/tss/player/{uid}",
|
||||||
|
"/api/tss/player/{uid}/games", "/api/tss/player/{uid}/history",
|
||||||
|
"/api/tss/search/{nick}", "/api/tss/match/{session_id}",
|
||||||
|
"/api/tss/match/{session_id}/scoreboard", "/api/tss/matches/search",
|
||||||
|
"/api/tss/maps", "/api/tss/leaderboard/players",
|
||||||
|
"/api/tss/leaderboard/vehicles", "/api/tss/leaderboard/stats",
|
||||||
|
"/api/tss/tournaments", "/api/tss/tournament/{id}",
|
||||||
|
"/api/tss/tournament/{id}/standings", "/api/tss/tournament/{id}/matches",
|
||||||
|
]
|
||||||
|
|
||||||
|
info_router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@info_router.get("/api/tss/info")
|
||||||
|
async def info() -> dict:
|
||||||
|
matches = await db.query_one(db.battles_path(), "SELECT COUNT(*) c FROM match_summary")
|
||||||
|
pg = await db.query_one(db.battles_path(), "SELECT COUNT(*) c FROM player_games_hist")
|
||||||
|
tn = await db.query_one(db.tournaments_path(), "SELECT COUNT(*) c FROM tournaments")
|
||||||
|
return {
|
||||||
|
"service": "tss",
|
||||||
|
"counts": {
|
||||||
|
"matches": matches["c"] if matches else 0,
|
||||||
|
"player_games": pg["c"] if pg else 0,
|
||||||
|
"tournaments": tn["c"] if tn else 0,
|
||||||
|
},
|
||||||
|
"endpoints": ENDPOINTS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
app = FastAPI(title="TSS HTTP API", version="1.0.0")
|
||||||
|
|
||||||
|
@app.exception_handler(StarletteHTTPException)
|
||||||
|
async def _err(_: Request, exc: StarletteHTTPException) -> JSONResponse:
|
||||||
|
detail = exc.detail
|
||||||
|
body = detail if isinstance(detail, dict) else {"error": str(detail)}
|
||||||
|
return JSONResponse(status_code=exc.status_code, content=body)
|
||||||
|
|
||||||
|
app.include_router(info_router)
|
||||||
|
from web.api.routes_players import router as players_router
|
||||||
|
from web.api.routes_matches import router as matches_router
|
||||||
|
from web.api.routes_leaderboard import router as lb_router
|
||||||
|
from web.api.routes_tournaments import router as tour_router
|
||||||
|
app.include_router(players_router)
|
||||||
|
app.include_router(matches_router)
|
||||||
|
app.include_router(lb_router)
|
||||||
|
app.include_router(tour_router)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
# Make TSSBOT root importable for BOT.storage defaults.
|
||||||
|
_TSSBOT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
if str(_TSSBOT_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_TSSBOT_ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
def battles_path() -> Path:
|
||||||
|
override = os.getenv("TSS_API_BATTLES_DB", "").strip()
|
||||||
|
if override:
|
||||||
|
return Path(override)
|
||||||
|
from BOT.storage import TSS_BATTLES_DB_PATH
|
||||||
|
return TSS_BATTLES_DB_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def tournaments_path() -> Path:
|
||||||
|
override = os.getenv("TSS_API_TOURNAMENTS_DB", "").strip()
|
||||||
|
if override:
|
||||||
|
return Path(override)
|
||||||
|
from BOT.storage import STORAGE_DIR
|
||||||
|
return STORAGE_DIR / "tss_tournaments.db"
|
||||||
|
|
||||||
|
|
||||||
|
def _ro_uri(path: Path) -> str:
|
||||||
|
return f"file:{path}?mode=ro"
|
||||||
|
|
||||||
|
|
||||||
|
async def query(path: Path, sql: str, params: tuple[Any, ...] = ()) -> list[dict]:
|
||||||
|
async with aiosqlite.connect(_ro_uri(path), uri=True) as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
async with conn.execute(sql, params) as cur:
|
||||||
|
rows = await cur.fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def query_one(path: Path, sql: str, params: tuple[Any, ...] = ()) -> dict | None:
|
||||||
|
rows = await query(path, sql, params)
|
||||||
|
return rows[0] if rows else None
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
from web.api import db
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _window(start_date, end_date, tournament_id):
|
||||||
|
"""Return (where_sql, params) or raise 400 if no filter given."""
|
||||||
|
clauses: list[str] = []
|
||||||
|
params: list = []
|
||||||
|
if tournament_id is not None:
|
||||||
|
clauses.append("p.session_id IN (SELECT session_id FROM match_summary WHERE tournament_id = ?)")
|
||||||
|
params.append(tournament_id)
|
||||||
|
if start_date is not None:
|
||||||
|
clauses.append("p.endtime_unix >= ?"); params.append(start_date)
|
||||||
|
if end_date is not None:
|
||||||
|
clauses.append("p.endtime_unix <= ?"); params.append(end_date)
|
||||||
|
if not clauses:
|
||||||
|
raise HTTPException(status_code=400, detail={
|
||||||
|
"error": "a date window (start_date/end_date) or tournament_id is required",
|
||||||
|
"code": "FILTER_REQUIRED"})
|
||||||
|
return "WHERE " + " AND ".join(clauses), params
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/tss/leaderboard/players")
|
||||||
|
async def leaderboard_players(start_date: int | None = None, end_date: int | None = None,
|
||||||
|
tournament_id: int | None = None, limit: int = 100) -> dict:
|
||||||
|
where, params = _window(start_date, end_date, tournament_id)
|
||||||
|
params.append(max(1, min(limit, 500)))
|
||||||
|
rows = await db.query(db.battles_path(), f"""
|
||||||
|
SELECT p.UID uid, MAX(p.nick) nick, COUNT(*) battles,
|
||||||
|
SUM(CASE WHEN p.victor_bool='Win' THEN 1 ELSE 0 END) wins,
|
||||||
|
SUM(p.air_kills+p.ground_kills) kills, SUM(p.deaths) deaths, SUM(p.score) score
|
||||||
|
FROM player_games_hist p {where}
|
||||||
|
GROUP BY p.UID ORDER BY score DESC LIMIT ?""", tuple(params))
|
||||||
|
for r in rows:
|
||||||
|
r["uid"] = str(r["uid"])
|
||||||
|
return {"players": rows}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/tss/leaderboard/vehicles")
|
||||||
|
async def leaderboard_vehicles(start_date: int | None = None, end_date: int | None = None,
|
||||||
|
tournament_id: int | None = None, limit: int = 100) -> dict:
|
||||||
|
where, params = _window(start_date, end_date, tournament_id)
|
||||||
|
params.append(max(1, min(limit, 500)))
|
||||||
|
rows = await db.query(db.battles_path(), f"""
|
||||||
|
SELECT p.vehicle_internal, MAX(p.vehicle) vehicle, COUNT(*) battles,
|
||||||
|
SUM(p.air_kills+p.ground_kills) kills, SUM(p.deaths) deaths, SUM(p.score) score
|
||||||
|
FROM player_games_hist p {where}
|
||||||
|
GROUP BY p.vehicle_internal ORDER BY battles DESC LIMIT ?""", tuple(params))
|
||||||
|
return {"vehicles": rows}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/tss/leaderboard/stats")
|
||||||
|
async def leaderboard_stats(start_date: int | None = None, end_date: int | None = None,
|
||||||
|
tournament_id: int | None = None) -> dict:
|
||||||
|
where, params = _window(start_date, end_date, tournament_id)
|
||||||
|
totals = await db.query_one(db.battles_path(), f"""
|
||||||
|
SELECT COUNT(*) battles, COUNT(DISTINCT p.UID) players,
|
||||||
|
SUM(p.air_kills+p.ground_kills) kills, SUM(p.score) score
|
||||||
|
FROM player_games_hist p {where}""", tuple(params))
|
||||||
|
top = await db.query(db.battles_path(), f"""
|
||||||
|
SELECT p.vehicle_internal, MAX(p.vehicle) vehicle, COUNT(*) battles
|
||||||
|
FROM player_games_hist p {where}
|
||||||
|
GROUP BY p.vehicle_internal ORDER BY battles DESC LIMIT 10""", tuple(params))
|
||||||
|
return {"totals": totals or {}, "top_vehicles": top}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
from web.api import db
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/tss/live")
|
||||||
|
async def live(limit: int = 50) -> dict:
|
||||||
|
rows = await db.query(db.battles_path(),
|
||||||
|
"SELECT * FROM match_summary ORDER BY endtime_unix DESC LIMIT ?",
|
||||||
|
(max(1, min(limit, 200)),))
|
||||||
|
return {"total": len(rows), "matches": rows}
|
||||||
|
|
||||||
|
|
||||||
|
def _roster_team(rows: list[dict], slot: str, winning: str, losing: str, draw: int) -> dict:
|
||||||
|
players = [r for r in rows if (r.get("team_slot") or "") == slot]
|
||||||
|
for p in players:
|
||||||
|
p["UID"] = str(p["UID"])
|
||||||
|
return {
|
||||||
|
"team_slot": slot,
|
||||||
|
"team_name": players[0]["team_name"] if players else None,
|
||||||
|
"team_id": players[0]["team_id"] if players else None,
|
||||||
|
"is_winner": (not draw) and slot == winning,
|
||||||
|
"is_loser": (not draw) and slot == losing,
|
||||||
|
"players": players,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/tss/match/{session_id}")
|
||||||
|
async def match(session_id: str) -> dict:
|
||||||
|
summary = await db.query_one(db.battles_path(),
|
||||||
|
"SELECT * FROM match_summary WHERE session_id = ?", (session_id,))
|
||||||
|
if not summary:
|
||||||
|
raise HTTPException(status_code=404, detail=f"match {session_id} not found")
|
||||||
|
rows = await db.query(db.battles_path(),
|
||||||
|
"SELECT * FROM player_games_hist WHERE session_id = ?", (session_id,))
|
||||||
|
slots = sorted({(r.get("team_slot") or "") for r in rows if r.get("team_slot")})
|
||||||
|
teams = [_roster_team(rows, s, summary["winning_slot"], summary["losing_slot"],
|
||||||
|
summary["draw"]) for s in slots]
|
||||||
|
return {"match": summary, "teams": teams}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/tss/match/{session_id}/scoreboard")
|
||||||
|
async def scoreboard(session_id: str) -> dict:
|
||||||
|
base = await match(session_id) # reuses 404 + roster logic
|
||||||
|
logs_row = await db.query_one(db.battles_path(),
|
||||||
|
"SELECT chat_log_json, battle_log_json, event_log_json FROM match_logs WHERE session_id = ?",
|
||||||
|
(session_id,))
|
||||||
|
if logs_row:
|
||||||
|
def _parse(v):
|
||||||
|
try:
|
||||||
|
return json.loads(v) if v else None
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return None
|
||||||
|
logs = {"available": True,
|
||||||
|
"chat": _parse(logs_row["chat_log_json"]),
|
||||||
|
"battle": _parse(logs_row["battle_log_json"]),
|
||||||
|
"events": _parse(logs_row["event_log_json"])}
|
||||||
|
else:
|
||||||
|
logs = {"available": False}
|
||||||
|
return {**base, "logs": logs}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/tss/matches/search")
|
||||||
|
async def matches_search(player: str | None = None, team: str | None = None,
|
||||||
|
mission: str | None = None, tournament: str | None = None,
|
||||||
|
time_from: int | None = None, time_to: int | None = None,
|
||||||
|
limit: int = 50) -> dict:
|
||||||
|
clauses: list[str] = []
|
||||||
|
params: list = []
|
||||||
|
join = ""
|
||||||
|
if player or team:
|
||||||
|
join = "JOIN player_games_hist p ON p.session_id = m.session_id"
|
||||||
|
if player:
|
||||||
|
clauses.append("p.UID = ?"); params.append(player)
|
||||||
|
if team:
|
||||||
|
clauses.append("p.team_name = ?"); params.append(team)
|
||||||
|
if mission:
|
||||||
|
clauses.append("m.mission_name LIKE ?"); params.append(f"%{mission}%")
|
||||||
|
if tournament:
|
||||||
|
clauses.append("m.tournament_name LIKE ?"); params.append(f"%{tournament}%")
|
||||||
|
if time_from is not None:
|
||||||
|
clauses.append("m.endtime_unix >= ?"); params.append(time_from)
|
||||||
|
if time_to is not None:
|
||||||
|
clauses.append("m.endtime_unix <= ?"); params.append(time_to)
|
||||||
|
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||||
|
params.append(max(1, min(limit, 200)))
|
||||||
|
rows = await db.query(db.battles_path(),
|
||||||
|
f"SELECT DISTINCT m.* FROM match_summary m {join} {where} "
|
||||||
|
f"ORDER BY m.endtime_unix DESC LIMIT ?", tuple(params))
|
||||||
|
return {"total": len(rows), "matches": rows}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/tss/maps")
|
||||||
|
async def maps() -> dict:
|
||||||
|
rows = await db.query(db.battles_path(),
|
||||||
|
"SELECT DISTINCT mission_name, level_path FROM match_summary "
|
||||||
|
"WHERE mission_name IS NOT NULL ORDER BY mission_name")
|
||||||
|
return {"total": len(rows), "maps": rows}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
from web.api import db
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/tss/player/{uid}")
|
||||||
|
async def player(uid: str) -> dict:
|
||||||
|
bp = db.battles_path()
|
||||||
|
summary = await db.query_one(bp, """
|
||||||
|
SELECT COUNT(*) battles,
|
||||||
|
SUM(CASE WHEN victor_bool='Win' THEN 1 ELSE 0 END) wins,
|
||||||
|
SUM(CASE WHEN victor_bool!='Win' THEN 1 ELSE 0 END) losses,
|
||||||
|
SUM(ground_kills) ground_kills, SUM(air_kills) air_kills,
|
||||||
|
SUM(assists) assists, SUM(captures) captures, SUM(deaths) deaths,
|
||||||
|
SUM(score) score, AVG(pvp_ratio) avg_pvp_ratio
|
||||||
|
FROM player_games_hist WHERE UID = ?""", (uid,))
|
||||||
|
if not summary or summary["battles"] == 0:
|
||||||
|
raise HTTPException(status_code=404, detail=f"player {uid} not found")
|
||||||
|
vehicles = await db.query(bp, """
|
||||||
|
SELECT vehicle, vehicle_internal, COUNT(*) battles, SUM(air_kills+ground_kills) kills,
|
||||||
|
SUM(deaths) deaths, SUM(score) score
|
||||||
|
FROM player_games_hist WHERE UID = ?
|
||||||
|
GROUP BY vehicle_internal ORDER BY battles DESC""", (uid,))
|
||||||
|
nicks = await db.query(bp, """
|
||||||
|
SELECT nick, MIN(endtime_unix) first_seen, MAX(endtime_unix) last_seen
|
||||||
|
FROM player_games_hist WHERE UID = ? GROUP BY nick ORDER BY last_seen DESC""", (uid,))
|
||||||
|
team_history = await db.query(bp, """
|
||||||
|
SELECT team_id, team_name, tss_role, COUNT(*) battles,
|
||||||
|
MIN(endtime_unix) first_seen, MAX(endtime_unix) last_seen
|
||||||
|
FROM player_games_hist WHERE UID = ?
|
||||||
|
GROUP BY team_id, team_name, tss_role ORDER BY last_seen DESC""", (uid,))
|
||||||
|
win_rate = round(summary["wins"] / summary["battles"], 4) if summary["battles"] else 0.0
|
||||||
|
return {"uid": str(uid), "summary": {**summary, "win_rate": win_rate},
|
||||||
|
"vehicles": vehicles, "nicks": nicks, "team_history": team_history}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/tss/player/{uid}/games")
|
||||||
|
async def player_games(uid: str, limit: int = 100,
|
||||||
|
time_from: int | None = None, time_to: int | None = None) -> dict:
|
||||||
|
clauses = ["p.UID = ?"]
|
||||||
|
params: list = [uid]
|
||||||
|
if time_from is not None:
|
||||||
|
clauses.append("p.endtime_unix >= ?"); params.append(time_from)
|
||||||
|
if time_to is not None:
|
||||||
|
clauses.append("p.endtime_unix <= ?"); params.append(time_to)
|
||||||
|
params.append(max(1, min(limit, 1000)))
|
||||||
|
rows = await db.query(db.battles_path(), f"""
|
||||||
|
SELECT p.*, m.mission_name, m.tournament_name
|
||||||
|
FROM player_games_hist p LEFT JOIN match_summary m ON m.session_id = p.session_id
|
||||||
|
WHERE {' AND '.join(clauses)} ORDER BY p.endtime_unix DESC LIMIT ?""", tuple(params))
|
||||||
|
return {"uid": str(uid), "total": len(rows), "games": rows}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/tss/player/{uid}/history")
|
||||||
|
async def player_history(uid: str) -> dict:
|
||||||
|
rows = await db.query(db.battles_path(), """
|
||||||
|
SELECT date(endtime_unix, 'unixepoch') day, COUNT(*) battles,
|
||||||
|
SUM(CASE WHEN victor_bool='Win' THEN 1 ELSE 0 END) wins,
|
||||||
|
SUM(air_kills+ground_kills) kills, SUM(deaths) deaths, SUM(score) score
|
||||||
|
FROM player_games_hist WHERE UID = ? GROUP BY day ORDER BY day DESC""", (uid,))
|
||||||
|
return {"uid": str(uid), "days_with_battles_only": True, "history": rows}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/tss/search/{nick}")
|
||||||
|
async def search(nick: str, limit: int = 25) -> dict:
|
||||||
|
rows = await db.query(db.battles_path(), """
|
||||||
|
SELECT UID uid, nick, MAX(endtime_unix) last_seen, COUNT(*) battles
|
||||||
|
FROM player_games_hist WHERE nick LIKE ? COLLATE NOCASE
|
||||||
|
GROUP BY UID, nick ORDER BY last_seen DESC LIMIT ?""",
|
||||||
|
(f"%{nick}%", max(1, min(limit, 100))))
|
||||||
|
for r in rows:
|
||||||
|
r["uid"] = str(r["uid"])
|
||||||
|
return {"query": nick, "players": rows}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
from web.api import db
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _nullify(row: dict, keys: tuple[str, ...]) -> dict:
|
||||||
|
for k in keys:
|
||||||
|
if row.get(k) == "":
|
||||||
|
row[k] = None
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/tss/tournaments")
|
||||||
|
async def tournaments(status: str | None = None, game_mode: str | None = None,
|
||||||
|
cluster: str | None = None, limit: int = 100) -> dict:
|
||||||
|
clauses: list[str] = []
|
||||||
|
params: list = []
|
||||||
|
if status:
|
||||||
|
clauses.append("status = ?"); params.append(status)
|
||||||
|
if game_mode:
|
||||||
|
clauses.append("game_mode = ?"); params.append(game_mode)
|
||||||
|
if cluster:
|
||||||
|
clauses.append("cluster = ?"); params.append(cluster)
|
||||||
|
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||||
|
params.append(max(1, min(limit, 500)))
|
||||||
|
rows = await db.query(db.tournaments_path(),
|
||||||
|
f"SELECT * FROM tournaments {where} ORDER BY date_end DESC LIMIT ?", tuple(params))
|
||||||
|
return {"total": len(rows), "tournaments": rows}
|
||||||
|
|
||||||
|
|
||||||
|
async def _matches(tid: int) -> list[dict]:
|
||||||
|
rows = await db.query(db.tournaments_path(),
|
||||||
|
"SELECT * FROM tournament_matches WHERE tournament_id = ? ORDER BY round, position",
|
||||||
|
(tid,))
|
||||||
|
battles = await db.query(db.tournaments_path(),
|
||||||
|
"SELECT match_id, type_bracket, session_hex FROM tournament_battles WHERE tournament_id = ?",
|
||||||
|
(tid,))
|
||||||
|
by_match: dict[tuple, list[str]] = {}
|
||||||
|
for b in battles:
|
||||||
|
by_match.setdefault((b["match_id"], b["type_bracket"]), []).append(b["session_hex"])
|
||||||
|
out = []
|
||||||
|
for m in rows:
|
||||||
|
_nullify(m, ("round", "position"))
|
||||||
|
m["session_ids"] = by_match.get((m["match_id"], m["type_bracket"]), [])
|
||||||
|
out.append(m)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/tss/tournament/{tid}")
|
||||||
|
async def tournament(tid: int) -> dict:
|
||||||
|
meta = await db.query_one(db.tournaments_path(),
|
||||||
|
"SELECT * FROM tournaments WHERE tournament_id = ?", (tid,))
|
||||||
|
if not meta:
|
||||||
|
raise HTTPException(status_code=404, detail=f"tournament {tid} not found")
|
||||||
|
standings = await db.query(db.tournaments_path(),
|
||||||
|
"SELECT * FROM tournament_standings WHERE tournament_id = ? ORDER BY group_index, rank",
|
||||||
|
(tid,))
|
||||||
|
return {"tournament": meta, "standings": standings, "matches": await _matches(tid)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/tss/tournament/{tid}/standings")
|
||||||
|
async def standings(tid: int) -> dict:
|
||||||
|
rows = await db.query(db.tournaments_path(),
|
||||||
|
"SELECT * FROM tournament_standings WHERE tournament_id = ? ORDER BY group_index, rank",
|
||||||
|
(tid,))
|
||||||
|
return {"tournament_id": tid, "standings": rows}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/tss/tournament/{tid}/matches")
|
||||||
|
async def tournament_matches(tid: int) -> dict:
|
||||||
|
rows = await _matches(tid)
|
||||||
|
return {"tournament_id": tid, "total": len(rows), "matches": rows}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
_HERE = Path(__file__).resolve().parent.parent
|
||||||
|
load_dotenv(_HERE / ".env")
|
||||||
|
|
||||||
|
import uvicorn # noqa: E402
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run(
|
||||||
|
"web.api.app:app",
|
||||||
|
host=os.getenv("TSS_API_HOST", "127.0.0.1"),
|
||||||
|
port=int(os.getenv("TSS_API_PORT", "6100")),
|
||||||
|
reload=False,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user