"""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)