Files
2026-06-20 18:55:17 -07:00

100 lines
3.8 KiB
Python

"""TSS win/loss standings, scoped per tournament.
SREBOT tracks squadron W/L globally. TSS records only mean something inside a
bracket, so standings are scoped to ``(tournament_id, team_id)`` and derived live
from the rows already persisted per match — no separate counter table:
* ``match_summary`` — one row per session, carries ``tournament_id``
* ``player_games_hist`` — one row per used vehicle per player, carries
``team_id``, ``session_id`` and ``victor_bool`` ('Win'/'Loss')
A draw is already written as ``victor_bool = 'Loss'`` for *both* teams (the winning
slot is empty), so counting over ``victor_bool`` treats a draw as a loss for both
sides with no special handling.
``insert_match``/``insert_player_games`` run before the scoreboard renders (see
``tss_ws._handle_game``), so the record returned here includes the match on the
board being drawn.
"""
from __future__ import annotations
import logging
import os
import sqlite3
from typing import Any, Optional
log = logging.getLogger("tssbot.wl")
# One outcome per session (vehicle rows are duplicated across a player's lineup, so
# collapse to a single victor_bool per session before counting — mirrors the
# DISTINCT-first rule in storage.py's docstring), then tally wins vs losses.
_STANDINGS_SQL = """
SELECT
SUM(CASE WHEN victor = 'Win' THEN 1 ELSE 0 END) AS wins,
SUM(CASE WHEN victor = 'Loss' THEN 1 ELSE 0 END) AS losses
FROM (
SELECT pgh.session_id, MAX(pgh.victor_bool) AS victor
FROM player_games_hist pgh
JOIN match_summary ms ON ms.session_id = pgh.session_id
WHERE pgh.team_id = ? AND ms.tournament_id = ?
GROUP BY pgh.session_id
)
"""
def get_tss_standings(
team_id: Optional[Any], tournament_id: Optional[Any] = None
) -> Optional[dict[str, int]]:
"""Return ``{"wins": int, "losses": int}`` for a team within one tournament.
Returns ``None`` when either id is missing, the DB is unavailable, the query
errors, or the team has no recorded matches in that tournament — in every such
case the scoreboard leaves the W/L slot blank (matching offline/sample renders).
"""
if team_id is None or tournament_id is None:
return None
try:
tid = int(team_id)
tournament = int(tournament_id)
except (TypeError, ValueError):
return None
# Imported here so the module stays importable without STORAGE_VOL_PATH set
# (e.g. for unit tests that point _db_path at a temp database).
db_path = _db_path()
if db_path is None or not os.path.exists(db_path):
# No file → nothing to read (and don't let sqlite3.connect create a stray
# empty DB). Keeps offline/sample renders showing a blank slot.
return None
# NOT a ``mode=ro`` connection: the production DB is WAL (see storage._PRAGMAS),
# and a pure read-only open can fail when a -wal exists but the -shm wal-index
# does not (it can't be created read-only). A normal connection set to
# ``query_only`` reads WAL safely in every state while never writing. ``timeout``
# waits out a momentary write lock from the concurrent ingest path.
try:
conn = sqlite3.connect(db_path, timeout=5.0)
conn.execute("PRAGMA query_only=ON")
except sqlite3.Error:
return None
try:
row = conn.execute(_STANDINGS_SQL, (tid, tournament)).fetchone()
except sqlite3.Error as exc:
log.warning("[TSS-WL] standings query failed (team=%s tourn=%s): %s", tid, tournament, exc)
return None
finally:
conn.close()
if not row or row[0] is None:
return None
return {"wins": int(row[0] or 0), "losses": int(row[1] or 0)}
def _db_path() -> Optional[str]:
"""Resolve the battles DB path, or ``None`` if storage isn't configured."""
try:
from .storage import TSS_BATTLES_DB_PATH
except Exception:
return None
return str(TSS_BATTLES_DB_PATH)