add SREBOT, SHARED, TSSBOT contents (fixup for #1223)

PR #1223 only staged the deletions of the old paths because the new
top-level directories were still untracked when the commit was authored.
This commit adds the actual restructured tree: SREBOT/ (existing bot),
SHARED/ (vromfs, data_parser, ICONS/MAPS/FONTS, DAGOR_FILES,
update_game_files), and TSSBOT/ (skeleton).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
FURRO404
2026-05-13 23:17:02 -07:00
commit 2b399fdb81
186 changed files with 96596 additions and 0 deletions
+170
View File
@@ -0,0 +1,170 @@
"""
Diagnostic: query Discord's entitlements API directly and report what it returns
right now. Compare against the local entitlements.db cache.
Usage (on the server):
cd ~/GitHub/SREBOT_MEOW
source .venv/bin/activate
python scripts/diag_entitlements.py
Targeted guild: 1379510072815779961 (bot owner's max-tier server).
"""
from __future__ import annotations
import asyncio
import os
import sqlite3
import sys
import time
from pathlib import Path
import aiohttp
from dotenv import load_dotenv
load_dotenv()
APPLICATION_ID = "1254679514466877540"
TARGET_GUILD = "1379510072815779961"
TOKEN = os.environ.get("DISCORD_KEY", "")
_storage_env = os.environ.get("SREBOT_STORAGE_VOL_PATH", "").strip()
if not _storage_env:
raise RuntimeError("SREBOT_STORAGE_VOL_PATH must be set")
STORAGE = Path(_storage_env)
DB_PATH = STORAGE / "entitlements.db"
async def fetch_all_entitlements() -> tuple[list[dict], list[tuple[int, int, str, str]]]:
"""
Returns (entitlements, page_logs).
page_logs: list of (page_num, count, status, after_cursor)
"""
if not TOKEN:
print("ERROR: DISCORD_KEY env var not set", file=sys.stderr)
sys.exit(1)
headers = {"Authorization": f"Bot {TOKEN}"}
url = f"https://discord.com/api/v10/applications/{APPLICATION_ID}/entitlements"
params: dict[str, str] = {"exclude_ended": "true", "limit": "100"}
all_ents: list[dict] = []
page_logs: list[tuple[int, int, str, str]] = []
page = 0
after: str | None = None
timeout = aiohttp.ClientTimeout(total=60)
async with aiohttp.ClientSession(timeout=timeout) as session:
while True:
page += 1
q = dict(params)
if after:
q["after"] = after
t0 = time.monotonic()
async with session.get(url, headers=headers, params=q) as resp:
elapsed = time.monotonic() - t0
status = f"{resp.status} ({elapsed:.2f}s)"
if resp.status != 200:
body = await resp.text()
page_logs.append((page, 0, status, after or ""))
print(f"[!] page {page} failed: {status}\n {body[:300]}")
break
batch = await resp.json()
page_logs.append((page, len(batch), status, after or ""))
all_ents.extend(batch)
if len(batch) < 100:
break
# paginate forward by id
after = batch[-1]["id"]
# safety break
if page > 50:
print("[!] hit 50-page safety limit, stopping")
break
return all_ents, page_logs
def read_db_state() -> dict[str, int]:
if not DB_PATH.exists():
return {"db_missing": 1}
conn = sqlite3.connect(str(DB_PATH))
try:
cur = conn.cursor()
out: dict[str, int] = {}
out["guild_entitlements_total"] = cur.execute("SELECT COUNT(*) FROM guild_entitlements").fetchone()[0]
out["guild_entitlements_active"] = cur.execute("SELECT COUNT(*) FROM guild_entitlements WHERE status='active'").fetchone()[0]
out["manual_entitlements_total"] = cur.execute("SELECT COUNT(*) FROM manual_entitlements").fetchone()[0]
out["discord_entitlements_total"] = cur.execute("SELECT COUNT(*) FROM discord_entitlements").fetchone()[0]
row = cur.execute("SELECT * FROM discord_entitlements WHERE guild_id=?", (TARGET_GUILD,)).fetchone()
out["target_in_discord_table"] = 1 if row else 0
return out
finally:
conn.close()
async def main() -> None:
print("=" * 70)
print(f"Discord Entitlements Diagnostic — {time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())}")
print("=" * 70)
print(f"\n[1] Local DB state ({DB_PATH}):")
for k, v in read_db_state().items():
print(f" {k}: {v}")
print("\n[2] Discord API: GET /applications/{app_id}/entitlements?exclude_ended=true")
ents, pages = await fetch_all_entitlements()
print("\n Page log:")
for p, n, status, after in pages:
print(f" page {p:>2} count={n:>3} status={status:<18} after={after}")
print(f"\n Total entitlements returned: {len(ents)}")
# Group by guild
by_guild: dict[str, list[dict]] = {}
for e in ents:
gid = e.get("guild_id")
if gid:
by_guild.setdefault(str(gid), []).append(e)
print(f" Unique guild_ids: {len(by_guild)}")
print(f" Entitlements with NULL guild_id: {sum(1 for e in ents if not e.get('guild_id'))}")
# Status breakdown
deleted = sum(1 for e in ents if e.get("deleted"))
consumed = sum(1 for e in ents if e.get("consumed"))
starts_in_future = sum(1 for e in ents if e.get("starts_at") and e["starts_at"] > time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime()))
ends_in_past = sum(1 for e in ents if e.get("ends_at") and e["ends_at"] < time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime()))
print(f" deleted={deleted} consumed={consumed} starts_in_future={starts_in_future} ends_in_past={ends_in_past}")
print(f"\n[3] Target guild {TARGET_GUILD}:")
target_ents = by_guild.get(TARGET_GUILD, [])
if not target_ents:
print(f" *** NOT FOUND in Discord API response ***")
# Show sample of what we DID get to confirm we're not just paginating wrong
sample_guilds = list(by_guild.keys())[:5]
print(f" Sample of guild_ids returned: {sample_guilds}")
else:
for e in target_ents:
print(f" id={e.get('id')} sku_id={e.get('sku_id')} type={e.get('type')} "
f"deleted={e.get('deleted')} starts_at={e.get('starts_at')} ends_at={e.get('ends_at')}")
print("\n[4] SKU breakdown across all returned entitlements:")
sku_counts: dict[str, int] = {}
for e in ents:
sku = str(e.get("sku_id") or "")
sku_counts[sku] = sku_counts.get(sku, 0) + 1
for sku, c in sorted(sku_counts.items(), key=lambda x: -x[1]):
print(f" {sku}: {c}")
print("\n[5] Recommendation:")
if not target_ents:
print(" Discord is NOT returning the target guild's entitlement.")
print(" Either Discord's API is degraded right now, OR the entitlement no longer exists.")
print(" Re-run this script in a few minutes to see if results change.")
else:
print(" Discord IS returning the target guild's entitlement.")
print(" The bot's cache must be stale or wholesale-replaced by an empty result.")
print(" Force a refresh: restart bot OR call refresh_entitled_guilds(force=True).")
if __name__ == "__main__":
asyncio.run(main())
+123
View File
@@ -0,0 +1,123 @@
"""
Diagnostic: figure out why /meta shows no points per player.
Compares Guild_Metas userIDs against the uid keys returned by
obtain_clan_new_points() for the guild's bound squadron, and reports each
plausible failure mode (missing Guilds row, empty API response, uid format
mismatch, squadron-name mismatch).
Usage (on the server):
cd ~/SREBOT/SREBOT_MEOW
source .venv/bin/activate
python scripts/diag_meta_points.py 1378960248118841507
"""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
import aiosqlite
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from BOT.game_api import ClanInfoError, obtain_clan_new_points
from BOT.utils import STORAGE_DIR
async def diag(guild_id: str) -> None:
meta_db = STORAGE_DIR / "Meta.db"
print(f"== Meta.db: {meta_db} (exists={meta_db.exists()})")
if not meta_db.exists():
print("Meta.db missing — wrong STORAGE_DIR?")
return
async with aiosqlite.connect(meta_db) as db:
cur = await db.execute(
"SELECT squadron_name, squadron_clanID FROM Guilds WHERE guild_id=?",
(guild_id,),
)
guilds_row = await cur.fetchone()
print(f"== Guilds row for {guild_id}: {guilds_row}")
cur = await db.execute(
"SELECT COUNT(*) FROM Guild_Metas WHERE guild_id=?", (guild_id,)
)
gm_count_row = await cur.fetchone()
gm_count = gm_count_row[0] if gm_count_row else 0
print(f"== Guild_Metas count for {guild_id}: {gm_count}")
cur = await db.execute(
"SELECT userID, nick, clanName FROM Guild_Metas "
"WHERE guild_id=? ORDER BY nick LIMIT 5",
(guild_id,),
)
gm_rows = await cur.fetchall()
print("== Guild_Metas sample:")
for uid, nick, clan in gm_rows:
print(f" uid={uid!r} type={type(uid).__name__} nick={nick!r} clan={clan!r}")
if not guilds_row:
print("\nNo Guilds row — /meta-management was never run for this guild.")
return
sq_name = guilds_row[0]
print(f"\n== Calling obtain_clan_new_points({sq_name!r})...")
try:
members, total = await obtain_clan_new_points(sq_name)
except ClanInfoError as e:
print(f" ClanInfoError: {e}")
return
except Exception as e:
print(f" {type(e).__name__}: {e}")
return
print(f"== API returned: {len(members)} members, total_score={total}")
if not members:
print(" Empty members dict — JWT was likely refreshed; rerun the script.")
return
sample = list(members.items())[:3]
print("== API sample:")
for uid, info in sample:
print(f" uid={uid!r} type={type(uid).__name__} info={info}")
gm_rows_list = list(gm_rows)
if gm_rows_list:
gm_uid = gm_rows_list[0][0]
print(
f"\n== Membership test for first Guild_Metas uid {gm_uid!r}:\n"
f" raw in api_map -> {gm_uid in members}\n"
f" str(uid) in api_map -> {str(gm_uid) in members}"
)
api_uids = set(members.keys())
gm_uids_all = set()
async with aiosqlite.connect(meta_db) as db:
cur = await db.execute(
"SELECT userID FROM Guild_Metas WHERE guild_id=?", (guild_id,)
)
gm_uids_all = {str(r[0]) for r in await cur.fetchall()}
api_uids_str = {str(u) for u in api_uids}
overlap = gm_uids_all & api_uids_str
only_gm = gm_uids_all - api_uids_str
only_api = api_uids_str - gm_uids_all
print(
f"\n== Set overlap (str-cast both sides):\n"
f" in both: {len(overlap)}\n"
f" only in Guild_Metas: {len(only_gm)} (left squadron / wrong sq_name?)\n"
f" only in API: {len(only_api)} (never bulk-added)"
)
def main() -> None:
if len(sys.argv) != 2:
print("Usage: python scripts/diag_meta_points.py <guild_id>")
sys.exit(2)
asyncio.run(diag(sys.argv[1]))
if __name__ == "__main__":
main()
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""Probe the War Thunder clan leaderboard for a specific squadron.
This mirrors the bot's ``obtain_clans_leaderboard()`` flow:
1. Load the JWT from the storage auth file.
2. Call the clan leaderboard endpoint with ``action=cln_clan_get_leaderboard``.
3. Decode the BLK payload.
4. Print leaderboard counts and the first matching squadron entry.
Usage:
python3 scripts/leaderboard_score_probe.py
python3 scripts/leaderboard_score_probe.py --needle SCORE --count 1000
python3 scripts/leaderboard_score_probe.py --auth-file /mnt/.../STORAGE/auth_JWT.json --char-url "$WT_CHAR_URL"
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
from pathlib import Path
from typing import Any
from dotenv import load_dotenv
def _infer_storage_root(auth_file: Path) -> Path:
if auth_file.parent.name == "AUTH":
return auth_file.parent.parent
return auth_file.parent
def _prepare_env(storage_root: Path) -> None:
os.environ["SREBOT_STORAGE_VOL_PATH"] = str(storage_root)
def _score_hit(clan: dict[str, Any], needle: str) -> bool:
needle_u = needle.upper()
values = (
str(clan.get("short_name", "")).upper(),
str(clan.get("tag", "")).upper(),
str(clan.get("long_name", "")).upper(),
)
return any(v == needle_u or needle_u in v for v in values)
async def _main() -> int:
repo_root = Path(__file__).resolve().parents[1]
env_path = repo_root / ".env"
if str(repo_root) not in sys.path:
sys.path.insert(0, str(repo_root))
parser = argparse.ArgumentParser()
parser.add_argument("--needle", default="SCORE", help="Squadron name to search for")
parser.add_argument("--start", type=int, default=0, help="Leaderboard offset")
parser.add_argument("--count", type=int, default=1000, help="Leaderboard page size")
parser.add_argument(
"--auth-file",
default="",
help="Path to the JWT auth JSON file. If omitted, the script will use a temp file under the storage root.",
)
parser.add_argument(
"--char-url",
default="",
help="War Thunder char API URL (defaults to WT_CHAR_URL env var)",
)
args = parser.parse_args()
if args.auth_file:
auth_file = Path(args.auth_file).expanduser().resolve()
storage_root = _infer_storage_root(auth_file)
else:
storage_root_env = os.environ.get("SREBOT_STORAGE_VOL_PATH", "").strip()
if not storage_root_env:
print(
"SREBOT_STORAGE_VOL_PATH is not set. Export it or pass --auth-file.",
file=sys.stderr,
)
return 2
storage_root = Path(storage_root_env)
auth_file = storage_root / "AUTH" / "auth_JWT.json"
_prepare_env(storage_root)
auth_file.parent.mkdir(parents=True, exist_ok=True)
load_dotenv(dotenv_path=env_path)
if not args.char_url:
args.char_url = os.environ.get("WT_CHAR_URL", "")
if args.char_url:
os.environ["WT_CHAR_URL"] = args.char_url
elif not os.environ.get("WT_CHAR_URL"):
print(
"WT_CHAR_URL is not set. Pass --char-url or export WT_CHAR_URL first.",
file=sys.stderr,
)
return 2
# Import after env setup so BOT.game_api picks up the correct paths.
from BOT import game_api # type: ignore
import aiohttp
game_api.AUTH_FILE = auth_file
bin_blk_to_json = game_api.bin_blk_to_json
if not auth_file.exists():
await game_api.get_JWT()
with auth_file.open("r", encoding="utf-8") as f:
jwt = json.load(f)["jwt"]
headers = {
"action": "cln_clan_get_leaderboard",
"token": jwt,
"start": str(args.start),
"count": str(args.count),
"sortField": "dr_era5_hist",
"shortMode": "off",
}
url = os.environ["WT_CHAR_URL"]
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers) as res:
raw = await res.read()
print(f"http_status={res.status}")
if res.status != 200:
print(raw[:1000].decode("utf-8", "replace"), file=sys.stderr)
return 1
data = await bin_blk_to_json(raw)
if "root" in data:
data = data["root"]
clans = data.get("clan") or []
print(f"returned_clans={len(clans)}")
positive = sum(1 for clan in clans if int((clan.get("astat") or {}).get("dr_era5_hist") or 0) > 0)
print(f"positive_clans={positive}")
matches = []
for idx, clan in enumerate(clans, start=1):
if _score_hit(clan, args.needle):
astat = clan.get("astat") or {}
matches.append(
{
"index": idx,
"clan_id": clan.get("_id"),
"tag": clan.get("tag"),
"name": clan.get("name"),
"short_name": clan.get("tag", "")[1:-1] if clan.get("tag") else "",
"clanrating": astat.get("dr_era5_hist"),
"members": clan.get("members_cnt"),
"position": clan.get("pos"),
}
)
if not matches:
print(f"needle={args.needle!r} not found")
return 0
print("matches=" + json.dumps(matches[:10], ensure_ascii=False))
first = matches[0]["index"]
lo = max(1, first - 3)
hi = min(len(clans), first + 3)
window = []
for idx in range(lo, hi + 1):
clan = clans[idx - 1]
astat = clan.get("astat") or {}
window.append(
{
"index": idx,
"tag": clan.get("tag"),
"name": clan.get("name"),
"clanrating": astat.get("dr_era5_hist"),
"position": clan.get("pos"),
}
)
print("window=" + json.dumps(window, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(_main()))
+739
View File
@@ -0,0 +1,739 @@
"""
One-shot migration: switch SREBOT storage to use clan_id (numeric squadron UID)
as the canonical identifier across DB tables and preference JSON files.
Goals
-----
1. Add `clan_id` to historical tables (player_games_hist, match_summary).
2. Rebuild points.db tables (profile_member_points, profile_totals,
game_cache) so their PKs include `clan_id` instead of squadron text.
3. Rebuild wl.db tables to use clan_id (currently empty so trivial).
4. Add `squadron_name_history` table to squadrons.db (for old-name → clan_id
redirects when a squadron renames). Seed with current squadrons_data.
5. Re-key every PREFERENCES/<guild_id>-preferences.json so squadron entries
are keyed by `str(clan_id)` instead of long_name. Special wildcard keys
("Global", "everything", "all", "*") are preserved.
6. Backups: write a tarball of STORAGE/ before any change. Each preferences
file gets copied to PREFERENCES_BACKUP_<timestamp>/.
Run with `--dry-run` first. The script prints exactly what it would do.
Usage
-----
source .venv/bin/activate
python scripts/migrate_clan_id.py --dry-run
python scripts/migrate_clan_id.py --apply
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import shutil
import sqlite3
import sys
import tarfile
import time
from pathlib import Path
from typing import Any, Optional
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
log = logging.getLogger("migrate_clan_id")
# Auto-load the repo's .env so SREBOT_STORAGE_VOL_PATH and friends resolve when
# the script is run directly (PM2/services already get them from .env).
try:
from dotenv import load_dotenv
_here = Path(__file__).resolve()
for candidate in (_here.parent / ".env", _here.parent.parent / ".env"):
if candidate.exists():
load_dotenv(candidate)
break
except ImportError:
pass
_storage_env = os.environ.get("SREBOT_STORAGE_VOL_PATH", "").strip()
if not _storage_env:
raise RuntimeError("SREBOT_STORAGE_VOL_PATH must be set")
STORAGE_DIR = Path(_storage_env)
SQ_BATTLES_DB = STORAGE_DIR / "sq_battles.db"
SQUADRONS_DB = STORAGE_DIR / "squadrons.db"
POINTS_DB = STORAGE_DIR / "points.db"
WL_DB = STORAGE_DIR / "wl.db"
PREFS_DIR = STORAGE_DIR / "PREFERENCES"
WILDCARD_KEYS = {"global", "everything", "all", "*"}
PGH_BATCH = 50_000
def backup_storage() -> Path:
ts = time.strftime("%Y%m%d-%H%M%S")
backup_path = STORAGE_DIR.parent / f"STORAGE_BACKUP_{ts}.tar.gz"
log.info("Creating tarball backup at %s (this may take a while)...", backup_path)
with tarfile.open(backup_path, "w:gz") as tar:
for db in (SQ_BATTLES_DB, SQUADRONS_DB, POINTS_DB, WL_DB):
if db.exists():
tar.add(db, arcname=db.name)
if PREFS_DIR.exists():
tar.add(PREFS_DIR, arcname=PREFS_DIR.name)
log.info("Backup complete: %s", backup_path)
return backup_path
def load_squadron_index() -> tuple[dict[str, int], dict[str, int], dict[int, dict[str, Any]]]:
"""Return (long_name_lower -> clan_id, short_name_lower -> clan_id, clan_id -> row)."""
long_to_id: dict[str, int] = {}
short_to_id: dict[str, int] = {}
by_id: dict[int, dict[str, Any]] = {}
con = sqlite3.connect(SQUADRONS_DB)
try:
con.row_factory = sqlite3.Row
for row in con.execute(
"SELECT clan_id, long_name, short_name, tag_name FROM squadrons_data"
):
cid = int(row["clan_id"])
by_id[cid] = dict(row)
if row["long_name"]:
long_to_id[row["long_name"].lower()] = cid
if row["short_name"]:
short_to_id[row["short_name"].lower()] = cid
finally:
con.close()
log.info("Loaded %d squadrons from squadrons_data", len(by_id))
return long_to_id, short_to_id, by_id
def ensure_squadron_name_history(apply: bool, by_id: dict[int, dict[str, Any]]) -> None:
"""Create squadron_name_history table and seed from current squadrons_data.
Schema:
clan_id INTEGER NOT NULL
long_name TEXT NOT NULL
first_seen INTEGER NOT NULL (unix)
last_seen INTEGER NOT NULL (unix)
PRIMARY KEY (clan_id, long_name)
"""
con = sqlite3.connect(SQUADRONS_DB)
try:
existing = con.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='squadron_name_history'"
).fetchone()
if existing:
log.info("squadron_name_history already exists; skipping create")
else:
log.info("Creating squadron_name_history table")
if apply:
con.executescript(
"""
CREATE TABLE squadron_name_history (
clan_id INTEGER NOT NULL,
long_name TEXT NOT NULL,
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
PRIMARY KEY (clan_id, long_name)
);
CREATE INDEX idx_snh_long_name ON squadron_name_history(long_name COLLATE NOCASE);
CREATE INDEX idx_snh_clan_id ON squadron_name_history(clan_id);
"""
)
now = int(time.time())
rows = [
(cid, row["long_name"], now, now)
for cid, row in by_id.items()
if row.get("long_name")
]
log.info("Seeding squadron_name_history with %d (clan_id, long_name) pairs", len(rows))
if apply and rows:
con.executemany(
"""
INSERT INTO squadron_name_history (clan_id, long_name, first_seen, last_seen)
VALUES (?, ?, ?, ?)
ON CONFLICT(clan_id, long_name) DO UPDATE SET last_seen = excluded.last_seen
""",
rows,
)
con.commit()
finally:
con.close()
def add_squadrons_points_index(apply: bool) -> None:
con = sqlite3.connect(SQUADRONS_DB)
try:
log.info("Adding clan_id index on squadrons_points")
if apply:
con.executescript(
"""
CREATE INDEX IF NOT EXISTS idx_squadrons_points_clanid_time
ON squadrons_points(clan_id, unix_time);
"""
)
con.commit()
finally:
con.close()
def migrate_player_games_hist(
apply: bool, long_to_id: dict[str, int]
) -> None:
"""Add clan_id INTEGER column + backfill via long_name lookup. Add index."""
con = sqlite3.connect(SQ_BATTLES_DB)
try:
con.execute("PRAGMA journal_mode=WAL")
cols = [r[1] for r in con.execute("PRAGMA table_info(player_games_hist)").fetchall()]
if "clan_id" not in cols:
log.info("Adding clan_id column to player_games_hist")
if apply:
con.execute("ALTER TABLE player_games_hist ADD COLUMN clan_id INTEGER")
con.commit()
else:
log.info("player_games_hist.clan_id already present")
# Refresh after the conditional ALTER.
cols = [r[1] for r in con.execute("PRAGMA table_info(player_games_hist)").fetchall()]
total = con.execute("SELECT COUNT(*) FROM player_games_hist").fetchone()[0]
if "clan_id" in cols:
unbackfilled = con.execute(
"SELECT COUNT(*) FROM player_games_hist WHERE clan_id IS NULL"
).fetchone()[0]
else:
unbackfilled = total
log.info(
"player_games_hist: %d total rows, %d need backfill",
total,
unbackfilled,
)
if apply and unbackfilled:
# Faster path: pre-build a name → clan_id map in Python and run one
# indexed UPDATE per distinct squadron_name. Each UPDATE hits the
# idx_pgh_squadron_name index. ~1500 small ops vs one giant
# correlated subquery. squadron_name is the WT short_name (after
# alphanum strip), but very rarely a long_name leaks through, so
# we accept matches against either.
log.info("Building name → clan_id map in Python...")
sq_con = sqlite3.connect(f"file:{SQUADRONS_DB}?mode=ro", uri=True)
try:
sq_con.row_factory = sqlite3.Row
name_to_id: dict[str, int] = {}
for row in sq_con.execute(
"SELECT clan_id, long_name, short_name FROM squadrons_data"
):
cid = int(row["clan_id"])
if row["long_name"]:
name_to_id.setdefault(row["long_name"].lower(), cid)
if row["short_name"]:
name_to_id.setdefault(row["short_name"].lower(), cid)
finally:
sq_con.close()
log.info("Fetching distinct squadron_name values from player_games_hist...")
distinct_names = [
r[0] for r in con.execute(
"SELECT DISTINCT squadron_name FROM player_games_hist WHERE clan_id IS NULL"
).fetchall()
if r[0]
]
log.info("Distinct unbackfilled squadron_names: %d", len(distinct_names))
updates = [
(name_to_id[n.lower()], n)
for n in distinct_names
if n.lower() in name_to_id
]
log.info("Will UPDATE %d distinct names that resolve to clan_ids", len(updates))
if updates:
cur = con.executemany(
"UPDATE player_games_hist SET clan_id = ? "
"WHERE squadron_name = ? AND clan_id IS NULL",
updates,
)
con.commit()
log.info("Backfill: %s row-updates committed", cur.rowcount)
cols = [r[1] for r in con.execute("PRAGMA table_info(player_games_hist)").fetchall()]
if "clan_id" in cols:
still_null = con.execute(
"SELECT COUNT(*) FROM player_games_hist WHERE clan_id IS NULL"
).fetchone()[0]
log.info("player_games_hist orphans (clan_id NULL after backfill): %d", still_null)
else:
log.info("player_games_hist orphans: n/a (column absent in dry-run)")
log.info("Ensuring index on player_games_hist(clan_id, endtime_unix)")
if apply:
con.execute(
"CREATE INDEX IF NOT EXISTS idx_pgh_clanid_endtime "
"ON player_games_hist(clan_id, endtime_unix)"
)
con.commit()
finally:
con.close()
def migrate_match_summary(apply: bool) -> None:
"""Add winning_clan_id + losing_clan_id, backfill via squadrons_data."""
con = sqlite3.connect(SQ_BATTLES_DB)
try:
con.execute("PRAGMA journal_mode=WAL")
cols = [r[1] for r in con.execute("PRAGMA table_info(match_summary)").fetchall()]
for new_col in ("winning_clan_id", "losing_clan_id"):
if new_col not in cols:
log.info("Adding %s column to match_summary", new_col)
if apply:
con.execute(f"ALTER TABLE match_summary ADD COLUMN {new_col} INTEGER")
con.commit()
else:
log.info("match_summary.%s already present", new_col)
# Refresh after potential ALTERs.
cols = [r[1] for r in con.execute("PRAGMA table_info(match_summary)").fetchall()]
total = con.execute("SELECT COUNT(*) FROM match_summary").fetchone()[0]
if "winning_clan_id" in cols and "losing_clan_id" in cols:
unbackfilled = con.execute(
"SELECT COUNT(*) FROM match_summary "
"WHERE winning_clan_id IS NULL OR losing_clan_id IS NULL"
).fetchone()[0]
else:
# Dry-run path: columns don't exist yet, so every row is "to be backfilled".
unbackfilled = total
log.info(
"match_summary: %d total rows, %d need backfill",
total,
unbackfilled,
)
if apply and unbackfilled:
con.execute(f"ATTACH DATABASE '{SQUADRONS_DB}' AS sq")
try:
# winning_sq / losing_sq can be either short_name or tag_name
# depending on replay metadata. Try short_name first since that's
# what the autologger writes most often.
for col_in, col_out in (
("winning_sq", "winning_clan_id"),
("losing_sq", "losing_clan_id"),
):
log.info("Backfilling %s%s via short_name then long_name", col_out, col_in)
con.execute(
f"""
UPDATE match_summary
SET {col_out} = (
SELECT clan_id FROM sq.squadrons_data
WHERE LOWER(squadrons_data.short_name) = LOWER(match_summary.{col_in})
LIMIT 1
)
WHERE {col_out} IS NULL AND {col_in} IS NOT NULL
"""
)
con.execute(
f"""
UPDATE match_summary
SET {col_out} = (
SELECT clan_id FROM sq.squadrons_data
WHERE LOWER(squadrons_data.long_name) = LOWER(match_summary.{col_in})
LIMIT 1
)
WHERE {col_out} IS NULL AND {col_in} IS NOT NULL
"""
)
con.commit()
finally:
con.execute("DETACH DATABASE sq")
cols = [r[1] for r in con.execute("PRAGMA table_info(match_summary)").fetchall()]
for col, label in (("winning_clan_id", "winning"), ("losing_clan_id", "losing")):
if col not in cols:
log.info("match_summary orphans (%s NULL): n/a (column absent in dry-run)", label)
continue
n = con.execute(
f"SELECT COUNT(*) FROM match_summary WHERE {col} IS NULL"
).fetchone()[0]
log.info("match_summary orphans (%s NULL): %d", label, n)
log.info("Ensuring indexes on match_summary clan_id columns")
if apply:
con.execute(
"CREATE INDEX IF NOT EXISTS idx_ms_winning_clanid ON match_summary(winning_clan_id)"
)
con.execute(
"CREATE INDEX IF NOT EXISTS idx_ms_losing_clanid ON match_summary(losing_clan_id)"
)
con.commit()
finally:
con.close()
def _resolve_squadron_to_clan_id(name: Optional[str], name_to_id: dict[str, int]) -> int:
if not name:
return -1
return name_to_id.get(name.lower(), -1)
def rebuild_points_db(apply: bool, long_to_id: dict[str, int]) -> None:
"""Rebuild profile_member_points, profile_totals, game_cache with clan_id columns.
The squadron text column is preserved for reference but the new tables key
off clan_id for primary keys. Resolution happens in Python so we don't have
to ATTACH squadrons.db (avoids the cross-db lock issue we hit earlier).
"""
# Build name → clan_id map once (long_name AND short_name).
sq_con = sqlite3.connect(f"file:{SQUADRONS_DB}?mode=ro", uri=True)
try:
sq_con.row_factory = sqlite3.Row
name_to_id: dict[str, int] = {}
for row in sq_con.execute(
"SELECT clan_id, long_name, short_name FROM squadrons_data"
):
cid = int(row["clan_id"])
if row["long_name"]:
name_to_id.setdefault(row["long_name"].lower(), cid)
if row["short_name"]:
name_to_id.setdefault(row["short_name"].lower(), cid)
finally:
sq_con.close()
con = sqlite3.connect(POINTS_DB)
try:
con.execute("PRAGMA journal_mode=WAL")
# profile_member_points -------------------------------------------------
cols = [r[1] for r in con.execute("PRAGMA table_info(profile_member_points)").fetchall()]
if "clan_id" in cols:
log.info("profile_member_points already migrated; skipping rebuild")
else:
log.info("Rebuilding profile_member_points with clan_id PK")
if apply:
# Idempotency: a previous half-finished run may have left _new behind.
con.execute("DROP TABLE IF EXISTS profile_member_points_new")
con.execute(
"""CREATE TABLE profile_member_points_new (
clan_id INTEGER NOT NULL,
squadron TEXT NOT NULL,
uid TEXT NOT NULL,
points INTEGER NOT NULL,
PRIMARY KEY (clan_id, uid)
)"""
)
src = con.execute(
"SELECT squadron, uid, points FROM profile_member_points"
).fetchall()
rows = [
(_resolve_squadron_to_clan_id(s, name_to_id), s, u, p)
for (s, u, p) in src
]
con.executemany(
"INSERT OR IGNORE INTO profile_member_points_new "
"(clan_id, squadron, uid, points) VALUES (?, ?, ?, ?)",
rows,
)
con.commit()
log.info("profile_member_points: copied %d rows", len(rows))
con.execute("DROP TABLE profile_member_points")
con.execute("ALTER TABLE profile_member_points_new RENAME TO profile_member_points")
con.execute("CREATE INDEX IF NOT EXISTS idx_pmp_squadron ON profile_member_points(squadron)")
con.commit()
# profile_totals --------------------------------------------------------
cols = [r[1] for r in con.execute("PRAGMA table_info(profile_totals)").fetchall()]
if "clan_id" in cols:
log.info("profile_totals already migrated; skipping rebuild")
else:
log.info("Rebuilding profile_totals with clan_id PK")
if apply:
con.execute("DROP TABLE IF EXISTS profile_totals_new")
con.execute(
"""CREATE TABLE profile_totals_new (
clan_id INTEGER PRIMARY KEY,
squadron TEXT NOT NULL,
total INTEGER NOT NULL
)"""
)
src = con.execute("SELECT squadron, total FROM profile_totals").fetchall()
rows = [
(_resolve_squadron_to_clan_id(s, name_to_id), s, t)
for (s, t) in src
]
con.executemany(
"INSERT OR IGNORE INTO profile_totals_new (clan_id, squadron, total) VALUES (?, ?, ?)",
rows,
)
con.commit()
log.info("profile_totals: copied %d rows", len(rows))
con.execute("DROP TABLE profile_totals")
con.execute("ALTER TABLE profile_totals_new RENAME TO profile_totals")
con.commit()
# game_cache ------------------------------------------------------------
cols = [r[1] for r in con.execute("PRAGMA table_info(game_cache)").fetchall()]
if "clan_id" in cols:
log.info("game_cache already migrated; skipping rebuild")
else:
log.info("Rebuilding game_cache with clan_id in PK")
if apply:
con.execute("DROP TABLE IF EXISTS game_cache_new")
con.execute(
"""CREATE TABLE game_cache_new (
game_id TEXT NOT NULL,
clan_id INTEGER NOT NULL,
squadron TEXT NOT NULL,
diffs_json TEXT NOT NULL,
diff_total INTEGER NOT NULL,
updated_json TEXT NOT NULL,
created_at REAL NOT NULL DEFAULT (strftime('%s','now')),
PRIMARY KEY (game_id, clan_id)
)"""
)
src = con.execute(
"SELECT game_id, squadron, diffs_json, diff_total, updated_json, created_at FROM game_cache"
).fetchall()
rows = [
(
gid,
_resolve_squadron_to_clan_id(s, name_to_id),
s,
dj,
dt,
uj,
ca,
)
for (gid, s, dj, dt, uj, ca) in src
]
con.executemany(
"INSERT OR IGNORE INTO game_cache_new "
"(game_id, clan_id, squadron, diffs_json, diff_total, updated_json, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
rows,
)
con.commit()
log.info("game_cache: copied %d rows", len(rows))
con.execute("DROP TABLE game_cache")
con.execute("ALTER TABLE game_cache_new RENAME TO game_cache")
con.commit()
finally:
con.close()
def rebuild_wl_db(apply: bool) -> None:
"""wl_events / wl_standings are empty - swap schemas to clan_id keyed tables."""
con = sqlite3.connect(WL_DB)
try:
# wl_standings
cols = [r[1] for r in con.execute("PRAGMA table_info(wl_standings)").fetchall()]
if "clan_id" in cols:
log.info("wl_standings already migrated")
else:
log.info("Recreating wl_standings keyed by clan_id (was empty)")
if apply:
con.executescript(
"""
DROP TABLE IF EXISTS wl_standings;
CREATE TABLE wl_standings (
clan_id INTEGER PRIMARY KEY,
squadron TEXT NOT NULL,
wins INTEGER NOT NULL DEFAULT 0,
losses INTEGER NOT NULL DEFAULT 0
);
"""
)
con.commit()
# wl_events: store winner_clan_id alongside winner text
cols = [r[1] for r in con.execute("PRAGMA table_info(wl_events)").fetchall()]
if "winner_clan_id" in cols:
log.info("wl_events already migrated")
else:
log.info("Adding winner_clan_id column to wl_events (table is empty)")
if apply:
con.execute("ALTER TABLE wl_events ADD COLUMN winner_clan_id INTEGER")
con.commit()
finally:
con.close()
def migrate_preferences(
apply: bool,
long_to_id: dict[str, int],
short_to_id: dict[str, int],
by_id: dict[int, dict[str, Any]],
) -> None:
"""Re-key every PREFERENCES/<guild_id>-preferences.json from long_name → str(clan_id).
Special wildcard keys (Global, everything, all, *) are preserved as-is.
Each migrated entry gets a `Long` field with the squadron's current
long_name so display fallback works if squadrons_data is unavailable.
Original files are copied to PREFERENCES_BACKUP_<timestamp>/.
"""
if not PREFS_DIR.exists():
log.warning("PREFERENCES dir missing at %s; skipping prefs migration", PREFS_DIR)
return
ts = time.strftime("%Y%m%d-%H%M%S")
backup_dir = STORAGE_DIR / f"PREFERENCES_BACKUP_{ts}"
if apply:
backup_dir.mkdir(parents=True, exist_ok=True)
pref_files = sorted(PREFS_DIR.glob("*-preferences.json"))
log.info("Found %d preference files to migrate", len(pref_files))
migrated_count = 0
orphan_count = 0
skipped_count = 0
files_changed = 0
for pref_file in pref_files:
try:
data = json.loads(pref_file.read_text(encoding="utf-8"))
except Exception as e:
log.warning("Could not read %s: %s", pref_file.name, e)
continue
if not isinstance(data, dict):
log.warning("Skipping %s: not an object", pref_file.name)
continue
new_data: dict[str, Any] = {}
any_changes = False
for key, value in data.items():
if not isinstance(value, dict):
new_data[key] = value
continue
key_lower = str(key).lower()
# Preserve wildcards / Global
if key_lower in WILDCARD_KEYS:
new_data[key] = value
continue
# Already a numeric clan_id (running migration twice)
if str(key).isdigit():
new_data[key] = value
continue
# Try long_name then short_name
cid = long_to_id.get(key_lower) or short_to_id.get(key_lower)
# Maybe entry has a "Short" hint we can use as fallback
if cid is None and value.get("Short"):
cid = short_to_id.get(str(value["Short"]).lower())
if cid is None:
log.warning(
" [%s] orphan key %r — squadron not found in squadrons_data",
pref_file.name,
key,
)
# Keep the original key so the user doesn't lose their data;
# a future load_guild_preferences will surface it for cleanup.
new_data[key] = value
orphan_count += 1
continue
new_key = str(cid)
row = by_id.get(cid, {})
merged = dict(value)
# Preserve display fields - the bot uses them when squadrons_data is stale
merged.setdefault("Long", row.get("long_name") or key)
if row.get("short_name"):
merged["Short"] = row["short_name"]
if new_key in new_data:
# Two prefs entries for the same squadron (rare). Merge,
# preferring values from this iteration.
existing = new_data[new_key]
if isinstance(existing, dict):
existing.update(merged)
new_data[new_key] = existing
else:
new_data[new_key] = merged
else:
new_data[new_key] = merged
if new_key != key:
any_changes = True
migrated_count += 1
if not any_changes:
skipped_count += 1
continue
if apply:
backup_path = backup_dir / pref_file.name
shutil.copy2(pref_file, backup_path)
tmp = pref_file.with_suffix(".json.tmp")
tmp.write_text(json.dumps(new_data, ensure_ascii=False), encoding="utf-8")
os.replace(tmp, pref_file)
files_changed += 1
log.info(
"Preferences migration: %d files changed, %d unchanged, %d entries migrated, %d orphans",
files_changed,
skipped_count,
migrated_count,
orphan_count,
)
if apply:
log.info("Preference backups saved to %s", backup_dir)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--apply", action="store_true", help="Actually apply changes")
parser.add_argument(
"--dry-run",
action="store_true",
help="Print what would happen without writing anything (default)",
)
parser.add_argument(
"--skip-backup",
action="store_true",
help="Skip the tarball backup step (use only if you already have one)",
)
args = parser.parse_args()
if args.apply and args.dry_run:
log.error("--apply and --dry-run are mutually exclusive")
return 2
apply = args.apply
log.info("STORAGE_DIR=%s", STORAGE_DIR)
log.info("apply=%s", apply)
for db in (SQ_BATTLES_DB, SQUADRONS_DB, POINTS_DB, WL_DB):
if not db.exists():
log.error("Required DB missing: %s", db)
return 1
if apply and not args.skip_backup:
backup_storage()
long_to_id, short_to_id, by_id = load_squadron_index()
ensure_squadron_name_history(apply, by_id)
add_squadrons_points_index(apply)
migrate_player_games_hist(apply, long_to_id)
migrate_match_summary(apply)
rebuild_points_db(apply, long_to_id)
rebuild_wl_db(apply)
migrate_preferences(apply, long_to_id, short_to_id, by_id)
if not apply:
log.info("DRY RUN complete. Re-run with --apply to actually write changes.")
else:
log.info("Migration applied successfully.")
return 0
if __name__ == "__main__":
sys.exit(main())
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""
Move legacy repo-root replays into STORAGE/REPLAYS.
Legacy directories were named replays/0<hex_session_id>. The new canonical
layout is STORAGE/REPLAYS/<hex_session_id>.
"""
from __future__ import annotations
import argparse
import os
import re
import shutil
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_SOURCE = REPO_ROOT / "replays"
_storage_env = os.environ.get("SREBOT_STORAGE_VOL_PATH", "").strip()
if not _storage_env:
raise RuntimeError("SREBOT_STORAGE_VOL_PATH must be set")
DEFAULT_STORAGE = Path(_storage_env)
LEGACY_REPLAY_DIR = re.compile(r"^0([0-9a-fA-F]+)$")
HEX_REPLAY_DIR = re.compile(r"^[0-9a-fA-F]+$")
def canonical_name(name: str) -> str | None:
legacy = LEGACY_REPLAY_DIR.fullmatch(name)
if legacy:
return legacy.group(1).lower()
if HEX_REPLAY_DIR.fullmatch(name):
return name.lower()
return None
def iter_files(root: Path) -> list[Path]:
return [p for p in root.rglob("*") if p.is_file()]
def copy_replay_dir(source: Path, dest: Path, dry_run: bool) -> tuple[int, int]:
copied = 0
skipped = 0
for src_file in iter_files(source):
rel = src_file.relative_to(source)
dst_file = dest / rel
if dst_file.exists() and dst_file.stat().st_size == src_file.stat().st_size:
skipped += 1
continue
copied += 1
if dry_run:
continue
dst_file.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src_file, dst_file)
return copied, skipped
def copied_completely(source: Path, dest: Path) -> bool:
for src_file in iter_files(source):
dst_file = dest / src_file.relative_to(source)
if not dst_file.exists() or dst_file.stat().st_size != src_file.stat().st_size:
return False
return True
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", type=Path, default=DEFAULT_SOURCE, help="Legacy replays directory.")
parser.add_argument(
"--storage-dir",
type=Path,
default=DEFAULT_STORAGE,
help="Storage root. Destination defaults to <storage-dir>/REPLAYS.",
)
parser.add_argument("--dest", type=Path, help="Destination replay directory.")
parser.add_argument("--execute", action="store_true", help="Actually copy files. Default is dry-run.")
parser.add_argument("--move", action="store_true", help="Remove source dirs after a successful executed copy.")
args = parser.parse_args()
source = args.source.expanduser().resolve()
dest_root = (args.dest or (args.storage_dir / "REPLAYS")).expanduser().resolve()
dry_run = not args.execute
if args.move and dry_run:
parser.error("--move requires --execute")
if not source.exists():
raise SystemExit(f"Source does not exist: {source}")
planned = 0
copied_total = 0
skipped_total = 0
print(f"Source: {source}")
print(f"Destination: {dest_root}")
print(f"Mode: {'dry-run' if dry_run else 'execute'}")
for entry in sorted(source.iterdir()):
if not entry.is_dir():
continue
new_name = canonical_name(entry.name)
if new_name is None:
print(f"skip non-replay dir: {entry.name}")
continue
planned += 1
dest = dest_root / new_name
copied, skipped = copy_replay_dir(entry, dest, dry_run)
copied_total += copied
skipped_total += skipped
print(f"{entry.name} -> {new_name}: copy {copied}, skip {skipped}")
if args.move and copied_completely(entry, dest):
shutil.rmtree(entry)
print(f"removed source dir: {entry}")
print(f"Replay dirs: {planned}; files to copy: {copied_total}; already present: {skipped_total}")
if dry_run:
print("Dry-run only. Re-run with --execute to copy files.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# Restarts srebot-api + srebot-web and times the recovery milestones.
# Usage: bash scripts/restart-test.sh
set -u
T0=$(date +%s.%N)
echo "=== T0: $(date +%H:%M:%S.%3N) restarting srebot-api + srebot-web ==="
pm2 restart srebot-api srebot-web >/dev/null
echo "--- polling /health (every 0.5s until ready:true) ---"
for i in $(seq 1 120); do
R=$(curl -sS --max-time 2 http://127.0.0.1:6000/health 2>/dev/null || true)
if echo "$R" | grep -q '"ready":true'; then
echo "ready at +$(echo "$(date +%s.%N) - $T0" | bc)s : $R"
break
fi
sleep 0.5
done
echo "--- /api/squadrons/1066957 (first cold hit, separate connection) ---"
curl -sS -o /dev/null -w " HTTP=%{http_code} time=%{time_total}s\n" "http://127.0.0.1:6000/api/squadrons/1066957"
echo "--- 4 leaderboards in parallel (heavyDb concurrent reads) ---"
for ep in stats squadrons players vehicles; do
(curl -sS -o /dev/null -w " $ep: HTTP=%{http_code} time=%{time_total}s\n" "http://127.0.0.1:6000/api/leaderboard/$ep") &
done
wait
echo "=== full warm at +$(echo "$(date +%s.%N) - $T0" | bc)s ==="
echo "--- pm2 status ---"
pm2 info srebot-api | grep -E "uptime|restart|status" | head -5