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:
+321
-67
@@ -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()
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user