From e56951fcb219164ac4f005b002067c90157cf271 Mon Sep 17 00:00:00 2001 From: NotSoToothless <67082114+FURRO404@users.noreply.github.com> Date: Thu, 14 May 2026 14:03:59 -0700 Subject: [PATCH] TSS storage (#1233) --- BOT/storage.py | 236 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 3 + start_bot.py | 9 ++ 3 files changed, 248 insertions(+) create mode 100644 BOT/storage.py diff --git a/BOT/storage.py b/BOT/storage.py new file mode 100644 index 0000000..bb2ae15 --- /dev/null +++ b/BOT/storage.py @@ -0,0 +1,236 @@ +"""TSSBOT storage layer — SQLite paths + idempotent DB init. + +Two databases live under ``STORAGE_VOL_PATH`` (set in ``TSSBOT/.env``): + +* ``tss_teams.db`` — persistent team registry (analogue of SREBOT's squadrons.db) +* ``tss_battles.db`` — per-match summary + per-player game history + (analogue of SREBOT's sq_battles.db) + +Schemas mirror SREBOT shape so query patterns transfer directly. The +TSS-specific differences are: + +* "squadron" → "team" / "clan_id" → "team_id" +* Spectra ships an untransformed per-player stat blob, so we keep extra + columns the raw payload provides (``score``, ``missile_evades``, + ``shell_interceptions``, ``team_kills_stat``, ``country_id``, ``team_slot``). +* No vehicle-translate / stat-divide transform — one row per + ``(UID, session_id, vehicle_internal)`` for each used unit, with the + player's full stat line copied onto each row. Aggregation queries that + sum across vehicles must ``DISTINCT`` on session_id first. +""" +from __future__ import annotations + +import logging +import os +from pathlib import Path + +import aiosqlite + +log = logging.getLogger("tssbot.storage") + + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- + +def _require_storage_dir() -> Path: + raw = os.environ.get("STORAGE_VOL_PATH", "").strip() + if not raw: + raise RuntimeError("STORAGE_VOL_PATH must be set in TSSBOT/.env") + p = Path(raw).expanduser().resolve() + p.mkdir(parents=True, exist_ok=True) + return p + + +STORAGE_DIR: Path = _require_storage_dir() +TSS_BATTLES_DB_PATH: Path = STORAGE_DIR / "tss_battles.db" +TSS_TEAMS_DB_PATH: Path = STORAGE_DIR / "tss_teams.db" + + +# --------------------------------------------------------------------------- +# tss_battles.db — match_summary + player_games_hist +# --------------------------------------------------------------------------- + +_MATCH_SUMMARY_SQL = """ +CREATE TABLE IF NOT EXISTS match_summary ( + session_id TEXT PRIMARY KEY, + map_name TEXT, + mission_mode TEXT, + starttime_unix INTEGER, + endtime_unix INTEGER, + draw INTEGER NOT NULL DEFAULT 0, + winning_slot TEXT, + losing_slot TEXT, + winning_team TEXT, + losing_team TEXT, + winning_team_json TEXT, + losing_team_json TEXT, + received_unix INTEGER, + winning_team_id INTEGER, + losing_team_id INTEGER +) +""" + +_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_winning_team ON match_summary(winning_team)", + "CREATE INDEX IF NOT EXISTS idx_ms_losing_team ON match_summary(losing_team)", + "CREATE INDEX IF NOT EXISTS idx_ms_winning_team_id ON match_summary(winning_team_id)", + "CREATE INDEX IF NOT EXISTS idx_ms_losing_team_id ON match_summary(losing_team_id)", +] + + +_PLAYER_GAMES_SQL = """ +CREATE TABLE IF NOT EXISTS player_games_hist ( + UID TEXT NOT NULL, + nick TEXT NOT NULL, + team_name TEXT NOT NULL DEFAULT 'UNKNOWN', + team_tag TEXT NOT NULL DEFAULT 'UNKNOWN', + team_slot TEXT, + session_id TEXT NOT NULL, + vehicle TEXT, + vehicle_internal TEXT, + ground_kills INTEGER NOT NULL DEFAULT 0, + air_kills INTEGER NOT NULL DEFAULT 0, + assists INTEGER NOT NULL DEFAULT 0, + captures INTEGER NOT NULL DEFAULT 0, + deaths INTEGER NOT NULL DEFAULT 0, + score INTEGER NOT NULL DEFAULT 0, + missile_evades INTEGER NOT NULL DEFAULT 0, + shell_interceptions INTEGER NOT NULL DEFAULT 0, + team_kills_stat INTEGER NOT NULL DEFAULT 0, + country_id INTEGER, + victor_bool TEXT NOT NULL DEFAULT 'Loss', + endtime_unix INTEGER NOT NULL DEFAULT 0, + team_id INTEGER, + UNIQUE (UID, session_id, vehicle_internal) +) +""" + +_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)", + "CREATE INDEX IF NOT EXISTS idx_pgh_session_team_name ON player_games_hist(session_id, team_name)", + "CREATE INDEX IF NOT EXISTS idx_pgh_team_id_time ON player_games_hist(team_id, endtime_unix)", + "CREATE INDEX IF NOT EXISTS idx_pgh_vehicle_internal ON player_games_hist(vehicle_internal)", +] + + +# --------------------------------------------------------------------------- +# tss_teams.db — teams_data + team_members + team_name_history +# --------------------------------------------------------------------------- + +_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, + members INTEGER NOT NULL DEFAULT 0, + members_json TEXT, + captain_uid TEXT, + guild_id TEXT, + created_unix INTEGER, + updated_unix INTEGER +) +""" + +_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)", +] + + +_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', + joined_unix INTEGER, + PRIMARY KEY (team_id, uid) +) +""" + +_TEAM_MEMBERS_INDEXES = [ + "CREATE INDEX IF NOT EXISTS idx_team_members_team_id ON team_members(team_id)", + "CREATE INDEX IF NOT EXISTS idx_team_members_uid ON team_members(uid)", +] + + +_TEAM_NAME_HISTORY_SQL = """ +CREATE TABLE IF NOT EXISTS team_name_history ( + team_id INTEGER NOT NULL, + long_name TEXT NOT NULL, + first_seen INTEGER, + last_seen INTEGER, + PRIMARY KEY (team_id, long_name) +) +""" + +_TEAM_NAME_HISTORY_INDEXES = [ + "CREATE INDEX IF NOT EXISTS idx_team_name_history_name ON team_name_history(long_name COLLATE NOCASE)", +] + + +# --------------------------------------------------------------------------- +# Init +# --------------------------------------------------------------------------- + +_PRAGMAS = ( + "PRAGMA journal_mode=WAL;", + "PRAGMA synchronous=NORMAL;", + "PRAGMA busy_timeout=5000;", + "PRAGMA temp_store=MEMORY;", +) + + +async def _apply(conn: aiosqlite.Connection, statements: list[str]) -> None: + for sql in statements: + await conn.execute(sql) + + +async def _init_battles_db() -> None: + async with aiosqlite.connect(TSS_BATTLES_DB_PATH) as conn: + for sql in _PRAGMAS: + await conn.execute(sql) + await conn.execute(_MATCH_SUMMARY_SQL) + await conn.execute(_PLAYER_GAMES_SQL) + await _apply(conn, _MATCH_SUMMARY_INDEXES) + await _apply(conn, _PLAYER_GAMES_INDEXES) + await conn.commit() + + +async def _init_teams_db() -> None: + async with aiosqlite.connect(TSS_TEAMS_DB_PATH) as conn: + for sql in _PRAGMAS: + await conn.execute(sql) + await conn.execute(_TEAMS_DATA_SQL) + await conn.execute(_TEAM_MEMBERS_SQL) + await conn.execute(_TEAM_NAME_HISTORY_SQL) + await _apply(conn, _TEAMS_DATA_INDEXES) + await _apply(conn, _TEAM_MEMBERS_INDEXES) + await _apply(conn, _TEAM_NAME_HISTORY_INDEXES) + await conn.commit() + + +async def init_tss_dbs() -> None: + """Create both TSSBOT SQLite databases if they don't already exist. + + Idempotent — safe to call on every startup. Sets WAL pragmas, creates + tables, and ensures indexes. + """ + await _init_battles_db() + await _init_teams_db() + log.info( + "TSS DBs initialised: %s, %s", + TSS_BATTLES_DB_PATH, + TSS_TEAMS_DB_PATH, + ) diff --git a/requirements.txt b/requirements.txt index 8ac6ec2..38a7099 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,6 @@ # TSSBOT runtime deps — populate as the bot is built out. # Many shared utilities under BOTS/SHARED/ require the same deps SREBOT uses; # see ../SREBOT/requirements.txt for reference. +aiosqlite +discord.py +python-dotenv diff --git a/start_bot.py b/start_bot.py index 20848c4..184fe34 100644 --- a/start_bot.py +++ b/start_bot.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """Entry point for TSSBOT. Connects to Discord and idles until commands land.""" +import asyncio import logging import os import signal @@ -17,6 +18,9 @@ from dotenv import load_dotenv load_dotenv(dotenv_path=_HERE / ".env") +# Imported after load_dotenv so STORAGE_VOL_PATH is read from .env. +from BOT.storage import init_tss_dbs # noqa: E402 + logging.basicConfig( level=logging.INFO, format="[%(asctime)s] [%(levelname)s] [tssbot] %(message)s", @@ -54,4 +58,9 @@ if __name__ == "__main__": if not TOKEN: log.error("DISCORD_KEY not set in TSSBOT/.env — aborting") sys.exit(1) + try: + asyncio.run(init_tss_dbs()) + except Exception as e: + log.error(f"failed to initialise TSS databases: {e}") + sys.exit(1) bot.run(TOKEN, log_handler=None)