TSS storage (#1233)

This commit is contained in:
NotSoToothless
2026-05-14 14:03:59 -07:00
committed by GitHub
parent 335a7fb8d0
commit e56951fcb2
3 changed files with 248 additions and 0 deletions
+236
View File
@@ -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,
)
+3
View File
@@ -1,3 +1,6 @@
# TSSBOT runtime deps — populate as the bot is built out. # TSSBOT runtime deps — populate as the bot is built out.
# Many shared utilities under BOTS/SHARED/ require the same deps SREBOT uses; # Many shared utilities under BOTS/SHARED/ require the same deps SREBOT uses;
# see ../SREBOT/requirements.txt for reference. # see ../SREBOT/requirements.txt for reference.
aiosqlite
discord.py
python-dotenv
+9
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Entry point for TSSBOT. Connects to Discord and idles until commands land.""" """Entry point for TSSBOT. Connects to Discord and idles until commands land."""
import asyncio
import logging import logging
import os import os
import signal import signal
@@ -17,6 +18,9 @@ from dotenv import load_dotenv
load_dotenv(dotenv_path=_HERE / ".env") 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( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format="[%(asctime)s] [%(levelname)s] [tssbot] %(message)s", format="[%(asctime)s] [%(levelname)s] [tssbot] %(message)s",
@@ -54,4 +58,9 @@ if __name__ == "__main__":
if not TOKEN: if not TOKEN:
log.error("DISCORD_KEY not set in TSSBOT/.env — aborting") log.error("DISCORD_KEY not set in TSSBOT/.env — aborting")
sys.exit(1) 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) bot.run(TOKEN, log_handler=None)