3326 lines
128 KiB
Python
3326 lines
128 KiB
Python
"""SQLite persistence for historical CRCON scoreboard data."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from typing import Mapping
|
|
|
|
from .config import (
|
|
get_historical_refresh_overlap_hours,
|
|
get_historical_weekly_fallback_max_weekday,
|
|
get_historical_weekly_fallback_min_matches,
|
|
get_storage_path,
|
|
use_postgres_rcon_storage,
|
|
)
|
|
from .historical_models import HistoricalServerDefinition
|
|
from .monthly_mvp import build_monthly_mvp_rankings
|
|
from .monthly_mvp_v2 import build_monthly_mvp_v2_rankings
|
|
from .player_external_profiles import build_external_player_profile_fields
|
|
from .scoreboard_origins import (
|
|
list_trusted_public_scoreboard_origins,
|
|
resolve_trusted_scoreboard_match_url,
|
|
)
|
|
from .sqlite_utils import connect_sqlite_writer
|
|
|
|
|
|
DEFAULT_HISTORICAL_SERVERS = tuple(
|
|
HistoricalServerDefinition(
|
|
slug=origin.slug,
|
|
display_name=origin.display_name,
|
|
scoreboard_base_url=origin.base_url,
|
|
server_number=origin.server_number,
|
|
source_kind=origin.source_kind,
|
|
)
|
|
for origin in list_trusted_public_scoreboard_origins()
|
|
)
|
|
ALL_SERVERS_SLUG = "all-servers"
|
|
ALL_SERVERS_DISPLAY_NAME = "Todos"
|
|
DEFAULT_WEEKLY_WINDOW_DAYS = 7
|
|
SUPPORTED_WEEKLY_LEADERBOARD_METRICS = frozenset(
|
|
{
|
|
"kills",
|
|
"deaths",
|
|
"support",
|
|
"matches_over_100_kills",
|
|
}
|
|
)
|
|
SUPPORTED_MONTHLY_LEADERBOARD_METRICS = SUPPORTED_WEEKLY_LEADERBOARD_METRICS
|
|
|
|
|
|
def initialize_historical_storage(*, db_path: Path | None = None) -> Path:
|
|
"""Create or migrate the local SQLite schema for historical data."""
|
|
resolved_path = db_path or get_storage_path()
|
|
resolved_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
with _connect(resolved_path) as connection:
|
|
legacy_historical_schema = _has_legacy_historical_schema(connection)
|
|
if legacy_historical_schema:
|
|
_rename_legacy_historical_tables(connection)
|
|
connection.executescript(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS historical_servers (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
slug TEXT NOT NULL UNIQUE,
|
|
display_name TEXT NOT NULL,
|
|
scoreboard_base_url TEXT NOT NULL UNIQUE,
|
|
server_number INTEGER,
|
|
source_kind 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_maps (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
external_map_id TEXT UNIQUE,
|
|
map_name TEXT,
|
|
pretty_name TEXT,
|
|
game_mode TEXT,
|
|
image_name TEXT,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS historical_matches (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
historical_server_id INTEGER NOT NULL,
|
|
external_match_id TEXT NOT NULL,
|
|
historical_map_id INTEGER,
|
|
created_at_source TEXT,
|
|
started_at TEXT,
|
|
ended_at TEXT,
|
|
map_name TEXT,
|
|
map_pretty_name TEXT,
|
|
game_mode TEXT,
|
|
image_name TEXT,
|
|
allied_score INTEGER,
|
|
axis_score INTEGER,
|
|
last_seen_at TEXT NOT NULL,
|
|
raw_payload_ref TEXT,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(historical_server_id, external_match_id),
|
|
FOREIGN KEY (historical_server_id) REFERENCES historical_servers(id),
|
|
FOREIGN KEY (historical_map_id) REFERENCES historical_maps(id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS historical_players (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
stable_player_key TEXT NOT NULL UNIQUE,
|
|
display_name TEXT NOT NULL,
|
|
steam_id TEXT,
|
|
source_player_id TEXT,
|
|
first_seen_at TEXT NOT NULL,
|
|
last_seen_at 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,
|
|
historical_match_id INTEGER NOT NULL,
|
|
historical_player_id INTEGER NOT NULL,
|
|
match_player_ref TEXT,
|
|
team_side TEXT,
|
|
level INTEGER,
|
|
kills INTEGER,
|
|
deaths INTEGER,
|
|
teamkills INTEGER,
|
|
time_seconds INTEGER,
|
|
kills_per_minute REAL,
|
|
deaths_per_minute REAL,
|
|
kill_death_ratio REAL,
|
|
combat INTEGER,
|
|
offense INTEGER,
|
|
defense INTEGER,
|
|
support INTEGER,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(historical_match_id, historical_player_id),
|
|
FOREIGN KEY (historical_match_id) REFERENCES historical_matches(id),
|
|
FOREIGN KEY (historical_player_id) REFERENCES historical_players(id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS historical_ingestion_runs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
mode TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
started_at TEXT NOT NULL,
|
|
completed_at TEXT,
|
|
target_server_slug TEXT,
|
|
pages_processed INTEGER NOT NULL DEFAULT 0,
|
|
matches_seen INTEGER NOT NULL DEFAULT 0,
|
|
matches_inserted INTEGER NOT NULL DEFAULT 0,
|
|
matches_updated INTEGER NOT NULL DEFAULT 0,
|
|
player_rows_inserted INTEGER NOT NULL DEFAULT 0,
|
|
player_rows_updated INTEGER NOT NULL DEFAULT 0,
|
|
notes TEXT,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS historical_backfill_progress (
|
|
historical_server_id INTEGER NOT NULL,
|
|
mode TEXT NOT NULL,
|
|
next_page INTEGER NOT NULL DEFAULT 1,
|
|
last_completed_page INTEGER,
|
|
discovered_total_matches INTEGER,
|
|
discovered_total_pages INTEGER,
|
|
archive_exhausted INTEGER NOT NULL DEFAULT 0,
|
|
last_run_id INTEGER,
|
|
last_run_status TEXT,
|
|
last_run_started_at TEXT,
|
|
last_run_completed_at TEXT,
|
|
last_error TEXT,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (historical_server_id, mode),
|
|
FOREIGN KEY (historical_server_id) REFERENCES historical_servers(id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_historical_matches_server_end
|
|
ON historical_matches(historical_server_id, ended_at DESC, started_at DESC);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_historical_player_stats_match
|
|
ON historical_player_match_stats(historical_match_id);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_historical_players_steam
|
|
ON historical_players(steam_id);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_historical_backfill_progress_run
|
|
ON historical_backfill_progress(last_run_id);
|
|
"""
|
|
)
|
|
_seed_default_historical_servers(connection)
|
|
if legacy_historical_schema:
|
|
_migrate_legacy_historical_data(connection)
|
|
_normalize_historical_player_identities(connection)
|
|
_normalize_historical_match_identities(connection)
|
|
|
|
return resolved_path
|
|
|
|
|
|
def list_historical_servers(*, db_path: Path | None = None) -> list[dict[str, object]]:
|
|
"""Return configured CRCON historical sources."""
|
|
resolved_path = initialize_historical_storage(db_path=db_path)
|
|
with _connect(resolved_path) as connection:
|
|
rows = connection.execute(
|
|
"""
|
|
SELECT slug, display_name, scoreboard_base_url, server_number, source_kind
|
|
FROM historical_servers
|
|
ORDER BY slug ASC
|
|
"""
|
|
).fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
|
|
def start_ingestion_run(
|
|
*,
|
|
mode: str,
|
|
target_server_slug: str | None = None,
|
|
db_path: Path | None = None,
|
|
) -> int:
|
|
"""Create a row tracking one ingestion execution."""
|
|
resolved_path = initialize_historical_storage(db_path=db_path)
|
|
with _connect(resolved_path) as connection:
|
|
cursor = connection.execute(
|
|
"""
|
|
INSERT INTO historical_ingestion_runs (
|
|
mode,
|
|
status,
|
|
started_at,
|
|
target_server_slug
|
|
) VALUES (?, 'running', ?, ?)
|
|
""",
|
|
(mode, _utc_now_iso(), target_server_slug),
|
|
)
|
|
return int(cursor.lastrowid)
|
|
|
|
|
|
def finalize_ingestion_run(
|
|
run_id: int,
|
|
*,
|
|
status: str,
|
|
pages_processed: int,
|
|
matches_seen: int,
|
|
matches_inserted: int,
|
|
matches_updated: int,
|
|
player_rows_inserted: int,
|
|
player_rows_updated: int,
|
|
notes: str | None = None,
|
|
db_path: Path | None = None,
|
|
) -> None:
|
|
"""Update an ingestion run row with outcome metrics."""
|
|
resolved_path = initialize_historical_storage(db_path=db_path)
|
|
with _connect(resolved_path) as connection:
|
|
connection.execute(
|
|
"""
|
|
UPDATE historical_ingestion_runs
|
|
SET status = ?,
|
|
completed_at = ?,
|
|
pages_processed = ?,
|
|
matches_seen = ?,
|
|
matches_inserted = ?,
|
|
matches_updated = ?,
|
|
player_rows_inserted = ?,
|
|
player_rows_updated = ?,
|
|
notes = ?
|
|
WHERE id = ?
|
|
""",
|
|
(
|
|
status,
|
|
_utc_now_iso(),
|
|
pages_processed,
|
|
matches_seen,
|
|
matches_inserted,
|
|
matches_updated,
|
|
player_rows_inserted,
|
|
player_rows_updated,
|
|
notes,
|
|
run_id,
|
|
),
|
|
)
|
|
|
|
|
|
def mark_backfill_progress_started(
|
|
*,
|
|
server_slug: str,
|
|
mode: str,
|
|
run_id: int,
|
|
db_path: Path | None = None,
|
|
) -> None:
|
|
"""Persist the start of one resumable historical backfill attempt."""
|
|
resolved_path = initialize_historical_storage(db_path=db_path)
|
|
with _connect(resolved_path) as connection:
|
|
server_row = _resolve_historical_server(connection, server_slug)
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO historical_backfill_progress (
|
|
historical_server_id,
|
|
mode,
|
|
next_page,
|
|
archive_exhausted,
|
|
last_run_id,
|
|
last_run_status,
|
|
last_run_started_at,
|
|
last_run_completed_at,
|
|
last_error
|
|
) VALUES (?, ?, 1, 0, ?, 'running', ?, NULL, NULL)
|
|
ON CONFLICT(historical_server_id, mode) DO UPDATE SET
|
|
last_run_id = excluded.last_run_id,
|
|
last_run_status = excluded.last_run_status,
|
|
last_run_started_at = excluded.last_run_started_at,
|
|
last_run_completed_at = NULL,
|
|
last_error = NULL,
|
|
archive_exhausted = CASE
|
|
WHEN excluded.mode = 'bootstrap' THEN 0
|
|
ELSE historical_backfill_progress.archive_exhausted
|
|
END,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
""",
|
|
(server_row["id"], mode, run_id, _utc_now_iso()),
|
|
)
|
|
|
|
|
|
def mark_backfill_progress_page_completed(
|
|
*,
|
|
server_slug: str,
|
|
mode: str,
|
|
page_number: int,
|
|
page_size: int,
|
|
run_id: int,
|
|
discovered_total_matches: int | None,
|
|
db_path: Path | None = None,
|
|
) -> None:
|
|
"""Persist the latest completed page so bootstraps can resume safely."""
|
|
resolved_path = initialize_historical_storage(db_path=db_path)
|
|
discovered_total_pages = None
|
|
if discovered_total_matches and page_size > 0:
|
|
discovered_total_pages = (discovered_total_matches + page_size - 1) // page_size
|
|
|
|
with _connect(resolved_path) as connection:
|
|
server_row = _resolve_historical_server(connection, server_slug)
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO historical_backfill_progress (
|
|
historical_server_id,
|
|
mode,
|
|
next_page,
|
|
last_completed_page,
|
|
discovered_total_matches,
|
|
discovered_total_pages,
|
|
archive_exhausted,
|
|
last_run_id,
|
|
last_run_status,
|
|
last_run_started_at,
|
|
last_run_completed_at,
|
|
last_error
|
|
) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, NULL, NULL)
|
|
ON CONFLICT(historical_server_id, mode) DO UPDATE SET
|
|
next_page = excluded.next_page,
|
|
last_completed_page = excluded.last_completed_page,
|
|
discovered_total_matches = COALESCE(
|
|
excluded.discovered_total_matches,
|
|
historical_backfill_progress.discovered_total_matches
|
|
),
|
|
discovered_total_pages = COALESCE(
|
|
excluded.discovered_total_pages,
|
|
historical_backfill_progress.discovered_total_pages
|
|
),
|
|
archive_exhausted = 0,
|
|
last_run_id = excluded.last_run_id,
|
|
last_run_status = excluded.last_run_status,
|
|
last_run_started_at = excluded.last_run_started_at,
|
|
last_run_completed_at = NULL,
|
|
last_error = NULL,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
""",
|
|
(
|
|
server_row["id"],
|
|
mode,
|
|
page_number + 1,
|
|
page_number,
|
|
discovered_total_matches,
|
|
discovered_total_pages,
|
|
run_id,
|
|
"running",
|
|
_utc_now_iso(),
|
|
),
|
|
)
|
|
|
|
|
|
def finalize_backfill_progress(
|
|
*,
|
|
server_slug: str,
|
|
mode: str,
|
|
run_id: int,
|
|
status: str,
|
|
archive_exhausted: bool = False,
|
|
error_message: str | None = None,
|
|
db_path: Path | None = None,
|
|
) -> None:
|
|
"""Persist the final state of one resumable historical backfill attempt."""
|
|
resolved_path = initialize_historical_storage(db_path=db_path)
|
|
with _connect(resolved_path) as connection:
|
|
server_row = _resolve_historical_server(connection, server_slug)
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO historical_backfill_progress (
|
|
historical_server_id,
|
|
mode,
|
|
next_page,
|
|
archive_exhausted,
|
|
last_run_id,
|
|
last_run_status,
|
|
last_run_started_at,
|
|
last_run_completed_at,
|
|
last_error
|
|
) VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(historical_server_id, mode) DO UPDATE SET
|
|
archive_exhausted = CASE
|
|
WHEN excluded.last_run_status = 'success' AND excluded.archive_exhausted = 1
|
|
THEN 1
|
|
WHEN excluded.last_run_status = 'success'
|
|
THEN historical_backfill_progress.archive_exhausted
|
|
ELSE historical_backfill_progress.archive_exhausted
|
|
END,
|
|
last_run_id = excluded.last_run_id,
|
|
last_run_status = excluded.last_run_status,
|
|
last_run_started_at = COALESCE(
|
|
historical_backfill_progress.last_run_started_at,
|
|
excluded.last_run_started_at
|
|
),
|
|
last_run_completed_at = excluded.last_run_completed_at,
|
|
last_error = excluded.last_error,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
""",
|
|
(
|
|
server_row["id"],
|
|
mode,
|
|
1 if archive_exhausted else 0,
|
|
run_id,
|
|
status,
|
|
_utc_now_iso(),
|
|
_utc_now_iso(),
|
|
error_message,
|
|
),
|
|
)
|
|
|
|
|
|
def get_backfill_resume_page(
|
|
server_slug: str,
|
|
*,
|
|
mode: str = "bootstrap",
|
|
db_path: Path | None = None,
|
|
) -> int:
|
|
"""Return the next page recorded for one resumable historical backfill."""
|
|
resolved_path = initialize_historical_storage(db_path=db_path)
|
|
with _connect(resolved_path) as connection:
|
|
server_row = _resolve_historical_server(connection, server_slug)
|
|
row = connection.execute(
|
|
"""
|
|
SELECT next_page
|
|
FROM historical_backfill_progress
|
|
WHERE historical_server_id = ? AND mode = ?
|
|
""",
|
|
(server_row["id"], mode),
|
|
).fetchone()
|
|
return max(1, int(row["next_page"])) if row and row["next_page"] else 1
|
|
|
|
|
|
def list_historical_backfill_progress(
|
|
*,
|
|
server_slug: str | None = None,
|
|
mode: str = "bootstrap",
|
|
db_path: Path | None = None,
|
|
) -> list[dict[str, object]]:
|
|
"""Return persisted resume checkpoints and last run state per server."""
|
|
resolved_path = initialize_historical_storage(db_path=db_path)
|
|
where_clause = ""
|
|
params: list[object] = [mode]
|
|
if server_slug:
|
|
where_clause = "WHERE historical_servers.slug = ?"
|
|
params.append(server_slug)
|
|
|
|
with _connect(resolved_path) as connection:
|
|
rows = connection.execute(
|
|
f"""
|
|
SELECT
|
|
historical_servers.slug AS server_slug,
|
|
historical_servers.display_name AS server_name,
|
|
progress.mode AS mode,
|
|
progress.next_page AS next_page,
|
|
progress.last_completed_page AS last_completed_page,
|
|
progress.discovered_total_matches AS discovered_total_matches,
|
|
progress.discovered_total_pages AS discovered_total_pages,
|
|
progress.archive_exhausted AS archive_exhausted,
|
|
progress.last_run_id AS last_run_id,
|
|
progress.last_run_status AS last_run_status,
|
|
progress.last_run_started_at AS last_run_started_at,
|
|
progress.last_run_completed_at AS last_run_completed_at,
|
|
progress.last_error AS last_error
|
|
FROM historical_servers
|
|
LEFT JOIN historical_backfill_progress AS progress
|
|
ON progress.historical_server_id = historical_servers.id
|
|
AND progress.mode = ?
|
|
{where_clause}
|
|
ORDER BY historical_servers.server_number ASC, historical_servers.slug ASC
|
|
""",
|
|
params,
|
|
).fetchall()
|
|
|
|
items: list[dict[str, object]] = []
|
|
for row in rows:
|
|
items.append(
|
|
{
|
|
"server": {
|
|
"slug": row["server_slug"],
|
|
"name": row["server_name"],
|
|
},
|
|
"mode": row["mode"] or mode,
|
|
"next_page": int(row["next_page"] or 1),
|
|
"last_completed_page": _coerce_int(row["last_completed_page"]),
|
|
"discovered_total_matches": _coerce_int(row["discovered_total_matches"]),
|
|
"discovered_total_pages": _coerce_int(row["discovered_total_pages"]),
|
|
"archive_exhausted": bool(row["archive_exhausted"]),
|
|
"last_run": {
|
|
"run_id": _coerce_int(row["last_run_id"]),
|
|
"status": _stringify(row["last_run_status"]),
|
|
"started_at": _stringify(row["last_run_started_at"]),
|
|
"completed_at": _stringify(row["last_run_completed_at"]),
|
|
"error": _stringify(row["last_error"]),
|
|
},
|
|
}
|
|
)
|
|
return items
|
|
|
|
|
|
def upsert_historical_match(
|
|
*,
|
|
server_slug: str,
|
|
match_payload: Mapping[str, object],
|
|
db_path: Path | None = None,
|
|
) -> dict[str, int]:
|
|
"""Persist one historical match and its player stats idempotently."""
|
|
resolved_path = initialize_historical_storage(db_path=db_path)
|
|
match_external_id = _stringify(match_payload.get("id"))
|
|
if not match_external_id:
|
|
raise ValueError("Historical match payload is missing a stable id.")
|
|
|
|
with _connect(resolved_path) as connection:
|
|
server_row = _resolve_historical_server(connection, server_slug)
|
|
map_id = _upsert_historical_map(connection, match_payload)
|
|
match_row = connection.execute(
|
|
"""
|
|
SELECT id
|
|
FROM historical_matches
|
|
WHERE historical_server_id = ? AND external_match_id = ?
|
|
""",
|
|
(server_row["id"], match_external_id),
|
|
).fetchone()
|
|
match_exists = match_row is not None
|
|
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO historical_matches (
|
|
historical_server_id,
|
|
external_match_id,
|
|
historical_map_id,
|
|
created_at_source,
|
|
started_at,
|
|
ended_at,
|
|
map_name,
|
|
map_pretty_name,
|
|
game_mode,
|
|
image_name,
|
|
allied_score,
|
|
axis_score,
|
|
last_seen_at,
|
|
raw_payload_ref
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(historical_server_id, external_match_id) DO UPDATE SET
|
|
historical_map_id = excluded.historical_map_id,
|
|
created_at_source = excluded.created_at_source,
|
|
started_at = excluded.started_at,
|
|
ended_at = excluded.ended_at,
|
|
map_name = excluded.map_name,
|
|
map_pretty_name = excluded.map_pretty_name,
|
|
game_mode = excluded.game_mode,
|
|
image_name = excluded.image_name,
|
|
allied_score = excluded.allied_score,
|
|
axis_score = excluded.axis_score,
|
|
last_seen_at = excluded.last_seen_at,
|
|
raw_payload_ref = excluded.raw_payload_ref,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
""",
|
|
(
|
|
server_row["id"],
|
|
match_external_id,
|
|
map_id,
|
|
_normalize_timestamp(match_payload.get("creation_time")),
|
|
_normalize_timestamp(match_payload.get("start")),
|
|
_normalize_timestamp(match_payload.get("end")),
|
|
_extract_map_name(match_payload),
|
|
_extract_map_pretty_name(match_payload),
|
|
_extract_map_game_mode(match_payload),
|
|
_extract_map_image_name(match_payload),
|
|
_coerce_int(_get_nested(match_payload, "result", "allied")),
|
|
_coerce_int(_get_nested(match_payload, "result", "axis")),
|
|
_utc_now_iso(),
|
|
f"{server_row['scoreboard_base_url']}/games/{match_external_id}",
|
|
),
|
|
)
|
|
match_id_row = connection.execute(
|
|
"""
|
|
SELECT id
|
|
FROM historical_matches
|
|
WHERE historical_server_id = ? AND external_match_id = ?
|
|
""",
|
|
(server_row["id"], match_external_id),
|
|
).fetchone()
|
|
if match_id_row is None:
|
|
raise RuntimeError("Failed to persist historical match.")
|
|
|
|
player_rows_inserted = 0
|
|
player_rows_updated = 0
|
|
for player_payload in _coerce_list(match_payload.get("player_stats")):
|
|
player_id = _upsert_historical_player(connection, player_payload)
|
|
stat_exists = connection.execute(
|
|
"""
|
|
SELECT id
|
|
FROM historical_player_match_stats
|
|
WHERE historical_match_id = ? AND historical_player_id = ?
|
|
""",
|
|
(match_id_row["id"], player_id),
|
|
).fetchone()
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO historical_player_match_stats (
|
|
historical_match_id,
|
|
historical_player_id,
|
|
match_player_ref,
|
|
team_side,
|
|
level,
|
|
kills,
|
|
deaths,
|
|
teamkills,
|
|
time_seconds,
|
|
kills_per_minute,
|
|
deaths_per_minute,
|
|
kill_death_ratio,
|
|
combat,
|
|
offense,
|
|
defense,
|
|
support
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(historical_match_id, historical_player_id) DO UPDATE SET
|
|
match_player_ref = excluded.match_player_ref,
|
|
team_side = excluded.team_side,
|
|
level = excluded.level,
|
|
kills = excluded.kills,
|
|
deaths = excluded.deaths,
|
|
teamkills = excluded.teamkills,
|
|
time_seconds = excluded.time_seconds,
|
|
kills_per_minute = excluded.kills_per_minute,
|
|
deaths_per_minute = excluded.deaths_per_minute,
|
|
kill_death_ratio = excluded.kill_death_ratio,
|
|
combat = excluded.combat,
|
|
offense = excluded.offense,
|
|
defense = excluded.defense,
|
|
support = excluded.support,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
""",
|
|
(
|
|
match_id_row["id"],
|
|
player_id,
|
|
_stringify(player_payload.get("id")),
|
|
_stringify(_get_nested(player_payload, "team", "side")),
|
|
_coerce_int(player_payload.get("level")),
|
|
_coerce_int(player_payload.get("kills")),
|
|
_coerce_int(player_payload.get("deaths")),
|
|
_coerce_int(player_payload.get("teamkills")),
|
|
_coerce_int(player_payload.get("time_seconds")),
|
|
_coerce_float(player_payload.get("kills_per_minute")),
|
|
_coerce_float(player_payload.get("deaths_per_minute")),
|
|
_coerce_float(player_payload.get("kill_death_ratio")),
|
|
_coerce_int(player_payload.get("combat")),
|
|
_coerce_int(player_payload.get("offense")),
|
|
_coerce_int(player_payload.get("defense")),
|
|
_coerce_int(player_payload.get("support")),
|
|
),
|
|
)
|
|
if stat_exists is None:
|
|
player_rows_inserted += 1
|
|
else:
|
|
player_rows_updated += 1
|
|
|
|
return {
|
|
"matches_inserted": 0 if match_exists else 1,
|
|
"matches_updated": 1 if match_exists else 0,
|
|
"player_rows_inserted": player_rows_inserted,
|
|
"player_rows_updated": player_rows_updated,
|
|
}
|
|
|
|
|
|
def get_refresh_cutoff_for_server(
|
|
server_slug: str,
|
|
*,
|
|
overlap_hours: int | None = None,
|
|
db_path: Path | None = None,
|
|
) -> str | None:
|
|
"""Return the timestamp used to stop incremental scans once older pages appear."""
|
|
resolved_overlap_hours = (
|
|
get_historical_refresh_overlap_hours()
|
|
if overlap_hours is None
|
|
else overlap_hours
|
|
)
|
|
if resolved_overlap_hours < 0:
|
|
raise ValueError("overlap_hours must be zero or positive.")
|
|
resolved_path = initialize_historical_storage(db_path=db_path)
|
|
with _connect(resolved_path) as connection:
|
|
server_row = _resolve_historical_server(connection, server_slug)
|
|
row = connection.execute(
|
|
"""
|
|
SELECT COALESCE(MAX(ended_at), MAX(started_at), MAX(created_at_source)) AS latest_seen_at
|
|
FROM historical_matches
|
|
WHERE historical_server_id = ?
|
|
""",
|
|
(server_row["id"],),
|
|
).fetchone()
|
|
latest_seen_at = _stringify(row["latest_seen_at"] if row else None)
|
|
if not latest_seen_at:
|
|
return None
|
|
|
|
cutoff = _parse_timestamp(latest_seen_at) - timedelta(hours=resolved_overlap_hours)
|
|
return cutoff.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
def list_recent_historical_matches(
|
|
*,
|
|
server_slug: str | None = None,
|
|
limit: int = 20,
|
|
db_path: Path | None = None,
|
|
) -> list[dict[str, object]]:
|
|
"""Return recent persisted matches grouped for the historical API layer."""
|
|
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
|
from .postgres_display_storage import list_recent_scoreboard_matches
|
|
|
|
return list_recent_scoreboard_matches(server_slug=server_slug, limit=limit)
|
|
resolved_path = initialize_historical_storage(db_path=db_path)
|
|
where_clause = ""
|
|
params: list[object] = []
|
|
if server_slug and not _is_all_servers_selector(server_slug):
|
|
where_clause = "WHERE historical_servers.slug = ?"
|
|
params.append(server_slug)
|
|
params.append(limit)
|
|
|
|
with _connect(resolved_path) as connection:
|
|
rows = connection.execute(
|
|
f"""
|
|
SELECT
|
|
historical_servers.slug AS server_slug,
|
|
historical_servers.display_name AS server_name,
|
|
historical_matches.external_match_id,
|
|
historical_matches.started_at,
|
|
historical_matches.ended_at,
|
|
historical_matches.map_pretty_name,
|
|
historical_matches.map_name,
|
|
historical_matches.allied_score,
|
|
historical_matches.axis_score,
|
|
historical_matches.raw_payload_ref,
|
|
historical_servers.slug,
|
|
historical_servers.scoreboard_base_url,
|
|
COUNT(historical_player_match_stats.id) AS player_count
|
|
FROM historical_matches
|
|
INNER JOIN historical_servers
|
|
ON historical_servers.id = historical_matches.historical_server_id
|
|
LEFT JOIN historical_player_match_stats
|
|
ON historical_player_match_stats.historical_match_id = historical_matches.id
|
|
{where_clause}
|
|
GROUP BY historical_matches.id
|
|
ORDER BY COALESCE(historical_matches.ended_at, historical_matches.started_at) DESC
|
|
LIMIT ?
|
|
""",
|
|
params,
|
|
).fetchall()
|
|
items: list[dict[str, object]] = []
|
|
for row in rows:
|
|
items.append(
|
|
{
|
|
"server": {
|
|
"slug": row["server_slug"],
|
|
"name": row["server_name"],
|
|
},
|
|
"match_id": row["external_match_id"],
|
|
"started_at": row["started_at"],
|
|
"ended_at": row["ended_at"],
|
|
"closed_at": row["ended_at"] or row["started_at"],
|
|
"map": {
|
|
"name": row["map_name"],
|
|
"pretty_name": row["map_pretty_name"] or row["map_name"],
|
|
},
|
|
"result": {
|
|
"allied_score": _coerce_int(row["allied_score"]),
|
|
"axis_score": _coerce_int(row["axis_score"]),
|
|
"winner": _resolve_match_winner(
|
|
row["allied_score"],
|
|
row["axis_score"],
|
|
),
|
|
},
|
|
"player_count": int(row["player_count"] or 0),
|
|
"match_url": _resolve_safe_match_url(
|
|
row["raw_payload_ref"],
|
|
row["server_slug"],
|
|
),
|
|
}
|
|
)
|
|
return items
|
|
|
|
|
|
def get_historical_match_detail(
|
|
*,
|
|
server_slug: str,
|
|
match_id: str,
|
|
db_path: Path | None = None,
|
|
) -> dict[str, object] | None:
|
|
"""Return one persisted public-scoreboard match detail for the historical API layer."""
|
|
normalized_server_slug = _stringify(server_slug)
|
|
normalized_match_id = _stringify(match_id)
|
|
if not normalized_server_slug or not normalized_match_id:
|
|
return None
|
|
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
|
from .postgres_display_storage import get_scoreboard_match_detail
|
|
|
|
return get_scoreboard_match_detail(
|
|
server_slug=normalized_server_slug,
|
|
match_id=normalized_match_id,
|
|
)
|
|
resolved_path = initialize_historical_storage(db_path=db_path)
|
|
with _connect(resolved_path) as connection:
|
|
row = connection.execute(
|
|
"""
|
|
SELECT
|
|
historical_matches.id AS match_pk,
|
|
historical_servers.slug AS server_slug,
|
|
historical_servers.display_name AS server_name,
|
|
historical_matches.external_match_id,
|
|
historical_matches.started_at,
|
|
historical_matches.ended_at,
|
|
historical_matches.map_pretty_name,
|
|
historical_matches.map_name,
|
|
historical_matches.allied_score,
|
|
historical_matches.axis_score,
|
|
historical_matches.raw_payload_ref,
|
|
historical_servers.slug,
|
|
historical_servers.scoreboard_base_url,
|
|
COUNT(historical_player_match_stats.id) AS player_count,
|
|
SUM(COALESCE(historical_player_match_stats.time_seconds, 0)) AS total_time_seconds
|
|
FROM historical_matches
|
|
INNER JOIN historical_servers
|
|
ON historical_servers.id = historical_matches.historical_server_id
|
|
LEFT JOIN historical_player_match_stats
|
|
ON historical_player_match_stats.historical_match_id = historical_matches.id
|
|
WHERE historical_servers.slug = ?
|
|
AND historical_matches.external_match_id = ?
|
|
GROUP BY historical_matches.id
|
|
LIMIT 1
|
|
""",
|
|
(normalized_server_slug, normalized_match_id),
|
|
).fetchone()
|
|
player_rows = []
|
|
if row is not None:
|
|
player_rows = connection.execute(
|
|
"""
|
|
SELECT
|
|
historical_players.display_name,
|
|
historical_players.stable_player_key,
|
|
historical_players.steam_id,
|
|
historical_player_match_stats.team_side,
|
|
historical_player_match_stats.level,
|
|
historical_player_match_stats.kills,
|
|
historical_player_match_stats.deaths,
|
|
historical_player_match_stats.teamkills,
|
|
historical_player_match_stats.combat,
|
|
historical_player_match_stats.offense,
|
|
historical_player_match_stats.defense,
|
|
historical_player_match_stats.support,
|
|
historical_player_match_stats.time_seconds
|
|
FROM historical_player_match_stats
|
|
INNER JOIN historical_players
|
|
ON historical_players.id = historical_player_match_stats.historical_player_id
|
|
WHERE historical_player_match_stats.historical_match_id = ?
|
|
ORDER BY
|
|
COALESCE(historical_player_match_stats.kills, 0) DESC,
|
|
historical_players.display_name ASC
|
|
""",
|
|
(row["match_pk"],),
|
|
).fetchall()
|
|
if row is None:
|
|
return None
|
|
started_at = row["started_at"]
|
|
ended_at = row["ended_at"]
|
|
return {
|
|
"server": {
|
|
"slug": row["server_slug"],
|
|
"name": row["server_name"],
|
|
},
|
|
"match_id": row["external_match_id"],
|
|
"started_at": started_at,
|
|
"ended_at": ended_at,
|
|
"closed_at": ended_at or started_at,
|
|
"duration_seconds": _calculate_match_duration_seconds(started_at, ended_at),
|
|
"map": {
|
|
"name": row["map_name"],
|
|
"pretty_name": row["map_pretty_name"] or row["map_name"],
|
|
},
|
|
"result": {
|
|
"allied_score": _coerce_int(row["allied_score"]),
|
|
"axis_score": _coerce_int(row["axis_score"]),
|
|
"winner": _resolve_match_winner(
|
|
row["allied_score"],
|
|
row["axis_score"],
|
|
),
|
|
},
|
|
"player_count": int(row["player_count"] or 0),
|
|
"total_time_seconds": _coerce_int(row["total_time_seconds"]),
|
|
"players": [
|
|
{
|
|
"name": player_row["display_name"],
|
|
"stable_player_key": player_row["stable_player_key"],
|
|
"team_side": player_row["team_side"],
|
|
**build_external_player_profile_fields(steam_id=player_row["steam_id"]),
|
|
"level": _coerce_int(player_row["level"]),
|
|
"kills": _coerce_int(player_row["kills"]),
|
|
"deaths": _coerce_int(player_row["deaths"]),
|
|
"teamkills": _coerce_int(player_row["teamkills"]),
|
|
"combat": _coerce_int(player_row["combat"]),
|
|
"offense": _coerce_int(player_row["offense"]),
|
|
"defense": _coerce_int(player_row["defense"]),
|
|
"support": _coerce_int(player_row["support"]),
|
|
"time_seconds": _coerce_int(player_row["time_seconds"]),
|
|
}
|
|
for player_row in player_rows
|
|
],
|
|
"capture_basis": "public-scoreboard-match",
|
|
"match_url": _resolve_safe_match_url(
|
|
row["raw_payload_ref"],
|
|
row["server_slug"],
|
|
),
|
|
}
|
|
|
|
|
|
def list_historical_server_summaries(
|
|
*,
|
|
server_slug: str | None = None,
|
|
db_path: Path | None = None,
|
|
) -> list[dict[str, object]]:
|
|
"""Return aggregate historical metrics per server."""
|
|
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
|
from .postgres_display_storage import list_scoreboard_server_summaries
|
|
|
|
return list_scoreboard_server_summaries(server_slug=server_slug)
|
|
resolved_path = initialize_historical_storage(db_path=db_path)
|
|
if _is_all_servers_selector(server_slug):
|
|
return [_build_all_servers_summary(db_path=resolved_path)]
|
|
|
|
where_clause = ""
|
|
params: list[object] = []
|
|
if server_slug:
|
|
where_clause = "WHERE historical_servers.slug = ?"
|
|
params.append(server_slug)
|
|
|
|
with _connect(resolved_path) as connection:
|
|
summary_rows = connection.execute(
|
|
f"""
|
|
SELECT
|
|
historical_servers.slug AS server_slug,
|
|
historical_servers.display_name AS server_name,
|
|
COUNT(DISTINCT historical_matches.id) AS matches_count,
|
|
COUNT(DISTINCT historical_players.id) AS unique_players,
|
|
COALESCE(SUM(historical_player_match_stats.kills), 0) AS total_kills,
|
|
COUNT(DISTINCT COALESCE(historical_matches.map_pretty_name, historical_matches.map_name)) AS map_count,
|
|
MIN(COALESCE(historical_matches.ended_at, historical_matches.started_at, historical_matches.created_at_source)) AS first_match_at,
|
|
MAX(COALESCE(historical_matches.ended_at, historical_matches.started_at, historical_matches.created_at_source)) AS last_match_at
|
|
FROM historical_servers
|
|
LEFT JOIN historical_matches
|
|
ON historical_matches.historical_server_id = historical_servers.id
|
|
LEFT JOIN historical_player_match_stats
|
|
ON historical_player_match_stats.historical_match_id = historical_matches.id
|
|
LEFT JOIN historical_players
|
|
ON historical_players.id = historical_player_match_stats.historical_player_id
|
|
{where_clause}
|
|
GROUP BY historical_servers.id
|
|
ORDER BY historical_servers.server_number ASC, historical_servers.slug ASC
|
|
""",
|
|
params,
|
|
).fetchall()
|
|
|
|
map_rows = connection.execute(
|
|
f"""
|
|
SELECT
|
|
historical_servers.slug AS server_slug,
|
|
COALESCE(historical_matches.map_pretty_name, historical_matches.map_name, 'Mapa no disponible') AS map_name,
|
|
COUNT(*) AS matches_count
|
|
FROM historical_matches
|
|
INNER JOIN historical_servers
|
|
ON historical_servers.id = historical_matches.historical_server_id
|
|
{where_clause}
|
|
GROUP BY historical_servers.slug, COALESCE(historical_matches.map_pretty_name, historical_matches.map_name, 'Mapa no disponible')
|
|
ORDER BY historical_servers.slug ASC, matches_count DESC, map_name ASC
|
|
""",
|
|
params,
|
|
).fetchall()
|
|
|
|
progress_by_server = {
|
|
item["server"]["slug"]: item
|
|
for item in list_historical_backfill_progress(
|
|
server_slug=server_slug,
|
|
db_path=resolved_path,
|
|
)
|
|
}
|
|
top_maps_by_server: dict[str, list[dict[str, object]]] = {}
|
|
for row in map_rows:
|
|
server_key = str(row["server_slug"])
|
|
top_maps_by_server.setdefault(server_key, [])
|
|
if len(top_maps_by_server[server_key]) >= 3:
|
|
continue
|
|
top_maps_by_server[server_key].append(
|
|
{
|
|
"map_name": row["map_name"],
|
|
"matches_count": int(row["matches_count"] or 0),
|
|
}
|
|
)
|
|
|
|
items: list[dict[str, object]] = []
|
|
for row in summary_rows:
|
|
matches_count = int(row["matches_count"] or 0)
|
|
first_match_at = _stringify(row["first_match_at"])
|
|
last_match_at = _stringify(row["last_match_at"])
|
|
coverage_days = _calculate_coverage_days(first_match_at, last_match_at)
|
|
progress = progress_by_server.get(str(row["server_slug"]), {})
|
|
discovered_total_matches = _coerce_int(progress.get("discovered_total_matches"))
|
|
items.append(
|
|
{
|
|
"server": {
|
|
"slug": row["server_slug"],
|
|
"name": row["server_name"],
|
|
},
|
|
"matches_count": matches_count,
|
|
"imported_matches_count": matches_count,
|
|
"unique_players": int(row["unique_players"] or 0),
|
|
"total_kills": int(row["total_kills"] or 0),
|
|
"map_count": int(row["map_count"] or 0),
|
|
"top_maps": top_maps_by_server.get(str(row["server_slug"]), []),
|
|
"coverage": {
|
|
"basis": "persisted-import",
|
|
"status": _classify_coverage_status(matches_count, coverage_days),
|
|
"imported_matches_count": matches_count,
|
|
"discovered_total_matches": discovered_total_matches,
|
|
"first_match_at": first_match_at,
|
|
"last_match_at": last_match_at,
|
|
"coverage_days": coverage_days,
|
|
},
|
|
"backfill": {
|
|
"mode": progress.get("mode", "bootstrap"),
|
|
"next_page": _coerce_int(progress.get("next_page")) or 1,
|
|
"last_completed_page": _coerce_int(progress.get("last_completed_page")),
|
|
"discovered_total_matches": discovered_total_matches,
|
|
"discovered_total_pages": _coerce_int(progress.get("discovered_total_pages")),
|
|
"remaining_matches_estimate": (
|
|
max(discovered_total_matches - matches_count, 0)
|
|
if discovered_total_matches is not None
|
|
else None
|
|
),
|
|
"archive_exhausted": bool(progress.get("archive_exhausted")),
|
|
"last_run": progress.get("last_run"),
|
|
},
|
|
"time_range": {
|
|
"start": first_match_at,
|
|
"end": last_match_at,
|
|
},
|
|
}
|
|
)
|
|
return items
|
|
|
|
|
|
def list_historical_coverage_report(
|
|
*,
|
|
server_slug: str | None = None,
|
|
db_path: Path | None = None,
|
|
) -> list[dict[str, object]]:
|
|
"""Return persisted coverage metrics used to validate historical bootstrap depth."""
|
|
resolved_path = initialize_historical_storage(db_path=db_path)
|
|
where_clause = ""
|
|
params: list[object] = []
|
|
if server_slug:
|
|
where_clause = "WHERE historical_servers.slug = ?"
|
|
params.append(server_slug)
|
|
|
|
with _connect(resolved_path) as connection:
|
|
rows = connection.execute(
|
|
f"""
|
|
SELECT
|
|
historical_servers.slug AS server_slug,
|
|
historical_servers.display_name AS server_name,
|
|
historical_servers.scoreboard_base_url AS scoreboard_base_url,
|
|
historical_servers.server_number AS server_number,
|
|
COUNT(DISTINCT historical_matches.id) AS imported_matches_count,
|
|
COUNT(DISTINCT historical_players.id) AS unique_players,
|
|
COUNT(DISTINCT historical_player_match_stats.id) AS player_stat_rows,
|
|
MIN(COALESCE(historical_matches.ended_at, historical_matches.started_at, historical_matches.created_at_source)) AS first_match_at,
|
|
MAX(COALESCE(historical_matches.ended_at, historical_matches.started_at, historical_matches.created_at_source)) AS last_match_at
|
|
FROM historical_servers
|
|
LEFT JOIN historical_matches
|
|
ON historical_matches.historical_server_id = historical_servers.id
|
|
LEFT JOIN historical_player_match_stats
|
|
ON historical_player_match_stats.historical_match_id = historical_matches.id
|
|
LEFT JOIN historical_players
|
|
ON historical_players.id = historical_player_match_stats.historical_player_id
|
|
{where_clause}
|
|
GROUP BY historical_servers.id
|
|
ORDER BY historical_servers.server_number ASC, historical_servers.slug ASC
|
|
""",
|
|
params,
|
|
).fetchall()
|
|
|
|
items: list[dict[str, object]] = []
|
|
progress_by_server = {
|
|
item["server"]["slug"]: item
|
|
for item in list_historical_backfill_progress(
|
|
server_slug=server_slug,
|
|
db_path=resolved_path,
|
|
)
|
|
}
|
|
for row in rows:
|
|
first_match_at = _stringify(row["first_match_at"])
|
|
last_match_at = _stringify(row["last_match_at"])
|
|
progress = progress_by_server.get(str(row["server_slug"]), {})
|
|
items.append(
|
|
{
|
|
"server": {
|
|
"slug": row["server_slug"],
|
|
"name": row["server_name"],
|
|
"server_number": row["server_number"],
|
|
"scoreboard_base_url": row["scoreboard_base_url"],
|
|
},
|
|
"imported_matches_count": int(row["imported_matches_count"] or 0),
|
|
"unique_players": int(row["unique_players"] or 0),
|
|
"player_stat_rows": int(row["player_stat_rows"] or 0),
|
|
"first_match_at": first_match_at,
|
|
"last_match_at": last_match_at,
|
|
"coverage_days": _calculate_coverage_days(first_match_at, last_match_at),
|
|
"backfill": {
|
|
"next_page": _coerce_int(progress.get("next_page")) or 1,
|
|
"last_completed_page": _coerce_int(progress.get("last_completed_page")),
|
|
"discovered_total_matches": _coerce_int(
|
|
progress.get("discovered_total_matches")
|
|
),
|
|
"discovered_total_pages": _coerce_int(
|
|
progress.get("discovered_total_pages")
|
|
),
|
|
"archive_exhausted": bool(progress.get("archive_exhausted")),
|
|
"last_run": progress.get("last_run"),
|
|
},
|
|
}
|
|
)
|
|
return items
|
|
|
|
|
|
def get_historical_player_profile(
|
|
player_id: str,
|
|
*,
|
|
db_path: Path | None = None,
|
|
) -> dict[str, object] | None:
|
|
"""Return aggregate historical metrics for one player identity."""
|
|
resolved_player_id = player_id.strip()
|
|
if not resolved_player_id:
|
|
return None
|
|
|
|
resolved_path = initialize_historical_storage(db_path=db_path)
|
|
with _connect(resolved_path) as connection:
|
|
player_row = connection.execute(
|
|
"""
|
|
SELECT
|
|
historical_players.id,
|
|
historical_players.stable_player_key,
|
|
historical_players.display_name,
|
|
historical_players.steam_id,
|
|
historical_players.source_player_id,
|
|
COUNT(DISTINCT historical_matches.id) AS matches_count,
|
|
COALESCE(SUM(historical_player_match_stats.kills), 0) AS total_kills,
|
|
MIN(COALESCE(historical_matches.ended_at, historical_matches.started_at, historical_matches.created_at_source)) AS first_match_at,
|
|
MAX(COALESCE(historical_matches.ended_at, historical_matches.started_at, historical_matches.created_at_source)) AS last_match_at
|
|
FROM historical_players
|
|
LEFT JOIN historical_player_match_stats
|
|
ON historical_player_match_stats.historical_player_id = historical_players.id
|
|
LEFT JOIN historical_matches
|
|
ON historical_matches.id = historical_player_match_stats.historical_match_id
|
|
WHERE historical_players.stable_player_key = ?
|
|
OR historical_players.steam_id = ?
|
|
OR historical_players.source_player_id = ?
|
|
GROUP BY historical_players.id
|
|
ORDER BY historical_players.display_name ASC
|
|
LIMIT 1
|
|
""",
|
|
(resolved_player_id, resolved_player_id, resolved_player_id),
|
|
).fetchone()
|
|
if player_row is None:
|
|
return None
|
|
|
|
server_rows = connection.execute(
|
|
"""
|
|
SELECT
|
|
historical_servers.slug AS server_slug,
|
|
historical_servers.display_name AS server_name,
|
|
COUNT(DISTINCT historical_matches.id) AS matches_count,
|
|
COALESCE(SUM(historical_player_match_stats.kills), 0) AS total_kills,
|
|
MIN(COALESCE(historical_matches.ended_at, historical_matches.started_at, historical_matches.created_at_source)) AS first_match_at,
|
|
MAX(COALESCE(historical_matches.ended_at, historical_matches.started_at, historical_matches.created_at_source)) AS last_match_at
|
|
FROM historical_player_match_stats
|
|
INNER JOIN historical_matches
|
|
ON historical_matches.id = historical_player_match_stats.historical_match_id
|
|
INNER JOIN historical_servers
|
|
ON historical_servers.id = historical_matches.historical_server_id
|
|
WHERE historical_player_match_stats.historical_player_id = ?
|
|
GROUP BY historical_servers.id
|
|
ORDER BY total_kills DESC, historical_servers.server_number ASC, historical_servers.slug ASC
|
|
""",
|
|
(player_row["id"],),
|
|
).fetchall()
|
|
|
|
return {
|
|
"player": {
|
|
"stable_player_key": player_row["stable_player_key"],
|
|
"name": player_row["display_name"],
|
|
"steam_id": player_row["steam_id"],
|
|
"source_player_id": player_row["source_player_id"],
|
|
},
|
|
"matches_count": int(player_row["matches_count"] or 0),
|
|
"total_kills": int(player_row["total_kills"] or 0),
|
|
"time_range": {
|
|
"start": player_row["first_match_at"],
|
|
"end": player_row["last_match_at"],
|
|
},
|
|
"servers": [
|
|
{
|
|
"server": {
|
|
"slug": row["server_slug"],
|
|
"name": row["server_name"],
|
|
},
|
|
"matches_count": int(row["matches_count"] or 0),
|
|
"total_kills": int(row["total_kills"] or 0),
|
|
"time_range": {
|
|
"start": row["first_match_at"],
|
|
"end": row["last_match_at"],
|
|
},
|
|
}
|
|
for row in server_rows
|
|
],
|
|
}
|
|
|
|
|
|
def list_weekly_leaderboard(
|
|
*,
|
|
limit: int = 10,
|
|
server_id: str | None = None,
|
|
metric: str = "kills",
|
|
db_path: Path | None = None,
|
|
) -> dict[str, object]:
|
|
"""Return ranked weekly leaderboard totals from persisted historical match stats."""
|
|
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
|
from .postgres_display_storage import list_scoreboard_leaderboard
|
|
|
|
return list_scoreboard_leaderboard(
|
|
timeframe="weekly",
|
|
metric=metric,
|
|
server_id=server_id,
|
|
limit=limit,
|
|
)
|
|
resolved_path = initialize_historical_storage(db_path=db_path)
|
|
aggregate_all_servers = _is_all_servers_selector(server_id)
|
|
current_time = datetime.now(timezone.utc)
|
|
current_week_start = _start_of_week(current_time)
|
|
previous_week_start = current_week_start - timedelta(days=DEFAULT_WEEKLY_WINDOW_DAYS)
|
|
normalized_metric = metric.strip() if isinstance(metric, str) else ""
|
|
if normalized_metric not in SUPPORTED_WEEKLY_LEADERBOARD_METRICS:
|
|
raise ValueError(f"Unsupported weekly leaderboard metric: {metric}")
|
|
|
|
weekly_window = _select_weekly_window(
|
|
server_id=server_id,
|
|
current_time=current_time,
|
|
current_week_start=current_week_start,
|
|
previous_week_start=previous_week_start,
|
|
db_path=resolved_path,
|
|
)
|
|
window_start = weekly_window["window_start"]
|
|
window_end = weekly_window["window_end"]
|
|
where_clauses = [
|
|
"historical_matches.ended_at IS NOT NULL",
|
|
"historical_matches.ended_at >= ?",
|
|
"historical_matches.ended_at < ?",
|
|
]
|
|
params: list[object] = [
|
|
window_start.isoformat().replace("+00:00", "Z"),
|
|
window_end.isoformat().replace("+00:00", "Z"),
|
|
]
|
|
if server_id and not aggregate_all_servers:
|
|
normalized_server_id = server_id.strip()
|
|
where_clauses.append(
|
|
"(historical_servers.slug = ? OR CAST(historical_servers.server_number AS TEXT) = ?)"
|
|
)
|
|
params.extend([normalized_server_id, normalized_server_id])
|
|
|
|
server_slug_expression = (
|
|
f"'{ALL_SERVERS_SLUG}'"
|
|
if aggregate_all_servers
|
|
else "historical_servers.slug"
|
|
)
|
|
server_name_expression = (
|
|
f"'{ALL_SERVERS_DISPLAY_NAME}'"
|
|
if aggregate_all_servers
|
|
else "historical_servers.display_name"
|
|
)
|
|
partition_expression = (
|
|
f"'{ALL_SERVERS_SLUG}'"
|
|
if aggregate_all_servers
|
|
else "historical_servers.slug"
|
|
)
|
|
group_by_expression = (
|
|
"historical_players.id"
|
|
if aggregate_all_servers
|
|
else "historical_servers.slug, historical_players.id"
|
|
)
|
|
|
|
metric_sum_expression = {
|
|
"kills": "COALESCE(SUM(historical_player_match_stats.kills), 0)",
|
|
"deaths": "COALESCE(SUM(historical_player_match_stats.deaths), 0)",
|
|
"support": "COALESCE(SUM(historical_player_match_stats.support), 0)",
|
|
"matches_over_100_kills": (
|
|
"COALESCE(SUM(CASE WHEN COALESCE(historical_player_match_stats.kills, 0) >= 100 "
|
|
"THEN 1 ELSE 0 END), 0)"
|
|
),
|
|
}[normalized_metric]
|
|
|
|
with _connect(resolved_path) as connection:
|
|
rows = connection.execute(
|
|
f"""
|
|
WITH ranked_players AS (
|
|
SELECT
|
|
{server_slug_expression} AS server_slug,
|
|
{server_name_expression} AS server_name,
|
|
historical_players.stable_player_key,
|
|
historical_players.display_name AS player_name,
|
|
historical_players.steam_id,
|
|
COUNT(DISTINCT historical_matches.id) AS matches_count,
|
|
{metric_sum_expression} AS metric_value,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY {partition_expression}
|
|
ORDER BY
|
|
{metric_sum_expression} DESC,
|
|
COUNT(DISTINCT historical_matches.id) ASC,
|
|
historical_players.display_name ASC
|
|
) AS ranking_position
|
|
FROM historical_player_match_stats
|
|
INNER JOIN historical_matches
|
|
ON historical_matches.id = historical_player_match_stats.historical_match_id
|
|
INNER JOIN historical_servers
|
|
ON historical_servers.id = historical_matches.historical_server_id
|
|
INNER JOIN historical_players
|
|
ON historical_players.id = historical_player_match_stats.historical_player_id
|
|
WHERE {" AND ".join(where_clauses)}
|
|
GROUP BY {group_by_expression}
|
|
)
|
|
SELECT *
|
|
FROM ranked_players
|
|
WHERE ranking_position <= ?
|
|
ORDER BY server_slug ASC, ranking_position ASC
|
|
""",
|
|
[*params, limit],
|
|
).fetchall()
|
|
|
|
items: list[dict[str, object]] = []
|
|
for row in rows:
|
|
items.append(
|
|
{
|
|
"server": {
|
|
"slug": row["server_slug"],
|
|
"name": row["server_name"],
|
|
},
|
|
"time_range": {
|
|
"start": window_start.isoformat().replace("+00:00", "Z"),
|
|
"end": window_end.isoformat().replace("+00:00", "Z"),
|
|
"window_days": DEFAULT_WEEKLY_WINDOW_DAYS,
|
|
},
|
|
"player": {
|
|
"stable_player_key": row["stable_player_key"],
|
|
"name": row["player_name"],
|
|
"steam_id": row["steam_id"],
|
|
},
|
|
"metric": normalized_metric,
|
|
"ranking_position": int(row["ranking_position"]),
|
|
"metric_value": int(row["metric_value"] or 0),
|
|
"matches_considered": int(row["matches_count"] or 0),
|
|
}
|
|
)
|
|
|
|
return {
|
|
"metric": normalized_metric,
|
|
"window_start": window_start.isoformat().replace("+00:00", "Z"),
|
|
"window_end": window_end.isoformat().replace("+00:00", "Z"),
|
|
"window_days": DEFAULT_WEEKLY_WINDOW_DAYS,
|
|
"window_kind": weekly_window["window_kind"],
|
|
"window_label": weekly_window["window_label"],
|
|
"uses_fallback": weekly_window["uses_fallback"],
|
|
"selection_reason": weekly_window["selection_reason"],
|
|
"current_week_start": current_week_start.isoformat().replace("+00:00", "Z"),
|
|
"current_week_closed_matches": weekly_window["current_week_closed_matches"],
|
|
"previous_week_closed_matches": weekly_window["previous_week_closed_matches"],
|
|
"sufficient_sample": {
|
|
"minimum_closed_matches": weekly_window["minimum_closed_matches"],
|
|
"current_week_closed_matches": weekly_window["current_week_closed_matches"],
|
|
"current_week_has_sufficient_sample": weekly_window["current_week_has_sufficient_sample"],
|
|
"is_early_week": weekly_window["is_early_week"],
|
|
"fallback_max_weekday": weekly_window["fallback_max_weekday"],
|
|
},
|
|
"items": items,
|
|
}
|
|
|
|
|
|
def list_weekly_top_kills(
|
|
*,
|
|
limit: int = 10,
|
|
server_id: str | None = None,
|
|
db_path: Path | None = None,
|
|
) -> dict[str, object]:
|
|
"""Return ranked weekly kill totals from persisted historical match stats."""
|
|
result = list_weekly_leaderboard(
|
|
limit=limit,
|
|
server_id=server_id,
|
|
metric="kills",
|
|
db_path=db_path,
|
|
)
|
|
items = []
|
|
for item in result["items"]:
|
|
legacy_item = dict(item)
|
|
legacy_item["weekly_kills"] = legacy_item["metric_value"]
|
|
items.append(legacy_item)
|
|
|
|
return {
|
|
"metric": "kills",
|
|
"window_start": result["window_start"],
|
|
"window_end": result["window_end"],
|
|
"items": items,
|
|
}
|
|
|
|
|
|
def list_monthly_leaderboard(
|
|
*,
|
|
limit: int = 10,
|
|
server_id: str | None = None,
|
|
metric: str = "kills",
|
|
db_path: Path | None = None,
|
|
) -> dict[str, object]:
|
|
"""Return ranked monthly leaderboard totals from persisted historical match stats."""
|
|
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
|
from .postgres_display_storage import list_scoreboard_leaderboard
|
|
|
|
return list_scoreboard_leaderboard(
|
|
timeframe="monthly",
|
|
metric=metric,
|
|
server_id=server_id,
|
|
limit=limit,
|
|
)
|
|
resolved_path = initialize_historical_storage(db_path=db_path)
|
|
aggregate_all_servers = _is_all_servers_selector(server_id)
|
|
current_time = datetime.now(timezone.utc)
|
|
current_month_start = _start_of_month(current_time)
|
|
previous_month_start = _start_of_previous_month(current_month_start)
|
|
normalized_metric = metric.strip() if isinstance(metric, str) else ""
|
|
if normalized_metric not in SUPPORTED_MONTHLY_LEADERBOARD_METRICS:
|
|
raise ValueError(f"Unsupported monthly leaderboard metric: {metric}")
|
|
|
|
monthly_window = _select_monthly_window(
|
|
server_id=server_id,
|
|
current_time=current_time,
|
|
current_month_start=current_month_start,
|
|
previous_month_start=previous_month_start,
|
|
db_path=resolved_path,
|
|
)
|
|
window_start = monthly_window["window_start"]
|
|
window_end = monthly_window["window_end"]
|
|
where_clauses = [
|
|
"historical_matches.ended_at IS NOT NULL",
|
|
"historical_matches.ended_at >= ?",
|
|
"historical_matches.ended_at < ?",
|
|
]
|
|
params: list[object] = [
|
|
window_start.isoformat().replace("+00:00", "Z"),
|
|
window_end.isoformat().replace("+00:00", "Z"),
|
|
]
|
|
if server_id and not aggregate_all_servers:
|
|
normalized_server_id = server_id.strip()
|
|
where_clauses.append(
|
|
"(historical_servers.slug = ? OR CAST(historical_servers.server_number AS TEXT) = ?)"
|
|
)
|
|
params.extend([normalized_server_id, normalized_server_id])
|
|
|
|
server_slug_expression = (
|
|
f"'{ALL_SERVERS_SLUG}'"
|
|
if aggregate_all_servers
|
|
else "historical_servers.slug"
|
|
)
|
|
server_name_expression = (
|
|
f"'{ALL_SERVERS_DISPLAY_NAME}'"
|
|
if aggregate_all_servers
|
|
else "historical_servers.display_name"
|
|
)
|
|
partition_expression = (
|
|
f"'{ALL_SERVERS_SLUG}'"
|
|
if aggregate_all_servers
|
|
else "historical_servers.slug"
|
|
)
|
|
group_by_expression = (
|
|
"historical_players.id"
|
|
if aggregate_all_servers
|
|
else "historical_servers.slug, historical_players.id"
|
|
)
|
|
|
|
metric_sum_expression = {
|
|
"kills": "COALESCE(SUM(historical_player_match_stats.kills), 0)",
|
|
"deaths": "COALESCE(SUM(historical_player_match_stats.deaths), 0)",
|
|
"support": "COALESCE(SUM(historical_player_match_stats.support), 0)",
|
|
"matches_over_100_kills": (
|
|
"COALESCE(SUM(CASE WHEN COALESCE(historical_player_match_stats.kills, 0) >= 100 "
|
|
"THEN 1 ELSE 0 END), 0)"
|
|
),
|
|
}[normalized_metric]
|
|
|
|
with _connect(resolved_path) as connection:
|
|
rows = connection.execute(
|
|
f"""
|
|
WITH ranked_players AS (
|
|
SELECT
|
|
{server_slug_expression} AS server_slug,
|
|
{server_name_expression} AS server_name,
|
|
historical_players.stable_player_key,
|
|
historical_players.display_name AS player_name,
|
|
historical_players.steam_id,
|
|
COUNT(DISTINCT historical_matches.id) AS matches_count,
|
|
{metric_sum_expression} AS metric_value,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY {partition_expression}
|
|
ORDER BY
|
|
{metric_sum_expression} DESC,
|
|
COUNT(DISTINCT historical_matches.id) ASC,
|
|
historical_players.display_name ASC
|
|
) AS ranking_position
|
|
FROM historical_player_match_stats
|
|
INNER JOIN historical_matches
|
|
ON historical_matches.id = historical_player_match_stats.historical_match_id
|
|
INNER JOIN historical_servers
|
|
ON historical_servers.id = historical_matches.historical_server_id
|
|
INNER JOIN historical_players
|
|
ON historical_players.id = historical_player_match_stats.historical_player_id
|
|
WHERE {" AND ".join(where_clauses)}
|
|
GROUP BY {group_by_expression}
|
|
)
|
|
SELECT *
|
|
FROM ranked_players
|
|
WHERE ranking_position <= ?
|
|
ORDER BY server_slug ASC, ranking_position ASC
|
|
""",
|
|
[*params, limit],
|
|
).fetchall()
|
|
|
|
window_days = _calculate_window_days(window_start=window_start, window_end=window_end)
|
|
items: list[dict[str, object]] = []
|
|
for row in rows:
|
|
items.append(
|
|
{
|
|
"server": {
|
|
"slug": row["server_slug"],
|
|
"name": row["server_name"],
|
|
},
|
|
"time_range": {
|
|
"start": window_start.isoformat().replace("+00:00", "Z"),
|
|
"end": window_end.isoformat().replace("+00:00", "Z"),
|
|
"window_days": window_days,
|
|
},
|
|
"player": {
|
|
"stable_player_key": row["stable_player_key"],
|
|
"name": row["player_name"],
|
|
"steam_id": row["steam_id"],
|
|
},
|
|
"metric": normalized_metric,
|
|
"ranking_position": int(row["ranking_position"]),
|
|
"metric_value": int(row["metric_value"] or 0),
|
|
"matches_considered": int(row["matches_count"] or 0),
|
|
}
|
|
)
|
|
|
|
return {
|
|
"timeframe": "monthly",
|
|
"metric": normalized_metric,
|
|
"window_start": window_start.isoformat().replace("+00:00", "Z"),
|
|
"window_end": window_end.isoformat().replace("+00:00", "Z"),
|
|
"window_days": window_days,
|
|
"window_kind": monthly_window["window_kind"],
|
|
"window_label": monthly_window["window_label"],
|
|
"uses_fallback": monthly_window["uses_fallback"],
|
|
"selection_reason": monthly_window["selection_reason"],
|
|
"current_month_start": current_month_start.isoformat().replace("+00:00", "Z"),
|
|
"current_month_closed_matches": monthly_window["current_month_closed_matches"],
|
|
"previous_month_closed_matches": monthly_window["previous_month_closed_matches"],
|
|
"sufficient_sample": {
|
|
"minimum_closed_matches": monthly_window["minimum_closed_matches"],
|
|
"current_month_closed_matches": monthly_window["current_month_closed_matches"],
|
|
"current_month_has_sufficient_sample": monthly_window["current_month_has_sufficient_sample"],
|
|
"is_early_month": monthly_window["is_early_month"],
|
|
},
|
|
"items": items,
|
|
}
|
|
|
|
|
|
def list_monthly_mvp_ranking(
|
|
*,
|
|
limit: int = 10,
|
|
server_id: str | None = None,
|
|
db_path: Path | None = None,
|
|
) -> dict[str, object]:
|
|
"""Return the monthly MVP V1 ranking built from persisted historical totals."""
|
|
resolved_path = initialize_historical_storage(db_path=db_path)
|
|
aggregate_all_servers = _is_all_servers_selector(server_id)
|
|
current_time = datetime.now(timezone.utc)
|
|
current_month_start = _start_of_month(current_time)
|
|
previous_month_start = _start_of_previous_month(current_month_start)
|
|
monthly_window = _select_monthly_window(
|
|
server_id=server_id,
|
|
current_time=current_time,
|
|
current_month_start=current_month_start,
|
|
previous_month_start=previous_month_start,
|
|
db_path=resolved_path,
|
|
)
|
|
window_start = monthly_window["window_start"]
|
|
window_end = monthly_window["window_end"]
|
|
where_clauses = [
|
|
"historical_matches.ended_at IS NOT NULL",
|
|
"historical_matches.ended_at >= ?",
|
|
"historical_matches.ended_at < ?",
|
|
]
|
|
params: list[object] = [
|
|
window_start.isoformat().replace("+00:00", "Z"),
|
|
window_end.isoformat().replace("+00:00", "Z"),
|
|
]
|
|
if server_id and not aggregate_all_servers:
|
|
normalized_server_id = server_id.strip()
|
|
where_clauses.append(
|
|
"(historical_servers.slug = ? OR CAST(historical_servers.server_number AS TEXT) = ?)"
|
|
)
|
|
params.extend([normalized_server_id, normalized_server_id])
|
|
|
|
server_slug_expression = (
|
|
f"'{ALL_SERVERS_SLUG}'"
|
|
if aggregate_all_servers
|
|
else "historical_servers.slug"
|
|
)
|
|
server_name_expression = (
|
|
f"'{ALL_SERVERS_DISPLAY_NAME}'"
|
|
if aggregate_all_servers
|
|
else "historical_servers.display_name"
|
|
)
|
|
group_by_expression = (
|
|
"historical_players.id"
|
|
if aggregate_all_servers
|
|
else "historical_servers.slug, historical_players.id"
|
|
)
|
|
|
|
with _connect(resolved_path) as connection:
|
|
rows = connection.execute(
|
|
f"""
|
|
SELECT
|
|
{server_slug_expression} AS server_slug,
|
|
{server_name_expression} AS server_name,
|
|
historical_players.stable_player_key,
|
|
historical_players.display_name AS player_name,
|
|
historical_players.steam_id,
|
|
COUNT(DISTINCT historical_matches.id) AS matches_count,
|
|
COALESCE(SUM(historical_player_match_stats.kills), 0) AS total_kills,
|
|
COALESCE(SUM(historical_player_match_stats.deaths), 0) AS total_deaths,
|
|
COALESCE(SUM(historical_player_match_stats.support), 0) AS total_support,
|
|
COALESCE(SUM(historical_player_match_stats.teamkills), 0) AS total_teamkills,
|
|
COALESCE(SUM(historical_player_match_stats.time_seconds), 0) AS total_time_seconds
|
|
FROM historical_player_match_stats
|
|
INNER JOIN historical_matches
|
|
ON historical_matches.id = historical_player_match_stats.historical_match_id
|
|
INNER JOIN historical_servers
|
|
ON historical_servers.id = historical_matches.historical_server_id
|
|
INNER JOIN historical_players
|
|
ON historical_players.id = historical_player_match_stats.historical_player_id
|
|
WHERE {" AND ".join(where_clauses)}
|
|
GROUP BY {group_by_expression}
|
|
""",
|
|
params,
|
|
).fetchall()
|
|
|
|
ranking_result = build_monthly_mvp_rankings(
|
|
[dict(row) for row in rows],
|
|
limit=limit,
|
|
)
|
|
window_days = _calculate_window_days(window_start=window_start, window_end=window_end)
|
|
for item in ranking_result["items"]:
|
|
item["time_range"] = {
|
|
"start": window_start.isoformat().replace("+00:00", "Z"),
|
|
"end": window_end.isoformat().replace("+00:00", "Z"),
|
|
"window_days": window_days,
|
|
}
|
|
|
|
return {
|
|
"timeframe": "monthly",
|
|
"metric": "mvp",
|
|
"ranking_version": ranking_result["ranking_version"],
|
|
"window_start": window_start.isoformat().replace("+00:00", "Z"),
|
|
"window_end": window_end.isoformat().replace("+00:00", "Z"),
|
|
"window_days": window_days,
|
|
"window_kind": monthly_window["window_kind"],
|
|
"window_label": monthly_window["window_label"],
|
|
"uses_fallback": monthly_window["uses_fallback"],
|
|
"selection_reason": monthly_window["selection_reason"],
|
|
"current_month_start": current_month_start.isoformat().replace("+00:00", "Z"),
|
|
"current_month_closed_matches": monthly_window["current_month_closed_matches"],
|
|
"previous_month_closed_matches": monthly_window["previous_month_closed_matches"],
|
|
"sufficient_sample": {
|
|
"minimum_closed_matches": monthly_window["minimum_closed_matches"],
|
|
"current_month_closed_matches": monthly_window["current_month_closed_matches"],
|
|
"current_month_has_sufficient_sample": monthly_window["current_month_has_sufficient_sample"],
|
|
"is_early_month": monthly_window["is_early_month"],
|
|
},
|
|
"eligibility": ranking_result["eligibility"],
|
|
"eligible_players_count": ranking_result["eligible_players_count"],
|
|
"items": ranking_result["items"],
|
|
}
|
|
|
|
|
|
def list_monthly_mvp_v2_ranking(
|
|
*,
|
|
limit: int = 10,
|
|
server_id: str | None = None,
|
|
db_path: Path | None = None,
|
|
) -> dict[str, object]:
|
|
"""Return the monthly MVP V2 ranking built from monthly totals plus V2 signals."""
|
|
resolved_path = initialize_historical_storage(db_path=db_path)
|
|
aggregate_all_servers = _is_all_servers_selector(server_id)
|
|
current_time = datetime.now(timezone.utc)
|
|
current_month_start = _start_of_month(current_time)
|
|
previous_month_start = _start_of_previous_month(current_month_start)
|
|
monthly_window = _select_monthly_window(
|
|
server_id=server_id,
|
|
current_time=current_time,
|
|
current_month_start=current_month_start,
|
|
previous_month_start=previous_month_start,
|
|
db_path=resolved_path,
|
|
)
|
|
window_start = monthly_window["window_start"]
|
|
window_end = monthly_window["window_end"]
|
|
month_key = window_start.strftime("%Y-%m")
|
|
event_coverage = _get_monthly_player_event_coverage(
|
|
server_id=server_id,
|
|
month_key=month_key,
|
|
db_path=resolved_path,
|
|
)
|
|
window_days = _calculate_window_days(window_start=window_start, window_end=window_end)
|
|
|
|
empty_result = {
|
|
"timeframe": "monthly",
|
|
"metric": "mvp-v2",
|
|
"ranking_version": "v2",
|
|
"window_start": window_start.isoformat().replace("+00:00", "Z"),
|
|
"window_end": window_end.isoformat().replace("+00:00", "Z"),
|
|
"window_days": window_days,
|
|
"window_kind": monthly_window["window_kind"],
|
|
"window_label": monthly_window["window_label"],
|
|
"uses_fallback": monthly_window["uses_fallback"],
|
|
"selection_reason": monthly_window["selection_reason"],
|
|
"current_month_start": current_month_start.isoformat().replace("+00:00", "Z"),
|
|
"current_month_closed_matches": monthly_window["current_month_closed_matches"],
|
|
"previous_month_closed_matches": monthly_window["previous_month_closed_matches"],
|
|
"sufficient_sample": {
|
|
"minimum_closed_matches": monthly_window["minimum_closed_matches"],
|
|
"current_month_closed_matches": monthly_window["current_month_closed_matches"],
|
|
"current_month_has_sufficient_sample": monthly_window["current_month_has_sufficient_sample"],
|
|
"is_early_month": monthly_window["is_early_month"],
|
|
},
|
|
"event_coverage": event_coverage,
|
|
}
|
|
if not bool(event_coverage["ready"]):
|
|
return {
|
|
**empty_result,
|
|
"eligibility": None,
|
|
"eligible_players_count": 0,
|
|
"items": [],
|
|
}
|
|
|
|
where_clauses = [
|
|
"historical_matches.ended_at IS NOT NULL",
|
|
"historical_matches.ended_at >= ?",
|
|
"historical_matches.ended_at < ?",
|
|
]
|
|
params: list[object] = [
|
|
window_start.isoformat().replace("+00:00", "Z"),
|
|
window_end.isoformat().replace("+00:00", "Z"),
|
|
]
|
|
if server_id and not aggregate_all_servers:
|
|
normalized_server_id = server_id.strip()
|
|
where_clauses.append(
|
|
"(historical_servers.slug = ? OR CAST(historical_servers.server_number AS TEXT) = ?)"
|
|
)
|
|
params.extend([normalized_server_id, normalized_server_id])
|
|
|
|
event_scope_sql, event_scope_params = _build_player_event_scope_sql(server_id)
|
|
server_slug_expression = (
|
|
f"'{ALL_SERVERS_SLUG}'"
|
|
if aggregate_all_servers
|
|
else "historical_servers.slug"
|
|
)
|
|
server_name_expression = (
|
|
f"'{ALL_SERVERS_DISPLAY_NAME}'"
|
|
if aggregate_all_servers
|
|
else "historical_servers.display_name"
|
|
)
|
|
group_by_expression = (
|
|
"historical_players.id"
|
|
if aggregate_all_servers
|
|
else "historical_servers.slug, historical_players.id"
|
|
)
|
|
|
|
with _connect(resolved_path) as connection:
|
|
rows = connection.execute(
|
|
f"""
|
|
WITH most_killed_pairs AS (
|
|
SELECT
|
|
killer_player_key AS stable_player_key,
|
|
victim_player_key,
|
|
COALESCE(SUM(event_value), 0) AS total_kills
|
|
FROM player_event_raw_ledger
|
|
WHERE event_type = 'player_kill_summary'
|
|
AND occurred_at IS NOT NULL
|
|
AND substr(CAST(occurred_at AS TEXT), 1, 7) = ?
|
|
AND {event_scope_sql}
|
|
AND killer_player_key IS NOT NULL
|
|
AND victim_player_key IS NOT NULL
|
|
GROUP BY killer_player_key, victim_player_key
|
|
),
|
|
most_killed_by_player AS (
|
|
SELECT
|
|
stable_player_key,
|
|
MAX(total_kills) AS most_killed_count
|
|
FROM most_killed_pairs
|
|
GROUP BY stable_player_key
|
|
),
|
|
death_by_pairs AS (
|
|
SELECT
|
|
victim_player_key AS stable_player_key,
|
|
killer_player_key,
|
|
COALESCE(SUM(event_value), 0) AS total_kills
|
|
FROM player_event_raw_ledger
|
|
WHERE event_type = 'player_death_summary'
|
|
AND occurred_at IS NOT NULL
|
|
AND substr(CAST(occurred_at AS TEXT), 1, 7) = ?
|
|
AND {event_scope_sql}
|
|
AND killer_player_key IS NOT NULL
|
|
AND victim_player_key IS NOT NULL
|
|
GROUP BY victim_player_key, killer_player_key
|
|
),
|
|
death_by_player AS (
|
|
SELECT
|
|
stable_player_key,
|
|
MAX(total_kills) AS death_by_count
|
|
FROM death_by_pairs
|
|
GROUP BY stable_player_key
|
|
),
|
|
duel_pairs AS (
|
|
SELECT
|
|
CASE
|
|
WHEN COALESCE(killer_player_key, '') <= COALESCE(victim_player_key, '')
|
|
THEN killer_player_key
|
|
ELSE victim_player_key
|
|
END AS player_a_key,
|
|
CASE
|
|
WHEN COALESCE(killer_player_key, '') <= COALESCE(victim_player_key, '')
|
|
THEN victim_player_key
|
|
ELSE killer_player_key
|
|
END AS player_b_key,
|
|
COALESCE(
|
|
SUM(
|
|
CASE
|
|
WHEN COALESCE(killer_player_key, '') <= COALESCE(victim_player_key, '')
|
|
THEN event_value
|
|
ELSE -event_value
|
|
END
|
|
),
|
|
0
|
|
) AS net_duel_value
|
|
FROM player_event_raw_ledger
|
|
WHERE event_type = 'player_kill_summary'
|
|
AND occurred_at IS NOT NULL
|
|
AND substr(CAST(occurred_at AS TEXT), 1, 7) = ?
|
|
AND {event_scope_sql}
|
|
AND killer_player_key IS NOT NULL
|
|
AND victim_player_key IS NOT NULL
|
|
GROUP BY
|
|
CASE
|
|
WHEN COALESCE(killer_player_key, '') <= COALESCE(victim_player_key, '')
|
|
THEN killer_player_key
|
|
ELSE victim_player_key
|
|
END,
|
|
CASE
|
|
WHEN COALESCE(killer_player_key, '') <= COALESCE(victim_player_key, '')
|
|
THEN victim_player_key
|
|
ELSE killer_player_key
|
|
END
|
|
),
|
|
duel_player_values AS (
|
|
SELECT
|
|
player_a_key AS stable_player_key,
|
|
CASE WHEN net_duel_value > 0 THEN net_duel_value ELSE 0 END AS positive_duel_value
|
|
FROM duel_pairs
|
|
UNION ALL
|
|
SELECT
|
|
player_b_key AS stable_player_key,
|
|
CASE WHEN net_duel_value < 0 THEN -net_duel_value ELSE 0 END AS positive_duel_value
|
|
FROM duel_pairs
|
|
),
|
|
ranked_duel_values AS (
|
|
SELECT
|
|
stable_player_key,
|
|
positive_duel_value,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY stable_player_key
|
|
ORDER BY positive_duel_value DESC
|
|
) AS duel_rank
|
|
FROM duel_player_values
|
|
WHERE stable_player_key IS NOT NULL
|
|
AND positive_duel_value > 0
|
|
),
|
|
duel_control_by_player AS (
|
|
SELECT
|
|
stable_player_key,
|
|
COALESCE(SUM(positive_duel_value), 0) AS duel_control_raw
|
|
FROM ranked_duel_values
|
|
WHERE duel_rank <= 3
|
|
GROUP BY stable_player_key
|
|
)
|
|
SELECT
|
|
{server_slug_expression} AS server_slug,
|
|
{server_name_expression} AS server_name,
|
|
historical_players.stable_player_key,
|
|
historical_players.display_name AS player_name,
|
|
historical_players.steam_id,
|
|
COUNT(DISTINCT historical_matches.id) AS matches_count,
|
|
COALESCE(SUM(historical_player_match_stats.kills), 0) AS total_kills,
|
|
COALESCE(SUM(historical_player_match_stats.deaths), 0) AS total_deaths,
|
|
COALESCE(SUM(historical_player_match_stats.support), 0) AS total_support,
|
|
COALESCE(SUM(historical_player_match_stats.teamkills), 0) AS total_teamkills,
|
|
COALESCE(SUM(historical_player_match_stats.time_seconds), 0) AS total_time_seconds,
|
|
COALESCE(MAX(most_killed_by_player.most_killed_count), 0) AS most_killed_count,
|
|
COALESCE(MAX(death_by_player.death_by_count), 0) AS death_by_count,
|
|
COALESCE(MAX(duel_control_by_player.duel_control_raw), 0) AS duel_control_raw
|
|
FROM historical_player_match_stats
|
|
INNER JOIN historical_matches
|
|
ON historical_matches.id = historical_player_match_stats.historical_match_id
|
|
INNER JOIN historical_servers
|
|
ON historical_servers.id = historical_matches.historical_server_id
|
|
INNER JOIN historical_players
|
|
ON historical_players.id = historical_player_match_stats.historical_player_id
|
|
LEFT JOIN most_killed_by_player
|
|
ON most_killed_by_player.stable_player_key = historical_players.stable_player_key
|
|
LEFT JOIN death_by_player
|
|
ON death_by_player.stable_player_key = historical_players.stable_player_key
|
|
LEFT JOIN duel_control_by_player
|
|
ON duel_control_by_player.stable_player_key = historical_players.stable_player_key
|
|
WHERE {" AND ".join(where_clauses)}
|
|
GROUP BY {group_by_expression}
|
|
""",
|
|
[
|
|
month_key,
|
|
*event_scope_params,
|
|
month_key,
|
|
*event_scope_params,
|
|
month_key,
|
|
*event_scope_params,
|
|
*params,
|
|
],
|
|
).fetchall()
|
|
|
|
ranking_result = build_monthly_mvp_v2_rankings(
|
|
[dict(row) for row in rows],
|
|
limit=limit,
|
|
)
|
|
for item in ranking_result["items"]:
|
|
item["time_range"] = {
|
|
"start": window_start.isoformat().replace("+00:00", "Z"),
|
|
"end": window_end.isoformat().replace("+00:00", "Z"),
|
|
"window_days": window_days,
|
|
}
|
|
|
|
return {
|
|
**empty_result,
|
|
"ranking_version": ranking_result["ranking_version"],
|
|
"eligibility": ranking_result["eligibility"],
|
|
"eligible_players_count": ranking_result["eligible_players_count"],
|
|
"items": ranking_result["items"],
|
|
}
|
|
|
|
|
|
def _get_monthly_player_event_coverage(
|
|
*,
|
|
server_id: str | None,
|
|
month_key: str,
|
|
db_path: Path,
|
|
) -> dict[str, object]:
|
|
scope_sql, scope_params = _build_player_event_scope_sql(server_id)
|
|
with _connect(db_path) as connection:
|
|
latest_row = connection.execute(
|
|
f"""
|
|
SELECT MAX(substr(CAST(occurred_at AS TEXT), 1, 7)) AS latest_month_key
|
|
FROM player_event_raw_ledger
|
|
WHERE occurred_at IS NOT NULL
|
|
AND {scope_sql}
|
|
""",
|
|
scope_params,
|
|
).fetchone()
|
|
month_row = connection.execute(
|
|
f"""
|
|
SELECT
|
|
COUNT(*) AS event_count,
|
|
MIN(occurred_at) AS source_range_start,
|
|
MAX(occurred_at) AS source_range_end
|
|
FROM player_event_raw_ledger
|
|
WHERE occurred_at IS NOT NULL
|
|
AND substr(CAST(occurred_at AS TEXT), 1, 7) = ?
|
|
AND {scope_sql}
|
|
""",
|
|
[month_key, *scope_params],
|
|
).fetchone()
|
|
latest_month_key = str(latest_row["latest_month_key"]) if latest_row and latest_row["latest_month_key"] else None
|
|
event_count = int(month_row["event_count"] or 0) if month_row else 0
|
|
return {
|
|
"month_key": month_key,
|
|
"latest_month_key": latest_month_key,
|
|
"ready": bool(event_count > 0 and latest_month_key == month_key),
|
|
"event_count": event_count,
|
|
"source_range_start": month_row["source_range_start"] if month_row else None,
|
|
"source_range_end": month_row["source_range_end"] if month_row else None,
|
|
"selection_reason": (
|
|
"month-key-aligned"
|
|
if event_count > 0 and latest_month_key == month_key
|
|
else "player-event-month-mismatch-or-missing"
|
|
),
|
|
}
|
|
|
|
|
|
def _build_player_event_scope_sql(server_id: str | None) -> tuple[str, list[object]]:
|
|
if not server_id or _is_all_servers_selector(server_id):
|
|
return "1 = 1", []
|
|
normalized_server_id = server_id.strip()
|
|
return "server_slug = ?", [normalized_server_id]
|
|
|
|
|
|
def _connect(db_path: Path) -> sqlite3.Connection:
|
|
return connect_sqlite_writer(db_path)
|
|
|
|
|
|
def _resolve_match_winner(allied_score: object, axis_score: object) -> str | None:
|
|
allied = _coerce_int(allied_score)
|
|
axis = _coerce_int(axis_score)
|
|
if allied is None or axis is None:
|
|
return None
|
|
if allied > axis:
|
|
return "allies"
|
|
if axis > allied:
|
|
return "axis"
|
|
return "draw"
|
|
|
|
|
|
def _has_legacy_historical_schema(connection: sqlite3.Connection) -> bool:
|
|
columns = {
|
|
str(row["name"])
|
|
for row in connection.execute("PRAGMA table_info(historical_matches)").fetchall()
|
|
}
|
|
return bool(columns) and "historical_server_id" not in columns
|
|
|
|
|
|
def _rename_legacy_historical_tables(connection: sqlite3.Connection) -> None:
|
|
rename_plan = (
|
|
("historical_player_match_stats", "historical_player_match_stats_legacy"),
|
|
("historical_players", "historical_players_legacy"),
|
|
("historical_matches", "historical_matches_legacy"),
|
|
)
|
|
for current_name, legacy_name in rename_plan:
|
|
table_exists = connection.execute(
|
|
"""
|
|
SELECT 1
|
|
FROM sqlite_master
|
|
WHERE type = 'table' AND name = ?
|
|
""",
|
|
(current_name,),
|
|
).fetchone()
|
|
if not table_exists:
|
|
continue
|
|
|
|
legacy_exists = connection.execute(
|
|
"""
|
|
SELECT 1
|
|
FROM sqlite_master
|
|
WHERE type = 'table' AND name = ?
|
|
""",
|
|
(legacy_name,),
|
|
).fetchone()
|
|
if legacy_exists:
|
|
continue
|
|
|
|
connection.execute(f"ALTER TABLE {current_name} RENAME TO {legacy_name}")
|
|
|
|
|
|
def _migrate_legacy_historical_data(connection: sqlite3.Connection) -> None:
|
|
matches_table = connection.execute(
|
|
"""
|
|
SELECT 1
|
|
FROM sqlite_master
|
|
WHERE type = 'table' AND name = 'historical_matches_legacy'
|
|
"""
|
|
).fetchone()
|
|
if not matches_table:
|
|
return
|
|
|
|
player_map: dict[int, int] = {}
|
|
for row in connection.execute(
|
|
"""
|
|
SELECT id, source_player_ref, canonical_name, last_seen_name
|
|
FROM historical_players_legacy
|
|
ORDER BY id ASC
|
|
"""
|
|
).fetchall():
|
|
stable_player_key = _stringify(row["source_player_ref"]) or f"legacy-player:{row['id']}"
|
|
display_name = _stringify(row["last_seen_name"]) or _stringify(row["canonical_name"]) or "Unknown player"
|
|
now = _utc_now_iso()
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO historical_players (
|
|
stable_player_key,
|
|
display_name,
|
|
steam_id,
|
|
source_player_id,
|
|
first_seen_at,
|
|
last_seen_at
|
|
) VALUES (?, ?, NULL, NULL, ?, ?)
|
|
ON CONFLICT(stable_player_key) DO UPDATE SET
|
|
display_name = excluded.display_name,
|
|
last_seen_at = excluded.last_seen_at,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
""",
|
|
(stable_player_key, display_name, now, now),
|
|
)
|
|
new_row = connection.execute(
|
|
"SELECT id FROM historical_players WHERE stable_player_key = ?",
|
|
(stable_player_key,),
|
|
).fetchone()
|
|
if new_row is not None:
|
|
player_map[int(row["id"])] = int(new_row["id"])
|
|
|
|
match_map: dict[int, int] = {}
|
|
for row in connection.execute(
|
|
"""
|
|
SELECT *
|
|
FROM historical_matches_legacy
|
|
ORDER BY id ASC
|
|
"""
|
|
).fetchall():
|
|
server_slug = _stringify(row["external_server_id"]) or "comunidad-hispana-01"
|
|
server_row = _resolve_historical_server(connection, server_slug)
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO historical_matches (
|
|
historical_server_id,
|
|
external_match_id,
|
|
historical_map_id,
|
|
created_at_source,
|
|
started_at,
|
|
ended_at,
|
|
map_name,
|
|
map_pretty_name,
|
|
game_mode,
|
|
image_name,
|
|
allied_score,
|
|
axis_score,
|
|
last_seen_at,
|
|
raw_payload_ref
|
|
) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?)
|
|
ON CONFLICT(historical_server_id, external_match_id) DO UPDATE SET
|
|
started_at = excluded.started_at,
|
|
ended_at = excluded.ended_at,
|
|
map_name = excluded.map_name,
|
|
map_pretty_name = excluded.map_pretty_name,
|
|
game_mode = excluded.game_mode,
|
|
last_seen_at = excluded.last_seen_at,
|
|
raw_payload_ref = excluded.raw_payload_ref,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
""",
|
|
(
|
|
server_row["id"],
|
|
_stringify(row["source_match_ref"]) or f"legacy-match:{row['id']}",
|
|
_stringify(row["created_at"]),
|
|
_stringify(row["started_at"]),
|
|
_stringify(row["ended_at"]),
|
|
_stringify(row["map_name"]),
|
|
_stringify(row["map_name"]),
|
|
_stringify(row["mode_name"]),
|
|
_utc_now_iso(),
|
|
_stringify(row["source_url"]),
|
|
),
|
|
)
|
|
new_row = connection.execute(
|
|
"""
|
|
SELECT id
|
|
FROM historical_matches
|
|
WHERE historical_server_id = ? AND external_match_id = ?
|
|
""",
|
|
(
|
|
server_row["id"],
|
|
_stringify(row["source_match_ref"]) or f"legacy-match:{row['id']}",
|
|
),
|
|
).fetchone()
|
|
if new_row is not None:
|
|
match_map[int(row["id"])] = int(new_row["id"])
|
|
|
|
for row in connection.execute(
|
|
"""
|
|
SELECT *
|
|
FROM historical_player_match_stats_legacy
|
|
ORDER BY id ASC
|
|
"""
|
|
).fetchall():
|
|
new_match_id = match_map.get(int(row["match_id"]))
|
|
new_player_id = player_map.get(int(row["player_id"]))
|
|
if new_match_id is None or new_player_id is None:
|
|
continue
|
|
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO historical_player_match_stats (
|
|
historical_match_id,
|
|
historical_player_id,
|
|
match_player_ref,
|
|
team_side,
|
|
level,
|
|
kills,
|
|
deaths,
|
|
teamkills,
|
|
time_seconds,
|
|
kills_per_minute,
|
|
deaths_per_minute,
|
|
kill_death_ratio,
|
|
combat,
|
|
offense,
|
|
defense,
|
|
support
|
|
) VALUES (?, ?, NULL, NULL, NULL, ?, ?, NULL, ?, NULL, NULL, NULL, NULL, NULL, NULL, NULL)
|
|
ON CONFLICT(historical_match_id, historical_player_id) DO UPDATE SET
|
|
kills = excluded.kills,
|
|
deaths = excluded.deaths,
|
|
time_seconds = excluded.time_seconds,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
""",
|
|
(
|
|
new_match_id,
|
|
new_player_id,
|
|
_coerce_int(row["kills"]),
|
|
_coerce_int(row["deaths"]),
|
|
_coerce_int(row["time_seconds"]),
|
|
),
|
|
)
|
|
|
|
|
|
def _normalize_historical_player_identities(connection: sqlite3.Connection) -> None:
|
|
rows = connection.execute(
|
|
"""
|
|
SELECT id, stable_player_key, display_name, steam_id, source_player_id
|
|
FROM historical_players
|
|
ORDER BY id ASC
|
|
"""
|
|
).fetchall()
|
|
for row in rows:
|
|
player_id = int(row["id"])
|
|
canonical_key, steam_id, source_player_id, display_name = _canonicalize_stored_player_row(row)
|
|
existing = connection.execute(
|
|
"""
|
|
SELECT id
|
|
FROM historical_players
|
|
WHERE stable_player_key = ?
|
|
""",
|
|
(canonical_key,),
|
|
).fetchone()
|
|
if existing is not None and int(existing["id"]) != player_id:
|
|
_merge_historical_player_rows(
|
|
connection,
|
|
source_player_id=player_id,
|
|
target_player_id=int(existing["id"]),
|
|
display_name=display_name,
|
|
steam_id=steam_id,
|
|
source_ref=source_player_id,
|
|
)
|
|
continue
|
|
|
|
connection.execute(
|
|
"""
|
|
UPDATE historical_players
|
|
SET stable_player_key = ?,
|
|
display_name = ?,
|
|
steam_id = ?,
|
|
source_player_id = ?,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?
|
|
""",
|
|
(canonical_key, display_name, steam_id, source_player_id, player_id),
|
|
)
|
|
|
|
|
|
def _normalize_historical_match_identities(connection: sqlite3.Connection) -> None:
|
|
rows = connection.execute(
|
|
"""
|
|
SELECT
|
|
historical_matches.id,
|
|
historical_matches.historical_server_id,
|
|
historical_matches.external_match_id,
|
|
historical_matches.started_at,
|
|
historical_matches.ended_at,
|
|
historical_matches.created_at_source,
|
|
historical_matches.map_name,
|
|
historical_matches.map_pretty_name,
|
|
COUNT(historical_player_match_stats.id) AS player_count
|
|
FROM historical_matches
|
|
LEFT JOIN historical_player_match_stats
|
|
ON historical_player_match_stats.historical_match_id = historical_matches.id
|
|
WHERE historical_matches.started_at IS NOT NULL
|
|
GROUP BY historical_matches.id
|
|
ORDER BY historical_matches.historical_server_id ASC, historical_matches.started_at ASC, historical_matches.id ASC
|
|
"""
|
|
).fetchall()
|
|
|
|
grouped_matches: dict[tuple[int, str, str], list[sqlite3.Row]] = {}
|
|
for row in rows:
|
|
group_key = (
|
|
int(row["historical_server_id"]),
|
|
str(row["started_at"]),
|
|
_normalize_match_identity_label(row["map_pretty_name"] or row["map_name"]),
|
|
)
|
|
grouped_matches.setdefault(group_key, []).append(row)
|
|
|
|
for grouped_rows in grouped_matches.values():
|
|
if len(grouped_rows) < 2:
|
|
continue
|
|
target_row = max(grouped_rows, key=_match_identity_preference)
|
|
for source_row in grouped_rows:
|
|
if int(source_row["id"]) == int(target_row["id"]):
|
|
continue
|
|
_merge_historical_match_rows(
|
|
connection,
|
|
source_match_id=int(source_row["id"]),
|
|
target_match_id=int(target_row["id"]),
|
|
)
|
|
|
|
|
|
def _seed_default_historical_servers(connection: sqlite3.Connection) -> None:
|
|
for server in DEFAULT_HISTORICAL_SERVERS:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO historical_servers (
|
|
slug,
|
|
display_name,
|
|
scoreboard_base_url,
|
|
server_number,
|
|
source_kind
|
|
) VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(slug) DO UPDATE SET
|
|
display_name = excluded.display_name,
|
|
scoreboard_base_url = excluded.scoreboard_base_url,
|
|
server_number = excluded.server_number,
|
|
source_kind = excluded.source_kind,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
""",
|
|
(
|
|
server.slug,
|
|
server.display_name,
|
|
server.scoreboard_base_url,
|
|
server.server_number,
|
|
server.source_kind,
|
|
),
|
|
)
|
|
|
|
|
|
def _resolve_historical_server(
|
|
connection: sqlite3.Connection,
|
|
server_slug: str,
|
|
) -> sqlite3.Row:
|
|
row = connection.execute(
|
|
"""
|
|
SELECT id, slug, scoreboard_base_url
|
|
FROM historical_servers
|
|
WHERE slug = ?
|
|
""",
|
|
(server_slug,),
|
|
).fetchone()
|
|
if row is None:
|
|
raise ValueError(f"Unknown historical server slug: {server_slug}")
|
|
return row
|
|
|
|
|
|
def _upsert_historical_map(
|
|
connection: sqlite3.Connection,
|
|
match_payload: Mapping[str, object],
|
|
) -> int | None:
|
|
external_map_id = _stringify(_get_nested(match_payload, "map", "id"))
|
|
if not external_map_id:
|
|
return None
|
|
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO historical_maps (
|
|
external_map_id,
|
|
map_name,
|
|
pretty_name,
|
|
game_mode,
|
|
image_name
|
|
) VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(external_map_id) DO UPDATE SET
|
|
map_name = excluded.map_name,
|
|
pretty_name = excluded.pretty_name,
|
|
game_mode = excluded.game_mode,
|
|
image_name = excluded.image_name,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
""",
|
|
(
|
|
external_map_id,
|
|
_extract_map_name(match_payload),
|
|
_extract_map_pretty_name(match_payload),
|
|
_extract_map_game_mode(match_payload),
|
|
_extract_map_image_name(match_payload),
|
|
),
|
|
)
|
|
row = connection.execute(
|
|
"SELECT id FROM historical_maps WHERE external_map_id = ?",
|
|
(external_map_id,),
|
|
).fetchone()
|
|
return int(row["id"]) if row is not None else None
|
|
|
|
|
|
def _upsert_historical_player(
|
|
connection: sqlite3.Connection,
|
|
player_payload: Mapping[str, object],
|
|
) -> int:
|
|
stable_player_key, steam_id, source_player_id = _derive_player_identity(player_payload)
|
|
display_name = _normalize_player_display_name(player_payload.get("player")) or "Unknown player"
|
|
seen_at = _utc_now_iso()
|
|
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO historical_players (
|
|
stable_player_key,
|
|
display_name,
|
|
steam_id,
|
|
source_player_id,
|
|
first_seen_at,
|
|
last_seen_at
|
|
) VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(stable_player_key) DO UPDATE SET
|
|
display_name = excluded.display_name,
|
|
steam_id = COALESCE(excluded.steam_id, historical_players.steam_id),
|
|
source_player_id = COALESCE(excluded.source_player_id, historical_players.source_player_id),
|
|
last_seen_at = excluded.last_seen_at,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
""",
|
|
(
|
|
stable_player_key,
|
|
display_name,
|
|
steam_id,
|
|
source_player_id,
|
|
seen_at,
|
|
seen_at,
|
|
),
|
|
)
|
|
row = connection.execute(
|
|
"SELECT id FROM historical_players WHERE stable_player_key = ?",
|
|
(stable_player_key,),
|
|
).fetchone()
|
|
if row is None:
|
|
raise RuntimeError("Failed to persist historical player identity.")
|
|
return int(row["id"])
|
|
|
|
|
|
def _build_stable_player_key(player_payload: Mapping[str, object]) -> str:
|
|
stable_player_key, _, _ = _derive_player_identity(player_payload)
|
|
return stable_player_key
|
|
|
|
|
|
def _derive_player_identity(player_payload: Mapping[str, object]) -> tuple[str, str | None, str | None]:
|
|
steam_id = _stringify(_get_nested(player_payload, "steaminfo", "profile", "steamid"))
|
|
source_player_id = _stringify(player_payload.get("player_id"))
|
|
steaminfo_id = _stringify(_get_nested(player_payload, "steaminfo", "id"))
|
|
|
|
if steam_id:
|
|
return f"steam:{steam_id}", steam_id, source_player_id
|
|
if _is_probable_steam_id(source_player_id):
|
|
return f"steam:{source_player_id}", source_player_id, source_player_id
|
|
if source_player_id:
|
|
return f"crcon-player:{source_player_id}", None, source_player_id
|
|
if steaminfo_id:
|
|
return f"steaminfo:{steaminfo_id}", None, None
|
|
|
|
player_name = _normalize_player_display_name(player_payload.get("player")) or "unknown-player"
|
|
return f"name:{_normalize_name_key(player_name)}", None, None
|
|
|
|
|
|
def _extract_map_name(match_payload: Mapping[str, object]) -> str | None:
|
|
return _stringify(match_payload.get("map_name")) or _stringify(_get_nested(match_payload, "map", "name"))
|
|
|
|
|
|
def _extract_map_pretty_name(match_payload: Mapping[str, object]) -> str | None:
|
|
return _stringify(_get_nested(match_payload, "map", "pretty_name")) or _extract_map_name(match_payload)
|
|
|
|
|
|
def _extract_map_game_mode(match_payload: Mapping[str, object]) -> str | None:
|
|
return _stringify(_get_nested(match_payload, "map", "game_mode"))
|
|
|
|
|
|
def _extract_map_image_name(match_payload: Mapping[str, object]) -> str | None:
|
|
return _stringify(_get_nested(match_payload, "map", "image_name"))
|
|
|
|
|
|
def _coerce_list(value: object) -> list[Mapping[str, object]]:
|
|
if not isinstance(value, list):
|
|
return []
|
|
return [item for item in value if isinstance(item, Mapping)]
|
|
|
|
|
|
def _coerce_int(value: object) -> int | None:
|
|
if value in (None, ""):
|
|
return None
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _coerce_float(value: object) -> float | None:
|
|
if value in (None, ""):
|
|
return None
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _stringify(value: object) -> str | None:
|
|
if value is None:
|
|
return None
|
|
text = str(value).strip()
|
|
return text or None
|
|
|
|
|
|
def _normalize_player_display_name(value: object) -> str | None:
|
|
text = _stringify(value)
|
|
if not text:
|
|
return None
|
|
return " ".join(text.split())
|
|
|
|
|
|
def _normalize_name_key(player_name: str) -> str:
|
|
normalized_name = "".join(
|
|
character.lower() if character.isalnum() else "-"
|
|
for character in player_name
|
|
)
|
|
compact_name = "-".join(part for part in normalized_name.split("-") if part)
|
|
return compact_name or "unknown-player"
|
|
|
|
|
|
def _is_probable_steam_id(value: object) -> bool:
|
|
text = _stringify(value)
|
|
return bool(text and text.isdigit() and len(text) >= 16)
|
|
|
|
|
|
def _canonicalize_stored_player_row(
|
|
row: sqlite3.Row,
|
|
) -> tuple[str, str | None, str | None, str]:
|
|
stable_player_key = _stringify(row["stable_player_key"])
|
|
display_name = _normalize_player_display_name(row["display_name"]) or "Unknown player"
|
|
steam_id = _stringify(row["steam_id"])
|
|
source_player_id = _stringify(row["source_player_id"])
|
|
|
|
if _is_probable_steam_id(steam_id):
|
|
return f"steam:{steam_id}", steam_id, source_player_id, display_name
|
|
if _is_probable_steam_id(source_player_id):
|
|
return f"steam:{source_player_id}", source_player_id, source_player_id, display_name
|
|
if source_player_id:
|
|
return f"crcon-player:{source_player_id}", None, source_player_id, display_name
|
|
if stable_player_key and stable_player_key.startswith("steaminfo:"):
|
|
return stable_player_key, None, None, display_name
|
|
if stable_player_key and stable_player_key.startswith("name:"):
|
|
return stable_player_key, None, None, display_name
|
|
if stable_player_key and stable_player_key.startswith("steam:"):
|
|
return stable_player_key, steam_id, source_player_id, display_name
|
|
if stable_player_key and stable_player_key.startswith("crcon-player:"):
|
|
source_ref = stable_player_key.removeprefix("crcon-player:")
|
|
return stable_player_key, None, source_player_id or source_ref, display_name
|
|
if stable_player_key:
|
|
if _is_probable_steam_id(stable_player_key):
|
|
return f"steam:{stable_player_key}", stable_player_key, source_player_id, display_name
|
|
return f"crcon-player:{stable_player_key}", None, source_player_id or stable_player_key, display_name
|
|
return f"name:{_normalize_name_key(display_name)}", None, None, display_name
|
|
|
|
|
|
def _merge_historical_player_rows(
|
|
connection: sqlite3.Connection,
|
|
*,
|
|
source_player_id: int,
|
|
target_player_id: int,
|
|
display_name: str,
|
|
steam_id: str | None,
|
|
source_ref: str | None,
|
|
) -> None:
|
|
target_row = connection.execute(
|
|
"""
|
|
SELECT display_name, steam_id, source_player_id, first_seen_at, last_seen_at
|
|
FROM historical_players
|
|
WHERE id = ?
|
|
""",
|
|
(target_player_id,),
|
|
).fetchone()
|
|
if target_row is None:
|
|
return
|
|
|
|
connection.execute(
|
|
"""
|
|
UPDATE historical_players
|
|
SET display_name = ?,
|
|
steam_id = ?,
|
|
source_player_id = ?,
|
|
first_seen_at = MIN(first_seen_at, ?),
|
|
last_seen_at = MAX(last_seen_at, ?),
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?
|
|
""",
|
|
(
|
|
_pick_preferred_display_name(target_row["display_name"], display_name),
|
|
_pick_preferred_steam_id(target_row["steam_id"], steam_id),
|
|
_pick_preferred_source_player_id(target_row["source_player_id"], source_ref),
|
|
connection.execute(
|
|
"SELECT first_seen_at FROM historical_players WHERE id = ?",
|
|
(source_player_id,),
|
|
).fetchone()["first_seen_at"],
|
|
connection.execute(
|
|
"SELECT last_seen_at FROM historical_players WHERE id = ?",
|
|
(source_player_id,),
|
|
).fetchone()["last_seen_at"],
|
|
target_player_id,
|
|
),
|
|
)
|
|
|
|
stats_rows = connection.execute(
|
|
"""
|
|
SELECT *
|
|
FROM historical_player_match_stats
|
|
WHERE historical_player_id = ?
|
|
ORDER BY id ASC
|
|
""",
|
|
(source_player_id,),
|
|
).fetchall()
|
|
for stat_row in stats_rows:
|
|
existing = connection.execute(
|
|
"""
|
|
SELECT *
|
|
FROM historical_player_match_stats
|
|
WHERE historical_match_id = ? AND historical_player_id = ?
|
|
""",
|
|
(stat_row["historical_match_id"], target_player_id),
|
|
).fetchone()
|
|
if existing is None:
|
|
connection.execute(
|
|
"""
|
|
UPDATE historical_player_match_stats
|
|
SET historical_player_id = ?,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?
|
|
""",
|
|
(target_player_id, stat_row["id"]),
|
|
)
|
|
continue
|
|
|
|
_merge_player_match_stats_row(connection, existing["id"], stat_row)
|
|
connection.execute(
|
|
"DELETE FROM historical_player_match_stats WHERE id = ?",
|
|
(stat_row["id"],),
|
|
)
|
|
|
|
connection.execute(
|
|
"DELETE FROM historical_players WHERE id = ?",
|
|
(source_player_id,),
|
|
)
|
|
|
|
|
|
def _normalize_match_identity_label(value: object) -> str:
|
|
text = _stringify(value) or "unknown-map"
|
|
return " ".join(text.lower().split())
|
|
|
|
|
|
def _match_identity_preference(row: sqlite3.Row) -> tuple[int, int, int, str, int]:
|
|
return (
|
|
1 if _stringify(row["ended_at"]) else 0,
|
|
1 if (_stringify(row["external_match_id"]) or "").isdigit() else 0,
|
|
int(row["player_count"] or 0),
|
|
_stringify(row["created_at_source"]) or "",
|
|
int(row["id"]),
|
|
)
|
|
|
|
|
|
def _merge_historical_match_rows(
|
|
connection: sqlite3.Connection,
|
|
*,
|
|
source_match_id: int,
|
|
target_match_id: int,
|
|
) -> None:
|
|
source_row = connection.execute(
|
|
"SELECT * FROM historical_matches WHERE id = ?",
|
|
(source_match_id,),
|
|
).fetchone()
|
|
target_row = connection.execute(
|
|
"SELECT * FROM historical_matches WHERE id = ?",
|
|
(target_match_id,),
|
|
).fetchone()
|
|
if source_row is None or target_row is None:
|
|
return
|
|
|
|
connection.execute(
|
|
"""
|
|
UPDATE historical_matches
|
|
SET historical_map_id = COALESCE(historical_map_id, ?),
|
|
created_at_source = COALESCE(created_at_source, ?),
|
|
started_at = COALESCE(started_at, ?),
|
|
ended_at = COALESCE(ended_at, ?),
|
|
map_name = COALESCE(map_name, ?),
|
|
map_pretty_name = COALESCE(map_pretty_name, ?),
|
|
game_mode = COALESCE(game_mode, ?),
|
|
image_name = COALESCE(image_name, ?),
|
|
allied_score = COALESCE(allied_score, ?),
|
|
axis_score = COALESCE(axis_score, ?),
|
|
raw_payload_ref = COALESCE(raw_payload_ref, ?),
|
|
last_seen_at = MAX(last_seen_at, ?),
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?
|
|
""",
|
|
(
|
|
source_row["historical_map_id"],
|
|
source_row["created_at_source"],
|
|
source_row["started_at"],
|
|
source_row["ended_at"],
|
|
source_row["map_name"],
|
|
source_row["map_pretty_name"],
|
|
source_row["game_mode"],
|
|
source_row["image_name"],
|
|
source_row["allied_score"],
|
|
source_row["axis_score"],
|
|
source_row["raw_payload_ref"],
|
|
source_row["last_seen_at"],
|
|
target_match_id,
|
|
),
|
|
)
|
|
|
|
stats_rows = connection.execute(
|
|
"""
|
|
SELECT *
|
|
FROM historical_player_match_stats
|
|
WHERE historical_match_id = ?
|
|
ORDER BY id ASC
|
|
""",
|
|
(source_match_id,),
|
|
).fetchall()
|
|
for stat_row in stats_rows:
|
|
existing = connection.execute(
|
|
"""
|
|
SELECT *
|
|
FROM historical_player_match_stats
|
|
WHERE historical_match_id = ? AND historical_player_id = ?
|
|
""",
|
|
(target_match_id, stat_row["historical_player_id"]),
|
|
).fetchone()
|
|
if existing is None:
|
|
connection.execute(
|
|
"""
|
|
UPDATE historical_player_match_stats
|
|
SET historical_match_id = ?,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?
|
|
""",
|
|
(target_match_id, stat_row["id"]),
|
|
)
|
|
continue
|
|
|
|
_merge_player_match_stats_row(connection, existing["id"], stat_row)
|
|
connection.execute(
|
|
"DELETE FROM historical_player_match_stats WHERE id = ?",
|
|
(stat_row["id"],),
|
|
)
|
|
|
|
connection.execute(
|
|
"DELETE FROM historical_matches WHERE id = ?",
|
|
(source_match_id,),
|
|
)
|
|
|
|
|
|
def _merge_player_match_stats_row(
|
|
connection: sqlite3.Connection,
|
|
target_stat_id: int,
|
|
source_row: sqlite3.Row,
|
|
) -> None:
|
|
target_row = connection.execute(
|
|
"SELECT * FROM historical_player_match_stats WHERE id = ?",
|
|
(target_stat_id,),
|
|
).fetchone()
|
|
if target_row is None:
|
|
return
|
|
|
|
connection.execute(
|
|
"""
|
|
UPDATE historical_player_match_stats
|
|
SET match_player_ref = COALESCE(match_player_ref, ?),
|
|
team_side = COALESCE(team_side, ?),
|
|
level = ?,
|
|
kills = ?,
|
|
deaths = ?,
|
|
teamkills = ?,
|
|
time_seconds = ?,
|
|
kills_per_minute = ?,
|
|
deaths_per_minute = ?,
|
|
kill_death_ratio = ?,
|
|
combat = ?,
|
|
offense = ?,
|
|
defense = ?,
|
|
support = ?,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?
|
|
""",
|
|
(
|
|
source_row["match_player_ref"],
|
|
source_row["team_side"],
|
|
_max_int_value(target_row["level"], source_row["level"]),
|
|
_max_int_value(target_row["kills"], source_row["kills"]),
|
|
_max_int_value(target_row["deaths"], source_row["deaths"]),
|
|
_max_int_value(target_row["teamkills"], source_row["teamkills"]),
|
|
_max_int_value(target_row["time_seconds"], source_row["time_seconds"]),
|
|
_max_float_value(target_row["kills_per_minute"], source_row["kills_per_minute"]),
|
|
_max_float_value(target_row["deaths_per_minute"], source_row["deaths_per_minute"]),
|
|
_max_float_value(target_row["kill_death_ratio"], source_row["kill_death_ratio"]),
|
|
_max_int_value(target_row["combat"], source_row["combat"]),
|
|
_max_int_value(target_row["offense"], source_row["offense"]),
|
|
_max_int_value(target_row["defense"], source_row["defense"]),
|
|
_max_int_value(target_row["support"], source_row["support"]),
|
|
target_stat_id,
|
|
),
|
|
)
|
|
|
|
|
|
def _pick_preferred_display_name(current_value: object, incoming_value: object) -> str:
|
|
current_name = _normalize_player_display_name(current_value)
|
|
incoming_name = _normalize_player_display_name(incoming_value)
|
|
if not current_name:
|
|
return incoming_name or "Unknown player"
|
|
if not incoming_name:
|
|
return current_name
|
|
if len(incoming_name) > len(current_name):
|
|
return incoming_name
|
|
return current_name
|
|
|
|
|
|
def _pick_preferred_steam_id(current_value: object, incoming_value: object) -> str | None:
|
|
current_id = _stringify(current_value)
|
|
incoming_id = _stringify(incoming_value)
|
|
if _is_probable_steam_id(current_id):
|
|
return current_id
|
|
if _is_probable_steam_id(incoming_id):
|
|
return incoming_id
|
|
return None
|
|
|
|
|
|
def _pick_preferred_source_player_id(current_value: object, incoming_value: object) -> str | None:
|
|
current_id = _stringify(current_value)
|
|
incoming_id = _stringify(incoming_value)
|
|
if current_id:
|
|
return current_id
|
|
return incoming_id
|
|
|
|
|
|
def _max_int_value(current_value: object, incoming_value: object) -> int | None:
|
|
current_number = _coerce_int(current_value)
|
|
incoming_number = _coerce_int(incoming_value)
|
|
if current_number is None:
|
|
return incoming_number
|
|
if incoming_number is None:
|
|
return current_number
|
|
return max(current_number, incoming_number)
|
|
|
|
|
|
def _max_float_value(current_value: object, incoming_value: object) -> float | None:
|
|
current_number = _coerce_float(current_value)
|
|
incoming_number = _coerce_float(incoming_value)
|
|
if current_number is None:
|
|
return incoming_number
|
|
if incoming_number is None:
|
|
return current_number
|
|
return max(current_number, incoming_number)
|
|
|
|
|
|
def _normalize_timestamp(value: object) -> str | None:
|
|
text = _stringify(value)
|
|
if not text:
|
|
return None
|
|
try:
|
|
return _parse_timestamp(text).astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
except ValueError:
|
|
return text
|
|
|
|
|
|
def _parse_timestamp(value: str) -> datetime:
|
|
normalized = value.strip().replace("Z", "+00:00")
|
|
parsed = datetime.fromisoformat(normalized)
|
|
if parsed.tzinfo is None:
|
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
return parsed
|
|
|
|
|
|
def _calculate_coverage_days(
|
|
first_match_at: str | None,
|
|
last_match_at: str | None,
|
|
) -> float | None:
|
|
if not first_match_at or not last_match_at:
|
|
return None
|
|
try:
|
|
delta = _parse_timestamp(last_match_at) - _parse_timestamp(first_match_at)
|
|
except ValueError:
|
|
return None
|
|
return round(delta.total_seconds() / 86400, 2)
|
|
|
|
|
|
def _select_weekly_window(
|
|
*,
|
|
server_id: str | None,
|
|
current_time: datetime,
|
|
current_week_start: datetime,
|
|
previous_week_start: datetime,
|
|
db_path: Path,
|
|
) -> dict[str, object]:
|
|
fallback_max_weekday = get_historical_weekly_fallback_max_weekday()
|
|
current_week_closed_matches = _count_valid_matches_with_stats_in_window(
|
|
server_id=server_id,
|
|
window_start=current_week_start,
|
|
window_end=current_time,
|
|
db_path=db_path,
|
|
)
|
|
previous_week_closed_matches = _count_valid_matches_with_stats_in_window(
|
|
server_id=server_id,
|
|
window_start=previous_week_start,
|
|
window_end=current_week_start,
|
|
db_path=db_path,
|
|
)
|
|
is_early_week = current_time.weekday() <= fallback_max_weekday
|
|
min_matches = 1
|
|
current_week_has_sufficient_sample = current_week_closed_matches >= min_matches
|
|
uses_fallback = (
|
|
not current_week_has_sufficient_sample
|
|
and previous_week_closed_matches > 0
|
|
)
|
|
|
|
if uses_fallback:
|
|
return {
|
|
"window_start": previous_week_start,
|
|
"window_end": current_week_start,
|
|
"window_kind": "previous-closed-week-fallback",
|
|
"window_label": "Semana cerrada anterior",
|
|
"uses_fallback": True,
|
|
"selection_reason": "insufficient-current-week-sample",
|
|
"minimum_closed_matches": min_matches,
|
|
"current_week_closed_matches": current_week_closed_matches,
|
|
"previous_week_closed_matches": previous_week_closed_matches,
|
|
"current_week_has_sufficient_sample": False,
|
|
"is_early_week": is_early_week,
|
|
"fallback_max_weekday": fallback_max_weekday,
|
|
}
|
|
|
|
return {
|
|
"window_start": current_week_start,
|
|
"window_end": current_time,
|
|
"window_kind": "current-week",
|
|
"window_label": "Semana actual",
|
|
"uses_fallback": False,
|
|
"selection_reason": "current-week",
|
|
"minimum_closed_matches": min_matches,
|
|
"current_week_closed_matches": current_week_closed_matches,
|
|
"previous_week_closed_matches": previous_week_closed_matches,
|
|
"current_week_has_sufficient_sample": current_week_has_sufficient_sample,
|
|
"is_early_week": is_early_week,
|
|
"fallback_max_weekday": fallback_max_weekday,
|
|
}
|
|
|
|
|
|
def _select_monthly_window(
|
|
*,
|
|
server_id: str | None,
|
|
current_time: datetime,
|
|
current_month_start: datetime,
|
|
previous_month_start: datetime,
|
|
db_path: Path,
|
|
) -> dict[str, object]:
|
|
current_month_closed_matches = _count_closed_matches_in_window(
|
|
server_id=server_id,
|
|
window_start=current_month_start,
|
|
window_end=current_time,
|
|
db_path=db_path,
|
|
)
|
|
previous_month_closed_matches = _count_closed_matches_in_window(
|
|
server_id=server_id,
|
|
window_start=previous_month_start,
|
|
window_end=current_month_start,
|
|
db_path=db_path,
|
|
)
|
|
is_early_month = current_time.day <= 3
|
|
uses_fallback = current_month_closed_matches <= 0 and previous_month_closed_matches > 0
|
|
|
|
if uses_fallback:
|
|
return {
|
|
"window_start": previous_month_start,
|
|
"window_end": current_month_start,
|
|
"window_kind": "previous-closed-month-fallback",
|
|
"window_label": "Mes cerrado anterior",
|
|
"uses_fallback": True,
|
|
"selection_reason": "no-current-month-matches",
|
|
"minimum_closed_matches": 1,
|
|
"current_month_closed_matches": current_month_closed_matches,
|
|
"previous_month_closed_matches": previous_month_closed_matches,
|
|
"current_month_has_sufficient_sample": False,
|
|
"is_early_month": is_early_month,
|
|
}
|
|
|
|
return {
|
|
"window_start": current_month_start,
|
|
"window_end": current_time,
|
|
"window_kind": "current-month",
|
|
"window_label": "Mes actual",
|
|
"uses_fallback": False,
|
|
"selection_reason": "current-month",
|
|
"minimum_closed_matches": 1,
|
|
"current_month_closed_matches": current_month_closed_matches,
|
|
"previous_month_closed_matches": previous_month_closed_matches,
|
|
"current_month_has_sufficient_sample": current_month_closed_matches > 0,
|
|
"is_early_month": is_early_month,
|
|
}
|
|
|
|
|
|
def _count_closed_matches_in_window(
|
|
*,
|
|
server_id: str | None,
|
|
window_start: datetime,
|
|
window_end: datetime,
|
|
db_path: Path,
|
|
) -> int:
|
|
where_clauses = [
|
|
"historical_matches.ended_at IS NOT NULL",
|
|
"historical_matches.ended_at >= ?",
|
|
"historical_matches.ended_at < ?",
|
|
]
|
|
params: list[object] = [
|
|
window_start.isoformat().replace("+00:00", "Z"),
|
|
window_end.isoformat().replace("+00:00", "Z"),
|
|
]
|
|
if server_id and not _is_all_servers_selector(server_id):
|
|
normalized_server_id = server_id.strip()
|
|
where_clauses.append(
|
|
"(historical_servers.slug = ? OR CAST(historical_servers.server_number AS TEXT) = ?)"
|
|
)
|
|
params.extend([normalized_server_id, normalized_server_id])
|
|
|
|
with _connect(db_path) as connection:
|
|
row = connection.execute(
|
|
f"""
|
|
SELECT COUNT(DISTINCT historical_matches.id) AS matches_count
|
|
FROM historical_matches
|
|
INNER JOIN historical_servers
|
|
ON historical_servers.id = historical_matches.historical_server_id
|
|
WHERE {" AND ".join(where_clauses)}
|
|
""",
|
|
params,
|
|
).fetchone()
|
|
return int(row["matches_count"] or 0) if row is not None else 0
|
|
|
|
|
|
def _count_valid_matches_with_stats_in_window(
|
|
*,
|
|
server_id: str | None,
|
|
window_start: datetime,
|
|
window_end: datetime,
|
|
db_path: Path,
|
|
) -> int:
|
|
where_clauses = [
|
|
"historical_matches.ended_at IS NOT NULL",
|
|
"historical_matches.ended_at >= ?",
|
|
"historical_matches.ended_at < ?",
|
|
"("
|
|
"COALESCE(historical_player_match_stats.kills, 0) > 0 "
|
|
"OR COALESCE(historical_player_match_stats.deaths, 0) > 0 "
|
|
"OR COALESCE(historical_player_match_stats.support, 0) > 0 "
|
|
"OR COALESCE(historical_player_match_stats.combat, 0) > 0 "
|
|
"OR COALESCE(historical_player_match_stats.offense, 0) > 0 "
|
|
"OR COALESCE(historical_player_match_stats.defense, 0) > 0 "
|
|
"OR COALESCE(historical_player_match_stats.time_seconds, 0) > 0"
|
|
")",
|
|
]
|
|
params: list[object] = [
|
|
window_start.isoformat().replace("+00:00", "Z"),
|
|
window_end.isoformat().replace("+00:00", "Z"),
|
|
]
|
|
if server_id and not _is_all_servers_selector(server_id):
|
|
normalized_server_id = server_id.strip()
|
|
where_clauses.append(
|
|
"(historical_servers.slug = ? OR CAST(historical_servers.server_number AS TEXT) = ?)"
|
|
)
|
|
params.extend([normalized_server_id, normalized_server_id])
|
|
|
|
with _connect(db_path) as connection:
|
|
row = connection.execute(
|
|
f"""
|
|
SELECT COUNT(DISTINCT historical_matches.id) AS matches_count
|
|
FROM historical_matches
|
|
INNER JOIN historical_servers
|
|
ON historical_servers.id = historical_matches.historical_server_id
|
|
INNER JOIN historical_player_match_stats
|
|
ON historical_player_match_stats.historical_match_id = historical_matches.id
|
|
WHERE {" AND ".join(where_clauses)}
|
|
""",
|
|
params,
|
|
).fetchone()
|
|
return int(row["matches_count"] or 0) if row is not None else 0
|
|
|
|
|
|
def _classify_coverage_status(
|
|
matches_count: int,
|
|
coverage_days: float | None,
|
|
) -> str:
|
|
if matches_count <= 0:
|
|
return "empty"
|
|
if coverage_days is None:
|
|
return "range-unknown"
|
|
if coverage_days < DEFAULT_WEEKLY_WINDOW_DAYS:
|
|
return "under-week"
|
|
return "week-plus"
|
|
|
|
|
|
def _build_all_servers_summary(*, db_path: Path) -> dict[str, object]:
|
|
per_server_items = list_historical_server_summaries(db_path=db_path)
|
|
imported_matches_count = sum(int(item.get("matches_count") or 0) for item in per_server_items)
|
|
unique_players = _count_all_servers_unique_players(db_path=db_path)
|
|
total_kills = sum(int(item.get("total_kills") or 0) for item in per_server_items)
|
|
discovered_total_matches = sum(
|
|
_coerce_int(item.get("backfill", {}).get("discovered_total_matches")) or 0
|
|
for item in per_server_items
|
|
)
|
|
first_points = [
|
|
item.get("coverage", {}).get("first_match_at")
|
|
for item in per_server_items
|
|
if item.get("coverage", {}).get("first_match_at")
|
|
]
|
|
last_points = [
|
|
item.get("coverage", {}).get("last_match_at")
|
|
for item in per_server_items
|
|
if item.get("coverage", {}).get("last_match_at")
|
|
]
|
|
first_match_at = min(first_points) if first_points else None
|
|
last_match_at = max(last_points) if last_points else None
|
|
coverage_days = _calculate_coverage_days(first_match_at, last_match_at)
|
|
|
|
return {
|
|
"server": {
|
|
"slug": ALL_SERVERS_SLUG,
|
|
"name": ALL_SERVERS_DISPLAY_NAME,
|
|
},
|
|
"matches_count": imported_matches_count,
|
|
"imported_matches_count": imported_matches_count,
|
|
"unique_players": unique_players,
|
|
"total_kills": total_kills,
|
|
"map_count": _count_all_servers_maps(db_path=db_path),
|
|
"top_maps": _list_all_servers_top_maps(db_path=db_path, limit=3),
|
|
"coverage": {
|
|
"basis": "persisted-import-aggregate",
|
|
"status": _classify_coverage_status(imported_matches_count, coverage_days),
|
|
"imported_matches_count": imported_matches_count,
|
|
"discovered_total_matches": discovered_total_matches or None,
|
|
"first_match_at": first_match_at,
|
|
"last_match_at": last_match_at,
|
|
"coverage_days": coverage_days,
|
|
},
|
|
"backfill": {
|
|
"mode": "aggregate",
|
|
"server_count": len(per_server_items),
|
|
"discovered_total_matches": discovered_total_matches or None,
|
|
"remaining_matches_estimate": (
|
|
max(discovered_total_matches - imported_matches_count, 0)
|
|
if discovered_total_matches
|
|
else None
|
|
),
|
|
"archive_exhausted": all(
|
|
bool(item.get("backfill", {}).get("archive_exhausted"))
|
|
for item in per_server_items
|
|
),
|
|
"last_run": None,
|
|
},
|
|
"time_range": {
|
|
"start": first_match_at,
|
|
"end": last_match_at,
|
|
},
|
|
}
|
|
|
|
|
|
def _count_all_servers_unique_players(*, db_path: Path) -> int:
|
|
with _connect(db_path) as connection:
|
|
row = connection.execute(
|
|
"""
|
|
SELECT COUNT(DISTINCT historical_players.id) AS unique_players
|
|
FROM historical_player_match_stats
|
|
INNER JOIN historical_players
|
|
ON historical_players.id = historical_player_match_stats.historical_player_id
|
|
"""
|
|
).fetchone()
|
|
return int(row["unique_players"] or 0) if row is not None else 0
|
|
|
|
|
|
def _count_all_servers_maps(*, db_path: Path) -> int:
|
|
with _connect(db_path) as connection:
|
|
row = connection.execute(
|
|
"""
|
|
SELECT COUNT(DISTINCT COALESCE(map_pretty_name, map_name)) AS map_count
|
|
FROM historical_matches
|
|
"""
|
|
).fetchone()
|
|
return int(row["map_count"] or 0) if row is not None else 0
|
|
|
|
|
|
def _list_all_servers_top_maps(*, db_path: Path, limit: int) -> list[dict[str, object]]:
|
|
with _connect(db_path) as connection:
|
|
rows = connection.execute(
|
|
"""
|
|
SELECT
|
|
COALESCE(map_pretty_name, map_name, 'Mapa no disponible') AS map_name,
|
|
COUNT(*) AS matches_count
|
|
FROM historical_matches
|
|
GROUP BY COALESCE(map_pretty_name, map_name, 'Mapa no disponible')
|
|
ORDER BY matches_count DESC, map_name ASC
|
|
LIMIT ?
|
|
""",
|
|
(limit,),
|
|
).fetchall()
|
|
return [
|
|
{
|
|
"map_name": row["map_name"],
|
|
"matches_count": int(row["matches_count"] or 0),
|
|
}
|
|
for row in rows
|
|
]
|
|
|
|
|
|
def _is_all_servers_selector(value: str | None) -> bool:
|
|
return isinstance(value, str) and value.strip() == ALL_SERVERS_SLUG
|
|
|
|
|
|
def _resolve_safe_match_url(raw_payload_ref: object, server_slug: object) -> str | None:
|
|
return resolve_trusted_scoreboard_match_url(raw_payload_ref, server_slug)
|
|
|
|
|
|
def _calculate_match_duration_seconds(started_at: object, ended_at: object) -> int | None:
|
|
start_text = _stringify(started_at)
|
|
end_text = _stringify(ended_at)
|
|
if not start_text or not end_text:
|
|
return None
|
|
try:
|
|
duration = _parse_timestamp(end_text) - _parse_timestamp(start_text)
|
|
except ValueError:
|
|
return None
|
|
return max(0, int(duration.total_seconds()))
|
|
|
|
|
|
def _start_of_week(value: datetime) -> datetime:
|
|
normalized = value.astimezone(timezone.utc)
|
|
midnight = normalized.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
return midnight - timedelta(days=midnight.weekday())
|
|
|
|
|
|
def _start_of_month(value: datetime) -> datetime:
|
|
normalized = value.astimezone(timezone.utc)
|
|
return normalized.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
|
|
|
|
def _start_of_previous_month(value: datetime) -> datetime:
|
|
previous_day = value - timedelta(days=1)
|
|
return _start_of_month(previous_day)
|
|
|
|
|
|
def _calculate_window_days(*, window_start: datetime, window_end: datetime) -> int:
|
|
delta = window_end - window_start
|
|
return max(1, int((delta.total_seconds() + 86399) // 86400))
|
|
|
|
|
|
def _get_nested(payload: Mapping[str, object], *path: str) -> object:
|
|
current: object = payload
|
|
for key in path:
|
|
if not isinstance(current, Mapping):
|
|
return None
|
|
current = current.get(key)
|
|
return current
|
|
|
|
|
|
def _utc_now_iso() -> str:
|
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|