Auto merge dev → main (#1332)

* feat(tssbot): build_match_logs + match_logs persistence

* feat(tssbot): create match_logs table and write logs at ingest

* feat(tssbot): one-time match_logs backfill script

* feat(srebot): persist chat/battle logs to match_logs (parity, no backfill)

* feat(tssbot): Battle/Chat Log buttons on Discord scoreboards
This commit is contained in:
NotSoToothless
2026-06-18 01:02:59 -07:00
committed by GitHub
parent 53e3db9159
commit 32e747212f
6 changed files with 370 additions and 0 deletions
+71
View File
@@ -11,7 +11,9 @@ only receives one scoreboard for a given game.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import logging import logging
import sqlite3
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
@@ -98,6 +100,53 @@ def _bar_color(game: dict[str, Any], guild_id: int) -> str:
# Scoreboard view + render/send # Scoreboard view + render/send
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _load_match_logs(session_id: str) -> tuple[list[str], list[str]]:
"""Read (chat_log, battle_log) for a session from match_logs; empty on miss."""
from .storage import TSS_BATTLES_DB_PATH
try:
conn = sqlite3.connect(TSS_BATTLES_DB_PATH)
try:
row = conn.execute(
"SELECT chat_log_json, battle_log_json FROM match_logs WHERE session_id = ?",
(str(session_id),),
).fetchone()
finally:
conn.close()
except Exception:
return [], []
if not row:
return [], []
chat = json.loads(row[0]) if row[0] else []
battle = json.loads(row[1]) if row[1] else []
return chat, battle
async def _send_log(interaction: discord.Interaction, lines: list[str], title: str) -> None:
"""Send a log as ephemeral diff-formatted message(s), chunked under Discord's limit."""
await interaction.response.defer(thinking=True, ephemeral=True)
if not lines:
await interaction.followup.send("No log available for this match.", ephemeral=True)
return
chunks: list[str] = []
chunk: list[str] = []
length = 0
for line in lines:
if length + len(line) + 1 > 1800:
chunks.append("\n".join(chunk))
chunk = [line]
length = len(line) + 1
else:
chunk.append(line)
length += len(line) + 1
if chunk:
chunks.append("\n".join(chunk))
first = True
for c in chunks:
content = (f"**{title}**\n" if first else "") + f"```diff\n{c}\n```"
await interaction.followup.send(content, ephemeral=True)
first = False
def build_tss_scoreboard_view(session_id: str) -> discord.ui.View: def build_tss_scoreboard_view(session_id: str) -> discord.ui.View:
"""Link buttons under a scoreboard: in-game replay + the TSS website.""" """Link buttons under a scoreboard: in-game replay + the TSS website."""
view = discord.ui.View(timeout=None) view = discord.ui.View(timeout=None)
@@ -110,6 +159,28 @@ def build_tss_scoreboard_view(session_id: str) -> discord.ui.View:
label="View on Website", style=discord.ButtonStyle.link, label="View on Website", style=discord.ButtonStyle.link,
url=f"https://tss.pawjob.us/games/{session_id}", emoji="🌐", url=f"https://tss.pawjob.us/games/{session_id}", emoji="🌐",
)) ))
chat_log, battle_log = _load_match_logs(session_id)
battle_btn = discord.ui.Button(label="Battle Log", style=discord.ButtonStyle.green)
async def _battle_cb(interaction: discord.Interaction) -> None:
_, b = _load_match_logs(session_id)
await _send_log(interaction, b, f"Battle Log · {session_id}")
battle_btn.callback = _battle_cb
view.add_item(battle_btn)
if chat_log:
chat_btn = discord.ui.Button(label="Chat Log", style=discord.ButtonStyle.green)
async def _chat_cb(interaction: discord.Interaction) -> None:
c, _ = _load_match_logs(session_id)
await _send_log(interaction, c, f"Chat Log · {session_id}")
chat_btn.callback = _chat_cb
view.add_item(chat_btn)
return view return view
+184
View File
@@ -0,0 +1,184 @@
"""Chat + battle log builder and persistence for TSS matches.
Ports SREBOT/BOT/utils.py log formatting so both sites render identically.
Logs are stored in match_logs (tss_battles.db) as JSON string arrays.
"""
from __future__ import annotations
import base64
import json
import logging
from typing import Any
import aiosqlite
try:
from data_parser import LangTableReader, apply_vehicle_name_filters # type: ignore
except Exception: # pragma: no cover
LangTableReader = None # type: ignore
def apply_vehicle_name_filters(name, strip_decorations=True): # type: ignore
return name
log = logging.getLogger("tssbot.match_logs")
MATCH_LOGS_SQL = """
CREATE TABLE IF NOT EXISTS match_logs (
session_id TEXT PRIMARY KEY,
chat_log_json TEXT,
battle_log_json TEXT,
built_unix INTEGER
)
"""
def _fmt_time(ms: int) -> str:
total_s = int(ms) // 1000
return f"{total_s // 60:02d}:{total_s % 60:02d}"
def _decompress_events(raw_events: Any) -> dict:
if isinstance(raw_events, str):
try:
import zstandard
compressed = base64.b85decode(raw_events)
return json.loads(zstandard.decompress(compressed).decode("utf-8"))
except Exception as exc: # pragma: no cover - corrupt payloads
log.error("Failed to decompress events: %s", exc)
return {}
return raw_events or {}
def _strip_tag(tag: str) -> str:
s = (tag or "").strip()
if len(s) >= 3 and not s[0].isalnum() and not s[-1].isalnum():
return s[1:-1]
return s
def build_match_logs(game: dict[str, Any]) -> tuple[list[str], list[str]]:
"""Return (chat_log, battle_log) as pre-formatted string lists."""
players = game.get("players", {}) or {}
winner_winged = str(game.get("winner") or "")
loser_winged = str(game.get("loser") or "")
winner_sq = _strip_tag(winner_winged)
loser_sq = _strip_tag(loser_winged)
uid_lookup: dict[str, dict[str, str]] = {}
for uid_str, p in players.items():
tag = p.get("tag", "") or ""
uid_lookup[str(uid_str)] = {
"name": p.get("name", "") or "",
"tag_stripped": tag[1:-1] if tag else "",
}
translate = None
if LangTableReader is not None:
try:
translate = LangTableReader("English")
except Exception: # pragma: no cover
translate = None
def _veh(cdk: str | None) -> str:
if not cdk:
return "Unknown"
if translate is not None:
t = translate.get_translate(cdk)
if t:
return apply_vehicle_name_filters(t)
return cdk
def _player(uid: str | None) -> tuple[str, str]:
if uid is None:
return "Unknown", ""
info = uid_lookup.get(str(uid))
if info:
return info["name"], info["tag_stripped"]
return f"Player#{uid}", ""
def _prefix(sq: str) -> str:
if sq == winner_sq:
return "+"
if sq == loser_sq:
return "-"
return " "
chat_log: list[str] = []
for c in game.get("chat", []) or []:
info = uid_lookup.get(
str(c.get("uid", "")), {"name": "Unknown", "tag_stripped": "???"}
)
chat_log.append(
f"[{_fmt_time(c.get('time', 0))}] [{c.get('type', 'ALL')}] "
f"[{info['tag_stripped']}] `{info['name']}`: {c.get('message', '')}"
)
events = _decompress_events(game.get("events", {}))
merged: list[dict[str, Any]] = []
for k in events.get("kills", []) or []:
merged.append({
"kind": "kill", "time": k.get("time", 0),
"off_uid": str(k["offender_uid"]) if k.get("offender_uid") is not None else None,
"off_unit": k.get("offender_unit"),
"vic_uid": str(k["offended_uid"]) if k.get("offended_uid") is not None else None,
"vic_unit": k.get("offended_unit"),
"crashed": k.get("crashed", False), "afire": False,
})
for d in events.get("damage", []) or []:
merged.append({
"kind": "damage", "time": d.get("time", 0),
"off_uid": str(d["offender_uid"]) if d.get("offender_uid") is not None else None,
"off_unit": d.get("offender_unit"),
"vic_uid": str(d["offended_uid"]) if d.get("offended_uid") is not None else None,
"vic_unit": d.get("offended_unit"),
"crashed": False, "afire": d.get("afire", False),
})
merged.sort(key=lambda e: e.get("time", 0))
battle_log: list[str] = []
for ev in merged:
ts = _fmt_time(ev["time"])
vic_name, vic_sq = _player(ev["vic_uid"])
vic_veh = _veh(ev["vic_unit"])
if ev["kind"] == "kill":
if ev["off_uid"] is None or ev["crashed"]:
battle_log.append(
f"{_prefix(vic_sq)}[{ts}] {f'[{vic_sq}]':<7} "
f"{vic_name} ({vic_veh}) crashed"
)
else:
name, sq = _player(ev["off_uid"])
battle_log.append(
f"{_prefix(sq)}[{ts}] {f'[{sq}]':<7} {name} ({_veh(ev['off_unit'])}) "
f"destroyed {vic_name} ({vic_veh})"
)
else:
if ev["off_uid"] is None:
continue
name, sq = _player(ev["off_uid"])
afire = "(FIRE) " if ev["afire"] else ""
battle_log.append(
f"{_prefix(sq)}[{ts}] {f'[{sq}]':<7} {name} ({_veh(ev['off_unit'])}) "
f"damaged {afire}{vic_name} ({vic_veh})"
)
return chat_log, battle_log
async def upsert_match_logs(
db_path, session_id: str, chat_log: list[str], battle_log: list[str]
) -> None:
import time
async with aiosqlite.connect(db_path) as conn:
await conn.execute(MATCH_LOGS_SQL)
await conn.execute(
"""INSERT INTO match_logs (session_id, chat_log_json, battle_log_json, built_unix)
VALUES (?, ?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET
chat_log_json=excluded.chat_log_json,
battle_log_json=excluded.battle_log_json,
built_unix=excluded.built_unix""",
(str(session_id), json.dumps(chat_log, ensure_ascii=False),
json.dumps(battle_log, ensure_ascii=False), int(time.time())),
)
await conn.commit()
+2
View File
@@ -222,6 +222,8 @@ async def _init_battles_db() -> None:
await conn.execute(sql) await conn.execute(sql)
await conn.execute(_MATCH_SUMMARY_SQL) await conn.execute(_MATCH_SUMMARY_SQL)
await conn.execute(_PLAYER_GAMES_SQL) await conn.execute(_PLAYER_GAMES_SQL)
from BOT.match_logs import MATCH_LOGS_SQL
await conn.execute(MATCH_LOGS_SQL)
await _rebuild_table(conn, "match_summary", _MATCH_SUMMARY_SQL, [ await _rebuild_table(conn, "match_summary", _MATCH_SUMMARY_SQL, [
"session_id", "mission_mode", "mission_name", "level_path", "session_id", "mission_mode", "mission_name", "level_path",
"mission_path", "difficulty", "mission_path", "difficulty",
+49
View File
@@ -0,0 +1,49 @@
"""Backfill match_logs from on-disk TSS replays (one-time).
Usage: python TSSBOT/scripts/backfill_match_logs.py [--dry-run]
"""
import asyncio
import gzip
import json
import os
import sys
import pathlib
ROOT = pathlib.Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT.parent / "SHARED"))
from BOT.match_logs import build_match_logs, upsert_match_logs, MATCH_LOGS_SQL
from BOT.storage import TSS_BATTLES_DB_PATH
REPLAYS = pathlib.Path(os.environ["STORAGE_VOL_PATH"]) / "REPLAYS" / "TSS"
async def main(dry_run: bool) -> None:
import aiosqlite
files = sorted(REPLAYS.glob("*/replay_data.json.gz"))
print(f"Found {len(files)} replays under {REPLAYS}")
async with aiosqlite.connect(TSS_BATTLES_DB_PATH) as conn:
await conn.execute(MATCH_LOGS_SQL)
await conn.commit()
done = 0
for f in files:
sid = f.parent.name
try:
with gzip.open(f, "rb") as fh:
game = json.loads(fh.read().decode("utf-8"))
except Exception as exc:
print(f" skip {sid}: {exc}")
continue
chat, battle = build_match_logs(game)
if dry_run:
print(f" {sid}: chat={len(chat)} battle={len(battle)}")
else:
await upsert_match_logs(TSS_BATTLES_DB_PATH, sid, chat, battle)
done += 1
print(f"{'Would backfill' if dry_run else 'Backfilled'} "
f"{len(files) if dry_run else done} sessions")
if __name__ == "__main__":
asyncio.run(main("--dry-run" in sys.argv))
+60
View File
@@ -0,0 +1,60 @@
import sys
import pathlib
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.match_logs import build_match_logs
def _game():
return {
"_id": "abc",
"winner": "[WIN]", "loser": "[LOS]",
"players": {
"1": {"name": "alice", "tag": "[WIN]", "team": "1",
"units": [{"unit": "ussr_t_34", "used": True}],
"air_kills": 0, "ground_kills": 1, "assists": 0,
"deaths": 0, "captures": 0, "score": 100},
"2": {"name": "bob", "tag": "[LOS]", "team": "2",
"units": [{"unit": "germ_pz_iv", "used": True}],
"air_kills": 0, "ground_kills": 0, "assists": 0,
"deaths": 1, "captures": 0, "score": 10},
},
"chat": [{"uid": "1", "type": "ALL", "message": "gg", "time": 65000}],
"events": {"kills": [{"time": 60000, "offender_uid": 1,
"offender_unit": "ussr_t_34",
"offended_uid": 2, "offended_unit": "germ_pz_iv",
"crashed": False}]},
}
def test_chat_log_format():
chat, _ = build_match_logs(_game())
assert chat == ["[01:05] [ALL] [WIN] `alice`: gg"]
def test_battle_log_kill_prefix_and_text():
_, battle = build_match_logs(_game())
assert len(battle) == 1
line = battle[0]
assert line.startswith("+[01:00] [WIN]")
assert "alice" in line and "destroyed" in line and "bob" in line
def test_events_base85_zstd_compressed():
import base64
import json
import zstandard
g = _game()
raw = json.dumps(g["events"]).encode()
g["events"] = base64.b85encode(zstandard.ZstdCompressor().compress(raw)).decode()
_, battle = build_match_logs(g)
assert battle and "destroyed" in battle[0]
if __name__ == "__main__":
test_chat_log_format()
test_battle_log_kill_prefix_and_text()
test_events_base85_zstd_compressed()
print("ALL TESTS PASSED")
+4
View File
@@ -164,6 +164,10 @@ async def _handle_game(game: Dict[str, Any]) -> None:
await insert_match(game) await insert_match(game)
await insert_player_games(game) await insert_player_games(game)
await upsert_tss_teams(game) await upsert_tss_teams(game)
from BOT.match_logs import build_match_logs, upsert_match_logs
from BOT.storage import TSS_BATTLES_DB_PATH
chat_log, battle_log = build_match_logs(game)
await upsert_match_logs(TSS_BATTLES_DB_PATH, sid, chat_log, battle_log)
log.info("Stored game %s in DB", sid) log.info("Stored game %s in DB", sid)
except Exception as exc: except Exception as exc:
log.error("DB insert failed for %s: %s", sid, exc) log.error("DB insert failed for %s: %s", sid, exc)