diff --git a/BOT/tss_tournaments.py b/BOT/tss_tournaments.py new file mode 100644 index 0000000..bf675bb --- /dev/null +++ b/BOT/tss_tournaments.py @@ -0,0 +1,794 @@ +"""TSSBOT tournament structure layer — ``tss_tournaments.db``. + +The website needs the *authoritative* bracket of each tournament, not one +reconstructed from the replays we happened to capture (a partial sample). The +TSS site exposes the full structure; this module fetches it and stores it so the +website can read it read-only and overlay the replays we hold. + +Trigger: when a game arrives carrying ``tss.tournament_id`` we haven't indexed +(or whose tournament is still live and stale), ``tss_ws`` schedules +``scan_and_store`` in the background. + +Everything we know about the TSS API (endpoints, fields, examples, edge cases) +is documented in ``TSSBOT/docs/tss_tournament_api_reference.md``. The pure parse +helpers below are intentionally network-free so they can be unit-tested against +the captured sample payloads. +""" +from __future__ import annotations + +import asyncio +import json +import logging +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 BOT.storage import STORAGE_DIR + +log = logging.getLogger("tssbot.tss_tournaments") + +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 + +# 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). +RESCAN_INTERVAL_SECONDS = 600 +RESCAN_END_BUFFER_SECONDS = 6 * 3600 + + +# --------------------------------------------------------------------------- +# Schema +# --------------------------------------------------------------------------- + +_TOURNAMENTS_SQL = """ +CREATE TABLE IF NOT EXISTS 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 +) +""" + +_TOURNAMENT_MATCHES_SQL = """ +CREATE TABLE IF NOT EXISTS 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) +) +""" + +_TOURNAMENT_BATTLES_SQL = """ +CREATE TABLE IF NOT EXISTS 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 +) +""" + +_TOURNAMENT_STANDINGS_SQL = """ +CREATE TABLE IF NOT EXISTS 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) +) +""" + +_INDEXES = [ + "CREATE INDEX IF NOT EXISTS idx_tm_tournament ON tournament_matches(tournament_id)", + "CREATE INDEX IF NOT EXISTS idx_tb_tournament ON tournament_battles(tournament_id)", + "CREATE INDEX IF NOT EXISTS idx_tb_match ON tournament_battles(tournament_id, match_id)", + "CREATE INDEX IF NOT EXISTS idx_ts_tournament ON tournament_standings(tournament_id)", + "CREATE INDEX IF NOT EXISTS idx_tournaments_end ON tournaments(date_end)", +] + +_PRAGMAS = ( + "PRAGMA journal_mode=WAL;", + "PRAGMA synchronous=NORMAL;", + "PRAGMA busy_timeout=5000;", + "PRAGMA temp_store=MEMORY;", +) + + +async def init_tss_tournaments_db() -> None: + """Create ``tss_tournaments.db`` if absent. Idempotent.""" + _init_tss_tournaments_db_sync() + log.info("tss_tournaments.db initialised: %s", TSS_TOURNAMENTS_DB_PATH) + + +def _connect() -> sqlite3.Connection: + conn = sqlite3.connect(TSS_TOURNAMENTS_DB_PATH, timeout=5) + for sql in _PRAGMAS: + conn.execute(sql) + return conn + + +def _init_tss_tournaments_db_sync() -> None: + with _connect() as conn: + conn.execute(_TOURNAMENTS_SQL) + conn.execute(_TOURNAMENT_MATCHES_SQL) + conn.execute(_TOURNAMENT_BATTLES_SQL) + conn.execute(_TOURNAMENT_STANDINGS_SQL) + for sql in _INDEXES: + conn.execute(sql) + conn.commit() + + +# --------------------------------------------------------------------------- +# Pure helpers (network-free, unit-tested) +# --------------------------------------------------------------------------- + +def to_hex(decimal_url: Any) -> Optional[str]: + """Convert a TSS decimal ``url`` to our hex session id (matches tss_ws).""" + if decimal_url in (None, ""): + return None + try: + return hex(int(decimal_url))[2:].lower() + except (ValueError, TypeError): + return None + + +def normalize_side(type_bracket: Optional[str]) -> str: + """Bucket a TSS bracket/group label into a display side. + + Order matters: ``LooserFinal`` contains both 'looser' and 'final'. + """ + t = (type_bracket or "").lower() + if "swiss" in t: + return "swiss" + if "group" in t: + return "group" + if "looser" in t or "loser" in t: + return "loser" + if "semifinal" in t or "final" in t: + return "final" + if "winner" in t: + return "winner" + return t or "match" + + +def derive_format( + type_brackets: Any, type_tournament: Optional[str] = None +) -> str: + """Authoritative format. ``typeTournament`` wins; else derive from sides.""" + tt = (type_tournament or "").lower() + if "swiss" in tt: + return "swiss" + if tt.startswith("group"): + return "group" + if "double" in tt: + return "double-elim" + if "single" in tt: + return "single-elim" + + sides = {str(t).lower() for t in (type_brackets or [])} + if any("swiss" in s for s in sides): + return "swiss" + if any("group" in s for s in sides): + return "group" + if any(("looser" in s or "loser" in s) for s in sides): + return "double-elim" + if any(("winner" in s or "final" in s) for s in sides): + return "single-elim" + return "unknown" + + +def team_name_map(all_teams: Any) -> Dict[str, str]: + """Build {team_uuid: realName}, skipping the empty-UUID rows TSS sometimes emits.""" + out: Dict[str, str] = {} + for row in all_teams or []: + if not isinstance(row, dict): + continue + uuid = row.get("teamName") + name = row.get("realName") + if uuid: + out[str(uuid)] = name or "" + return out + + +def _as_int(value: Any) -> Optional[int]: + try: + return int(value) + except (ValueError, TypeError): + return None + + +def parse_schedule_match(row: Dict[str, Any], names: Dict[str, str]) -> Dict[str, Any]: + """Normalize an elimination match row. + + Schedule rows carry ``matchNumber``/``finished``; bracket fallback rows do not, + but they often carry inline ``realNameA/B``. Handle both shapes. + """ + team_a = row.get("teamA") or "" + team_b = row.get("teamB") or "" + winner = row.get("winner") or "" + type_bracket = str(row.get("typeBracket") or "Winner") + finished = str(row.get("finished") or "") == "1" + if "finished" not in row and (winner or row.get("matchResult")): + finished = True + has_both = bool(team_a) and bool(team_b) + + if has_both: + status = "played" if finished else "pending" + elif winner: + status = "bye" + else: + status = "pending" + + return { + "match_id": str(row.get("id") or ""), + "type_bracket": type_bracket, + "side": normalize_side(type_bracket), + "round": _as_int(row.get("round")), + "position": _as_int(row.get("matchNumber")), + "team_a_uuid": team_a or None, + "team_a_name": (names.get(team_a) if team_a else None) or row.get("realNameA") or None, + "team_b_uuid": team_b or None, + "team_b_name": (names.get(team_b) if team_b else None) or row.get("realNameB") or None, + "winner_name": (names.get(winner) if winner else None) + or (row.get("realNameA") if winner and winner == team_a else None) + or (row.get("realNameB") if winner and winner == team_b else None), + "score_a": _as_int(row.get("scoreA")) or 0, + "score_b": _as_int(row.get("scoreB")) or 0, + "status": status, + "time_start": _as_int(row.get("timeStart")), + } + + +def parse_group_match(row: Dict[str, Any]) -> Dict[str, Any]: + """Normalize a ``GroupMatch[]`` (swiss/group) row — names are inline.""" + team_a = row.get("teamA") or "" + team_b = row.get("teamB") or "" + winner = row.get("winner") or "" + type_bracket = str(row.get("typeGroup") or "Group") + name_a = row.get("realNameA") + name_b = row.get("realNameB") + winner_name = None + if winner: + if winner == team_a: + winner_name = name_a + elif winner == team_b: + winner_name = name_b + played = bool(row.get("statsStatus")) + + return { + "match_id": str(row.get("id") or ""), + "type_bracket": type_bracket, + "side": normalize_side(type_bracket), + "round": None, + "position": None, + "team_a_uuid": team_a or None, + "team_a_name": name_a, + "team_b_uuid": team_b or None, + "team_b_name": name_b, + "winner_name": winner_name, + "score_a": _as_int(row.get("scoreA")) or 0, + "score_b": _as_int(row.get("scoreB")) or 0, + "status": "played" if played else "pending", + "time_start": _as_int(row.get("timeStart")), + } + + +def collect_group_rows(value: Any) -> List[Dict[str, Any]]: + """Recursively pull match-like dicts from nested bracket/group/swiss structures.""" + out: List[Dict[str, Any]] = [] + if isinstance(value, dict): + if value.get("id") and ("teamA" in value or "idTeamA" in value): + out.append(value) + for child in value.values(): + out.extend(collect_group_rows(child)) + elif isinstance(value, list): + for child in value: + out.extend(collect_group_rows(child)) + return out + + +def fill_names_from_battles(match: Dict[str, Any], rows: Any) -> None: + """Use getListAllBattles team objects as a fallback name source. + + ``all_teams`` occasionally omits a UUID. Battle rows include full team + objects, but the object order is not a reliable slot signal, so match by UUID. + """ + if not isinstance(rows, list): + return + uuid_to_name: Dict[str, str] = {} + for row in rows: + if not isinstance(row, dict): + continue + for slot in ("teamA", "teamB"): + team = row.get(slot) + if not isinstance(team, dict): + continue + uuid = team.get("teamName") + name = team.get("realName") + if uuid and name: + uuid_to_name[str(uuid)] = str(name) + + if not match.get("team_a_name") and match.get("team_a_uuid"): + match["team_a_name"] = uuid_to_name.get(str(match["team_a_uuid"])) + if not match.get("team_b_name") and match.get("team_b_uuid"): + match["team_b_name"] = uuid_to_name.get(str(match["team_b_uuid"])) + if not match.get("winner_name"): + for uuid_key in ("team_a_uuid", "team_b_uuid"): + uuid = match.get(uuid_key) + if uuid and str(uuid) in uuid_to_name: + if ( + uuid_key == "team_a_uuid" + and match.get("score_a", 0) > match.get("score_b", 0) + ) or ( + uuid_key == "team_b_uuid" + and match.get("score_b", 0) > match.get("score_a", 0) + ): + match["winner_name"] = uuid_to_name[str(uuid)] + + +def parse_standings(group_stage: Any) -> List[Dict[str, Any]]: + """Flatten ``GroupStage`` (list of groups of standings rows).""" + out: List[Dict[str, Any]] = [] + if not isinstance(group_stage, list): + return out + for group_index, group in enumerate(group_stage): + rows = group if isinstance(group, list) else [group] + for rank, row in enumerate(rows, start=1): + if not isinstance(row, dict) or not row.get("teamName"): + continue + try: + buchholz = float(row.get("buchholz_points") or 0) + except (ValueError, TypeError): + buchholz = 0.0 + out.append({ + "group_index": group_index, + "team_uuid": str(row.get("teamName")), + "team_name": row.get("realName"), + "points": _as_int(row.get("points")) or 0, + "wins": _as_int(row.get("won")) or 0, + "draws": _as_int(row.get("draw")) or 0, + "losses": _as_int(row.get("defeats")) or 0, + "buchholz": buchholz, + "rank": rank, + }) + return out + + +def parse_battles( + rows: Any, tournament_id: int, match_id: str, type_bracket: str +) -> Tuple[List[Dict[str, Any]], bool]: + """Return (battle rows with a session id, saw_technical_victory).""" + battles: List[Dict[str, Any]] = [] + technical = False + for row in rows or []: + if not isinstance(row, dict): + continue + status_replay = row.get("statusReplay") + if status_replay == "technical victory": + technical = True + if status_replay in ("wait", "technical victory"): + continue + session_hex = to_hex(row.get("url")) + if not session_hex: + continue + battles.append({ + "session_hex": session_hex, + "session_decimal": str(row.get("url")), + "tournament_id": tournament_id, + "match_id": match_id, + "type_bracket": type_bracket, + "position": _as_int(row.get("position")), + "winner_name": row.get("winner"), + "status_replay": status_replay, + }) + return battles, technical + + +def compute_status( + matches: List[Dict[str, Any]], + date_end: Optional[int], + now: int, + in_active: Optional[bool] = True, +) -> str: + """Tournament status from match completion + end date + live-list presence. + + 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. + """ + 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" + return "active" + + +def team_count(matches: List[Dict[str, Any]], standings: List[Dict[str, Any]]) -> int: + names = set() + for m in matches: + for key in ("team_a_name", "team_b_name"): + if m.get(key): + names.add(str(m[key]).lower()) + for s in standings: + if s.get("team_name"): + names.add(str(s["team_name"]).lower()) + return len(names) + + +# --------------------------------------------------------------------------- +# Network gather (sync — run via a dedicated thread) +# --------------------------------------------------------------------------- + +def _request(method: str, action: str, **params: Any) -> Any: + fields = {"action": action, **{k: str(v) for k, v in params.items() if v is not None}} + if method == "GET": + url = f"{_API_URL}?{urllib.parse.urlencode(fields)}" + req = urllib.request.Request(url, headers=_API_HEADERS, method="GET") + else: + body = urllib.parse.urlencode(fields).encode() + req = urllib.request.Request(_API_URL, data=body, headers=_API_HEADERS, method="POST") + with urllib.request.urlopen(req, timeout=_API_TIMEOUT) as resp: + text = resp.read().decode("utf-8") + if not text: + return None + try: + return json.loads(text) + except json.JSONDecodeError: + return None + + +def _data_dict(response: Any) -> Dict[str, Any]: + data = response.get("data") if isinstance(response, dict) else None + return data if isinstance(data, dict) else {} + + +# GetActiveTournaments returns ~hundreds of rows; cache it briefly so a burst of +# scans doesn't refetch the whole list each time. +_ACTIVE_CACHE: Dict[str, Any] = {"at": 0, "by_id": {}} +_ACTIVE_TTL_SECONDS = 120 + + +def _active_meta(tournament_id: int, now: int) -> Tuple[Optional[Dict[str, Any]], Optional[bool]]: + """Return (metadata, in_active_list) for a tournament from GetActiveTournaments. + + ``in_active`` is False when a successful active-list fetch says the + tournament has dropped off the live/upcoming list. It is None when we could + not establish the active list, so callers must not mark the tournament + finished solely from absence. + """ + if now - _ACTIVE_CACHE["at"] > _ACTIVE_TTL_SECONDS: + resp = _request("POST", "GetActiveTournaments") + rows = resp.get("data") if isinstance(resp, dict) else None + by_id: Dict[str, Any] = {} + for row in rows or []: + if isinstance(row, dict) and row.get("tournamentID") is not None: + by_id[str(row["tournamentID"])] = row + # Only trust a non-empty fetch; a transient empty reply shouldn't mark + # every tournament finished. + if by_id: + _ACTIVE_CACHE["by_id"] = by_id + _ACTIVE_CACHE["at"] = now + by_id = _ACTIVE_CACHE["by_id"] + meta = by_id.get(str(tournament_id)) + if not by_id: + return meta, None + return meta, meta is not None + + +def build_scan_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``. + """ + now = now or int(time.time()) + if active_meta is None: + active_meta, in_active = _active_meta(tournament_id, now) + else: + in_active = True + sched = _data_dict(_request("POST", "get_shedule_matches", tournamentId=tournament_id)) + names = team_name_map(sched.get("all_teams")) + raw_matches = sched.get("all_matches") if isinstance(sched.get("all_matches"), list) else [] + + matches: List[Dict[str, Any]] = [] + standings: List[Dict[str, Any]] = [] + + 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}), + ): + data = _data_dict(_request("POST", action, **kw)) + rows = collect_group_rows(data.get("GroupMatch")) + if rows: + matches = [parse_group_match(r) for r in rows] + 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) + + type_set = {m["type_bracket"] for m in matches} + meta = active_meta or {} + name = meta.get("nameEN") or fallback_name + date_start = _as_int(meta.get("dateStartTournament")) + date_end = _as_int(meta.get("dateEndTournament")) + + return { + "tournament_id": tournament_id, + "name": name, + "format": derive_format(type_set, meta.get("typeTournament")), + "game_mode": meta.get("gameMode"), + "cluster": meta.get("cluster"), + "date_start": date_start, + "date_end": date_end, + "team_count": team_count(matches, standings), + "match_count": len(matches), + "status": compute_status(matches, date_end, now, in_active), + "scanned_unix": now, + "matches": matches, + "battles": battles, + "standings": standings, + } + + +# --------------------------------------------------------------------------- +# Storage +# --------------------------------------------------------------------------- + +async def store_scan(scan: Dict[str, Any]) -> None: + """Upsert one assembled scan into ``tss_tournaments.db`` transactionally.""" + _store_scan_sync(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"] + with _connect() as conn: + 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, + ), + ) + # Replace child rows for this tournament (authoritative snapshot). + conn.execute("DELETE FROM tournament_matches WHERE tournament_id = ?", (tid,)) + conn.execute("DELETE FROM tournament_battles WHERE tournament_id = ?", (tid,)) + conn.execute("DELETE FROM tournament_standings WHERE tournament_id = ?", (tid,)) + + conn.executemany( + """ + 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"] + ], + ) + 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"] + ], + ) + conn.executemany( + """ + INSERT OR REPLACE INTO tournament_standings + (tournament_id, group_index, team_uuid, team_name, points, + wins, draws, losses, buchholz, rank) + VALUES (?,?,?,?,?,?,?,?,?,?) + """, + [ + ( + tid, s["group_index"], s["team_uuid"], s["team_name"], + s["points"], s["wins"], s["draws"], s["losses"], + s["buchholz"], s["rank"], + ) + for s in scan["standings"] + ], + ) + conn.commit() + + +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.""" + now = now or int(time.time()) + row = _scan_state_sync(tournament_id) + if row is None: + return True + 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 + + +def _scan_state_sync(tournament_id: int) -> Optional[Tuple[str, Optional[int], Optional[int]]]: + with _connect() as conn: + return conn.execute( + "SELECT status, date_end, scanned_unix FROM tournaments WHERE tournament_id = ?", + (tournament_id,), + ).fetchone() + + +_db_ready = False +_inflight: set = set() +_tasks: set = set() + + +async def _ensure_db() -> None: + global _db_ready + if not _db_ready: + await init_tss_tournaments_db() + _db_ready = True + + +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.""" + tss = game.get("tss") or {} + raw_tid = tss.get("tournament_id") + try: + tid = int(raw_tid) + except (TypeError, ValueError): + return + if tid <= 0 or tid in _inflight: + return + await _ensure_db() + if not await needs_scan(tid): + return + + name = tss.get("tournament_name") or None + _inflight.add(tid) + + async def _run() -> None: + try: + await scan_and_store(tid, fallback_name=name) + finally: + _inflight.discard(tid) + + task = asyncio.create_task(_run()) + _tasks.add(task) + task.add_done_callback(_tasks.discard) + + +async def scan_and_store( + tournament_id: int, *, fallback_name: Optional[str] = None +) -> None: + """Fetch (off-thread) and persist one tournament. Safe to fire-and-forget.""" + try: + scan = await run_in_thread(build_scan_sync, tournament_id, fallback_name=fallback_name) + await store_scan(scan) + log.info( + "scanned tournament %s: %d matches, %d battles, %d standings (%s)", + tournament_id, scan["match_count"], len(scan["battles"]), + len(scan["standings"]), scan["status"], + ) + except Exception as exc: # pragma: no cover - network/transient + log.error("tournament scan failed for %s: %s", tournament_id, exc) + + +async def run_in_thread(fn, *args, **kwargs): + """Run a blocking callable without relying on asyncio's default executor.""" + loop = asyncio.get_running_loop() + fut = loop.create_future() + + def _target() -> None: + try: + result = fn(*args, **kwargs) + except Exception as exc: # pragma: no cover - pass-through + loop.call_soon_threadsafe(fut.set_exception, exc) + else: + loop.call_soon_threadsafe(fut.set_result, result) + + threading.Thread(target=_target, daemon=True).start() + return await fut diff --git a/scripts/backfill_tournaments.py b/scripts/backfill_tournaments.py new file mode 100644 index 0000000..107c984 --- /dev/null +++ b/scripts/backfill_tournaments.py @@ -0,0 +1,63 @@ +"""Backfill tss_tournaments.db from tournament ids already in match_summary. + +Usage: + python TSSBOT/scripts/backfill_tournaments.py [--dry-run] [--limit N] [--sleep SECONDS] +""" +import argparse +import asyncio +import pathlib +import sqlite3 +import sys +from typing import List, Optional, Tuple + +ROOT = pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT.parent / "SHARED")) + +from BOT.storage import TSS_BATTLES_DB_PATH # noqa: E402 +from BOT.tss_tournaments import init_tss_tournaments_db, scan_and_store # noqa: E402 + + +async def tournament_ids(limit: Optional[int]) -> List[Tuple[int, Optional[str]]]: + sql = """ + SELECT tournament_id, NULLIF(MAX(tournament_name), '') AS tournament_name + FROM match_summary + WHERE tournament_id IS NOT NULL AND tournament_id > 0 + GROUP BY tournament_id + ORDER BY MAX(endtime_unix) DESC + """ + if limit: + sql += " LIMIT ?" + params = (limit,) + else: + params = () + + with sqlite3.connect(TSS_BATTLES_DB_PATH) as conn: + rows = conn.execute(sql, params).fetchall() + return [(int(row[0]), row[1]) 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) + args = parser.parse_args() + + rows = await tournament_ids(args.limit) + print(f"Found {len(rows)} tournament ids in {TSS_BATTLES_DB_PATH}") + 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) + if args.sleep and index < len(rows): + await asyncio.sleep(args.sleep) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/start_bot.py b/start_bot.py index f500a52..f02c57e 100644 --- a/start_bot.py +++ b/start_bot.py @@ -132,6 +132,8 @@ if __name__ == "__main__": sys.exit(1) try: asyncio.run(init_tss_dbs()) + from BOT.tss_tournaments import init_tss_tournaments_db + asyncio.run(init_tss_tournaments_db()) except Exception as e: log.error(f"failed to initialise TSS databases: {e}") sys.exit(1) diff --git a/tests/test_tss_tournaments.py b/tests/test_tss_tournaments.py new file mode 100644 index 0000000..8efe286 --- /dev/null +++ b/tests/test_tss_tournaments.py @@ -0,0 +1,235 @@ +import asyncio +import os +import pathlib +import sys +import tempfile + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "SHARED")) + +# STORAGE_DIR is resolved at import time from STORAGE_VOL_PATH; point it at a temp +# dir so importing the module (and creating tss_tournaments.db) is self-contained. +_TMP = tempfile.mkdtemp(prefix="tss_tournaments_test_") +os.environ.setdefault("STORAGE_VOL_PATH", _TMP) + +from BOT import tss_tournaments as tt # noqa: E402 + + +# --- captured sample shapes (see TSSBOT/docs/tss_tournament_api_reference.md) --- + +SCHEDULE = { + "all_teams": [ + {"teamName": "uuid-a", "realName": "NUGOB", "id_team": "1"}, + {"teamName": "uuid-b", "realName": "GRIDAC", "id_team": "2"}, + {"teamName": "", "realName": "IVOXY", "id_team": "3"}, # empty uuid row + ], + "all_matches": [ + { # played + "id": "686556", "typeBracket": "Looser", "round": "1", "matchNumber": "0", + "teamA": "uuid-a", "teamB": "uuid-b", "winner": "uuid-a", + "scoreA": "2", "scoreB": "1", "finished": "1", "timeStart": "1720286700", + }, + { # TBD future match (no teams yet) + "id": "686600", "typeBracket": "Final", "round": "5", "matchNumber": "0", + "teamA": "", "teamB": "", "winner": "", "scoreA": "0", "scoreB": "0", + "finished": "0", "timeStart": "0", + }, + ], +} + +SWISS = { + "GroupMatch": [ + { + "id": "203269", "typeGroup": "Swiss", + "teamA": "uuid-x", "teamB": "uuid-y", "winner": "uuid-x", + "realNameA": "avrcls", "realNameB": "111111", + "scoreA": "1", "scoreB": "0", "statsStatus": 1, + } + ], + "GroupStage": [[ + {"teamName": "uuid-x", "realName": "avrcls", "points": 4, "won": 2, + "draw": 0, "defeats": 1, "buchholz_points": "36"}, + {"teamName": "uuid-y", "realName": "111111", "points": 2, "won": 1, + "draw": 0, "defeats": 2, "buchholz_points": "30"}, + ]], +} + +BATTLES_PLAYED = [ + {"url": "224584316650954636", "position": 0, "statusReplay": "view replay", "winner": "NUGOB"}, + {"url": "224584400000000000", "position": 1, "statusReplay": "view replay", "winner": "NUGOB"}, +] +BATTLES_TECHNICAL = [{"url": "", "position": 0, "statusReplay": "technical victory", "winner": "GRIDAC"}] +BATTLES_WAIT = [{"url": "224584500000000000", "position": 0, "statusReplay": "wait", "winner": ""}] + + +def test_to_hex(): + assert tt.to_hex("224584316650954636") == "31de23f001a9f8c" + assert tt.to_hex("") is None + assert tt.to_hex(None) is None + assert tt.to_hex("not-a-number") is None + + +def test_normalize_side(): + assert tt.normalize_side("Winner") == "winner" + assert tt.normalize_side("Looser") == "loser" + assert tt.normalize_side("LooserFinal") == "loser" # 'looser' wins over 'final' + assert tt.normalize_side("Final") == "final" + assert tt.normalize_side("Semifinal") == "final" + assert tt.normalize_side("Swiss") == "swiss" + assert tt.normalize_side("Group") == "group" + + +def test_derive_format(): + assert tt.derive_format([], "double-elumination") == "double-elim" + assert tt.derive_format([], "single-elumination") == "single-elim" + assert tt.derive_format({"Winner", "Final"}) == "single-elim" + assert tt.derive_format({"Winner", "Looser", "Final"}) == "double-elim" + assert tt.derive_format({"Swiss"}) == "swiss" + assert tt.derive_format({"Group"}) == "group" + + +def test_team_name_map_skips_empty_uuid(): + names = tt.team_name_map(SCHEDULE["all_teams"]) + assert names == {"uuid-a": "NUGOB", "uuid-b": "GRIDAC"} + assert "" not in names + + +def test_parse_schedule_match_played_and_tbd(): + names = tt.team_name_map(SCHEDULE["all_teams"]) + played = tt.parse_schedule_match(SCHEDULE["all_matches"][0], names) + assert played["team_a_name"] == "NUGOB" + assert played["team_b_name"] == "GRIDAC" + assert played["winner_name"] == "NUGOB" + assert played["side"] == "loser" + assert (played["round"], played["position"]) == (1, 0) + assert (played["score_a"], played["score_b"]) == (2, 1) + assert played["status"] == "played" + + tbd = tt.parse_schedule_match(SCHEDULE["all_matches"][1], names) + assert tbd["team_a_name"] is None and tbd["team_b_name"] is None + assert tbd["status"] == "pending" + + +def test_parse_bracket_fallback_uses_inline_names(): + row = { + "id": "686548", "typeBracket": "Looser", "round": "0", + "teamA": "", "teamB": "uuid-b", "realNameA": "", "realNameB": "GRIDAC", + "winner": "uuid-b", "scoreA": "0", "scoreB": "2", "matchResult": "done", + } + match = tt.parse_schedule_match(row, {}) + assert match["team_b_name"] == "GRIDAC" + assert match["winner_name"] == "GRIDAC" + assert match["status"] == "bye" + + +def test_parse_group_match_inline_names_and_winner(): + m = tt.parse_group_match(SWISS["GroupMatch"][0]) + assert m["team_a_name"] == "avrcls" + assert m["team_b_name"] == "111111" + assert m["winner_name"] == "avrcls" + assert m["side"] == "swiss" + assert m["round"] is None + assert m["status"] == "played" + + +def test_parse_standings(): + rows = tt.parse_standings(SWISS["GroupStage"]) + assert len(rows) == 2 + assert rows[0]["team_name"] == "avrcls" + assert rows[0]["wins"] == 2 and rows[0]["losses"] == 1 + assert rows[0]["buchholz"] == 36.0 + assert rows[0]["rank"] == 1 and rows[1]["rank"] == 2 + + +def test_parse_battles_filters_and_flags(): + battles, technical = tt.parse_battles(BATTLES_PLAYED, 20042, "686556", "Looser") + assert len(battles) == 2 + assert battles[0]["session_hex"] == "31de23f001a9f8c" + assert technical is False + + none_battles, tech = tt.parse_battles(BATTLES_TECHNICAL, 20042, "686548", "Looser") + assert none_battles == [] + assert tech is True + + waiting, wait_technical = tt.parse_battles(BATTLES_WAIT, 20042, "686700", "Winner") + assert waiting == [] + assert wait_technical is False + + +def test_fill_names_from_battles_by_uuid(): + match = { + "team_a_uuid": "uuid-a", "team_a_name": None, + "team_b_uuid": "uuid-b", "team_b_name": None, + "winner_name": None, "score_a": 2, "score_b": 1, + } + rows = [{ + "teamA": {"teamName": "uuid-b", "realName": "GRIDAC"}, + "teamB": {"teamName": "uuid-a", "realName": "NUGOB"}, + }] + tt.fill_names_from_battles(match, rows) + assert match["team_a_name"] == "NUGOB" + assert match["team_b_name"] == "GRIDAC" + assert match["winner_name"] == "NUGOB" + + +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" + # 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 + assert tt.compute_status(mixed, None, 1000, in_active=None) == "active" + + +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"]] + battles, _ = tt.parse_battles(BATTLES_PLAYED, 99999, "686556", "Looser") + scan = { + "tournament_id": 99999, "name": "Test Cup", "format": "double-elim", + "game_mode": "RB", "cluster": "EU", "date_start": 1, "date_end": 2, + "team_count": tt.team_count(matches, []), "match_count": len(matches), + "status": "finished", "scanned_unix": 1000, + "matches": matches, "battles": battles, "standings": [], + } + + async def run(): + await tt.init_tss_tournaments_db() + await tt.store_scan(scan) + import sqlite3 + with sqlite3.connect(tt.TSS_TOURNAMENTS_DB_PATH) as conn: + trow = conn.execute( + "SELECT name, format, match_count FROM tournaments WHERE tournament_id=99999" + ).fetchone() + (mcount,) = conn.execute( + "SELECT COUNT(*) FROM tournament_matches WHERE tournament_id=99999" + ).fetchone() + hexes = [ + r[0] for r in conn.execute( + "SELECT session_hex FROM tournament_battles WHERE tournament_id=99999 ORDER BY position" + ).fetchall() + ] + return trow, mcount, hexes + + trow, mcount, hexes = asyncio.run(run()) + assert trow == ("Test Cup", "double-elim", 2) + assert mcount == 2 + assert hexes == ["31de23f001a9f8c", "31de25268182000"] + + # Idempotent re-store replaces children, doesn't duplicate. + asyncio.run(_restore_and_count(scan)) + + +async def _restore_and_count(scan): + import sqlite3 + await tt.store_scan(scan) + with sqlite3.connect(tt.TSS_TOURNAMENTS_DB_PATH) as conn: + (mcount,) = conn.execute( + "SELECT COUNT(*) FROM tournament_matches WHERE tournament_id=99999" + ).fetchone() + assert mcount == 2 diff --git a/tss_ws.py b/tss_ws.py index 3dc2020..05c41e7 100644 --- a/tss_ws.py +++ b/tss_ws.py @@ -171,6 +171,13 @@ 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. + try: + from BOT.tss_tournaments import maybe_scan_tournament + await maybe_scan_tournament(game) + except Exception as exc: + log.error("tournament scan 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)