diff --git a/BOT/scoreboard.py b/BOT/scoreboard.py index def2902..363c185 100644 --- a/BOT/scoreboard.py +++ b/BOT/scoreboard.py @@ -225,14 +225,20 @@ def _create_scoreboard_sync(model: dict[str, Any], output_path: str, bar_color: name_x = start_x if not flipped else start_x + section_width - name_w draw.text((name_x, header_y), team_name, font=fonts["team"], fill=header_fill) - # W/L slot — only drawn once real per-tournament standings exist (stub returns - # None today, so nothing renders under the team name). - standings = get_tss_standings(team.get("team_id")) + # W/L slot — per-tournament record under the team name. Coloured exactly like + # SREBOT: green W · grey dash · red L · grey dash · win-rate% on a red→green + # gradient. Placed below the team name's full glyph box (descender-inclusive) + # so names with p/g/y descenders don't clip into the record line. + standings = get_tss_standings(team.get("team_id"), model.get("tournament_id")) if standings: - wl_text = f"{standings['wins']}W - {standings['losses']}L" - wb = draw.textbbox((0, 0), wl_text, font=fonts["sub"]) - wl_x = start_x if not flipped else start_x + section_width - (wb[2] - wb[0]) - draw.text((wl_x, header_y + (nb[3] - nb[1]) + 18), wl_text, font=fonts["sub"], fill=GREY_FILL) + wl_segs = _winloss_segments(standings) + seg_w = _segments_width(draw, wl_segs, fonts["sub"]) + wl_x = start_x if not flipped else start_x + section_width - seg_w + # Sit halfway between the original (descender-clipping) baseline and a + # full descender-clear drop — enough to clear p/g/y, no more. + name_full_h = fonts["team"].getbbox("ApgjyQ")[3] + wl_y = header_y + ((nb[3] - nb[1] + 18) + (name_full_h + 10)) // 2 + _draw_segments(draw, wl_x, wl_y, wl_segs, fonts["sub"]) # Stat columns (rating now lives inline next to the player name). col_labels = ["Air", "Ground", "Assists", "Deaths", "Caps"] @@ -270,7 +276,7 @@ def _create_scoreboard_sync(model: dict[str, Any], output_path: str, bar_color: pass # Player rows. Layout per row: - # top line — player name + inline (rating ±delta) at the column edge; stats + # top line — player name + inline (rating) at the column edge; stats # lower line — vehicle icons next to their translated names body = fonts["body"] small = fonts["veh"] @@ -287,7 +293,7 @@ def _create_scoreboard_sync(model: dict[str, Any], output_path: str, bar_color: veh_y = line_y + name_h + 12 # vehicle line (icons + names together) vname_y = veh_y + (veh_icon - small_h) // 2 name_w = draw.textbbox((0, 0), name, font=body)[2] - rating_segs = _rating_segments(p.get("pvp_ratio"), p.get("pvp_ratio_delta")) + rating_segs = _rating_segments(p.get("pvp_ratio")) rating_w = _segments_width(draw, rating_segs, body) if not flipped: @@ -342,16 +348,45 @@ def _create_scoreboard_sync(model: dict[str, Any], output_path: str, bar_color: RATING_FILL = (170, 200, 255, 255) -def _rating_segments(rating, delta): - """Build colored (text, fill) segments for the inline ``(1468 +2)`` rating. +# W/L record colours — matched exactly to SREBOT's scoreboard. +WL_WIN_FILL = (0, 255, 0, 255) +WL_LOSS_FILL = (255, 60, 60, 255) +WL_DASH_FILL = (200, 200, 200, 255) - ``delta`` is a signed change vs the player's previous rating; it's ``None`` until - rating history is tracked (see wl.py), so for now only the current value shows. + +def _winrate_gradient(win_rate: float) -> tuple: + """Red→yellow→green gradient for a win-rate percentage (SREBOT's get_gradient_color). + + 0% = red (255,0,0), 50% = yellow (255,255,0), 100% = green (0,255,0). """ + win_rate = max(0.0, min(100.0, win_rate)) + if win_rate <= 50: + return (255, int(255 * (win_rate / 50)), 0, 255) + return (int(255 * (1 - (win_rate - 50) / 50)), 255, 0, 255) + + +def _winloss_segments(standings: dict[str, int]) -> list[tuple[str, tuple]]: + """Colored ``3W - 2L - 60%`` segments for the per-tournament record line. + + Win count + 'W' green, ' - ' grey, loss count + 'L' red, ' - ' grey, then the + win-rate% on the red→green gradient — mirroring SREBOT's _draw_winloss. + """ + wins = int(standings.get("wins") or 0) + losses = int(standings.get("losses") or 0) + total = wins + losses + pct = (wins / total) * 100 if total else 0.0 + return [ + (f"{wins}W", WL_WIN_FILL), + (" - ", WL_DASH_FILL), + (f"{losses}L", WL_LOSS_FILL), + (" - ", WL_DASH_FILL), + (f"{pct:.0f}%", _winrate_gradient(pct)), + ] + + +def _rating_segments(rating): + """Build colored (text, fill) segments for the inline ``(1468)`` rating.""" rating_str = f"{int(round(rating))}" if isinstance(rating, (int, float)) else "—" - if isinstance(delta, (int, float)) and delta != 0: - dcol = WIN_FILL if delta > 0 else LOSS_FILL - return [(f"({rating_str} ", RATING_FILL), (f"{'+' if delta > 0 else ''}{int(delta)}", dcol), (")", RATING_FILL)] return [(f"({rating_str})", RATING_FILL)] diff --git a/BOT/transform.py b/BOT/transform.py index e3cf8c4..7003fc1 100644 --- a/BOT/transform.py +++ b/BOT/transform.py @@ -168,6 +168,7 @@ def build_scoreboard_model(game: dict[str, Any], lang_column: str = "") "duration": game.get("duration"), "map": str(game.get("mission_name") or ""), "mission_mode": str(game.get("mission_mode") or ""), + "tournament_id": tss.get("tournament_id"), "tournament_name": str(tss.get("tournament_name") or ""), "bracket": str(tss.get("bracket") or ""), "is_draw": is_draw, diff --git a/BOT/wl.py b/BOT/wl.py index be3700a..ff9e388 100644 --- a/BOT/wl.py +++ b/BOT/wl.py @@ -1,20 +1,99 @@ -"""TSS win/loss standings — STUB. +"""TSS win/loss standings, scoped per tournament. -SREBOT tracks squadron W/L globally. TSS needs the same idea but **scoped per -tournament** (a team's record only means something inside its bracket), so the real -implementation is deferred. Until then ``get_tss_standings`` returns ``None`` for -every team and the scoreboard renders a neutral placeholder in the W/L slot. +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: -TODO(tss-wl): persist per-(tournament_id, team_id) wins/losses and resolve here. +* ``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") -def get_tss_standings(team_id: Optional[str], tournament_id: Optional[Any] = None) -> Optional[dict[str, int]]: - """Return ``{"wins": int, "losses": int}`` for a team, or ``None`` if untracked. +# 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 +) +""" - Stub: always ``None`` until per-tournament W/L tracking lands. + +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). """ - return None + 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) diff --git a/tests/test_transform_model.py b/tests/test_transform_model.py new file mode 100644 index 0000000..b299bd8 --- /dev/null +++ b/tests/test_transform_model.py @@ -0,0 +1,31 @@ +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "SHARED")) + +from BOT.transform import build_scoreboard_model + + +def _game(): + return { + "_id": "abc", + "winner": "1", + "mission_name": "Kursk", + "tss": { + "tournament_id": 24965, + "tournament_name": "2x2 RBm Tanks", + "1": {"team_id": "111", "team_name": "A", "players": [{"uid": "1", "pvp_ratio": 1500}]}, + "2": {"team_id": "222", "team_name": "B", "players": [{"uid": "2", "pvp_ratio": 1400}]}, + }, + "players": { + "1": {"name": "alice", "team": "1", "units": [{"unit": "t34", "used": True}]}, + "2": {"name": "bob", "team": "2", "units": [{"unit": "pz", "used": True}]}, + }, + } + + +def test_model_carries_tournament_id(): + model = build_scoreboard_model(_game()) + assert model is not None + assert model["tournament_id"] == 24965 diff --git a/tests/test_wl.py b/tests/test_wl.py new file mode 100644 index 0000000..c795970 --- /dev/null +++ b/tests/test_wl.py @@ -0,0 +1,155 @@ +import pathlib +import sqlite3 +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "SHARED")) + +from BOT import wl + + +def _make_db(path: str, matches, players): + """Seed the two real tables with the columns get_tss_standings reads. + + matches: list of (session_id, tournament_id) + players: list of (session_id, team_id, vehicle_internal, victor_bool) + """ + conn = sqlite3.connect(path) + conn.execute( + "CREATE TABLE match_summary (session_id TEXT PRIMARY KEY, tournament_id INTEGER)" + ) + conn.execute( + "CREATE TABLE player_games_hist (" + " UID TEXT, session_id TEXT, team_id INTEGER," + " vehicle_internal TEXT, victor_bool TEXT)" + ) + conn.executemany("INSERT INTO match_summary VALUES (?, ?)", matches) + conn.executemany( + "INSERT INTO player_games_hist (UID, session_id, team_id, vehicle_internal, victor_bool)" + " VALUES ('u', ?, ?, ?, ?)", + players, + ) + conn.commit() + conn.close() + + +def _point_at(monkeypatch, path): + monkeypatch.setattr(wl, "_db_path", lambda: str(path)) + + +def test_basic_wins_and_losses(tmp_path, monkeypatch): + db = tmp_path / "battles.db" + _make_db( + str(db), + matches=[("s1", 100), ("s2", 100), ("s3", 100)], + players=[ + ("s1", 7, "t34", "Win"), + ("s2", 7, "t34", "Win"), + ("s3", 7, "t34", "Loss"), + ], + ) + _point_at(monkeypatch, db) + assert wl.get_tss_standings(7, 100) == {"wins": 2, "losses": 1} + + +def test_per_tournament_isolation(tmp_path, monkeypatch): + db = tmp_path / "battles.db" + # Same team_id appears in two tournaments; records must not bleed across. + _make_db( + str(db), + matches=[("a", 100), ("b", 100), ("c", 200)], + players=[ + ("a", 7, "t34", "Win"), + ("b", 7, "t34", "Loss"), + ("c", 7, "t34", "Win"), + ], + ) + _point_at(monkeypatch, db) + assert wl.get_tss_standings(7, 100) == {"wins": 1, "losses": 1} + assert wl.get_tss_standings(7, 200) == {"wins": 1, "losses": 0} + + +def test_draw_is_loss_for_both_teams(tmp_path, monkeypatch): + db = tmp_path / "battles.db" + # A draw is persisted as victor_bool='Loss' for both participating teams. + _make_db( + str(db), + matches=[("s1", 100)], + players=[ + ("s1", 7, "t34", "Loss"), + ("s1", 9, "pz", "Loss"), + ], + ) + _point_at(monkeypatch, db) + assert wl.get_tss_standings(7, 100) == {"wins": 0, "losses": 1} + assert wl.get_tss_standings(9, 100) == {"wins": 0, "losses": 1} + + +def test_vehicle_duplicate_rows_count_once(tmp_path, monkeypatch): + db = tmp_path / "battles.db" + # One player on the team flew two vehicles -> two rows, same session/outcome. + _make_db( + str(db), + matches=[("s1", 100)], + players=[ + ("s1", 7, "t34", "Win"), + ("s1", 7, "is2", "Win"), + ], + ) + _point_at(monkeypatch, db) + assert wl.get_tss_standings(7, 100) == {"wins": 1, "losses": 0} + + +def test_string_team_id_is_coerced(tmp_path, monkeypatch): + db = tmp_path / "battles.db" + _make_db(str(db), matches=[("s1", 100)], players=[("s1", 7, "t34", "Win")]) + _point_at(monkeypatch, db) + # The render model carries team_id as a string. + assert wl.get_tss_standings("7", 100) == {"wins": 1, "losses": 0} + + +def test_missing_ids_return_none(tmp_path, monkeypatch): + db = tmp_path / "battles.db" + _make_db(str(db), matches=[("s1", 100)], players=[("s1", 7, "t34", "Win")]) + _point_at(monkeypatch, db) + assert wl.get_tss_standings(None, 100) is None + assert wl.get_tss_standings(7, None) is None + assert wl.get_tss_standings("not-a-number", 100) is None + + +def test_unknown_team_returns_none(tmp_path, monkeypatch): + db = tmp_path / "battles.db" + _make_db(str(db), matches=[("s1", 100)], players=[("s1", 7, "t34", "Win")]) + _point_at(monkeypatch, db) + assert wl.get_tss_standings(999, 100) is None + + +def test_missing_db_returns_none(tmp_path, monkeypatch): + _point_at(monkeypatch, tmp_path / "does_not_exist.db") + assert wl.get_tss_standings(7, 100) is None + + +def test_reads_wal_mode_db_with_open_writer(tmp_path, monkeypatch): + # The production DB is WAL. Reproduce a live-ingest state: WAL journal, no + # autocheckpoint (so a non-empty -wal/-shm lingers), and a writer connection + # still open while we read — get_tss_standings must still return the record. + db = tmp_path / "battles.db" + _make_db( + str(db), + matches=[("s1", 100), ("s2", 100)], + players=[("s1", 7, "t34", "Win"), ("s2", 7, "t34", "Loss")], + ) + writer = sqlite3.connect(str(db)) + writer.execute("PRAGMA journal_mode=WAL") + writer.execute("PRAGMA wal_autocheckpoint=0") + writer.execute( + "INSERT INTO player_games_hist (UID, session_id, team_id, vehicle_internal, victor_bool)" + " VALUES ('u', 's3', 7, 't34', 'Win')" + ) + writer.execute("INSERT INTO match_summary VALUES ('s3', 100)") + writer.commit() # committed to the WAL, not checkpointed; writer stays open + try: + _point_at(monkeypatch, db) + assert wl.get_tss_standings(7, 100) == {"wins": 2, "losses": 1} + finally: + writer.close()