add tss tournament stuff (#1346)
This commit is contained in:
@@ -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
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user