From 24335a2677761b995981f0948f805781dd80fc1e Mon Sep 17 00:00:00 2001 From: NotSoToothless <67082114+FURRO404@users.noreply.github.com> Date: Sun, 28 Jun 2026 03:38:20 -0700 Subject: [PATCH] =?UTF-8?q?Auto=20merge=20dev=20=E2=86=92=20main=20(#1353)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gateway): hashed key store with grant + hot reload Co-Authored-By: Claude Opus 4.8 * feat(gateway): channel registry + aiohttp app (keyed auth, whoami, per-channel ws/proxy) Co-Authored-By: Claude Opus 4.8 * feat(gateway): manage_keys CLI (add/list/revoke) Co-Authored-By: Claude Opus 4.8 * feat(gateway): retire srebot_external, run relay-gateway under PM2 Co-Authored-By: Claude Opus 4.8 * feat(gateway): point ecosystem + README at relay-gateway Co-Authored-By: Claude Opus 4.8 * feat(tss): replay outbox producer for relay gateway Co-Authored-By: Claude Opus 4.8 * feat(tss): forward processed games to relay outbox Co-Authored-By: Claude Opus 4.8 * feat(tss-api): db helpers, app skeleton, info endpoint, fixtures Co-Authored-By: Claude Opus 4.8 * feat(tss-api): player, games, history, search endpoints Co-Authored-By: Claude Opus 4.8 * feat(tss-api): live, match, scoreboard, matches-search, maps Co-Authored-By: Claude Opus 4.8 * feat(tss-api): filter-required leaderboards (players/vehicles/stats) Co-Authored-By: Claude Opus 4.8 * feat(tss-api): tournament list/detail/standings/matches Co-Authored-By: Claude Opus 4.8 * feat: wire tss upstream through gateway + tssbot-api PM2 app Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- BOT/receiver_bridge.py | 60 +++++++++++++++++ pytest.ini | 2 + tests/conftest.py | 96 +++++++++++++++++++++++++++ tests/test_receiver_bridge.py | 33 ++++++++++ tests/test_tss_api_info.py | 9 +++ tests/test_tss_api_leaderboard.py | 24 +++++++ tests/test_tss_api_matches.py | 35 ++++++++++ tests/test_tss_api_players.py | 37 +++++++++++ tests/test_tss_api_tournaments.py | 28 ++++++++ tss_ws.py | 7 ++ web/api/__init__.py | 0 web/api/app.py | 60 +++++++++++++++++ web/api/db.py | 47 ++++++++++++++ web/api/routes_leaderboard.py | 69 ++++++++++++++++++++ web/api/routes_matches.py | 104 ++++++++++++++++++++++++++++++ web/api/routes_players.py | 77 ++++++++++++++++++++++ web/api/routes_tournaments.py | 76 ++++++++++++++++++++++ web/main.py | 13 ++++ 18 files changed, 777 insertions(+) create mode 100644 BOT/receiver_bridge.py create mode 100644 pytest.ini create mode 100644 tests/conftest.py create mode 100644 tests/test_receiver_bridge.py create mode 100644 tests/test_tss_api_info.py create mode 100644 tests/test_tss_api_leaderboard.py create mode 100644 tests/test_tss_api_matches.py create mode 100644 tests/test_tss_api_players.py create mode 100644 tests/test_tss_api_tournaments.py create mode 100644 web/api/__init__.py create mode 100644 web/api/app.py create mode 100644 web/api/db.py create mode 100644 web/api/routes_leaderboard.py create mode 100644 web/api/routes_matches.py create mode 100644 web/api/routes_players.py create mode 100644 web/api/routes_tournaments.py create mode 100644 web/main.py diff --git a/BOT/receiver_bridge.py b/BOT/receiver_bridge.py new file mode 100644 index 0000000..e6614ce --- /dev/null +++ b/BOT/receiver_bridge.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import asyncio +import json +import logging +import os +import time +from pathlib import Path +from typing import Any + +import aiofiles + +logger = logging.getLogger(__name__) + + +def _env(name: str, default: str = "") -> str: + return os.getenv(name, default).strip() + + +_storage_root_raw = _env("STORAGE_VOL_PATH") +if not _storage_root_raw: + raise RuntimeError("STORAGE_VOL_PATH must be set") +_STORAGE_ROOT = Path(_storage_root_raw) +TSS_EXTERNAL_OUTBOX_PATH = Path( + _env("TSS_EXTERNAL_OUTBOX_PATH", str(_STORAGE_ROOT / "tss_bridge_outbox.jsonl")) +) +TSS_EXTERNAL_OUTBOX_PATH.parent.mkdir(parents=True, exist_ok=True) + +_LOCK: asyncio.Lock | None = None + + +def _lock() -> asyncio.Lock: + global _LOCK + if _LOCK is None: + _LOCK = asyncio.Lock() + return _LOCK + + +async def _append(envelope: dict[str, Any]) -> None: + line = json.dumps(envelope, ensure_ascii=False, separators=(",", ":")) + async with _lock(): + async with aiofiles.open(TSS_EXTERNAL_OUTBOX_PATH, "a", encoding="utf-8") as handle: + await handle.write(line + "\n") + logger.info("TSS bridge envelope queued type=%s", envelope.get("type")) + + +async def publish_replay_batch(replays: list[dict[str, Any]]) -> None: + if not replays: + return + await _append({ + "type": "tss.replay_batch", "version": 1, "source": "tss", + "sent_at": time.time(), "payload": {"replays": replays}, + }) + + +async def publish_event(event_type: str, payload: dict[str, Any]) -> None: + await _append({ + "type": event_type, "version": 1, "source": "tss", + "sent_at": time.time(), "payload": payload, + }) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..2f4c80e --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +asyncio_mode = auto diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a302103 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,96 @@ +import sqlite3 +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "SHARED")) + +BATTLES_DDL = """ +CREATE TABLE match_summary ( + session_id TEXT PRIMARY KEY, mission_mode TEXT, mission_name TEXT, level_path TEXT, + mission_path TEXT, difficulty TEXT, starttime_unix INTEGER, endtime_unix INTEGER, + duration REAL, draw INTEGER NOT NULL DEFAULT 0, winning_slot TEXT, losing_slot TEXT, + received_unix INTEGER, tournament_id INTEGER, tournament_name TEXT, match_id TEXT, bracket TEXT); +CREATE TABLE player_games_hist ( + UID TEXT NOT NULL, nick TEXT NOT NULL, team_name TEXT, 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, tss_role TEXT, pvp_ratio REAL, + UNIQUE (UID, session_id, vehicle_internal)); +CREATE TABLE match_logs ( + session_id TEXT PRIMARY KEY, chat_log_json TEXT, battle_log_json TEXT, built_unix INTEGER, event_log_json TEXT); +""" + +TOURN_DDL = """ +CREATE TABLE tournaments ( + tournament_id INTEGER PRIMARY KEY, name TEXT, format TEXT, game_mode TEXT, cluster TEXT, + date_start INTEGER, date_end INTEGER, team_count INTEGER NOT NULL DEFAULT 0, + match_count INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'pending', + first_seen_unix INTEGER, scanned_unix INTEGER); +CREATE TABLE tournament_matches ( + tournament_id INTEGER NOT NULL, match_id TEXT NOT NULL, type_bracket TEXT NOT NULL, side TEXT, + round INTEGER, position INTEGER, team_a_uuid TEXT, team_a_name TEXT, team_b_uuid TEXT, team_b_name TEXT, + winner_name TEXT, score_a INTEGER NOT NULL DEFAULT 0, score_b INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending', time_start INTEGER, + PRIMARY KEY (tournament_id, match_id, type_bracket)); +CREATE TABLE tournament_battles ( + session_hex TEXT PRIMARY KEY, session_decimal TEXT, tournament_id INTEGER NOT NULL, match_id TEXT NOT NULL, + type_bracket TEXT NOT NULL, position INTEGER, winner_name TEXT, status_replay TEXT); +CREATE TABLE tournament_standings ( + tournament_id INTEGER NOT NULL, group_index INTEGER NOT NULL DEFAULT 0, team_uuid TEXT NOT NULL, + team_name TEXT, points INTEGER NOT NULL DEFAULT 0, wins INTEGER NOT NULL DEFAULT 0, + draws INTEGER NOT NULL DEFAULT 0, losses INTEGER NOT NULL DEFAULT 0, buchholz REAL NOT NULL DEFAULT 0, + rank INTEGER, PRIMARY KEY (tournament_id, group_index, team_uuid)); +""" + + +def _seed(battles: Path, tourn: Path) -> None: + b = sqlite3.connect(battles); b.executescript(BATTLES_DDL) + b.execute("INSERT INTO match_summary VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ("sess1", "Tournament", "Gladiators 1x1 - Kursk", "levels/kursk.bin", + "gamedata/missions/x.blk", "realistic", 1780937467, 1780937680, 156.84, 0, + "1", "2", 1780937817, 24839, "Cadet 1x1 RB Air", "884571", "single-elim")) + b.execute("INSERT INTO player_games_hist (UID,nick,team_name,team_slot,session_id,vehicle,vehicle_internal,ground_kills,air_kills,assists,captures,deaths,score,country_id,victor_bool,endtime_unix,team_id,tss_role,pvp_ratio) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ("148919027", "Joe", "SunThunder", "1", "sess1", "I-153 M-62", "i-153_m62", + 0, 1, 0, 0, 1, 270, 3, "Win", 1780937680, 1211052, "captain", 1.0)) + b.execute("INSERT INTO player_games_hist (UID,nick,team_name,team_slot,session_id,vehicle,vehicle_internal,deaths,score,victor_bool,endtime_unix,team_id,tss_role) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + ("80080809", "Foe", "RedTeam", "2", "sess1", "I-153 M-62", "i-153_m62", + 2, 90, "Loss", 1780937680, 1211099, "player")) + b.execute("INSERT INTO match_logs VALUES (?,?,?,?,?)", + ("sess1", "[]", '["[01:18] kill"]', 1781776039, '{"kills": []}')) + b.commit(); b.close() + + t = sqlite3.connect(tourn); t.executescript(TOURN_DDL) + t.execute("INSERT INTO tournaments VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + (24839, "Cadet 1x1 RB Air", "single-elim", "RB", "EU", 1780937400, 1780944668, + 63, 63, "finished", 1782016814, 1782016814)) + t.execute("INSERT INTO tournament_matches VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + (24839, "336136", "Swiss", "swiss", "", "", "uuid-a", "BenisPutt", "uuid-b", "roflan", + "BenisPutt", 1, 0, "played", 1782014460)) + t.execute("INSERT INTO tournament_battles VALUES (?,?,?,?,?,?,?,?)", + ("6cbc20b001de1ee", "489698337002217966", 24839, "336136", "Swiss", 0, "BenisPutt", "view replay")) + t.execute("INSERT INTO tournament_standings VALUES (?,?,?,?,?,?,?,?,?,?)", + (24839, 0, "uuid-a", "BenisPutt", 4, 2, 0, 5, 41.0, 1)) + t.commit(); t.close() + + +@pytest.fixture +def client(tmp_path, monkeypatch): + battles = tmp_path / "tss_battles.db" + tourn = tmp_path / "tss_tournaments.db" + _seed(battles, tourn) + monkeypatch.setenv("TSS_API_BATTLES_DB", str(battles)) + monkeypatch.setenv("TSS_API_TOURNAMENTS_DB", str(tourn)) + monkeypatch.setenv("STORAGE_VOL_PATH", str(tmp_path)) + from fastapi.testclient import TestClient + import importlib + import web.api.db as dbmod + importlib.reload(dbmod) + import web.api.app as appmod + importlib.reload(appmod) + return TestClient(appmod.app) diff --git a/tests/test_receiver_bridge.py b/tests/test_receiver_bridge.py new file mode 100644 index 0000000..5b93cd2 --- /dev/null +++ b/tests/test_receiver_bridge.py @@ -0,0 +1,33 @@ +import json +import importlib +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "SHARED")) + + +@pytest.fixture +def bridge(tmp_path, monkeypatch): + monkeypatch.setenv("STORAGE_VOL_PATH", str(tmp_path)) + import BOT.receiver_bridge as rb + importlib.reload(rb) + return rb + + +async def test_publish_replay_batch_writes_tss_envelope(bridge, tmp_path): + await bridge.publish_replay_batch([{"sessionIdHex": "abc", "x": 1}]) + line = (tmp_path / "tss_bridge_outbox.jsonl").read_text(encoding="utf-8").strip() + env = json.loads(line) + assert env["type"] == "tss.replay_batch" + assert env["source"] == "tss" + assert env["version"] == 1 + assert env["payload"]["replays"][0]["sessionIdHex"] == "abc" + assert isinstance(env["sent_at"], float) + + +async def test_publish_replay_batch_empty_is_noop(bridge, tmp_path): + await bridge.publish_replay_batch([]) + assert not (tmp_path / "tss_bridge_outbox.jsonl").exists() diff --git a/tests/test_tss_api_info.py b/tests/test_tss_api_info.py new file mode 100644 index 0000000..5de5276 --- /dev/null +++ b/tests/test_tss_api_info.py @@ -0,0 +1,9 @@ +def test_info_reports_counts(client): + r = client.get("/api/tss/info") + assert r.status_code == 200 + body = r.json() + assert body["service"] == "tss" + assert body["counts"]["matches"] == 1 + assert body["counts"]["player_games"] == 2 + assert body["counts"]["tournaments"] == 1 + assert "/api/tss/player/{uid}" in body["endpoints"] diff --git a/tests/test_tss_api_leaderboard.py b/tests/test_tss_api_leaderboard.py new file mode 100644 index 0000000..a40b60b --- /dev/null +++ b/tests/test_tss_api_leaderboard.py @@ -0,0 +1,24 @@ +def test_leaderboard_requires_filter(client): + r = client.get("/api/tss/leaderboard/players") + assert r.status_code == 400 + assert r.json()["code"] == "FILTER_REQUIRED" + + +def test_leaderboard_players_with_window(client): + r = client.get("/api/tss/leaderboard/players?start_date=1780000000&end_date=1790000000") + assert r.status_code == 200 + rows = r.json()["players"] + assert rows[0]["uid"] == "148919027" # highest score + + +def test_leaderboard_vehicles_with_tournament(client): + r = client.get("/api/tss/leaderboard/vehicles?tournament_id=24839") + assert r.status_code == 200 + assert r.json()["vehicles"][0]["vehicle_internal"] == "i-153_m62" + + +def test_leaderboard_stats(client): + r = client.get("/api/tss/leaderboard/stats?start_date=1780000000&end_date=1790000000") + body = r.json() + assert body["totals"]["battles"] == 2 + assert body["top_vehicles"][0]["vehicle_internal"] == "i-153_m62" diff --git a/tests/test_tss_api_matches.py b/tests/test_tss_api_matches.py new file mode 100644 index 0000000..c9ed88e --- /dev/null +++ b/tests/test_tss_api_matches.py @@ -0,0 +1,35 @@ +def test_live_recent_matches(client): + r = client.get("/api/tss/live?limit=10") + assert r.json()["matches"][0]["session_id"] == "sess1" + + +def test_match_with_rosters(client): + r = client.get("/api/tss/match/sess1") + assert r.status_code == 200 + body = r.json() + assert body["match"]["mission_name"].startswith("Gladiators") + slots = {t["team_slot"] for t in body["teams"]} + assert slots == {"1", "2"} + win = next(t for t in body["teams"] if t["team_slot"] == "1") + assert win["is_winner"] is True + assert win["players"][0]["UID"] == "148919027" + + +def test_match_unknown_404(client): + assert client.get("/api/tss/match/nope").status_code == 404 + + +def test_scoreboard_includes_logs(client): + r = client.get("/api/tss/match/sess1/scoreboard") + assert r.status_code == 200 + assert r.json()["logs"]["available"] is True + + +def test_matches_search_by_player(client): + r = client.get("/api/tss/matches/search?player=148919027") + assert r.json()["matches"][0]["session_id"] == "sess1" + + +def test_maps_distinct(client): + maps = client.get("/api/tss/maps").json()["maps"] + assert any(m["mission_name"].startswith("Gladiators") for m in maps) diff --git a/tests/test_tss_api_players.py b/tests/test_tss_api_players.py new file mode 100644 index 0000000..a339897 --- /dev/null +++ b/tests/test_tss_api_players.py @@ -0,0 +1,37 @@ +def test_player_summary_and_team_history(client): + r = client.get("/api/tss/player/148919027") + assert r.status_code == 200 + body = r.json() + assert body["uid"] == "148919027" + assert body["summary"]["battles"] == 1 + assert body["summary"]["wins"] == 1 + assert body["summary"]["air_kills"] == 1 + assert any(v["vehicle_internal"] == "i-153_m62" for v in body["vehicles"]) + assert any(t["team_name"] == "SunThunder" and t["tss_role"] == "captain" + for t in body["team_history"]) + assert any(n["nick"] == "Joe" for n in body["nicks"]) + + +def test_player_unknown_is_404(client): + assert client.get("/api/tss/player/000").status_code == 404 + + +def test_player_games_rows(client): + r = client.get("/api/tss/player/148919027/games") + assert r.status_code == 200 + rows = r.json()["games"] + assert rows[0]["session_id"] == "sess1" + assert rows[0]["UID"] == "148919027" + + +def test_player_history_daily(client): + r = client.get("/api/tss/player/148919027/history") + body = r.json() + assert body["days_with_battles_only"] is True + assert body["history"][0]["battles"] == 1 + + +def test_search_by_nick(client): + r = client.get("/api/tss/search/Joe") + hits = r.json()["players"] + assert hits[0]["uid"] == "148919027" diff --git a/tests/test_tss_api_tournaments.py b/tests/test_tss_api_tournaments.py new file mode 100644 index 0000000..820772c --- /dev/null +++ b/tests/test_tss_api_tournaments.py @@ -0,0 +1,28 @@ +def test_list_tournaments(client): + r = client.get("/api/tss/tournaments") + assert r.json()["tournaments"][0]["tournament_id"] == 24839 + + +def test_tournament_detail(client): + r = client.get("/api/tss/tournament/24839") + body = r.json() + assert body["tournament"]["name"] == "Cadet 1x1 RB Air" + assert body["standings"][0]["team_name"] == "BenisPutt" + assert len(body["matches"]) == 1 + + +def test_tournament_unknown_404(client): + assert client.get("/api/tss/tournament/999").status_code == 404 + + +def test_tournament_matches_coerce_empty_to_null(client): + r = client.get("/api/tss/tournament/24839/matches") + m = r.json()["matches"][0] + assert m["round"] is None + assert m["position"] is None + assert m["session_ids"] == ["6cbc20b001de1ee"] + + +def test_tournament_standings(client): + r = client.get("/api/tss/tournament/24839/standings") + assert r.json()["standings"][0]["rank"] == 1 diff --git a/tss_ws.py b/tss_ws.py index 05c41e7..21e581f 100644 --- a/tss_ws.py +++ b/tss_ws.py @@ -30,6 +30,7 @@ from websockets.asyncio.client import connect as wsconnect from BOT.storage import insert_match, insert_player_games, upsert_tss_teams from BOT.autologging import process_game as autolog_process_game +from BOT.receiver_bridge import publish_replay_batch from spectra_ws_payload import SpectraPayloadError, decode_spectra_ws_payload _HERE = Path(__file__).resolve().parent @@ -171,6 +172,12 @@ async def _handle_game(game: Dict[str, Any]) -> None: log.info("Stored game %s in DB", sid) except Exception as exc: log.error("DB insert failed for %s: %s", sid, exc) + # Forward the processed game to the relay gateway (tss channel). Best-effort: + # a bridge failure must never break ingest. + try: + await publish_replay_batch([game]) + except Exception as exc: + log.warning("[TSS-BRIDGE] Failed to forward replay batch for %s: %s", sid, exc) # If this game belongs to a tournament we haven't indexed (or one that's live # and stale), scan its authoritative bracket in the background. Never blocks. try: diff --git a/web/api/__init__.py b/web/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/api/app.py b/web/api/app.py new file mode 100644 index 0000000..234f636 --- /dev/null +++ b/web/api/app.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from fastapi import APIRouter, FastAPI, Request +from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException as StarletteHTTPException + +from web.api import db + +ENDPOINTS = [ + "/api/tss/info", "/api/tss/live", "/api/tss/player/{uid}", + "/api/tss/player/{uid}/games", "/api/tss/player/{uid}/history", + "/api/tss/search/{nick}", "/api/tss/match/{session_id}", + "/api/tss/match/{session_id}/scoreboard", "/api/tss/matches/search", + "/api/tss/maps", "/api/tss/leaderboard/players", + "/api/tss/leaderboard/vehicles", "/api/tss/leaderboard/stats", + "/api/tss/tournaments", "/api/tss/tournament/{id}", + "/api/tss/tournament/{id}/standings", "/api/tss/tournament/{id}/matches", +] + +info_router = APIRouter() + + +@info_router.get("/api/tss/info") +async def info() -> dict: + matches = await db.query_one(db.battles_path(), "SELECT COUNT(*) c FROM match_summary") + pg = await db.query_one(db.battles_path(), "SELECT COUNT(*) c FROM player_games_hist") + tn = await db.query_one(db.tournaments_path(), "SELECT COUNT(*) c FROM tournaments") + return { + "service": "tss", + "counts": { + "matches": matches["c"] if matches else 0, + "player_games": pg["c"] if pg else 0, + "tournaments": tn["c"] if tn else 0, + }, + "endpoints": ENDPOINTS, + } + + +def create_app() -> FastAPI: + app = FastAPI(title="TSS HTTP API", version="1.0.0") + + @app.exception_handler(StarletteHTTPException) + async def _err(_: Request, exc: StarletteHTTPException) -> JSONResponse: + detail = exc.detail + body = detail if isinstance(detail, dict) else {"error": str(detail)} + return JSONResponse(status_code=exc.status_code, content=body) + + app.include_router(info_router) + from web.api.routes_players import router as players_router + from web.api.routes_matches import router as matches_router + from web.api.routes_leaderboard import router as lb_router + from web.api.routes_tournaments import router as tour_router + app.include_router(players_router) + app.include_router(matches_router) + app.include_router(lb_router) + app.include_router(tour_router) + return app + + +app = create_app() diff --git a/web/api/db.py b/web/api/db.py new file mode 100644 index 0000000..40563ac --- /dev/null +++ b/web/api/db.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import os +import sqlite3 +import sys +from pathlib import Path +from typing import Any + +import aiosqlite + +# Make TSSBOT root importable for BOT.storage defaults. +_TSSBOT_ROOT = Path(__file__).resolve().parents[2] +if str(_TSSBOT_ROOT) not in sys.path: + sys.path.insert(0, str(_TSSBOT_ROOT)) + + +def battles_path() -> Path: + override = os.getenv("TSS_API_BATTLES_DB", "").strip() + if override: + return Path(override) + from BOT.storage import TSS_BATTLES_DB_PATH + return TSS_BATTLES_DB_PATH + + +def tournaments_path() -> Path: + override = os.getenv("TSS_API_TOURNAMENTS_DB", "").strip() + if override: + return Path(override) + from BOT.storage import STORAGE_DIR + return STORAGE_DIR / "tss_tournaments.db" + + +def _ro_uri(path: Path) -> str: + return f"file:{path}?mode=ro" + + +async def query(path: Path, sql: str, params: tuple[Any, ...] = ()) -> list[dict]: + async with aiosqlite.connect(_ro_uri(path), uri=True) as conn: + conn.row_factory = sqlite3.Row + async with conn.execute(sql, params) as cur: + rows = await cur.fetchall() + return [dict(r) for r in rows] + + +async def query_one(path: Path, sql: str, params: tuple[Any, ...] = ()) -> dict | None: + rows = await query(path, sql, params) + return rows[0] if rows else None diff --git a/web/api/routes_leaderboard.py b/web/api/routes_leaderboard.py new file mode 100644 index 0000000..e721d76 --- /dev/null +++ b/web/api/routes_leaderboard.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from fastapi import APIRouter, HTTPException + +from web.api import db + +router = APIRouter() + + +def _window(start_date, end_date, tournament_id): + """Return (where_sql, params) or raise 400 if no filter given.""" + clauses: list[str] = [] + params: list = [] + if tournament_id is not None: + clauses.append("p.session_id IN (SELECT session_id FROM match_summary WHERE tournament_id = ?)") + params.append(tournament_id) + if start_date is not None: + clauses.append("p.endtime_unix >= ?"); params.append(start_date) + if end_date is not None: + clauses.append("p.endtime_unix <= ?"); params.append(end_date) + if not clauses: + raise HTTPException(status_code=400, detail={ + "error": "a date window (start_date/end_date) or tournament_id is required", + "code": "FILTER_REQUIRED"}) + return "WHERE " + " AND ".join(clauses), params + + +@router.get("/api/tss/leaderboard/players") +async def leaderboard_players(start_date: int | None = None, end_date: int | None = None, + tournament_id: int | None = None, limit: int = 100) -> dict: + where, params = _window(start_date, end_date, tournament_id) + params.append(max(1, min(limit, 500))) + rows = await db.query(db.battles_path(), f""" + SELECT p.UID uid, MAX(p.nick) nick, COUNT(*) battles, + SUM(CASE WHEN p.victor_bool='Win' THEN 1 ELSE 0 END) wins, + SUM(p.air_kills+p.ground_kills) kills, SUM(p.deaths) deaths, SUM(p.score) score + FROM player_games_hist p {where} + GROUP BY p.UID ORDER BY score DESC LIMIT ?""", tuple(params)) + for r in rows: + r["uid"] = str(r["uid"]) + return {"players": rows} + + +@router.get("/api/tss/leaderboard/vehicles") +async def leaderboard_vehicles(start_date: int | None = None, end_date: int | None = None, + tournament_id: int | None = None, limit: int = 100) -> dict: + where, params = _window(start_date, end_date, tournament_id) + params.append(max(1, min(limit, 500))) + rows = await db.query(db.battles_path(), f""" + SELECT p.vehicle_internal, MAX(p.vehicle) vehicle, COUNT(*) battles, + SUM(p.air_kills+p.ground_kills) kills, SUM(p.deaths) deaths, SUM(p.score) score + FROM player_games_hist p {where} + GROUP BY p.vehicle_internal ORDER BY battles DESC LIMIT ?""", tuple(params)) + return {"vehicles": rows} + + +@router.get("/api/tss/leaderboard/stats") +async def leaderboard_stats(start_date: int | None = None, end_date: int | None = None, + tournament_id: int | None = None) -> dict: + where, params = _window(start_date, end_date, tournament_id) + totals = await db.query_one(db.battles_path(), f""" + SELECT COUNT(*) battles, COUNT(DISTINCT p.UID) players, + SUM(p.air_kills+p.ground_kills) kills, SUM(p.score) score + FROM player_games_hist p {where}""", tuple(params)) + top = await db.query(db.battles_path(), f""" + SELECT p.vehicle_internal, MAX(p.vehicle) vehicle, COUNT(*) battles + FROM player_games_hist p {where} + GROUP BY p.vehicle_internal ORDER BY battles DESC LIMIT 10""", tuple(params)) + return {"totals": totals or {}, "top_vehicles": top} diff --git a/web/api/routes_matches.py b/web/api/routes_matches.py new file mode 100644 index 0000000..1be8372 --- /dev/null +++ b/web/api/routes_matches.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import json + +from fastapi import APIRouter, HTTPException + +from web.api import db + +router = APIRouter() + + +@router.get("/api/tss/live") +async def live(limit: int = 50) -> dict: + rows = await db.query(db.battles_path(), + "SELECT * FROM match_summary ORDER BY endtime_unix DESC LIMIT ?", + (max(1, min(limit, 200)),)) + return {"total": len(rows), "matches": rows} + + +def _roster_team(rows: list[dict], slot: str, winning: str, losing: str, draw: int) -> dict: + players = [r for r in rows if (r.get("team_slot") or "") == slot] + for p in players: + p["UID"] = str(p["UID"]) + return { + "team_slot": slot, + "team_name": players[0]["team_name"] if players else None, + "team_id": players[0]["team_id"] if players else None, + "is_winner": (not draw) and slot == winning, + "is_loser": (not draw) and slot == losing, + "players": players, + } + + +@router.get("/api/tss/match/{session_id}") +async def match(session_id: str) -> dict: + summary = await db.query_one(db.battles_path(), + "SELECT * FROM match_summary WHERE session_id = ?", (session_id,)) + if not summary: + raise HTTPException(status_code=404, detail=f"match {session_id} not found") + rows = await db.query(db.battles_path(), + "SELECT * FROM player_games_hist WHERE session_id = ?", (session_id,)) + slots = sorted({(r.get("team_slot") or "") for r in rows if r.get("team_slot")}) + teams = [_roster_team(rows, s, summary["winning_slot"], summary["losing_slot"], + summary["draw"]) for s in slots] + return {"match": summary, "teams": teams} + + +@router.get("/api/tss/match/{session_id}/scoreboard") +async def scoreboard(session_id: str) -> dict: + base = await match(session_id) # reuses 404 + roster logic + logs_row = await db.query_one(db.battles_path(), + "SELECT chat_log_json, battle_log_json, event_log_json FROM match_logs WHERE session_id = ?", + (session_id,)) + if logs_row: + def _parse(v): + try: + return json.loads(v) if v else None + except (json.JSONDecodeError, TypeError): + return None + logs = {"available": True, + "chat": _parse(logs_row["chat_log_json"]), + "battle": _parse(logs_row["battle_log_json"]), + "events": _parse(logs_row["event_log_json"])} + else: + logs = {"available": False} + return {**base, "logs": logs} + + +@router.get("/api/tss/matches/search") +async def matches_search(player: str | None = None, team: str | None = None, + mission: str | None = None, tournament: str | None = None, + time_from: int | None = None, time_to: int | None = None, + limit: int = 50) -> dict: + clauses: list[str] = [] + params: list = [] + join = "" + if player or team: + join = "JOIN player_games_hist p ON p.session_id = m.session_id" + if player: + clauses.append("p.UID = ?"); params.append(player) + if team: + clauses.append("p.team_name = ?"); params.append(team) + if mission: + clauses.append("m.mission_name LIKE ?"); params.append(f"%{mission}%") + if tournament: + clauses.append("m.tournament_name LIKE ?"); params.append(f"%{tournament}%") + if time_from is not None: + clauses.append("m.endtime_unix >= ?"); params.append(time_from) + if time_to is not None: + clauses.append("m.endtime_unix <= ?"); params.append(time_to) + where = ("WHERE " + " AND ".join(clauses)) if clauses else "" + params.append(max(1, min(limit, 200))) + rows = await db.query(db.battles_path(), + f"SELECT DISTINCT m.* FROM match_summary m {join} {where} " + f"ORDER BY m.endtime_unix DESC LIMIT ?", tuple(params)) + return {"total": len(rows), "matches": rows} + + +@router.get("/api/tss/maps") +async def maps() -> dict: + rows = await db.query(db.battles_path(), + "SELECT DISTINCT mission_name, level_path FROM match_summary " + "WHERE mission_name IS NOT NULL ORDER BY mission_name") + return {"total": len(rows), "maps": rows} diff --git a/web/api/routes_players.py b/web/api/routes_players.py new file mode 100644 index 0000000..4169ea6 --- /dev/null +++ b/web/api/routes_players.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from fastapi import APIRouter, HTTPException + +from web.api import db + +router = APIRouter() + + +@router.get("/api/tss/player/{uid}") +async def player(uid: str) -> dict: + bp = db.battles_path() + summary = await db.query_one(bp, """ + SELECT COUNT(*) battles, + SUM(CASE WHEN victor_bool='Win' THEN 1 ELSE 0 END) wins, + SUM(CASE WHEN victor_bool!='Win' THEN 1 ELSE 0 END) losses, + SUM(ground_kills) ground_kills, SUM(air_kills) air_kills, + SUM(assists) assists, SUM(captures) captures, SUM(deaths) deaths, + SUM(score) score, AVG(pvp_ratio) avg_pvp_ratio + FROM player_games_hist WHERE UID = ?""", (uid,)) + if not summary or summary["battles"] == 0: + raise HTTPException(status_code=404, detail=f"player {uid} not found") + vehicles = await db.query(bp, """ + SELECT vehicle, vehicle_internal, COUNT(*) battles, SUM(air_kills+ground_kills) kills, + SUM(deaths) deaths, SUM(score) score + FROM player_games_hist WHERE UID = ? + GROUP BY vehicle_internal ORDER BY battles DESC""", (uid,)) + nicks = await db.query(bp, """ + SELECT nick, MIN(endtime_unix) first_seen, MAX(endtime_unix) last_seen + FROM player_games_hist WHERE UID = ? GROUP BY nick ORDER BY last_seen DESC""", (uid,)) + team_history = await db.query(bp, """ + SELECT team_id, team_name, tss_role, COUNT(*) battles, + MIN(endtime_unix) first_seen, MAX(endtime_unix) last_seen + FROM player_games_hist WHERE UID = ? + GROUP BY team_id, team_name, tss_role ORDER BY last_seen DESC""", (uid,)) + win_rate = round(summary["wins"] / summary["battles"], 4) if summary["battles"] else 0.0 + return {"uid": str(uid), "summary": {**summary, "win_rate": win_rate}, + "vehicles": vehicles, "nicks": nicks, "team_history": team_history} + + +@router.get("/api/tss/player/{uid}/games") +async def player_games(uid: str, limit: int = 100, + time_from: int | None = None, time_to: int | None = None) -> dict: + clauses = ["p.UID = ?"] + params: list = [uid] + if time_from is not None: + clauses.append("p.endtime_unix >= ?"); params.append(time_from) + if time_to is not None: + clauses.append("p.endtime_unix <= ?"); params.append(time_to) + params.append(max(1, min(limit, 1000))) + rows = await db.query(db.battles_path(), f""" + SELECT p.*, m.mission_name, m.tournament_name + FROM player_games_hist p LEFT JOIN match_summary m ON m.session_id = p.session_id + WHERE {' AND '.join(clauses)} ORDER BY p.endtime_unix DESC LIMIT ?""", tuple(params)) + return {"uid": str(uid), "total": len(rows), "games": rows} + + +@router.get("/api/tss/player/{uid}/history") +async def player_history(uid: str) -> dict: + rows = await db.query(db.battles_path(), """ + SELECT date(endtime_unix, 'unixepoch') day, COUNT(*) battles, + SUM(CASE WHEN victor_bool='Win' THEN 1 ELSE 0 END) wins, + SUM(air_kills+ground_kills) kills, SUM(deaths) deaths, SUM(score) score + FROM player_games_hist WHERE UID = ? GROUP BY day ORDER BY day DESC""", (uid,)) + return {"uid": str(uid), "days_with_battles_only": True, "history": rows} + + +@router.get("/api/tss/search/{nick}") +async def search(nick: str, limit: int = 25) -> dict: + rows = await db.query(db.battles_path(), """ + SELECT UID uid, nick, MAX(endtime_unix) last_seen, COUNT(*) battles + FROM player_games_hist WHERE nick LIKE ? COLLATE NOCASE + GROUP BY UID, nick ORDER BY last_seen DESC LIMIT ?""", + (f"%{nick}%", max(1, min(limit, 100)))) + for r in rows: + r["uid"] = str(r["uid"]) + return {"query": nick, "players": rows} diff --git a/web/api/routes_tournaments.py b/web/api/routes_tournaments.py new file mode 100644 index 0000000..c88fae5 --- /dev/null +++ b/web/api/routes_tournaments.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from fastapi import APIRouter, HTTPException + +from web.api import db + +router = APIRouter() + + +def _nullify(row: dict, keys: tuple[str, ...]) -> dict: + for k in keys: + if row.get(k) == "": + row[k] = None + return row + + +@router.get("/api/tss/tournaments") +async def tournaments(status: str | None = None, game_mode: str | None = None, + cluster: str | None = None, limit: int = 100) -> dict: + clauses: list[str] = [] + params: list = [] + if status: + clauses.append("status = ?"); params.append(status) + if game_mode: + clauses.append("game_mode = ?"); params.append(game_mode) + if cluster: + clauses.append("cluster = ?"); params.append(cluster) + where = ("WHERE " + " AND ".join(clauses)) if clauses else "" + params.append(max(1, min(limit, 500))) + rows = await db.query(db.tournaments_path(), + f"SELECT * FROM tournaments {where} ORDER BY date_end DESC LIMIT ?", tuple(params)) + return {"total": len(rows), "tournaments": rows} + + +async def _matches(tid: int) -> list[dict]: + rows = await db.query(db.tournaments_path(), + "SELECT * FROM tournament_matches WHERE tournament_id = ? ORDER BY round, position", + (tid,)) + battles = await db.query(db.tournaments_path(), + "SELECT match_id, type_bracket, session_hex FROM tournament_battles WHERE tournament_id = ?", + (tid,)) + by_match: dict[tuple, list[str]] = {} + for b in battles: + by_match.setdefault((b["match_id"], b["type_bracket"]), []).append(b["session_hex"]) + out = [] + for m in rows: + _nullify(m, ("round", "position")) + m["session_ids"] = by_match.get((m["match_id"], m["type_bracket"]), []) + out.append(m) + return out + + +@router.get("/api/tss/tournament/{tid}") +async def tournament(tid: int) -> dict: + meta = await db.query_one(db.tournaments_path(), + "SELECT * FROM tournaments WHERE tournament_id = ?", (tid,)) + if not meta: + raise HTTPException(status_code=404, detail=f"tournament {tid} not found") + standings = await db.query(db.tournaments_path(), + "SELECT * FROM tournament_standings WHERE tournament_id = ? ORDER BY group_index, rank", + (tid,)) + return {"tournament": meta, "standings": standings, "matches": await _matches(tid)} + + +@router.get("/api/tss/tournament/{tid}/standings") +async def standings(tid: int) -> dict: + rows = await db.query(db.tournaments_path(), + "SELECT * FROM tournament_standings WHERE tournament_id = ? ORDER BY group_index, rank", + (tid,)) + return {"tournament_id": tid, "standings": rows} + + +@router.get("/api/tss/tournament/{tid}/matches") +async def tournament_matches(tid: int) -> dict: + rows = await _matches(tid) + return {"tournament_id": tid, "total": len(rows), "matches": rows} diff --git a/web/main.py b/web/main.py new file mode 100644 index 0000000..f901f58 --- /dev/null +++ b/web/main.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +import os + +import uvicorn + +if __name__ == "__main__": + uvicorn.run( + "web.api.app:app", + host=os.getenv("TSS_API_HOST", "127.0.0.1"), + port=int(os.getenv("TSS_API_PORT", "6100")), + reload=False, + )