tss db wipe and update (#1305)

This commit is contained in:
NotSoToothless
2026-06-07 17:28:16 -07:00
committed by GitHub
parent 632a441080
commit abab7ea9fa
7 changed files with 223 additions and 396 deletions
+17 -26
View File
@@ -15,7 +15,7 @@ from typing import Any, Optional
import discord import discord
from . import preferences, storage from . import preferences
log = logging.getLogger("tssbot.autolog") log = logging.getLogger("tssbot.autolog")
@@ -33,18 +33,21 @@ def set_bot(bot: discord.Client) -> None:
_bot = bot _bot = bot
def _present_team_tags(game: dict[str, Any]) -> set[str]: def _present_teams(game: dict[str, Any]) -> tuple[set[str], set[str]]:
"""Return the set of team tags present in a game dict.""" """Return stable TSS team IDs and names embedded in a replay."""
players = game.get("players") or {} tss = game.get("tss") or {}
tags: set[str] = set() team_ids: set[str] = set()
for p in players.values(): team_names: set[str] = set()
if not isinstance(p, dict): for slot in ("1", "2"):
team = tss.get(slot)
if not isinstance(team, dict):
continue continue
tag_raw = p.get("tag") or "" if team.get("team_id") is not None:
tag = tag_raw[1:-1] if len(tag_raw) > 2 else tag_raw team_ids.add(str(team["team_id"]))
if tag: name = str(team.get("team_name") or team.get("name") or "").strip()
tags.add(tag) if name:
return tags team_names.add(name.lower())
return team_ids, team_names
async def process_game(game: dict[str, Any]) -> None: async def process_game(game: dict[str, Any]) -> None:
@@ -59,19 +62,7 @@ async def process_game(game: dict[str, Any]) -> None:
if not session_id: if not session_id:
return return
present_tags = _present_team_tags(game) present_team_ids, present_team_names = _present_teams(game)
tags_lower = {t.lower() for t in present_tags}
# Resolve present tags → team_ids so team subscriptions (keyed by team_id) match.
present_team_ids: set[str] = set()
for tag in present_tags:
try:
tid = await storage.resolve_team_id_for_tag(tag)
except Exception as exc:
log.error("tag resolve failed for %s: %s", tag, exc)
tid = None
if tid is not None:
present_team_ids.add(str(tid))
sent = _sent_channels_by_session.setdefault(session_id, set()) sent = _sent_channels_by_session.setdefault(session_id, set())
@@ -84,7 +75,7 @@ async def process_game(game: dict[str, Any]) -> None:
continue continue
name = (entry.get("Name") or "").lower() name = (entry.get("Name") or "").lower()
matched = str(entity_id) in present_team_ids or (name and name in tags_lower) matched = str(entity_id) in present_team_ids or (name and name in present_team_names)
if not matched or channel_id in sent: if not matched or channel_id in sent:
continue continue
+6 -6
View File
@@ -58,7 +58,7 @@ async def team_autocomplete(interaction: discord.Interaction, current: str) -> L
return [] return []
choices = [] choices = []
for r in rows: for r in rows:
label = (r.get("tag_name") or r.get("long_name") or str(r["team_id"]))[:100] label = (r.get("long_name") or str(r["team_id"]))[:100]
choices.append(app_commands.Choice(name=label, value=str(r["team_id"]))) choices.append(app_commands.Choice(name=label, value=str(r["team_id"])))
return choices return choices
@@ -112,7 +112,7 @@ class TssCommands(commands.Cog):
# ── /set-team ────────────────────────────────────────────────────────── # ── /set-team ──────────────────────────────────────────────────────────
@app_commands.command(name="set-team", description="Set this server's team") @app_commands.command(name="set-team", description="Set this server's team")
@app_commands.describe(team="Team name, tag, or ID") @app_commands.describe(team="TSS team name or ID")
@app_commands.autocomplete(team=team_autocomplete) @app_commands.autocomplete(team=team_autocomplete)
@app_commands.checks.has_permissions(manage_guild=True) @app_commands.checks.has_permissions(manage_guild=True)
@not_blacklisted() @not_blacklisted()
@@ -123,7 +123,7 @@ class TssCommands(commands.Cog):
resolved = await storage.resolve_team(team) resolved = await storage.resolve_team(team)
if not resolved: if not resolved:
return await interaction.followup.send(f"Could not find a team matching **{team}**.", ephemeral=True) return await interaction.followup.send(f"Could not find a team matching **{team}**.", ephemeral=True)
name = resolved.get("tag_name") or resolved.get("long_name") or str(resolved["team_id"]) name = resolved.get("long_name") or str(resolved["team_id"])
preferences.set_guild_team(interaction.guild_id, int(resolved["team_id"]), name) preferences.set_guild_team(interaction.guild_id, int(resolved["team_id"]), name)
await interaction.followup.send( await interaction.followup.send(
f"✅ This server's team is now **{name}** (id `{resolved['team_id']}`).", ephemeral=True f"✅ This server's team is now **{name}** (id `{resolved['team_id']}`).", ephemeral=True
@@ -131,7 +131,7 @@ class TssCommands(commands.Cog):
# ── /log-team ────────────────────────────────────────────────────────── # ── /log-team ──────────────────────────────────────────────────────────
@app_commands.command(name="log-team", description="Send a team's matches to this channel") @app_commands.command(name="log-team", description="Send a team's matches to this channel")
@app_commands.describe(team="Team name, tag, or ID") @app_commands.describe(team="TSS team name or ID")
@app_commands.autocomplete(team=team_autocomplete) @app_commands.autocomplete(team=team_autocomplete)
@app_commands.checks.has_permissions(manage_guild=True) @app_commands.checks.has_permissions(manage_guild=True)
@not_blacklisted() @not_blacklisted()
@@ -142,7 +142,7 @@ class TssCommands(commands.Cog):
resolved = await storage.resolve_team(team) resolved = await storage.resolve_team(team)
if not resolved: if not resolved:
return await interaction.followup.send(f"Could not find a team matching **{team}**.", ephemeral=True) return await interaction.followup.send(f"Could not find a team matching **{team}**.", ephemeral=True)
name = resolved.get("tag_name") or resolved.get("long_name") or str(resolved["team_id"]) name = resolved.get("long_name") or str(resolved["team_id"])
preferences.upsert_log_entry( preferences.upsert_log_entry(
interaction.guild_id, int(resolved["team_id"]), "tss-team", name, interaction.channel_id interaction.guild_id, int(resolved["team_id"]), "tss-team", name, interaction.channel_id
) )
@@ -223,7 +223,7 @@ class TssCommands(commands.Cog):
embed.add_field( embed.add_field(
name="Teams seen with", name="Teams seen with",
value="\n".join( value="\n".join(
f"`{(tm.get('team_tag') or '?')}` — {tm.get('games') or 0} games" for tm in top f"`{(tm.get('team_name') or '?')}` — {tm.get('games') or 0} games" for tm in top
), ),
inline=False, inline=False,
) )
+198 -131
View File
@@ -2,7 +2,7 @@
Two databases live under ``STORAGE_VOL_PATH`` (set in ``TSSBOT/.env``): Two databases live under ``STORAGE_VOL_PATH`` (set in ``TSSBOT/.env``):
* ``tss_teams.db`` — persistent team registry * ``tss_teams.db`` — replay-sourced TSS team registry
* ``tss_battles.db`` — per-match summary + per-player/per-vehicle game history * ``tss_battles.db`` — per-match summary + per-player/per-vehicle game history
One row is written to ``player_games_hist`` per vehicle *actually used* by each One row is written to ``player_games_hist`` per vehicle *actually used* by each
@@ -51,8 +51,10 @@ TSS_TEAMS_DB_PATH: Path = STORAGE_DIR / "tss_teams.db"
_MATCH_SUMMARY_SQL = """ _MATCH_SUMMARY_SQL = """
CREATE TABLE IF NOT EXISTS match_summary ( CREATE TABLE IF NOT EXISTS match_summary (
session_id TEXT PRIMARY KEY, session_id TEXT PRIMARY KEY,
map_name TEXT,
mission_mode TEXT, mission_mode TEXT,
mission_name TEXT,
level_path TEXT,
mission_path TEXT,
difficulty TEXT, difficulty TEXT,
starttime_unix INTEGER, starttime_unix INTEGER,
endtime_unix INTEGER, endtime_unix INTEGER,
@@ -61,14 +63,19 @@ CREATE TABLE IF NOT EXISTS match_summary (
winning_slot TEXT, winning_slot TEXT,
losing_slot TEXT, losing_slot TEXT,
received_unix INTEGER, received_unix INTEGER,
tournament_id INTEGER tournament_id INTEGER,
tournament_name TEXT,
match_id TEXT,
bracket TEXT
) )
""" """
_MATCH_SUMMARY_INDEXES = [ _MATCH_SUMMARY_INDEXES = [
"CREATE INDEX IF NOT EXISTS idx_ms_map_name ON match_summary(map_name)", "CREATE INDEX IF NOT EXISTS idx_ms_mission_name ON match_summary(mission_name)",
"CREATE INDEX IF NOT EXISTS idx_ms_endtime ON match_summary(endtime_unix)", "CREATE INDEX IF NOT EXISTS idx_ms_level_path ON match_summary(level_path)",
"CREATE INDEX IF NOT EXISTS idx_ms_difficulty ON match_summary(difficulty)", "CREATE INDEX IF NOT EXISTS idx_ms_mission_path ON match_summary(mission_path)",
"CREATE INDEX IF NOT EXISTS idx_ms_endtime ON match_summary(endtime_unix)",
"CREATE INDEX IF NOT EXISTS idx_ms_difficulty ON match_summary(difficulty)",
] ]
@@ -77,7 +84,6 @@ CREATE TABLE IF NOT EXISTS player_games_hist (
UID TEXT NOT NULL, UID TEXT NOT NULL,
nick TEXT NOT NULL, nick TEXT NOT NULL,
team_name TEXT, team_name TEXT,
team_tag TEXT NOT NULL DEFAULT 'UNKNOWN',
team_slot TEXT, team_slot TEXT,
session_id TEXT NOT NULL, session_id TEXT NOT NULL,
vehicle TEXT, vehicle TEXT,
@@ -95,9 +101,7 @@ CREATE TABLE IF NOT EXISTS player_games_hist (
victor_bool TEXT NOT NULL DEFAULT 'Loss', victor_bool TEXT NOT NULL DEFAULT 'Loss',
endtime_unix INTEGER NOT NULL DEFAULT 0, endtime_unix INTEGER NOT NULL DEFAULT 0,
team_id INTEGER, team_id INTEGER,
tss_team_uuid TEXT,
tss_role TEXT, tss_role TEXT,
tss_place INTEGER,
pvp_ratio REAL, pvp_ratio REAL,
UNIQUE (UID, session_id, vehicle_internal) UNIQUE (UID, session_id, vehicle_internal)
) )
@@ -106,7 +110,6 @@ CREATE TABLE IF NOT EXISTS player_games_hist (
_PLAYER_GAMES_INDEXES = [ _PLAYER_GAMES_INDEXES = [
"CREATE INDEX IF NOT EXISTS idx_pgh_session ON player_games_hist(session_id)", "CREATE INDEX IF NOT EXISTS idx_pgh_session ON player_games_hist(session_id)",
"CREATE INDEX IF NOT EXISTS idx_pgh_uid_time ON player_games_hist(UID, endtime_unix)", "CREATE INDEX IF NOT EXISTS idx_pgh_uid_time ON player_games_hist(UID, endtime_unix)",
"CREATE INDEX IF NOT EXISTS idx_pgh_team_tag_time ON player_games_hist(team_tag, endtime_unix)",
"CREATE INDEX IF NOT EXISTS idx_pgh_team_name_time ON player_games_hist(team_name, endtime_unix)", "CREATE INDEX IF NOT EXISTS idx_pgh_team_name_time ON player_games_hist(team_name, endtime_unix)",
"CREATE INDEX IF NOT EXISTS idx_pgh_endtime ON player_games_hist(endtime_unix)", "CREATE INDEX IF NOT EXISTS idx_pgh_endtime ON player_games_hist(endtime_unix)",
"CREATE INDEX IF NOT EXISTS idx_pgh_nick ON player_games_hist(nick COLLATE NOCASE)", "CREATE INDEX IF NOT EXISTS idx_pgh_nick ON player_games_hist(nick COLLATE NOCASE)",
@@ -122,25 +125,15 @@ _PLAYER_GAMES_INDEXES = [
_TEAMS_DATA_SQL = """ _TEAMS_DATA_SQL = """
CREATE TABLE IF NOT EXISTS teams_data ( CREATE TABLE IF NOT EXISTS teams_data (
team_id INTEGER PRIMARY KEY AUTOINCREMENT, team_id INTEGER PRIMARY KEY,
long_name TEXT NOT NULL UNIQUE, long_name TEXT NOT NULL,
short_name TEXT UNIQUE,
tag_name TEXT,
description TEXT,
region TEXT,
members INTEGER NOT NULL DEFAULT 0, members INTEGER NOT NULL DEFAULT 0,
members_json TEXT, captain_uid TEXT
captain_uid TEXT,
guild_id TEXT,
clanrating INTEGER,
created_unix INTEGER,
updated_unix INTEGER
) )
""" """
_TEAMS_DATA_INDEXES = [ _TEAMS_DATA_INDEXES = [
"CREATE INDEX IF NOT EXISTS idx_teams_data_tag_name ON teams_data(tag_name COLLATE NOCASE)", "CREATE INDEX IF NOT EXISTS idx_teams_data_long_name ON teams_data(long_name COLLATE NOCASE)",
"CREATE INDEX IF NOT EXISTS idx_teams_data_guild_id ON teams_data(guild_id)",
] ]
@@ -148,10 +141,7 @@ _TEAM_MEMBERS_SQL = """
CREATE TABLE IF NOT EXISTS team_members ( CREATE TABLE IF NOT EXISTS team_members (
team_id INTEGER NOT NULL, team_id INTEGER NOT NULL,
uid TEXT NOT NULL, uid TEXT NOT NULL,
nick TEXT,
role TEXT NOT NULL DEFAULT 'player', role TEXT NOT NULL DEFAULT 'player',
points INTEGER NOT NULL DEFAULT 0,
joined_unix INTEGER,
PRIMARY KEY (team_id, uid) PRIMARY KEY (team_id, uid)
) )
""" """
@@ -177,22 +167,6 @@ _TEAM_NAME_HISTORY_INDEXES = [
] ]
_TEAMS_POINTS_SQL = """
CREATE TABLE IF NOT EXISTS teams_points (
team_id INTEGER NOT NULL,
long_name TEXT,
unix_time INTEGER NOT NULL,
total_score INTEGER,
PRIMARY KEY (team_id, unix_time)
)
"""
_TEAMS_POINTS_INDEXES = [
"CREATE INDEX IF NOT EXISTS idx_teams_points_long_name ON teams_points(long_name COLLATE NOCASE)",
"CREATE INDEX IF NOT EXISTS idx_teams_points_unix_time ON teams_points(unix_time)",
]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Init # Init
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -215,19 +189,31 @@ async def _existing_columns(conn: aiosqlite.Connection, table: str) -> set[str]:
return {row[1] for row in rows} return {row[1] for row in rows}
async def _migrate( async def _rebuild_table(
conn: aiosqlite.Connection, table: str, additions: dict[str, str] conn: aiosqlite.Connection,
table: str,
create_sql: str,
desired_columns: list[str],
) -> None: ) -> None:
"""Add any missing columns. `additions` maps column name → full column DDL. """Rebuild a table when its schema contains obsolete or missing columns."""
existing = await _existing_columns(conn, table)
desired = set(desired_columns)
if existing == desired:
return
SQLite's ``ALTER TABLE ADD COLUMN`` only accepts a literal DEFAULT, which old_table = f"{table}_old_schema"
is fine for all our additions. Existing rows pick up the DEFAULT value. await conn.execute(f"DROP TABLE IF EXISTS {old_table}")
""" await conn.execute(f"ALTER TABLE {table} RENAME TO {old_table}")
cols = await _existing_columns(conn, table) await conn.execute(create_sql)
for col, ddl in additions.items(): common = [column for column in desired_columns if column in existing]
if col not in cols: if common:
await conn.execute(f"ALTER TABLE {table} ADD COLUMN {ddl}") columns = ", ".join(common)
log.info("migrated %s: added column %s", table, col) await conn.execute(
f"INSERT OR IGNORE INTO {table} ({columns}) "
f"SELECT {columns} FROM {old_table}"
)
await conn.execute(f"DROP TABLE {old_table}")
log.info("migrated %s: rebuilt replay-native schema", table)
async def _init_battles_db() -> None: async def _init_battles_db() -> None:
@@ -236,17 +222,22 @@ 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)
await _rebuild_table(conn, "match_summary", _MATCH_SUMMARY_SQL, [
"session_id", "mission_mode", "mission_name", "level_path",
"mission_path", "difficulty",
"starttime_unix", "endtime_unix", "duration", "draw",
"winning_slot", "losing_slot", "received_unix", "tournament_id",
"tournament_name", "match_id", "bracket",
])
await _rebuild_table(conn, "player_games_hist", _PLAYER_GAMES_SQL, [
"UID", "nick", "team_name", "team_slot", "session_id",
"vehicle", "vehicle_internal", "ground_kills", "air_kills",
"assists", "captures", "deaths", "score", "missile_evades",
"shell_interceptions", "team_kills_stat", "country_id",
"victor_bool", "endtime_unix", "team_id", "tss_role", "pvp_ratio",
])
await _apply(conn, _MATCH_SUMMARY_INDEXES) await _apply(conn, _MATCH_SUMMARY_INDEXES)
await _apply(conn, _PLAYER_GAMES_INDEXES) await _apply(conn, _PLAYER_GAMES_INDEXES)
await _migrate(conn, "match_summary", {
"tournament_id": "tournament_id INTEGER",
})
await _migrate(conn, "player_games_hist", {
"tss_team_uuid": "tss_team_uuid TEXT",
"tss_role": "tss_role TEXT",
"tss_place": "tss_place INTEGER",
"pvp_ratio": "pvp_ratio REAL",
})
await conn.commit() await conn.commit()
@@ -257,23 +248,17 @@ async def _init_teams_db() -> None:
await conn.execute(_TEAMS_DATA_SQL) await conn.execute(_TEAMS_DATA_SQL)
await conn.execute(_TEAM_MEMBERS_SQL) await conn.execute(_TEAM_MEMBERS_SQL)
await conn.execute(_TEAM_NAME_HISTORY_SQL) await conn.execute(_TEAM_NAME_HISTORY_SQL)
await conn.execute(_TEAMS_POINTS_SQL) await conn.execute("DROP TABLE IF EXISTS teams_points")
await _rebuild_table(conn, "teams_data", _TEAMS_DATA_SQL, [
# Forward-only migrations for DBs created before a column landed. "team_id", "long_name", "members", "captain_uid",
# Keep CREATE TABLE statements above in sync — these only matter when ])
# the table already existed. await _rebuild_table(conn, "team_members", _TEAM_MEMBERS_SQL, [
await _migrate(conn, "teams_data", { "team_id", "uid", "role",
"clanrating": "clanrating INTEGER", ])
})
await _migrate(conn, "team_members", {
"points": "points INTEGER NOT NULL DEFAULT 0",
})
await _apply(conn, _TEAMS_DATA_INDEXES) await _apply(conn, _TEAMS_DATA_INDEXES)
await _apply(conn, _TEAM_MEMBERS_INDEXES) await _apply(conn, _TEAM_MEMBERS_INDEXES)
await _apply(conn, _TEAM_NAME_HISTORY_INDEXES) await _apply(conn, _TEAM_NAME_HISTORY_INDEXES)
await _apply(conn, _TEAMS_POINTS_INDEXES)
await conn.commit() await conn.commit()
@@ -300,23 +285,44 @@ async def insert_match(game: Dict[str, Any]) -> None:
"""Insert one row into match_summary from a normalised game dict. """Insert one row into match_summary from a normalised game dict.
``game["_id"]`` must already be a hex string (normalised by tss_ws). ``game["_id"]`` must already be a hex string (normalised by tss_ws).
Safe to call multiple times — INSERT OR IGNORE skips duplicates. Safe to call multiple times; newer replay metadata refreshes the row.
""" """
async with aiosqlite.connect(TSS_BATTLES_DB_PATH) as conn: async with aiosqlite.connect(TSS_BATTLES_DB_PATH) as conn:
for sql in _PRAGMAS: for sql in _PRAGMAS:
await conn.execute(sql) await conn.execute(sql)
tss = game.get("tss") or {}
await conn.execute( await conn.execute(
""" """
INSERT OR IGNORE INTO match_summary INSERT INTO match_summary
(session_id, map_name, mission_mode, difficulty, (session_id, mission_mode, mission_name, level_path, mission_path,
difficulty,
starttime_unix, endtime_unix, duration, starttime_unix, endtime_unix, duration,
draw, winning_slot, losing_slot, received_unix) draw, winning_slot, losing_slot, received_unix,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) tournament_id, tournament_name, match_id, bracket)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET
mission_mode = excluded.mission_mode,
mission_name = excluded.mission_name,
level_path = excluded.level_path,
mission_path = excluded.mission_path,
difficulty = excluded.difficulty,
starttime_unix = excluded.starttime_unix,
endtime_unix = excluded.endtime_unix,
duration = excluded.duration,
draw = excluded.draw,
winning_slot = excluded.winning_slot,
losing_slot = excluded.losing_slot,
tournament_id = excluded.tournament_id,
tournament_name = excluded.tournament_name,
match_id = excluded.match_id,
bracket = excluded.bracket
""", """,
( (
str(game["_id"]), str(game["_id"]),
str(game.get("mission_name") or game.get("level_path") or ""),
str(game.get("mission_mode") or ""), str(game.get("mission_mode") or ""),
str(game.get("mission_name") or ""),
str(game.get("level_path") or ""),
str(game.get("mission_path") or ""),
str(game.get("difficulty") or ""), str(game.get("difficulty") or ""),
int(game.get("start_ts") or 0), int(game.get("start_ts") or 0),
int(game.get("end_ts") or 0), int(game.get("end_ts") or 0),
@@ -325,6 +331,10 @@ async def insert_match(game: Dict[str, Any]) -> None:
str(game.get("winner") or ""), str(game.get("winner") or ""),
str(game.get("loser") or ""), str(game.get("loser") or ""),
int(time.time()), int(time.time()),
tss.get("tournament_id"),
str(tss.get("tournament_name") or ""),
str(tss.get("match_id") or ""),
str(tss.get("bracket") or ""),
), ),
) )
await conn.commit() await conn.commit()
@@ -335,18 +345,30 @@ async def insert_player_games(game: Dict[str, Any]) -> None:
victor_bool is set to 'Win' when the player's team slot matches the victor_bool is set to 'Win' when the player's team slot matches the
winning slot, 'Loss' otherwise. winning slot, 'Loss' otherwise.
Safe to call multiple times — INSERT OR IGNORE skips duplicates. Safe to call multiple times; newer replay metadata refreshes existing rows.
""" """
session_id = str(game["_id"]) session_id = str(game["_id"])
winner_slot = str(game.get("winner") or "") winner_slot = str(game.get("winner") or "")
end_ts = int(game.get("end_ts") or 0) end_ts = int(game.get("end_ts") or 0)
players = game.get("players") or {} players = game.get("players") or {}
tss = game.get("tss") or {}
tss_players: dict[str, dict[str, Any]] = {}
tss_teams: dict[str, dict[str, Any]] = {}
for slot in ("1", "2"):
team = tss.get(slot)
if not isinstance(team, dict):
continue
tss_teams[slot] = team
for member in team.get("players") or []:
if isinstance(member, dict) and member.get("uid") is not None:
tss_players[str(member["uid"])] = member
rows = [] rows = []
for uid_str, p in players.items(): for uid_str, p in players.items():
victor_bool = "Win" if str(p.get("team", "")) == winner_slot else "Loss" victor_bool = "Win" if str(p.get("team", "")) == winner_slot else "Loss"
tag_raw = p.get("tag") or "" tss_team = tss_teams.get(str(p.get("team") or ""), {})
team_tag = tag_raw[1:-1] if len(tag_raw) > 2 else tag_raw tss_player = tss_players.get(str(uid_str), {})
used_units = [u for u in (p.get("units") or []) if u.get("used")] used_units = [u for u in (p.get("units") or []) if u.get("used")]
if not used_units: if not used_units:
@@ -356,8 +378,7 @@ async def insert_player_games(game: Dict[str, Any]) -> None:
rows.append(( rows.append((
str(uid_str), str(uid_str),
str(p.get("name") or ""), str(p.get("name") or ""),
"", # team_name — resolved later str(tss_team.get("team_name") or tss_team.get("name") or ""),
team_tag,
str(p.get("team") or ""), # team_slot ("1" or "2") str(p.get("team") or ""), # team_slot ("1" or "2")
session_id, session_id,
str(unit.get("unit_normalized") or ""), str(unit.get("unit_normalized") or ""),
@@ -374,7 +395,9 @@ async def insert_player_games(game: Dict[str, Any]) -> None:
p.get("country_id"), p.get("country_id"),
victor_bool, victor_bool,
end_ts, end_ts,
None, # team_id — resolved later tss_team.get("team_id"),
str(tss_player.get("role") or ""),
tss_player.get("pvp_ratio"),
)) ))
if not rows: if not rows:
@@ -385,29 +408,98 @@ async def insert_player_games(game: Dict[str, Any]) -> None:
await conn.execute(sql) await conn.execute(sql)
await conn.executemany( await conn.executemany(
""" """
INSERT OR IGNORE INTO player_games_hist INSERT INTO player_games_hist
(UID, nick, team_name, team_tag, team_slot, session_id, (UID, nick, team_name, team_slot, session_id,
vehicle, vehicle_internal, vehicle, vehicle_internal,
ground_kills, air_kills, assists, captures, deaths, score, ground_kills, air_kills, assists, captures, deaths, score,
missile_evades, shell_interceptions, team_kills_stat, missile_evades, shell_interceptions, team_kills_stat,
country_id, victor_bool, endtime_unix, team_id) country_id, victor_bool, endtime_unix, team_id, tss_role, pvp_ratio)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(UID, session_id, vehicle_internal) DO UPDATE SET
nick = excluded.nick,
team_name = excluded.team_name,
team_slot = excluded.team_slot,
vehicle = excluded.vehicle,
ground_kills = excluded.ground_kills,
air_kills = excluded.air_kills,
assists = excluded.assists,
captures = excluded.captures,
deaths = excluded.deaths,
score = excluded.score,
missile_evades = excluded.missile_evades,
shell_interceptions = excluded.shell_interceptions,
team_kills_stat = excluded.team_kills_stat,
country_id = excluded.country_id,
victor_bool = excluded.victor_bool,
endtime_unix = excluded.endtime_unix,
team_id = excluded.team_id,
tss_role = excluded.tss_role,
pvp_ratio = excluded.pvp_ratio
""", """,
rows, rows,
) )
await conn.commit() await conn.commit()
async def upsert_tss_teams(game: Dict[str, Any]) -> None:
"""Upsert replay-sourced team identity, roster, and name history."""
tss = game.get("tss") or {}
seen_unix = int(game.get("end_ts") or time.time())
async with aiosqlite.connect(TSS_TEAMS_DB_PATH) as conn:
for sql in _PRAGMAS:
await conn.execute(sql)
for slot in ("1", "2"):
team = tss.get(slot)
if not isinstance(team, dict) or team.get("team_id") is None:
continue
team_id = int(team["team_id"])
team_name = str(team.get("team_name") or team.get("name") or "")
if not team_name:
continue
members = [
member for member in (team.get("players") or [])
if isinstance(member, dict) and member.get("uid") is not None
]
await conn.execute(
"""
INSERT INTO teams_data (team_id, long_name, members, captain_uid)
VALUES (?, ?, ?, ?)
ON CONFLICT(team_id) DO UPDATE SET
long_name = excluded.long_name,
members = excluded.members,
captain_uid = excluded.captain_uid
""",
(team_id, team_name, len(members), str(team.get("captain_id") or "")),
)
await conn.execute(
"""
INSERT INTO team_name_history (team_id, long_name, first_seen, last_seen)
VALUES (?, ?, ?, ?)
ON CONFLICT(team_id, long_name) DO UPDATE SET last_seen = excluded.last_seen
""",
(team_id, team_name, seen_unix, seen_unix),
)
if "players" in team:
await conn.execute("DELETE FROM team_members WHERE team_id = ?", (team_id,))
for member in members:
await conn.execute(
"""
INSERT INTO team_members (team_id, uid, role)
VALUES (?, ?, ?)
ON CONFLICT(team_id, uid) DO UPDATE SET role = excluded.role
""",
(team_id, str(member["uid"]), str(member.get("role") or "player")),
)
await conn.commit()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Team resolve / lookup (python twin of the Rust backend's find_team) # Team resolve / lookup (python twin of the Rust backend's find_team)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def resolve_team(name_or_id: str) -> Optional[Dict[str, Any]]: async def resolve_team(name_or_id: str) -> Optional[Dict[str, Any]]:
"""Resolve a team by numeric id, or by tag/short/long name (case-insensitive). """Resolve a team by numeric ID or full TSS team name."""
Returns ``{team_id, long_name, short_name, tag_name}`` or None.
Tag matches rank above short, then long name.
"""
text = (name_or_id or "").strip() text = (name_or_id or "").strip()
if not text: if not text:
return None return None
@@ -420,18 +512,10 @@ async def resolve_team(name_or_id: str) -> Optional[Dict[str, Any]]:
conn.row_factory = aiosqlite.Row conn.row_factory = aiosqlite.Row
async with conn.execute( async with conn.execute(
""" """
SELECT team_id, long_name, short_name, tag_name SELECT team_id, long_name
FROM teams_data FROM teams_data
WHERE team_id = ?1 WHERE team_id = ?1
OR long_name = ?2 COLLATE NOCASE OR long_name = ?2 COLLATE NOCASE
OR short_name = ?2 COLLATE NOCASE
OR tag_name = ?2 COLLATE NOCASE
ORDER BY CASE
WHEN tag_name = ?2 COLLATE NOCASE THEN 0
WHEN short_name = ?2 COLLATE NOCASE THEN 1
WHEN long_name = ?2 COLLATE NOCASE THEN 2
ELSE 3
END
LIMIT 1 LIMIT 1
""", """,
(as_id, text), (as_id, text),
@@ -440,30 +524,16 @@ async def resolve_team(name_or_id: str) -> Optional[Dict[str, Any]]:
return dict(row) if row else None return dict(row) if row else None
async def resolve_team_id_for_tag(tag: str) -> Optional[int]:
"""Return the team_id whose tag_name matches ``tag`` (case-insensitive)."""
tag = (tag or "").strip()
if not tag:
return None
async with aiosqlite.connect(TSS_TEAMS_DB_PATH) as conn:
async with conn.execute(
"SELECT team_id FROM teams_data WHERE tag_name = ? COLLATE NOCASE LIMIT 1",
(tag,),
) as cur:
row = await cur.fetchone()
return int(row[0]) if row else None
async def search_teams(query: str, limit: int = 25) -> List[Dict[str, Any]]: async def search_teams(query: str, limit: int = 25) -> List[Dict[str, Any]]:
"""Autocomplete-friendly team search. Empty query → top teams by rating.""" """Autocomplete-friendly TSS team-name search."""
q = (query or "").strip() q = (query or "").strip()
async with aiosqlite.connect(TSS_TEAMS_DB_PATH) as conn: async with aiosqlite.connect(TSS_TEAMS_DB_PATH) as conn:
conn.row_factory = aiosqlite.Row conn.row_factory = aiosqlite.Row
if not q: if not q:
async with conn.execute( async with conn.execute(
""" """
SELECT team_id, long_name, tag_name FROM teams_data SELECT team_id, long_name FROM teams_data
ORDER BY clanrating DESC NULLS LAST, members DESC ORDER BY members DESC, long_name COLLATE NOCASE
LIMIT ? LIMIT ?
""", """,
(limit,), (limit,),
@@ -473,13 +543,11 @@ async def search_teams(query: str, limit: int = 25) -> List[Dict[str, Any]]:
like = f"%{q}%" like = f"%{q}%"
async with conn.execute( async with conn.execute(
""" """
SELECT team_id, long_name, tag_name FROM teams_data SELECT team_id, long_name FROM teams_data
WHERE long_name LIKE ?1 COLLATE NOCASE WHERE long_name LIKE ?1 COLLATE NOCASE
OR tag_name LIKE ?1 COLLATE NOCASE
ORDER BY CASE ORDER BY CASE
WHEN tag_name = ?2 COLLATE NOCASE THEN 0 WHEN long_name = ?2 COLLATE NOCASE THEN 0
WHEN long_name = ?2 COLLATE NOCASE THEN 1 ELSE 1
ELSE 2
END END
LIMIT ?3 LIMIT ?3
""", """,
@@ -603,14 +671,13 @@ async def player_teams(uid: str) -> List[Dict[str, Any]]:
conn.row_factory = aiosqlite.Row conn.row_factory = aiosqlite.Row
async with conn.execute( async with conn.execute(
""" """
SELECT team_tag, SELECT team_id,
MAX(team_name) AS team_name, MAX(team_name) AS team_name,
team_id,
COUNT(DISTINCT session_id) AS games, COUNT(DISTINCT session_id) AS games,
MAX(endtime_unix) AS last_seen MAX(endtime_unix) AS last_seen
FROM player_games_hist FROM player_games_hist
WHERE UID = ? WHERE UID = ?
GROUP BY team_tag GROUP BY team_id, team_name
ORDER BY last_seen DESC ORDER BY last_seen DESC
""", """,
(uid,), (uid,),
Binary file not shown.
Binary file not shown.
-232
View File
@@ -1,232 +0,0 @@
#!/usr/bin/env python3
"""
tss-stats-collector.py
Queries the TSS tournament API (tss.warthunder.com) for a specific tournament
and returns enriched team/player data for a given set of player UIDs.
Intended to run as an enrichment step on game receipt:
1. Game arrives with player UIDs
2. Call fetch_players_from_tournament(tournament_id, uids) to get team info
3. Merge results into player rows before writing to DB
Usage (standalone):
python tss-stats-collector.py --tournament-id 20000 --uids 627841 118846315
python tss-stats-collector.py --tournament-id 20000 --uids 627841 --full-team
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import random
from typing import Any, Optional
import aiohttp
log = logging.getLogger("tss-stats-collector")
TSS_API_URL = "https://tss.warthunder.com/functions.php"
TSS_API_HEADERS = {"Content-Type": "application/x-www-form-urlencoded"}
RETRYABLE_STATUS = {408, 425, 429, 500, 502, 503, 504}
# ---------------------------------------------------------------------------
# HTTP client (ported from TSS/collector/client.py)
# ---------------------------------------------------------------------------
class TSSClient:
def __init__(
self,
*,
request_timeout: float = 15.0,
retry_limit: int = 2,
retry_base_delay: float = 1.0,
retry_max_delay: float = 10.0,
):
self._timeout = aiohttp.ClientTimeout(total=request_timeout)
self._retry_limit = retry_limit
self._retry_base = retry_base_delay
self._retry_max = retry_max_delay
self._session: Optional[aiohttp.ClientSession] = None
async def __aenter__(self) -> "TSSClient":
self._session = aiohttp.ClientSession(
connector=aiohttp.TCPConnector(ttl_dns_cache=300, enable_cleanup_closed=True),
timeout=self._timeout,
headers=TSS_API_HEADERS,
)
return self
async def __aexit__(self, *_exc) -> None:
if self._session:
await self._session.close()
self._session = None
async def call(self, action: str, **params: Any) -> Optional[dict]:
"""POST to the TSS API; returns parsed JSON or None on failure."""
assert self._session, "use `async with TSSClient()`"
data = {"action": action, **{k: str(v) for k, v in params.items() if v is not None}}
attempt = 0
while True:
try:
async with self._session.post(TSS_API_URL, data=data) as resp:
if resp.status in RETRYABLE_STATUS:
raise aiohttp.ClientResponseError(
resp.request_info, resp.history,
status=resp.status, message=resp.reason or "",
)
if resp.status != 200:
log.warning("%s %s -> HTTP %s", action, params, resp.status)
return None
text = await resp.text()
if not text:
return None
try:
return json.loads(text)
except json.JSONDecodeError:
log.error("%s -> non-JSON response", action)
return None
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
if attempt >= self._retry_limit:
log.error("%s failed after %d retries: %s", action, attempt, exc)
return None
delay = min(self._retry_max, self._retry_base * (2 ** attempt)) * (0.5 + random.random())
await asyncio.sleep(delay)
attempt += 1
# ---------------------------------------------------------------------------
# Enrichment functions (intended for use in TSSBOT receive pipeline)
# ---------------------------------------------------------------------------
async def fetch_tournament_short(client: TSSClient, tournament_id: int) -> Optional[dict]:
"""Raw GetStatsTournamentShort response for one tournament."""
result = await client.call("GetStatsTournamentShort", tournamentID=tournament_id)
if not result or result.get("status") == "ERROR":
return None
return result
async def fetch_team_info(client: TSSClient, tournament_id: int, team_id: int) -> Optional[dict]:
"""Raw infoTeam response for a specific team in a tournament."""
return await client.call("infoTeam", tournamentID=tournament_id, teamID=team_id)
async def fetch_players_from_tournament(
client: TSSClient,
tournament_id: int,
target_uids: set[str],
*,
include_team_info: bool = False,
) -> list[dict]:
"""
Main enrichment function. Given a tournament ID and a set of player UIDs,
returns a list of dicts with TSS data for each matching player:
{
tournament_id, player_id, nick, team_id, team_tag, team_uuid,
role, place, death, frag, exp_hit, pvp_ratio,
# if include_team_info=True, also:
team_country, team_hash, team_members, tournament_name_en, ...
}
Returns empty list if tournament not found or no UIDs match.
"""
result = await fetch_tournament_short(client, tournament_id)
if not result:
return []
# Merge allUserStats + readyTopTeamsTournament, keyed by UID
combined: dict[str, dict] = {}
for entry in (result.get("allUserStats") or []) + (result.get("readyTopTeamsTournament") or []):
uid = str(entry.get("userID") or "")
if uid:
combined.setdefault(uid, entry)
hits = []
for uid, entry in combined.items():
if uid not in target_uids:
continue
hits.append({
"tournament_id": tournament_id,
"player_id": entry.get("userID"),
"nick": entry.get("nick"),
"team_id": entry.get("teamID"),
"team_tag": entry.get("realName"),
"team_uuid": entry.get("teamName"),
"role": entry.get("role"),
"place": entry.get("place"),
"death": entry.get("DEATH"),
"frag": entry.get("FRAG"),
"exp_hit": entry.get("EXP_HIT"),
"pvp_ratio": entry.get("pvp_ratio"),
})
if include_team_info and hits:
# One infoTeam call per unique team_id
team_ids = {h["team_id"] for h in hits if h.get("team_id")}
team_cache: dict = {}
for team_id in team_ids:
info = await fetch_team_info(client, tournament_id, int(team_id))
if info:
team_cache[team_id] = info
for h in hits:
info = team_cache.get(h.get("team_id"))
if not info:
continue
param_team = info.get("param_team") or {}
h["team_country"] = param_team.get("country")
h["team_hash"] = param_team.get("teamID")
h["team_members"] = [
{"player_id": e.get("userID"), "nick": e.get("nick"), "role": e.get("role")}
for e in (info.get("users_team") or [])
]
param_t = info.get("param_tournaments") or {}
h["tournament_name_en"] = param_t.get("nameEN")
h["tournament_name_ru"] = param_t.get("nameRU")
return hits
# ---------------------------------------------------------------------------
# CLI (for manual lookups / testing)
# ---------------------------------------------------------------------------
async def _main(args: argparse.Namespace) -> None:
logging.basicConfig(level=logging.WARNING, format="[%(levelname)s] %(message)s")
target_uids = {str(u) for u in args.uids}
print(f"Querying tournament {args.tournament_id} for UIDs: {sorted(target_uids)}\n")
async with TSSClient() as client:
results = await fetch_players_from_tournament(
client,
args.tournament_id,
target_uids,
include_team_info=args.full_team,
)
if not results:
print("No matching players found in that tournament.")
return
print(f"Found {len(results)} match(es):\n")
for r in results:
print(json.dumps(r, indent=2, ensure_ascii=False))
print()
def _parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--tournament-id", type=int, required=True, help="TSS tournament ID to query")
p.add_argument("--uids", nargs="+", type=int, required=True, help="Player UIDs to look up")
p.add_argument("--full-team", action="store_true", help="Also fetch infoTeam for richer data (members, country, etc.)")
return p.parse_args()
if __name__ == "__main__":
asyncio.run(_main(_parse_args()))
+2 -1
View File
@@ -28,7 +28,7 @@ from typing import Any, Callable, Awaitable, List, Dict, Optional
from dotenv import load_dotenv from dotenv import load_dotenv
from websockets.asyncio.client import connect as wsconnect from websockets.asyncio.client import connect as wsconnect
from BOT.storage import insert_match, insert_player_games from BOT.storage import insert_match, insert_player_games, upsert_tss_teams
from BOT.autologging import process_game as autolog_process_game from BOT.autologging import process_game as autolog_process_game
from spectra_ws_payload import SpectraPayloadError, decode_spectra_ws_payload from spectra_ws_payload import SpectraPayloadError, decode_spectra_ws_payload
@@ -163,6 +163,7 @@ async def _handle_game(game: Dict[str, Any]) -> None:
try: try:
await insert_match(game) await insert_match(game)
await insert_player_games(game) await insert_player_games(game)
await upsert_tss_teams(game)
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)