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:
@@ -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,
|
||||||
|
})
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -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
@@ -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,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user