327 lines
12 KiB
Python
327 lines
12 KiB
Python
"""Persistence and queries for scoreboard-backed historical player stats."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
from .config import get_storage_path
|
|
|
|
|
|
def initialize_historical_storage(*, db_path: Path | None = None) -> Path:
|
|
"""Ensure the local database contains the historical scoreboard tables."""
|
|
resolved_path = db_path or get_storage_path()
|
|
resolved_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
with _connect(resolved_path) as connection:
|
|
connection.executescript(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS historical_matches (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
external_server_id TEXT NOT NULL,
|
|
source_url TEXT NOT NULL,
|
|
source_match_ref TEXT NOT NULL,
|
|
server_name TEXT NOT NULL,
|
|
started_at TEXT NOT NULL,
|
|
ended_at TEXT,
|
|
duration_seconds INTEGER,
|
|
map_slug TEXT,
|
|
map_name TEXT NOT NULL,
|
|
mode_name TEXT,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE (external_server_id, source_match_ref)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS historical_players (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
source_player_ref TEXT NOT NULL UNIQUE,
|
|
canonical_name TEXT NOT NULL,
|
|
last_seen_name TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS historical_player_match_stats (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
match_id INTEGER NOT NULL,
|
|
player_id INTEGER NOT NULL,
|
|
player_name TEXT NOT NULL,
|
|
kills INTEGER NOT NULL DEFAULT 0,
|
|
deaths INTEGER,
|
|
time_seconds INTEGER,
|
|
captured_at TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE (match_id, player_id),
|
|
FOREIGN KEY (match_id) REFERENCES historical_matches(id),
|
|
FOREIGN KEY (player_id) REFERENCES historical_players(id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_historical_matches_server_started_at
|
|
ON historical_matches(external_server_id, started_at);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_historical_player_match_stats_match_id
|
|
ON historical_player_match_stats(match_id);
|
|
"""
|
|
)
|
|
|
|
return resolved_path
|
|
|
|
|
|
def persist_historical_capture(
|
|
capture: dict[str, object],
|
|
*,
|
|
db_path: Path | None = None,
|
|
) -> dict[str, object]:
|
|
"""Persist one scoreboard capture without duplicating match-player rows."""
|
|
resolved_path = initialize_historical_storage(db_path=db_path)
|
|
match = dict(capture.get("match") or {})
|
|
players = list(capture.get("players") or [])
|
|
|
|
with _connect(resolved_path) as connection:
|
|
match_id = _upsert_match(connection, capture, match)
|
|
persisted_players = 0
|
|
for player in players:
|
|
player_id = _upsert_player(connection, player)
|
|
_upsert_player_match_stats(connection, match_id, player_id, player)
|
|
persisted_players += 1
|
|
|
|
return {
|
|
"db_path": str(resolved_path),
|
|
"external_server_id": capture.get("external_server_id"),
|
|
"source_match_ref": match.get("source_match_ref"),
|
|
"persisted_players": persisted_players,
|
|
}
|
|
|
|
|
|
def list_weekly_top_kills(
|
|
*,
|
|
limit: int = 10,
|
|
server_id: str | None = None,
|
|
now: datetime | None = None,
|
|
db_path: Path | None = None,
|
|
) -> dict[str, object]:
|
|
"""Return weekly aggregated kills grouped by server and player."""
|
|
resolved_path = initialize_historical_storage(db_path=db_path)
|
|
resolved_now = now or datetime.now(timezone.utc)
|
|
window_start = resolved_now - timedelta(days=7)
|
|
|
|
clauses = ["historical_matches.started_at >= ?"]
|
|
params: list[object] = [window_start.isoformat().replace("+00:00", "Z")]
|
|
if server_id:
|
|
clauses.append("historical_matches.external_server_id = ?")
|
|
params.append(server_id)
|
|
|
|
where_clause = " AND ".join(clauses)
|
|
query = f"""
|
|
SELECT
|
|
historical_matches.external_server_id,
|
|
historical_matches.server_name,
|
|
historical_players.source_player_ref AS player_ref,
|
|
historical_players.last_seen_name AS player_name,
|
|
SUM(historical_player_match_stats.kills) AS weekly_kills
|
|
FROM historical_player_match_stats
|
|
INNER JOIN historical_matches
|
|
ON historical_matches.id = historical_player_match_stats.match_id
|
|
INNER JOIN historical_players
|
|
ON historical_players.id = historical_player_match_stats.player_id
|
|
WHERE {where_clause}
|
|
GROUP BY
|
|
historical_matches.external_server_id,
|
|
historical_matches.server_name,
|
|
historical_players.id
|
|
ORDER BY
|
|
historical_matches.external_server_id ASC,
|
|
weekly_kills DESC,
|
|
player_name ASC
|
|
"""
|
|
|
|
with _connect(resolved_path) as connection:
|
|
rows = connection.execute(query, tuple(params)).fetchall()
|
|
|
|
grouped_items: list[dict[str, object]] = []
|
|
current_server_id = None
|
|
current_group: dict[str, object] | None = None
|
|
|
|
for row in rows:
|
|
external_server_id = str(row["external_server_id"])
|
|
if external_server_id != current_server_id:
|
|
current_server_id = external_server_id
|
|
current_group = {
|
|
"external_server_id": external_server_id,
|
|
"server_name": row["server_name"],
|
|
"rankings": [],
|
|
}
|
|
grouped_items.append(current_group)
|
|
|
|
assert current_group is not None
|
|
rankings = current_group["rankings"]
|
|
if len(rankings) >= limit:
|
|
continue
|
|
|
|
rankings.append(
|
|
{
|
|
"rank": len(rankings) + 1,
|
|
"player_id": row["player_ref"],
|
|
"player_name": row["player_name"],
|
|
"weekly_kills": int(row["weekly_kills"] or 0),
|
|
}
|
|
)
|
|
|
|
return {
|
|
"window_start": window_start.isoformat().replace("+00:00", "Z"),
|
|
"window_end": resolved_now.isoformat().replace("+00:00", "Z"),
|
|
"items": grouped_items,
|
|
}
|
|
|
|
|
|
def _connect(db_path: Path) -> sqlite3.Connection:
|
|
connection = sqlite3.connect(db_path)
|
|
connection.row_factory = sqlite3.Row
|
|
return connection
|
|
|
|
|
|
def _upsert_match(
|
|
connection: sqlite3.Connection,
|
|
capture: dict[str, object],
|
|
match: dict[str, object],
|
|
) -> int:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO historical_matches (
|
|
external_server_id,
|
|
source_url,
|
|
source_match_ref,
|
|
server_name,
|
|
started_at,
|
|
ended_at,
|
|
duration_seconds,
|
|
map_slug,
|
|
map_name,
|
|
mode_name
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(external_server_id, source_match_ref) DO UPDATE SET
|
|
server_name = excluded.server_name,
|
|
ended_at = excluded.ended_at,
|
|
duration_seconds = CASE
|
|
WHEN historical_matches.duration_seconds IS NULL THEN excluded.duration_seconds
|
|
WHEN excluded.duration_seconds IS NULL THEN historical_matches.duration_seconds
|
|
WHEN excluded.duration_seconds > historical_matches.duration_seconds
|
|
THEN excluded.duration_seconds
|
|
ELSE historical_matches.duration_seconds
|
|
END,
|
|
map_slug = excluded.map_slug,
|
|
map_name = excluded.map_name,
|
|
mode_name = excluded.mode_name,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
""",
|
|
(
|
|
capture.get("external_server_id"),
|
|
capture.get("scoreboard_base_url"),
|
|
match.get("source_match_ref"),
|
|
match.get("server_name"),
|
|
match.get("started_at"),
|
|
match.get("ended_at"),
|
|
match.get("duration_seconds"),
|
|
match.get("map_slug"),
|
|
match.get("map_name"),
|
|
match.get("mode_name"),
|
|
),
|
|
)
|
|
row = connection.execute(
|
|
"""
|
|
SELECT id
|
|
FROM historical_matches
|
|
WHERE external_server_id = ? AND source_match_ref = ?
|
|
""",
|
|
(capture.get("external_server_id"), match.get("source_match_ref")),
|
|
).fetchone()
|
|
if row is None:
|
|
raise RuntimeError("Failed to resolve historical match id.")
|
|
|
|
return int(row["id"])
|
|
|
|
|
|
def _upsert_player(connection: sqlite3.Connection, player: dict[str, object]) -> int:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO historical_players (
|
|
source_player_ref,
|
|
canonical_name,
|
|
last_seen_name
|
|
) VALUES (?, ?, ?)
|
|
ON CONFLICT(source_player_ref) DO UPDATE SET
|
|
canonical_name = excluded.canonical_name,
|
|
last_seen_name = excluded.last_seen_name,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
""",
|
|
(
|
|
player.get("source_player_ref"),
|
|
player.get("canonical_name"),
|
|
player.get("last_seen_name"),
|
|
),
|
|
)
|
|
row = connection.execute(
|
|
"SELECT id FROM historical_players WHERE source_player_ref = ?",
|
|
(player.get("source_player_ref"),),
|
|
).fetchone()
|
|
if row is None:
|
|
raise RuntimeError("Failed to resolve historical player id.")
|
|
|
|
return int(row["id"])
|
|
|
|
|
|
def _upsert_player_match_stats(
|
|
connection: sqlite3.Connection,
|
|
match_id: int,
|
|
player_id: int,
|
|
player: dict[str, object],
|
|
) -> None:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO historical_player_match_stats (
|
|
match_id,
|
|
player_id,
|
|
player_name,
|
|
kills,
|
|
deaths,
|
|
time_seconds,
|
|
captured_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(match_id, player_id) DO UPDATE SET
|
|
player_name = excluded.player_name,
|
|
kills = CASE
|
|
WHEN excluded.kills > historical_player_match_stats.kills
|
|
THEN excluded.kills
|
|
ELSE historical_player_match_stats.kills
|
|
END,
|
|
deaths = CASE
|
|
WHEN historical_player_match_stats.deaths IS NULL THEN excluded.deaths
|
|
WHEN excluded.deaths IS NULL THEN historical_player_match_stats.deaths
|
|
WHEN excluded.deaths > historical_player_match_stats.deaths
|
|
THEN excluded.deaths
|
|
ELSE historical_player_match_stats.deaths
|
|
END,
|
|
time_seconds = CASE
|
|
WHEN historical_player_match_stats.time_seconds IS NULL THEN excluded.time_seconds
|
|
WHEN excluded.time_seconds IS NULL THEN historical_player_match_stats.time_seconds
|
|
WHEN excluded.time_seconds > historical_player_match_stats.time_seconds
|
|
THEN excluded.time_seconds
|
|
ELSE historical_player_match_stats.time_seconds
|
|
END,
|
|
captured_at = excluded.captured_at,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
""",
|
|
(
|
|
match_id,
|
|
player_id,
|
|
player.get("last_seen_name"),
|
|
player.get("kills"),
|
|
player.get("deaths"),
|
|
player.get("time_seconds"),
|
|
player.get("captured_at"),
|
|
),
|
|
)
|