tss db wipe and update (#1305)
This commit is contained in:
+17
-26
@@ -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
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
+196
-129
@@ -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,12 +63,17 @@ 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_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_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_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.
@@ -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()))
|
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user