From a5abecb918d29769a8900f7df659676800eea172 Mon Sep 17 00:00:00 2001 From: NotSoToothless <67082114+FURRO404@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:23:34 -0700 Subject: [PATCH] =?UTF-8?q?Auto=20merge=20dev=20=E2=86=92=20main=20(#1361)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * refactor: split _store_scan_sync into reusable upsert helpers Co-Authored-By: Claude Opus 4.8 * feat: store_schedule writes meta/matches/standings without wiping battles Co-Authored-By: Claude Opus 4.8 * refactor: extract gather_structure_sync; add fetch_schedule_sync (no battles) Co-Authored-By: Claude Opus 4.8 * feat: reconcile_battles gap-fills replay links for played matches only Co-Authored-By: Claude Opus 4.8 * feat: debounced schedule refresh coalesces game bursts into one TSS call Co-Authored-By: Claude Opus 4.8 * feat: maybe_link_battle links replays from game match_id with zero TSS calls Co-Authored-By: Claude Opus 4.8 * feat: route received games through maybe_link_battle (fast-path bracket link) Co-Authored-By: Claude Opus 4.8 * 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 --------- Co-authored-by: Claude Opus 4.8 --- BOT/tss_tournaments.py | 486 ++++++++++++++++++++++++++-------- tests/test_tss_tournaments.py | 219 ++++++++++++++- tss_ws.py | 11 +- 3 files changed, 592 insertions(+), 124 deletions(-) diff --git a/BOT/tss_tournaments.py b/BOT/tss_tournaments.py index 346bd1c..1e1ecc7 100644 --- a/BOT/tss_tournaments.py +++ b/BOT/tss_tournaments.py @@ -448,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" @@ -537,20 +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, - battle_workers: int = _DEFAULT_BATTLE_WORKERS, - progress: Optional[Callable[[str], None]] = 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) @@ -566,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}), @@ -587,26 +588,6 @@ def build_scan_sync( apply_tournament_side_context(matches) - # Battles per match → session links. Dedupe match_ids (same id can repeat - # across sources); fetch once per (match_id, type_bracket). - 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, - ) - type_set = {m["type_bracket"] for m in matches} meta = active_meta or {} name = meta.get("nameEN") or fallback_name @@ -626,11 +607,60 @@ 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]], @@ -671,68 +701,34 @@ def fetch_battles_for_matches( return battles -# --------------------------------------------------------------------------- -# 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"] +def reconcile_targets_sync(tid: int) -> List[Dict[str, str]]: + """Matches that should have a replay link but don't yet: played/technical with + zero stored battles (a game we missed, or a not-yet-linked technical victory). + Byes (empty team slot) are excluded — no battle is expected.""" with _connect() as conn: - conn.execute( + rows = conn.execute( """ - INSERT INTO tournaments - (tournament_id, name, format, game_mode, cluster, date_start, - date_end, team_count, match_count, status, first_seen_unix, scanned_unix) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) - ON CONFLICT(tournament_id) DO UPDATE SET - name=COALESCE(excluded.name, tournaments.name), - format=excluded.format, - game_mode=COALESCE(excluded.game_mode, tournaments.game_mode), - cluster=COALESCE(excluded.cluster, tournaments.cluster), - date_start=COALESCE(excluded.date_start, tournaments.date_start), - date_end=COALESCE(excluded.date_end, tournaments.date_end), - team_count=excluded.team_count, - match_count=excluded.match_count, - status=excluded.status, - scanned_unix=excluded.scanned_unix + SELECT m.match_id, m.type_bracket + FROM tournament_matches m + WHERE m.tournament_id = ? + AND m.status IN ('played', 'technical') + AND NOT EXISTS ( + SELECT 1 FROM tournament_battles b + WHERE b.tournament_id = m.tournament_id + AND b.match_id = m.match_id + AND b.type_bracket = m.type_bracket + ) """, - ( - tid, scan.get("name"), scan.get("format"), scan.get("game_mode"), - scan.get("cluster"), scan.get("date_start"), scan.get("date_end"), - scan.get("team_count", 0), scan.get("match_count", 0), - scan.get("status", "pending"), now, now, - ), - ) - # Replace child rows for this tournament (authoritative snapshot). - conn.execute("DELETE FROM tournament_matches WHERE tournament_id = ?", (tid,)) - conn.execute("DELETE FROM tournament_battles WHERE tournament_id = ?", (tid,)) - conn.execute("DELETE FROM tournament_standings WHERE tournament_id = ?", (tid,)) + (tid,), + ).fetchall() + return [{"match_id": r[0], "type_bracket": r[1]} for r in rows] - conn.executemany( - """ - INSERT OR REPLACE INTO tournament_matches - (tournament_id, match_id, type_bracket, side, round, position, - team_a_uuid, team_a_name, team_b_uuid, team_b_name, winner_name, - score_a, score_b, status, time_start) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - """, - [ - ( - tid, m["match_id"], m["type_bracket"], m["side"], m["round"], - m["position"], m["team_a_uuid"], m["team_a_name"], m["team_b_uuid"], - m["team_b_name"], m["winner_name"], m["score_a"], m["score_b"], - m["status"], m["time_start"], - ) - for m in scan["matches"] - ], - ) + +def _insert_battles_sync(battles: List[Dict[str, Any]]) -> None: + """Upsert battle rows without deleting anything (preserves fast-path links).""" + if not battles: + return + with _connect() as conn: conn.executemany( """ INSERT OR REPLACE INTO tournament_battles @@ -746,39 +742,187 @@ def _store_scan_sync(scan: Dict[str, Any]) -> None: b["match_id"], b["type_bracket"], b["position"], b["winner_name"], b["status_replay"], ) - for b in scan["battles"] - ], - ) - conn.executemany( - """ - INSERT OR REPLACE INTO tournament_standings - (tournament_id, group_index, team_uuid, team_name, points, - wins, draws, losses, buchholz, rank) - VALUES (?,?,?,?,?,?,?,?,?,?) - """, - [ - ( - tid, s["group_index"], s["team_uuid"], s["team_name"], - s["points"], s["wins"], s["draws"], s["losses"], - s["buchholz"], s["rank"], - ) - for s in scan["standings"] + for b in battles ], ) conn.commit() +async def reconcile_battles( + tid: int, + *, + now: Optional[int] = None, + force: bool = False, + workers: int = _DEFAULT_BATTLE_WORKERS, +) -> int: + """Fill replay links for played matches that have no stored session. Calls + getListAllBattles ONLY for those gap matches. Rate-limited unless ``force``.""" + now = now or int(time.time()) + if not force and (_reconcile_last.get(tid, 0) + _RECONCILE_INTERVAL_SECONDS > now): + return 0 + targets = await run_in_thread(reconcile_targets_sync, tid) + if not targets: + _reconcile_last[tid] = now + return 0 + battles = await run_in_thread(fetch_battles_for_matches, tid, targets, workers=workers) + await run_in_thread(_insert_battles_sync, battles) + _reconcile_last[tid] = now + return len(battles) + + +# --------------------------------------------------------------------------- +# Storage +# --------------------------------------------------------------------------- + +async def store_scan(scan: Dict[str, Any]) -> None: + """Upsert one assembled scan into ``tss_tournaments.db`` transactionally.""" + await run_in_thread(_store_scan_sync, scan) + + +def _upsert_tournament_meta(conn: sqlite3.Connection, scan: Dict[str, Any]) -> None: + tid = scan["tournament_id"] + now = scan["scanned_unix"] + conn.execute( + """ + INSERT INTO tournaments + (tournament_id, name, format, game_mode, cluster, date_start, + date_end, team_count, match_count, status, first_seen_unix, scanned_unix) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(tournament_id) DO UPDATE SET + name=COALESCE(excluded.name, tournaments.name), + format=excluded.format, + game_mode=COALESCE(excluded.game_mode, tournaments.game_mode), + cluster=COALESCE(excluded.cluster, tournaments.cluster), + date_start=COALESCE(excluded.date_start, tournaments.date_start), + date_end=COALESCE(excluded.date_end, tournaments.date_end), + team_count=excluded.team_count, + match_count=excluded.match_count, + status=excluded.status, + scanned_unix=excluded.scanned_unix + """, + ( + tid, scan.get("name"), scan.get("format"), scan.get("game_mode"), + scan.get("cluster"), scan.get("date_start"), scan.get("date_end"), + scan.get("team_count", 0), scan.get("match_count", 0), + scan.get("status", "pending"), now, now, + ), + ) + + +def _replace_matches(conn: sqlite3.Connection, tid: int, matches: List[Dict[str, Any]]) -> None: + conn.execute("DELETE FROM tournament_matches WHERE tournament_id = ?", (tid,)) + conn.executemany( + """ + INSERT OR REPLACE INTO tournament_matches + (tournament_id, match_id, type_bracket, side, round, position, + team_a_uuid, team_a_name, team_b_uuid, team_b_name, winner_name, + score_a, score_b, status, time_start) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """, + [ + ( + tid, m["match_id"], m["type_bracket"], m["side"], m["round"], + m["position"], m["team_a_uuid"], m["team_a_name"], m["team_b_uuid"], + m["team_b_name"], m["winner_name"], m["score_a"], m["score_b"], + m["status"], m["time_start"], + ) + for m in matches + ], + ) + + +def _replace_standings(conn: sqlite3.Connection, tid: int, standings: List[Dict[str, Any]]) -> None: + conn.execute("DELETE FROM tournament_standings WHERE tournament_id = ?", (tid,)) + conn.executemany( + """ + INSERT OR REPLACE INTO tournament_standings + (tournament_id, group_index, team_uuid, team_name, points, + wins, draws, losses, buchholz, rank) + VALUES (?,?,?,?,?,?,?,?,?,?) + """, + [ + ( + tid, s["group_index"], s["team_uuid"], s["team_name"], + s["points"], s["wins"], s["draws"], s["losses"], + s["buchholz"], s["rank"], + ) + for s in standings + ], + ) + + +def _replace_battles(conn: sqlite3.Connection, tid: int, battles: List[Dict[str, Any]]) -> None: + # Authoritative full-scan replace. NOTE: this briefly drops any fast-path + # battle link (maybe_link_battle) for a session TSS has not yet listed in + # getListAllBattles; such a link is refilled by a later reconcile or scan. + # The debounced schedule refresh deliberately does NOT call this. + conn.execute("DELETE FROM tournament_battles WHERE tournament_id = ?", (tid,)) + conn.executemany( + """ + INSERT OR REPLACE INTO tournament_battles + (session_hex, session_decimal, tournament_id, match_id, + type_bracket, position, winner_name, status_replay) + VALUES (?,?,?,?,?,?,?,?) + """, + [ + ( + b["session_hex"], b["session_decimal"], b["tournament_id"], + b["match_id"], b["type_bracket"], b["position"], + b["winner_name"], b["status_replay"], + ) + for b in battles + ], + ) + + +def _store_scan_sync(scan: Dict[str, Any]) -> None: + """Synchronous implementation for ``store_scan``; run off the event loop.""" + tid = scan["tournament_id"] + with _connect() as conn: + _upsert_tournament_meta(conn, scan) + _replace_matches(conn, tid, scan["matches"]) + _replace_battles(conn, tid, scan["battles"]) + _replace_standings(conn, tid, scan["standings"]) + conn.commit() + + +def store_schedule_sync(scan: Dict[str, Any]) -> None: + """Persist a schedule-only refresh: meta + matches + standings. + + Deliberately does NOT touch ``tournament_battles`` — those rows are + maintained incrementally by the fast-path linker and the reconcile, and a + schedule refresh that wiped them would drop every replay link. + """ + tid = scan["tournament_id"] + with _connect() as conn: + _upsert_tournament_meta(conn, scan) + _replace_matches(conn, tid, scan["matches"]) + _replace_standings(conn, tid, scan["standings"]) + conn.commit() + + +async def store_schedule(scan: Dict[str, Any]) -> None: + """Async wrapper for ``store_schedule_sync``.""" + await run_in_thread(store_schedule_sync, scan) + + async def needs_scan(tournament_id: int, *, now: Optional[int] = None) -> bool: - """True if we've never scanned this tournament, or it's live and stale.""" + """True if we've never scanned this tournament, or it's live and stale. + + The only permanent stop is ``status == 'finished'`` (which ``compute_status`` + sets reliably once the tournament drops out of ``GetActiveTournaments``). We + deliberately do NOT gate on ``date_end``: it is only the *scheduled* end, and + scans are game-triggered — a game arriving for a tournament is itself evidence + it is still live, even days past that scheduled end. Gating on ``date_end`` + froze brackets whose final was played late. Re-scans stay interval-limited. + """ now = now or int(time.time()) row = _scan_state_sync(tournament_id) if row is None: return True - status, date_end, scanned_unix = row + status, _date_end, scanned_unix = row if status == "finished": return False - if date_end and now > date_end + RESCAN_END_BUFFER_SECONDS: - return False return (scanned_unix or 0) + RESCAN_INTERVAL_SECONDS <= now @@ -793,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: @@ -802,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.""" @@ -847,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() diff --git a/tests/test_tss_tournaments.py b/tests/test_tss_tournaments.py index 02fd50a..dfd11c0 100644 --- a/tests/test_tss_tournaments.py +++ b/tests/test_tss_tournaments.py @@ -222,14 +222,50 @@ def test_compute_status(): 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"]] @@ -277,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()) diff --git a/tss_ws.py b/tss_ws.py index 21e581f..8ed7f76 100644 --- a/tss_ws.py +++ b/tss_ws.py @@ -178,13 +178,14 @@ async def _handle_game(game: Dict[str, Any]) -> None: await publish_replay_batch([game]) except Exception as exc: log.warning("[TSS-BRIDGE] Failed to forward replay batch 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. + # 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_scan_tournament - await maybe_scan_tournament(game) + from BOT.tss_tournaments import maybe_link_battle + await maybe_link_battle(game) except Exception as exc: - log.error("tournament scan trigger failed for %s: %s", sid, 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)