Auto merge dev → main (#1361)

* fix: don't freeze tournament brackets that overrun their scheduled end

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

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

* refactor: split _store_scan_sync into reusable upsert helpers

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

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

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

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

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

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

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

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

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

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

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

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

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

* tss_tournaments: three small final-review cleanups

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

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
NotSoToothless
2026-06-29 04:23:34 -07:00
committed by GitHub
parent 43c2837f6d
commit a5abecb918
3 changed files with 592 additions and 124 deletions
+321 -67
View File
@@ -448,20 +448,28 @@ def compute_status(
now: int, now: int,
in_active: Optional[bool] = True, in_active: Optional[bool] = True,
) -> str: ) -> 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 Presence in ``GetActiveTournaments`` is the authoritative live/over signal:
— this is the reliable finished signal for old tournaments that carry no absence (``in_active`` False) is over, presence (True) is live. ``date_end``
``date_end``, and it stops ``needs_scan`` from re-scanning them forever. 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: if in_active is False:
return "finished" return "finished"
if not matches: if not matches:
return "pending" 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): if all(m["status"] in ("played", "bye", "technical") for m in matches):
return "finished" 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" 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 return meta, meta is not None
def build_scan_sync( def gather_structure_sync(
tournament_id: int, tournament_id: int,
*, *,
fallback_name: Optional[str] = None, fallback_name: Optional[str] = None,
active_meta: Optional[Dict[str, Any]] = None, active_meta: Optional[Dict[str, Any]] = None,
now: Optional[int] = None, now: Optional[int] = None,
battle_workers: int = _DEFAULT_BATTLE_WORKERS,
progress: Optional[Callable[[str], None]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Fetch + assemble the full authoritative structure for one tournament. """Fetch + assemble the bracket structure (matches, standings, meta) for one
tournament. Network-bound; call via ``run_in_thread``. No battles fetched."""
Network-bound; call via ``run_in_thread``. Returns a dict of rows ready for
``store_scan``.
"""
now = now or int(time.time()) now = now or int(time.time())
if active_meta is None: if active_meta is None:
active_meta, in_active = _active_meta(tournament_id, now) active_meta, in_active = _active_meta(tournament_id, now)
@@ -566,14 +569,12 @@ def build_scan_sync(
if raw_matches: if raw_matches:
matches = [parse_schedule_match(r, names) for r in raw_matches if isinstance(r, dict)] matches = [parse_schedule_match(r, names) for r in raw_matches if isinstance(r, dict)]
else: else:
# Empty schedule can mean bracket fallback, swiss/group, or pre-start.
bracket = _data_dict(_request("POST", "GetArrayBracketData", tournamentID=tournament_id)) bracket = _data_dict(_request("POST", "GetArrayBracketData", tournamentID=tournament_id))
bracket_rows = collect_group_rows(bracket.get("bracket") or bracket) bracket_rows = collect_group_rows(bracket.get("bracket") or bracket)
if bracket_rows: if bracket_rows:
matches = [parse_schedule_match(r, names) for r in bracket_rows if isinstance(r, dict)] matches = [parse_schedule_match(r, names) for r in bracket_rows if isinstance(r, dict)]
if not matches: if not matches:
# Empty schedule/bracket → swiss/group. Try swiss, then group.
for action, kw in ( for action, kw in (
("GetArraySwissData", {"tournamentID": tournament_id, "id_group": 1}), ("GetArraySwissData", {"tournamentID": tournament_id, "id_group": 1}),
("GetArrayGroupData", {"tournamentID": tournament_id}), ("GetArrayGroupData", {"tournamentID": tournament_id}),
@@ -587,26 +588,6 @@ def build_scan_sync(
apply_tournament_side_context(matches) 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} type_set = {m["type_bracket"] for m in matches}
meta = active_meta or {} meta = active_meta or {}
name = meta.get("nameEN") or fallback_name name = meta.get("nameEN") or fallback_name
@@ -626,11 +607,60 @@ def build_scan_sync(
"status": compute_status(matches, date_end, now, in_active), "status": compute_status(matches, date_end, now, in_active),
"scanned_unix": now, "scanned_unix": now,
"matches": matches, "matches": matches,
"battles": battles,
"standings": standings, "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( def fetch_battles_for_matches(
tournament_id: int, tournament_id: int,
matches: List[Dict[str, Any]], matches: List[Dict[str, Any]],
@@ -671,20 +701,87 @@ def fetch_battles_for_matches(
return battles return battles
def reconcile_targets_sync(tid: int) -> List[Dict[str, str]]:
"""Matches that should have a replay link but don't yet: played/technical with
zero stored battles (a game we missed, or a not-yet-linked technical victory).
Byes (empty team slot) are excluded — no battle is expected."""
with _connect() as conn:
rows = conn.execute(
"""
SELECT m.match_id, m.type_bracket
FROM tournament_matches m
WHERE m.tournament_id = ?
AND m.status IN ('played', 'technical')
AND NOT EXISTS (
SELECT 1 FROM tournament_battles b
WHERE b.tournament_id = m.tournament_id
AND b.match_id = m.match_id
AND b.type_bracket = m.type_bracket
)
""",
(tid,),
).fetchall()
return [{"match_id": r[0], "type_bracket": r[1]} for r in rows]
def _insert_battles_sync(battles: List[Dict[str, Any]]) -> None:
"""Upsert battle rows without deleting anything (preserves fast-path links)."""
if not battles:
return
with _connect() as conn:
conn.executemany(
"""
INSERT OR REPLACE INTO tournament_battles
(session_hex, session_decimal, tournament_id, match_id,
type_bracket, position, winner_name, status_replay)
VALUES (?,?,?,?,?,?,?,?)
""",
[
(
b["session_hex"], b["session_decimal"], b["tournament_id"],
b["match_id"], b["type_bracket"], b["position"],
b["winner_name"], b["status_replay"],
)
for b in battles
],
)
conn.commit()
async def reconcile_battles(
tid: int,
*,
now: Optional[int] = None,
force: bool = False,
workers: int = _DEFAULT_BATTLE_WORKERS,
) -> int:
"""Fill replay links for played matches that have no stored session. Calls
getListAllBattles ONLY for those gap matches. Rate-limited unless ``force``."""
now = now or int(time.time())
if not force and (_reconcile_last.get(tid, 0) + _RECONCILE_INTERVAL_SECONDS > now):
return 0
targets = await run_in_thread(reconcile_targets_sync, tid)
if not targets:
_reconcile_last[tid] = now
return 0
battles = await run_in_thread(fetch_battles_for_matches, tid, targets, workers=workers)
await run_in_thread(_insert_battles_sync, battles)
_reconcile_last[tid] = now
return len(battles)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Storage # Storage
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def store_scan(scan: Dict[str, Any]) -> None: async def store_scan(scan: Dict[str, Any]) -> None:
"""Upsert one assembled scan into ``tss_tournaments.db`` transactionally.""" """Upsert one assembled scan into ``tss_tournaments.db`` transactionally."""
_store_scan_sync(scan) await run_in_thread(_store_scan_sync, scan)
def _store_scan_sync(scan: Dict[str, Any]) -> None: def _upsert_tournament_meta(conn: sqlite3.Connection, scan: Dict[str, Any]) -> None:
"""Synchronous implementation for ``store_scan``; run off the event loop."""
tid = scan["tournament_id"] tid = scan["tournament_id"]
now = scan["scanned_unix"] now = scan["scanned_unix"]
with _connect() as conn:
conn.execute( conn.execute(
""" """
INSERT INTO tournaments INSERT INTO tournaments
@@ -710,11 +807,10 @@ def _store_scan_sync(scan: Dict[str, Any]) -> None:
scan.get("status", "pending"), now, now, scan.get("status", "pending"), now, now,
), ),
) )
# Replace child rows for this tournament (authoritative snapshot).
conn.execute("DELETE FROM tournament_matches WHERE tournament_id = ?", (tid,))
conn.execute("DELETE FROM tournament_battles WHERE tournament_id = ?", (tid,))
conn.execute("DELETE FROM tournament_standings WHERE tournament_id = ?", (tid,))
def _replace_matches(conn: sqlite3.Connection, tid: int, matches: List[Dict[str, Any]]) -> None:
conn.execute("DELETE FROM tournament_matches WHERE tournament_id = ?", (tid,))
conn.executemany( conn.executemany(
""" """
INSERT OR REPLACE INTO tournament_matches INSERT OR REPLACE INTO tournament_matches
@@ -730,25 +826,13 @@ def _store_scan_sync(scan: Dict[str, Any]) -> None:
m["team_b_name"], m["winner_name"], m["score_a"], m["score_b"], m["team_b_name"], m["winner_name"], m["score_a"], m["score_b"],
m["status"], m["time_start"], m["status"], m["time_start"],
) )
for m in scan["matches"] for m in 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"]
], ],
) )
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( conn.executemany(
""" """
INSERT OR REPLACE INTO tournament_standings INSERT OR REPLACE INTO tournament_standings
@@ -762,23 +846,83 @@ def _store_scan_sync(scan: Dict[str, Any]) -> None:
s["points"], s["wins"], s["draws"], s["losses"], s["points"], s["wins"], s["draws"], s["losses"],
s["buchholz"], s["rank"], s["buchholz"], s["rank"],
) )
for s in scan["standings"] for s in standings
], ],
) )
def _replace_battles(conn: sqlite3.Connection, tid: int, battles: List[Dict[str, Any]]) -> None:
# Authoritative full-scan replace. NOTE: this briefly drops any fast-path
# battle link (maybe_link_battle) for a session TSS has not yet listed in
# getListAllBattles; such a link is refilled by a later reconcile or scan.
# The debounced schedule refresh deliberately does NOT call this.
conn.execute("DELETE FROM tournament_battles WHERE tournament_id = ?", (tid,))
conn.executemany(
"""
INSERT OR REPLACE INTO tournament_battles
(session_hex, session_decimal, tournament_id, match_id,
type_bracket, position, winner_name, status_replay)
VALUES (?,?,?,?,?,?,?,?)
""",
[
(
b["session_hex"], b["session_decimal"], b["tournament_id"],
b["match_id"], b["type_bracket"], b["position"],
b["winner_name"], b["status_replay"],
)
for b in battles
],
)
def _store_scan_sync(scan: Dict[str, Any]) -> None:
"""Synchronous implementation for ``store_scan``; run off the event loop."""
tid = scan["tournament_id"]
with _connect() as conn:
_upsert_tournament_meta(conn, scan)
_replace_matches(conn, tid, scan["matches"])
_replace_battles(conn, tid, scan["battles"])
_replace_standings(conn, tid, scan["standings"])
conn.commit() 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: 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()) now = now or int(time.time())
row = _scan_state_sync(tournament_id) row = _scan_state_sync(tournament_id)
if row is None: if row is None:
return True return True
status, date_end, scanned_unix = row status, _date_end, scanned_unix = row
if status == "finished": if status == "finished":
return False return False
if date_end and now > date_end + RESCAN_END_BUFFER_SECONDS:
return False
return (scanned_unix or 0) + RESCAN_INTERVAL_SECONDS <= now 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 _db_ready = False
_inflight: set = set() _inflight: set = set()
_tasks: 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: async def _ensure_db() -> None:
@@ -802,6 +951,83 @@ async def _ensure_db() -> None:
_db_ready = True _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: async def maybe_scan_tournament(game: Dict[str, Any]) -> None:
"""Trigger entry point: schedule a background scan if this game's tournament """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.""" 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) 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): async def run_in_thread(fn, *args, **kwargs):
"""Run a blocking callable without relying on asyncio's default executor.""" """Run a blocking callable without relying on asyncio's default executor."""
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
+216 -3
View File
@@ -222,14 +222,50 @@ def test_compute_status():
assert tt.compute_status([], None, 1000) == "pending" assert tt.compute_status([], None, 1000) == "pending"
assert tt.compute_status(played, None, 1000) == "finished" assert tt.compute_status(played, None, 1000) == "finished"
assert tt.compute_status(mixed, None, 1000) == "active" assert tt.compute_status(mixed, None, 1000) == "active"
# past end + buffer → finished even with pending matches # A tournament still listed as active is NOT finished just because it overran
assert tt.compute_status(mixed, 100, 100 + tt.RESCAN_END_BUFFER_SECONDS + 1) == "finished" # 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) # absent from the active list → finished (old tournament, no date_end)
assert tt.compute_status(mixed, None, 1000, in_active=False) == "finished" 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" 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(): def test_store_scan_roundtrip():
names = tt.team_name_map(SCHEDULE["all_teams"]) names = tt.team_name_map(SCHEDULE["all_teams"])
matches = [tt.parse_schedule_match(r, names) for r in SCHEDULE["all_matches"]] 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" "SELECT COUNT(*) FROM tournament_matches WHERE tournament_id=99999"
).fetchone() ).fetchone()
assert mcount == 2 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())
+6 -5
View File
@@ -178,13 +178,14 @@ async def _handle_game(game: Dict[str, Any]) -> None:
await publish_replay_batch([game]) await publish_replay_batch([game])
except Exception as exc: except Exception as exc:
log.warning("[TSS-BRIDGE] Failed to forward replay batch for %s: %s", sid, 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 # Link this game's replay to its bracket slot from tss.match_id (zero TSS
# and stale), scan its authoritative bracket in the background. Never blocks. # calls) and request a debounced schedule refresh; unknown matches fall back
# to a full scan inside maybe_link_battle. Never blocks ingest.
try: try:
from BOT.tss_tournaments import maybe_scan_tournament from BOT.tss_tournaments import maybe_link_battle
await maybe_scan_tournament(game) await maybe_link_battle(game)
except Exception as exc: 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). # Autolog match/dispatch (no-ops in standalone mode where no bot is set).
try: try:
await autolog_process_game(game) await autolog_process_game(game)