add WL to tss scoreboards (#1345)

This commit is contained in:
NotSoToothless
2026-06-20 18:55:17 -07:00
committed by GitHub
parent f855e289c8
commit b665eb118f
5 changed files with 327 additions and 26 deletions
+51 -16
View File
@@ -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 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) 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 # W/L slot — per-tournament record under the team name. Coloured exactly like
# None today, so nothing renders under the team name). # SREBOT: green W · grey dash · red L · grey dash · win-rate% on a red→green
standings = get_tss_standings(team.get("team_id")) # 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: if standings:
wl_text = f"{standings['wins']}W - {standings['losses']}L" wl_segs = _winloss_segments(standings)
wb = draw.textbbox((0, 0), wl_text, font=fonts["sub"]) seg_w = _segments_width(draw, wl_segs, fonts["sub"])
wl_x = start_x if not flipped else start_x + section_width - (wb[2] - wb[0]) wl_x = start_x if not flipped else start_x + section_width - seg_w
draw.text((wl_x, header_y + (nb[3] - nb[1]) + 18), wl_text, font=fonts["sub"], fill=GREY_FILL) # 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). # Stat columns (rating now lives inline next to the player name).
col_labels = ["Air", "Ground", "Assists", "Deaths", "Caps"] 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 pass
# Player rows. Layout per row: # 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 # lower line — vehicle icons next to their translated names
body = fonts["body"] body = fonts["body"]
small = fonts["veh"] 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) veh_y = line_y + name_h + 12 # vehicle line (icons + names together)
vname_y = veh_y + (veh_icon - small_h) // 2 vname_y = veh_y + (veh_icon - small_h) // 2
name_w = draw.textbbox((0, 0), name, font=body)[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) rating_w = _segments_width(draw, rating_segs, body)
if not flipped: 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) RATING_FILL = (170, 200, 255, 255)
def _rating_segments(rating, delta): # W/L record colours — matched exactly to SREBOT's scoreboard.
"""Build colored (text, fill) segments for the inline ``(1468 +2)`` rating. 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 "" 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)] return [(f"({rating_str})", RATING_FILL)]
+1
View File
@@ -168,6 +168,7 @@ def build_scoreboard_model(game: dict[str, Any], lang_column: str = "<English>")
"duration": game.get("duration"), "duration": game.get("duration"),
"map": str(game.get("mission_name") or ""), "map": str(game.get("mission_name") or ""),
"mission_mode": str(game.get("mission_mode") or ""), "mission_mode": str(game.get("mission_mode") or ""),
"tournament_id": tss.get("tournament_id"),
"tournament_name": str(tss.get("tournament_name") or ""), "tournament_name": str(tss.get("tournament_name") or ""),
"bracket": str(tss.get("bracket") or ""), "bracket": str(tss.get("bracket") or ""),
"is_draw": is_draw, "is_draw": is_draw,
+88 -9
View File
@@ -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 SREBOT tracks squadron W/L globally. TSS records only mean something inside a
tournament** (a team's record only means something inside its bracket), so the real bracket, so standings are scoped to ``(tournament_id, team_id)`` and derived live
implementation is deferred. Until then ``get_tss_standings`` returns ``None`` for from the rows already persisted per match — no separate counter table:
every team and the scoreboard renders a neutral placeholder in the W/L slot.
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 from __future__ import annotations
import logging
import os
import sqlite3
from typing import Any, Optional 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]]: # One outcome per session (vehicle rows are duplicated across a player's lineup, so
"""Return ``{"wins": int, "losses": int}`` for a team, or ``None`` if untracked. # 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).
""" """
if team_id is None or tournament_id is None:
return 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)
+31
View File
@@ -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
+155
View File
@@ -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()