Compare commits

...

10 Commits

Author SHA1 Message Date
NotSoToothless cfd2458ab3 fix some scoreboard shit (#1364) 2026-06-30 07:22:13 -07:00
deploy c417226e9e fix: case-insensitive dead-vehicle matching for TSS scoreboard
Spectra sends events.kills[].offended_unit with uppercase roman
numerals (V/VI) while players[].units[].unit uses lowercase (v/vi).
85% of TSS games are affected — dead-vehicle strikethrough on the
Discord scoreboard never triggered because  was
case-sensitive.

Normalize both sides to lowercase before comparing.
2026-06-30 14:03:44 +00:00
NotSoToothless 6d251be97c update for spectra changes (#1363) 2026-06-29 11:05:51 -07:00
deploy fa203c653b pm2: add crash-loop governor to all apps; lower srebot max_memory_restart to 12000M
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 12:58:56 +00:00
NotSoToothless a5abecb918 Auto merge dev → main (#1361)
* fix: don't freeze tournament brackets that overrun their scheduled end

date_end (TSS dateEndTournament) is only the *scheduled* end; tournaments
overrun it. compute_status now trusts in_active (GetActiveTournaments
presence) over date_end, and needs_scan no longer disables re-scans past
date_end + buffer. Fixes brackets stuck while their final was still pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor: split _store_scan_sync into reusable upsert helpers

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: store_schedule writes meta/matches/standings without wiping battles

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor: extract gather_structure_sync; add fetch_schedule_sync (no battles)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: reconcile_battles gap-fills replay links for played matches only

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: debounced schedule refresh coalesces game bursts into one TSS call

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: maybe_link_battle links replays from game match_id with zero TSS calls

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: route received games through maybe_link_battle (fast-path bracket link)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* tss_tournaments: three small final-review cleanups

- link_battle_sync: drop unused team_a_name/team_b_name from SELECT and
  fix status index from r[3] to r[1]
- _replace_battles: add clarifying comment about fast-path caveat and
  that schedule refresh deliberately skips this function
- store_scan / store_schedule: dispatch sqlite writes off the event loop
  via run_in_thread instead of calling sync writers directly

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 04:23:34 -07:00
NotSoToothless 43c2837f6d meow (#1360) 2026-06-29 02:28:49 -07:00
FURRO404 2b792b4d0b tssbot-api: load dotenv before uvicorn to pick up STORAGE_VOL_PATH 2026-06-28 07:00:47 -07:00
NotSoToothless 24335a2677 Auto merge dev → main (#1353)
* feat(gateway): hashed key store with grant + hot reload

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(gateway): channel registry + aiohttp app (keyed auth, whoami, per-channel ws/proxy)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(gateway): manage_keys CLI (add/list/revoke)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(gateway): retire srebot_external, run relay-gateway under PM2

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(gateway): point ecosystem + README at relay-gateway

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(tss): replay outbox producer for relay gateway

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(tss): forward processed games to relay outbox

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(tss-api): db helpers, app skeleton, info endpoint, fixtures

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(tss-api): player, games, history, search endpoints

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(tss-api): live, match, scoreboard, matches-search, maps

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(tss-api): filter-required leaderboards (players/vehicles/stats)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(tss-api): tournament list/detail/standings/matches

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: wire tss upstream through gateway + tssbot-api PM2 app

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 03:38:20 -07:00
NotSoToothless f2a1a33c28 add tss tournament stuff (#1349) 2026-06-20 21:56:03 -07:00
NotSoToothless da66722e03 add tss tournament stuff (#1348) 2026-06-20 21:25:08 -07:00
28 changed files with 1951 additions and 142 deletions
+60
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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),
+385 -61
View File
@@ -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,25 +607,181 @@ 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,
}
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 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:
rows = conn.execute(
"""
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,),
).fetchall()
return [{"match_id": r[0], "type_bracket": r[1]} for r in rows]
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
(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
],
)
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."""
_store_scan_sync(scan)
await run_in_thread(_store_scan_sync, scan)
def _store_scan_sync(scan: Dict[str, Any]) -> None:
"""Synchronous implementation for ``store_scan``; run off the event loop."""
def _upsert_tournament_meta(conn: sqlite3.Connection, scan: Dict[str, Any]) -> None:
tid = scan["tournament_id"]
now = scan["scanned_unix"]
with _connect() as conn:
conn.execute(
"""
INSERT INTO tournaments
@@ -640,11 +807,10 @@ def _store_scan_sync(scan: Dict[str, Any]) -> None:
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,))
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
@@ -660,25 +826,13 @@ def _store_scan_sync(scan: Dict[str, Any]) -> None:
m["team_b_name"], m["winner_name"], m["score_a"], m["score_b"],
m["status"], m["time_start"],
)
for m in scan["matches"]
],
)
conn.executemany(
"""
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 scan["battles"]
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
@@ -692,23 +846,83 @@ def _store_scan_sync(scan: Dict[str, Any]) -> None:
s["points"], s["wins"], s["draws"], s["losses"],
s["buchholz"], s["rank"],
)
for s in scan["standings"]
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
View File
@@ -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,
+2
View File
@@ -0,0 +1,2 @@
[pytest]
asyncio_mode = auto
+45 -4
View File
@@ -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)
+252
View File
@@ -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())
+96
View File
@@ -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)
+33
View File
@@ -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()
+21
View File
@@ -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")
+39
View File
@@ -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"
+9
View File
@@ -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"]
+24
View File
@@ -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"
+35
View File
@@ -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)
+37
View File
@@ -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"
+28
View File
@@ -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
+260 -3
View File
@@ -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())
+19 -5
View File
@@ -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)
View File
+60
View File
@@ -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()
+47
View File
@@ -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
+69
View File
@@ -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}
+104
View File
@@ -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}
+77
View File
@@ -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}
+76
View File
@@ -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
View File
@@ -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,
)