Compare commits
10 Commits
| 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()
|
||||
if not internal:
|
||||
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 []
|
||||
tag_set = set(tags)
|
||||
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
|
||||
|
||||
|
||||
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]],
|
||||
player_id: int,
|
||||
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):
|
||||
continue
|
||||
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 []
|
||||
if not isinstance(path_raw, list):
|
||||
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)
|
||||
killer_id = _to_int(kill.get("offender_uid"), 0)
|
||||
kill_time = float(kill.get("time") or 0.0)
|
||||
victim_model = _unit_to_model_name(str(kill.get("offended_unit") or ""))
|
||||
killer_model = _unit_to_model_name(str(kill.get("offender_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_model_path") or kill.get("offender_unit") or ""))
|
||||
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)
|
||||
victim_pos = _position_at_time(victim_entity.get("Path", []), kill_time) if victim_entity else None
|
||||
killer_pos = _position_at_time(killer_entity.get("Path", []), kill_time) if killer_entity else None
|
||||
victim_pos = (
|
||||
_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] = {
|
||||
"Time": kill_time,
|
||||
"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),
|
||||
"OffenderID": _to_int(dmg.get("offender_uid"), 0),
|
||||
"OffendedID": _to_int(dmg.get("offended_uid"), 0),
|
||||
"OffenderModel": _unit_to_model_name(str(dmg.get("offender_unit") or "")),
|
||||
"OffendedModel": _unit_to_model_name(str(dmg.get("offended_unit") or "")),
|
||||
"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_model_path") or dmg.get("offended_unit") or "")),
|
||||
"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):
|
||||
continue
|
||||
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 []
|
||||
if not isinstance(path_raw, list):
|
||||
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)
|
||||
killer_id = _to_int(kill.get("offender_uid"), 0)
|
||||
kill_time = float(kill.get("time") or 0.0)
|
||||
victim_model = _unit_to_model_name(str(kill.get("offended_unit") or ""))
|
||||
killer_model = _unit_to_model_name(str(kill.get("offender_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_model_path") or kill.get("offender_unit") or ""))
|
||||
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)
|
||||
victim_pos = _position_at_time(victim_entity.get("Path", []), kill_time) if victim_entity else None
|
||||
killer_pos = _position_at_time(killer_entity.get("Path", []), kill_time) if killer_entity else None
|
||||
victim_pos = (
|
||||
_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] = {
|
||||
"Time": kill_time,
|
||||
"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),
|
||||
"OffenderID": _to_int(dmg.get("offender_uid"), 0),
|
||||
"OffendedID": _to_int(dmg.get("offended_uid"), 0),
|
||||
"OffenderModel": _unit_to_model_name(str(dmg.get("offender_unit") or "")),
|
||||
"OffendedModel": _unit_to_model_name(str(dmg.get("offended_unit") or "")),
|
||||
"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_model_path") or dmg.get("offended_unit") or "")),
|
||||
"Afire": bool(dmg.get("afire", False)),
|
||||
})
|
||||
|
||||
|
||||
+46
-2
@@ -23,8 +23,48 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from spectra_replay_normalize import strip_model_prefix
|
||||
|
||||
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
|
||||
@@ -377,14 +417,18 @@ async def insert_player_games(game: Dict[str, Any]) -> None:
|
||||
continue
|
||||
|
||||
for unit in used_units:
|
||||
vehicle_fields = _unit_vehicle_fields(unit)
|
||||
if vehicle_fields is None:
|
||||
continue
|
||||
vehicle, internal = vehicle_fields
|
||||
rows.append((
|
||||
str(uid_str),
|
||||
str(p.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")
|
||||
session_id,
|
||||
str(unit.get("unit_normalized") or ""),
|
||||
str(unit.get("unit") or ""),
|
||||
vehicle,
|
||||
internal,
|
||||
int(p.get("ground_kills") or 0),
|
||||
int(p.get("air_kills") or 0),
|
||||
int(p.get("assists") or 0),
|
||||
|
||||
+12
-6
@@ -14,6 +14,8 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from spectra_replay_normalize import strip_model_prefix
|
||||
|
||||
log = logging.getLogger("tssbot.transform")
|
||||
|
||||
# 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
|
||||
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]] = []
|
||||
for u in units or []:
|
||||
internal = str(u.get("unit") or "").strip()
|
||||
internal = strip_model_prefix(u.get("unit"))
|
||||
if not internal:
|
||||
continue
|
||||
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({
|
||||
"internal": internal,
|
||||
"name": translate(internal) or u.get("unit_normalized") or internal,
|
||||
"name": translate(internal) or internal,
|
||||
"used": bool(u.get("used")),
|
||||
"dead": is_dead,
|
||||
"unit_type": u.get("unit_type"),
|
||||
"unit_class": u.get("unit_class"),
|
||||
"unit_country": u.get("unit_country"),
|
||||
})
|
||||
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 []
|
||||
for k in kills:
|
||||
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:
|
||||
out.setdefault(uid, set()).add(unit)
|
||||
out.setdefault(uid, set()).add(unit.lower())
|
||||
return out
|
||||
|
||||
|
||||
@@ -143,6 +148,7 @@ def build_scoreboard_model(game: dict[str, Any], lang_column: str = "<English>")
|
||||
"fake_nick": None,
|
||||
"tag": str(p.get("tag") or ""),
|
||||
"country_id": p.get("country_id"),
|
||||
"country": p.get("country"),
|
||||
"air_kills": int(p.get("air_kills") or 0),
|
||||
"ground_kills": int(p.get("ground_kills") or 0),
|
||||
"assists": int(p.get("assists") or 0),
|
||||
|
||||
+432
-108
@@ -17,15 +17,17 @@ the captured sample payloads.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
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
|
||||
|
||||
@@ -36,6 +38,7 @@ TSS_TOURNAMENTS_DB_PATH: Path = STORAGE_DIR / "tss_tournaments.db"
|
||||
_API_URL = "https://tss.warthunder.com/functions.php"
|
||||
_API_HEADERS = {"Content-Type": "application/x-www-form-urlencoded"}
|
||||
_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
|
||||
# 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"
|
||||
|
||||
|
||||
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(
|
||||
type_brackets: Any, type_tournament: Optional[str] = None
|
||||
) -> str:
|
||||
@@ -426,20 +448,28 @@ def compute_status(
|
||||
now: int,
|
||||
in_active: Optional[bool] = True,
|
||||
) -> 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
|
||||
— this is the reliable finished signal for old tournaments that carry no
|
||||
``date_end``, and it stops ``needs_scan`` from re-scanning them forever.
|
||||
Presence in ``GetActiveTournaments`` is the authoritative live/over signal:
|
||||
absence (``in_active`` False) is over, presence (True) is live. ``date_end``
|
||||
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:
|
||||
return "finished"
|
||||
if not matches:
|
||||
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):
|
||||
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"
|
||||
|
||||
|
||||
@@ -515,18 +545,15 @@ def _active_meta(tournament_id: int, now: int) -> Tuple[Optional[Dict[str, Any]]
|
||||
return meta, meta is not None
|
||||
|
||||
|
||||
def build_scan_sync(
|
||||
def gather_structure_sync(
|
||||
tournament_id: int,
|
||||
*,
|
||||
fallback_name: Optional[str] = None,
|
||||
active_meta: Optional[Dict[str, Any]] = None,
|
||||
now: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Fetch + assemble the full authoritative structure for one tournament.
|
||||
|
||||
Network-bound; call via ``run_in_thread``. Returns a dict of rows ready for
|
||||
``store_scan``.
|
||||
"""
|
||||
"""Fetch + assemble the bracket structure (matches, standings, meta) for one
|
||||
tournament. Network-bound; call via ``run_in_thread``. No battles fetched."""
|
||||
now = now or int(time.time())
|
||||
if active_meta is None:
|
||||
active_meta, in_active = _active_meta(tournament_id, now)
|
||||
@@ -542,14 +569,12 @@ def build_scan_sync(
|
||||
if raw_matches:
|
||||
matches = [parse_schedule_match(r, names) for r in raw_matches if isinstance(r, dict)]
|
||||
else:
|
||||
# Empty schedule can mean bracket fallback, swiss/group, or pre-start.
|
||||
bracket = _data_dict(_request("POST", "GetArrayBracketData", tournamentID=tournament_id))
|
||||
bracket_rows = collect_group_rows(bracket.get("bracket") or bracket)
|
||||
if bracket_rows:
|
||||
matches = [parse_schedule_match(r, names) for r in bracket_rows if isinstance(r, dict)]
|
||||
|
||||
if not matches:
|
||||
# Empty schedule/bracket → swiss/group. Try swiss, then group.
|
||||
for action, kw in (
|
||||
("GetArraySwissData", {"tournamentID": tournament_id, "id_group": 1}),
|
||||
("GetArrayGroupData", {"tournamentID": tournament_id}),
|
||||
@@ -561,21 +586,7 @@ def build_scan_sync(
|
||||
standings = parse_standings(data.get("GroupStage"))
|
||||
break
|
||||
|
||||
# Battles per match → session links. Dedupe match_ids (same id can repeat
|
||||
# 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)
|
||||
apply_tournament_side_context(matches)
|
||||
|
||||
type_set = {m["type_bracket"] for m in matches}
|
||||
meta = active_meta or {}
|
||||
@@ -596,73 +607,128 @@ def build_scan_sync(
|
||||
"status": compute_status(matches, date_end, now, in_active),
|
||||
"scanned_unix": now,
|
||||
"matches": matches,
|
||||
"battles": battles,
|
||||
"standings": standings,
|
||||
"in_active": in_active,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Storage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def store_scan(scan: Dict[str, Any]) -> None:
|
||||
"""Upsert one assembled scan into ``tss_tournaments.db`` transactionally."""
|
||||
_store_scan_sync(scan)
|
||||
def fetch_schedule_sync(
|
||||
tournament_id: int,
|
||||
*,
|
||||
fallback_name: Optional[str] = None,
|
||||
now: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""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:
|
||||
"""Synchronous implementation for ``store_scan``; run off the event loop."""
|
||||
tid = scan["tournament_id"]
|
||||
now = scan["scanned_unix"]
|
||||
def build_scan_sync(
|
||||
tournament_id: int,
|
||||
*,
|
||||
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:
|
||||
conn.execute(
|
||||
rows = 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
|
||||
SELECT m.match_id, m.type_bracket
|
||||
FROM tournament_matches m
|
||||
WHERE m.tournament_id = ?
|
||||
AND m.status IN ('played', 'technical')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM tournament_battles b
|
||||
WHERE b.tournament_id = m.tournament_id
|
||||
AND b.match_id = m.match_id
|
||||
AND b.type_bracket = m.type_bracket
|
||||
)
|
||||
""",
|
||||
(
|
||||
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,
|
||||
),
|
||||
)
|
||||
# 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,))
|
||||
(tid,),
|
||||
).fetchall()
|
||||
return [{"match_id": r[0], "type_bracket": r[1]} for r in rows]
|
||||
|
||||
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 scan["matches"]
|
||||
],
|
||||
)
|
||||
|
||||
def _insert_battles_sync(battles: List[Dict[str, Any]]) -> None:
|
||||
"""Upsert battle rows without deleting anything (preserves fast-path links)."""
|
||||
if not battles:
|
||||
return
|
||||
with _connect() as conn:
|
||||
conn.executemany(
|
||||
"""
|
||||
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["winner_name"], b["status_replay"],
|
||||
)
|
||||
for b in scan["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"]
|
||||
for b in battles
|
||||
],
|
||||
)
|
||||
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:
|
||||
"""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())
|
||||
row = _scan_state_sync(tournament_id)
|
||||
if row is None:
|
||||
return True
|
||||
status, date_end, scanned_unix = row
|
||||
status, _date_end, scanned_unix = row
|
||||
if status == "finished":
|
||||
return False
|
||||
if date_end and now > date_end + RESCAN_END_BUFFER_SECONDS:
|
||||
return False
|
||||
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
|
||||
_inflight: 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:
|
||||
@@ -732,6 +951,83 @@ async def _ensure_db() -> None:
|
||||
_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:
|
||||
"""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."""
|
||||
@@ -777,6 +1073,34 @@ async def scan_and_store(
|
||||
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):
|
||||
"""Run a blocking callable without relying on asyncio's default executor."""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
@@ -10,11 +10,21 @@ const DEPLOY_PATH = __dirname;
|
||||
// SHARED/requirements.txt (both bots also share BOTS/SHARED for code).
|
||||
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 = {
|
||||
apps: [
|
||||
// Discord Bot
|
||||
{
|
||||
name: 'tssbot',
|
||||
...RESTART_POLICY,
|
||||
script: 'start_bot.py',
|
||||
interpreter: PY_INTERPRETER,
|
||||
cwd: DEPLOY_PATH,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
@@ -8,6 +8,7 @@ import asyncio
|
||||
import pathlib
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
@@ -23,7 +24,12 @@ except Exception:
|
||||
pass
|
||||
|
||||
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]]]:
|
||||
@@ -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]
|
||||
|
||||
|
||||
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:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--limit", type=int, default=None)
|
||||
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()
|
||||
|
||||
rows = await tournament_ids(args.limit)
|
||||
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:
|
||||
for tid, name in rows:
|
||||
print(f" {tid}: {name or 'Tournament ' + str(tid)}")
|
||||
return
|
||||
|
||||
await init_tss_tournaments_db()
|
||||
for index, (tid, name) in enumerate(rows, start=1):
|
||||
print(f"[{index}/{len(rows)}] scanning tournament {tid}")
|
||||
await scan_and_store(tid, fallback_name=name)
|
||||
started = time.monotonic()
|
||||
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):
|
||||
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())
|
||||
assert model is not None
|
||||
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"
|
||||
|
||||
|
||||
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():
|
||||
assert tt.derive_format([], "double-elumination") == "double-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"
|
||||
|
||||
|
||||
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():
|
||||
played = [{"status": "played"}, {"status": "bye"}]
|
||||
mixed = [{"status": "played"}, {"status": "pending"}]
|
||||
assert tt.compute_status([], None, 1000) == "pending"
|
||||
assert tt.compute_status(played, None, 1000) == "finished"
|
||||
assert tt.compute_status(mixed, None, 1000) == "active"
|
||||
# past end + buffer → finished even with pending matches
|
||||
assert tt.compute_status(mixed, 100, 100 + tt.RESCAN_END_BUFFER_SECONDS + 1) == "finished"
|
||||
# A tournament still listed as active is NOT finished just because it overran
|
||||
# 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)
|
||||
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"
|
||||
|
||||
|
||||
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():
|
||||
names = tt.team_name_map(SCHEDULE["all_teams"])
|
||||
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"
|
||||
).fetchone()
|
||||
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.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
|
||||
|
||||
_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:
|
||||
"""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)
|
||||
game["_id"] = sid # hex from this point forward
|
||||
session_dir = REPLAYS_DIR / sid
|
||||
@@ -90,6 +93,10 @@ def normalize(data: Any) -> Optional[List[Dict[str, Any]]]:
|
||||
if isinstance(data, dict):
|
||||
if "_id" in data or "id" in 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:
|
||||
return data["completed"]
|
||||
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)
|
||||
except Exception as 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
|
||||
# and stale), scan its authoritative bracket in the background. Never blocks.
|
||||
# Forward the processed game to the relay gateway (tss channel). Best-effort:
|
||||
# a bridge failure must never break ingest.
|
||||
try:
|
||||
from BOT.tss_tournaments import maybe_scan_tournament
|
||||
await maybe_scan_tournament(game)
|
||||
await publish_replay_batch([game])
|
||||
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).
|
||||
try:
|
||||
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