diff --git a/BOT/autologging.py b/BOT/autologging.py index 1f4dc73..a476503 100644 --- a/BOT/autologging.py +++ b/BOT/autologging.py @@ -15,7 +15,7 @@ from typing import Any, Optional import discord -from . import preferences, storage +from . import preferences log = logging.getLogger("tssbot.autolog") @@ -33,18 +33,21 @@ def set_bot(bot: discord.Client) -> None: _bot = bot -def _present_team_tags(game: dict[str, Any]) -> set[str]: - """Return the set of team tags present in a game dict.""" - players = game.get("players") or {} - tags: set[str] = set() - for p in players.values(): - if not isinstance(p, dict): +def _present_teams(game: dict[str, Any]) -> tuple[set[str], set[str]]: + """Return stable TSS team IDs and names embedded in a replay.""" + tss = game.get("tss") or {} + team_ids: set[str] = set() + team_names: set[str] = set() + for slot in ("1", "2"): + team = tss.get(slot) + if not isinstance(team, dict): continue - tag_raw = p.get("tag") or "" - tag = tag_raw[1:-1] if len(tag_raw) > 2 else tag_raw - if tag: - tags.add(tag) - return tags + if team.get("team_id") is not None: + team_ids.add(str(team["team_id"])) + name = str(team.get("team_name") or team.get("name") or "").strip() + if name: + team_names.add(name.lower()) + return team_ids, team_names 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: return - present_tags = _present_team_tags(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)) + present_team_ids, present_team_names = _present_teams(game) sent = _sent_channels_by_session.setdefault(session_id, set()) @@ -84,7 +75,7 @@ async def process_game(game: dict[str, Any]) -> None: continue 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: continue diff --git a/BOT/commands.py b/BOT/commands.py index 74a95c1..1f59090 100644 --- a/BOT/commands.py +++ b/BOT/commands.py @@ -58,7 +58,7 @@ async def team_autocomplete(interaction: discord.Interaction, current: str) -> L return [] choices = [] 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"]))) return choices @@ -112,7 +112,7 @@ class TssCommands(commands.Cog): # ── /set-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.checks.has_permissions(manage_guild=True) @not_blacklisted() @@ -123,7 +123,7 @@ class TssCommands(commands.Cog): resolved = await storage.resolve_team(team) if not resolved: 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) await interaction.followup.send( f"✅ This server's team is now **{name}** (id `{resolved['team_id']}`).", ephemeral=True @@ -131,7 +131,7 @@ class TssCommands(commands.Cog): # ── /log-team ────────────────────────────────────────────────────────── @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.checks.has_permissions(manage_guild=True) @not_blacklisted() @@ -142,7 +142,7 @@ class TssCommands(commands.Cog): resolved = await storage.resolve_team(team) if not resolved: 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( interaction.guild_id, int(resolved["team_id"]), "tss-team", name, interaction.channel_id ) @@ -223,7 +223,7 @@ class TssCommands(commands.Cog): embed.add_field( name="Teams seen with", 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, ) diff --git a/BOT/storage.py b/BOT/storage.py index 98656fe..c24d012 100644 --- a/BOT/storage.py +++ b/BOT/storage.py @@ -2,7 +2,7 @@ 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 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 = """ CREATE TABLE IF NOT EXISTS match_summary ( session_id TEXT PRIMARY KEY, - map_name TEXT, mission_mode TEXT, + mission_name TEXT, + level_path TEXT, + mission_path TEXT, difficulty TEXT, starttime_unix INTEGER, endtime_unix INTEGER, @@ -61,14 +63,19 @@ CREATE TABLE IF NOT EXISTS match_summary ( winning_slot TEXT, losing_slot TEXT, received_unix INTEGER, - tournament_id INTEGER + tournament_id INTEGER, + tournament_name TEXT, + match_id TEXT, + bracket TEXT ) """ _MATCH_SUMMARY_INDEXES = [ - "CREATE INDEX IF NOT EXISTS idx_ms_map_name ON match_summary(map_name)", - "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)", + "CREATE INDEX IF NOT EXISTS idx_ms_mission_name ON match_summary(mission_name)", + "CREATE INDEX IF NOT EXISTS idx_ms_level_path ON match_summary(level_path)", + "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, nick TEXT NOT NULL, team_name TEXT, - team_tag TEXT NOT NULL DEFAULT 'UNKNOWN', team_slot TEXT, session_id TEXT NOT NULL, vehicle TEXT, @@ -95,9 +101,7 @@ CREATE TABLE IF NOT EXISTS player_games_hist ( victor_bool TEXT NOT NULL DEFAULT 'Loss', endtime_unix INTEGER NOT NULL DEFAULT 0, team_id INTEGER, - tss_team_uuid TEXT, tss_role TEXT, - tss_place INTEGER, pvp_ratio REAL, UNIQUE (UID, session_id, vehicle_internal) ) @@ -106,7 +110,6 @@ CREATE TABLE IF NOT EXISTS player_games_hist ( _PLAYER_GAMES_INDEXES = [ "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_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_endtime ON player_games_hist(endtime_unix)", "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 = """ CREATE TABLE IF NOT EXISTS teams_data ( - team_id INTEGER PRIMARY KEY AUTOINCREMENT, - long_name TEXT NOT NULL UNIQUE, - short_name TEXT UNIQUE, - tag_name TEXT, - description TEXT, - region TEXT, + team_id INTEGER PRIMARY KEY, + long_name TEXT NOT NULL, members INTEGER NOT NULL DEFAULT 0, - members_json TEXT, - captain_uid TEXT, - guild_id TEXT, - clanrating INTEGER, - created_unix INTEGER, - updated_unix INTEGER + captain_uid TEXT ) """ _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_guild_id ON teams_data(guild_id)", + "CREATE INDEX IF NOT EXISTS idx_teams_data_long_name ON teams_data(long_name COLLATE NOCASE)", ] @@ -148,10 +141,7 @@ _TEAM_MEMBERS_SQL = """ CREATE TABLE IF NOT EXISTS team_members ( team_id INTEGER NOT NULL, uid TEXT NOT NULL, - nick TEXT, role TEXT NOT NULL DEFAULT 'player', - points INTEGER NOT NULL DEFAULT 0, - joined_unix INTEGER, 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 # --------------------------------------------------------------------------- @@ -215,19 +189,31 @@ async def _existing_columns(conn: aiosqlite.Connection, table: str) -> set[str]: return {row[1] for row in rows} -async def _migrate( - conn: aiosqlite.Connection, table: str, additions: dict[str, str] +async def _rebuild_table( + conn: aiosqlite.Connection, + table: str, + create_sql: str, + desired_columns: list[str], ) -> 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 - is fine for all our additions. Existing rows pick up the DEFAULT value. - """ - cols = await _existing_columns(conn, table) - for col, ddl in additions.items(): - if col not in cols: - await conn.execute(f"ALTER TABLE {table} ADD COLUMN {ddl}") - log.info("migrated %s: added column %s", table, col) + old_table = f"{table}_old_schema" + await conn.execute(f"DROP TABLE IF EXISTS {old_table}") + await conn.execute(f"ALTER TABLE {table} RENAME TO {old_table}") + await conn.execute(create_sql) + common = [column for column in desired_columns if column in existing] + if common: + columns = ", ".join(common) + 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: @@ -236,17 +222,22 @@ async def _init_battles_db() -> None: await conn.execute(sql) await conn.execute(_MATCH_SUMMARY_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, _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() @@ -257,23 +248,17 @@ async def _init_teams_db() -> None: await conn.execute(_TEAMS_DATA_SQL) await conn.execute(_TEAM_MEMBERS_SQL) await conn.execute(_TEAM_NAME_HISTORY_SQL) - await conn.execute(_TEAMS_POINTS_SQL) - - # Forward-only migrations for DBs created before a column landed. - # Keep CREATE TABLE statements above in sync — these only matter when - # the table already existed. - await _migrate(conn, "teams_data", { - "clanrating": "clanrating INTEGER", - }) - - await _migrate(conn, "team_members", { - "points": "points INTEGER NOT NULL DEFAULT 0", - }) + await conn.execute("DROP TABLE IF EXISTS teams_points") + await _rebuild_table(conn, "teams_data", _TEAMS_DATA_SQL, [ + "team_id", "long_name", "members", "captain_uid", + ]) + await _rebuild_table(conn, "team_members", _TEAM_MEMBERS_SQL, [ + "team_id", "uid", "role", + ]) await _apply(conn, _TEAMS_DATA_INDEXES) await _apply(conn, _TEAM_MEMBERS_INDEXES) await _apply(conn, _TEAM_NAME_HISTORY_INDEXES) - await _apply(conn, _TEAMS_POINTS_INDEXES) 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. ``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: for sql in _PRAGMAS: await conn.execute(sql) + tss = game.get("tss") or {} await conn.execute( """ - INSERT OR IGNORE INTO match_summary - (session_id, map_name, mission_mode, difficulty, + INSERT INTO match_summary + (session_id, mission_mode, mission_name, level_path, mission_path, + difficulty, starttime_unix, endtime_unix, duration, - draw, winning_slot, losing_slot, received_unix) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + draw, winning_slot, losing_slot, received_unix, + 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.get("mission_name") or game.get("level_path") 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 ""), int(game.get("start_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("loser") or ""), 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() @@ -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 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"]) winner_slot = str(game.get("winner") or "") end_ts = int(game.get("end_ts") or 0) 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 = [] for uid_str, p in players.items(): victor_bool = "Win" if str(p.get("team", "")) == winner_slot else "Loss" - tag_raw = p.get("tag") or "" - team_tag = tag_raw[1:-1] if len(tag_raw) > 2 else tag_raw + tss_team = tss_teams.get(str(p.get("team") or ""), {}) + tss_player = tss_players.get(str(uid_str), {}) used_units = [u for u in (p.get("units") or []) if u.get("used")] if not used_units: @@ -356,8 +378,7 @@ async def insert_player_games(game: Dict[str, Any]) -> None: rows.append(( str(uid_str), str(p.get("name") or ""), - "", # team_name — resolved later - team_tag, + str(tss_team.get("team_name") or tss_team.get("name") or ""), str(p.get("team") or ""), # team_slot ("1" or "2") session_id, str(unit.get("unit_normalized") or ""), @@ -374,7 +395,9 @@ async def insert_player_games(game: Dict[str, Any]) -> None: p.get("country_id"), victor_bool, 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: @@ -385,29 +408,98 @@ async def insert_player_games(game: Dict[str, Any]) -> None: await conn.execute(sql) await conn.executemany( """ - INSERT OR IGNORE INTO player_games_hist - (UID, nick, team_name, team_tag, team_slot, session_id, + INSERT INTO player_games_hist + (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) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + country_id, victor_bool, endtime_unix, team_id, tss_role, pvp_ratio) + 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, ) 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) # --------------------------------------------------------------------------- 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). - - Returns ``{team_id, long_name, short_name, tag_name}`` or None. - Tag matches rank above short, then long name. - """ + """Resolve a team by numeric ID or full TSS team name.""" text = (name_or_id or "").strip() if not text: return None @@ -420,18 +512,10 @@ async def resolve_team(name_or_id: str) -> Optional[Dict[str, Any]]: conn.row_factory = aiosqlite.Row async with conn.execute( """ - SELECT team_id, long_name, short_name, tag_name + SELECT team_id, long_name FROM teams_data WHERE team_id = ?1 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 """, (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 -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]]: - """Autocomplete-friendly team search. Empty query → top teams by rating.""" + """Autocomplete-friendly TSS team-name search.""" q = (query or "").strip() async with aiosqlite.connect(TSS_TEAMS_DB_PATH) as conn: conn.row_factory = aiosqlite.Row if not q: async with conn.execute( """ - SELECT team_id, long_name, tag_name FROM teams_data - ORDER BY clanrating DESC NULLS LAST, members DESC + SELECT team_id, long_name FROM teams_data + ORDER BY members DESC, long_name COLLATE NOCASE LIMIT ? """, (limit,), @@ -473,13 +543,11 @@ async def search_teams(query: str, limit: int = 25) -> List[Dict[str, Any]]: like = f"%{q}%" 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 - OR tag_name LIKE ?1 COLLATE NOCASE ORDER BY CASE - WHEN tag_name = ?2 COLLATE NOCASE THEN 0 - WHEN long_name = ?2 COLLATE NOCASE THEN 1 - ELSE 2 + WHEN long_name = ?2 COLLATE NOCASE THEN 0 + ELSE 1 END LIMIT ?3 """, @@ -603,14 +671,13 @@ async def player_teams(uid: str) -> List[Dict[str, Any]]: conn.row_factory = aiosqlite.Row async with conn.execute( """ - SELECT team_tag, + SELECT team_id, MAX(team_name) AS team_name, - team_id, COUNT(DISTINCT session_id) AS games, MAX(endtime_unix) AS last_seen FROM player_games_hist WHERE UID = ? - GROUP BY team_tag + GROUP BY team_id, team_name ORDER BY last_seen DESC """, (uid,), diff --git a/replays_local/6b7801f00031a48/replay_data.json.gz b/replays_local/6b7801f00031a48/replay_data.json.gz new file mode 100644 index 0000000..15a423f Binary files /dev/null and b/replays_local/6b7801f00031a48/replay_data.json.gz differ diff --git a/replays_local/6ba5e84001245fa/replay_data.json.gz b/replays_local/6ba5e84001245fa/replay_data.json.gz new file mode 100644 index 0000000..7a39a24 Binary files /dev/null and b/replays_local/6ba5e84001245fa/replay_data.json.gz differ diff --git a/tss-stats-collector.py b/tss-stats-collector.py deleted file mode 100644 index a9f6335..0000000 --- a/tss-stats-collector.py +++ /dev/null @@ -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())) diff --git a/tss_ws.py b/tss_ws.py index 033e2fb..327d8cc 100644 --- a/tss_ws.py +++ b/tss_ws.py @@ -28,7 +28,7 @@ from typing import Any, Callable, Awaitable, List, Dict, Optional from dotenv import load_dotenv 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 spectra_ws_payload import SpectraPayloadError, decode_spectra_ws_payload @@ -163,6 +163,7 @@ async def _handle_game(game: Dict[str, Any]) -> None: try: await insert_match(game) await insert_player_games(game) + await upsert_tss_teams(game) log.info("Stored game %s in DB", sid) except Exception as exc: log.error("DB insert failed for %s: %s", sid, exc)