Auto merge dev → main (#1353)

* feat(gateway): hashed key store with grant + hot reload

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(gateway): channel registry + aiohttp app (keyed auth, whoami, per-channel ws/proxy)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(gateway): manage_keys CLI (add/list/revoke)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(gateway): retire srebot_external, run relay-gateway under PM2

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(gateway): point ecosystem + README at relay-gateway

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(tss): replay outbox producer for relay gateway

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(tss): forward processed games to relay outbox

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(tss-api): db helpers, app skeleton, info endpoint, fixtures

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(tss-api): player, games, history, search endpoints

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(tss-api): live, match, scoreboard, matches-search, maps

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(tss-api): filter-required leaderboards (players/vehicles/stats)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(tss-api): tournament list/detail/standings/matches

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: wire tss upstream through gateway + tssbot-api PM2 app

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
NotSoToothless
2026-06-28 03:38:20 -07:00
committed by GitHub
parent f2a1a33c28
commit 24335a2677
18 changed files with 777 additions and 0 deletions
+60
View File
@@ -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,
})
+2
View File
@@ -0,0 +1,2 @@
[pytest]
asyncio_mode = auto
+96
View File
@@ -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)
+33
View File
@@ -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()
+9
View File
@@ -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"]
+24
View File
@@ -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"
+35
View File
@@ -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)
+37
View File
@@ -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"
+28
View File
@@ -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
+7
View File
@@ -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.storage import insert_match, insert_player_games, upsert_tss_teams
from BOT.autologging import process_game as autolog_process_game from BOT.autologging import process_game as autolog_process_game
from BOT.receiver_bridge import publish_replay_batch
from spectra_ws_payload import SpectraPayloadError, decode_spectra_ws_payload from spectra_ws_payload import SpectraPayloadError, decode_spectra_ws_payload
_HERE = Path(__file__).resolve().parent _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) log.info("Stored game %s in DB", sid)
except Exception as exc: except Exception as exc:
log.error("DB insert failed for %s: %s", sid, exc) log.error("DB insert failed for %s: %s", sid, exc)
# 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 # 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. # and stale), scan its authoritative bracket in the background. Never blocks.
try: try:
View File
+60
View File
@@ -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()
+47
View File
@@ -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
+69
View File
@@ -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}
+104
View File
@@ -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}
+77
View File
@@ -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}
+76
View File
@@ -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}
+13
View File
@@ -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,
)