Add annual ranking snapshots for supported metrics

This commit is contained in:
devRaGonSa
2026-06-10 12:12:51 +02:00
parent 6a27f4c2b5
commit d5dee75166
7 changed files with 492 additions and 93 deletions

View File

@@ -201,7 +201,7 @@ CREATE TABLE IF NOT EXISTS rcon_annual_ranking_snapshots (
status TEXT NOT NULL DEFAULT 'ready',
source_matches_count INTEGER NOT NULL DEFAULT 0,
CHECK (limit_size > 0),
CHECK (metric IN ('kills', 'deaths', 'matches_over_100_kills', 'support')),
CHECK (metric IN ('kills', 'deaths', 'teamkills', 'matches_considered', 'kd_ratio', 'kills_per_match')),
UNIQUE (year, server_key, metric)
);
@@ -211,7 +211,7 @@ CREATE TABLE IF NOT EXISTS rcon_annual_ranking_snapshot_items (
ranking_position INTEGER NOT NULL,
player_id TEXT NOT NULL,
player_name TEXT NOT NULL,
metric_value BIGINT NOT NULL DEFAULT 0,
metric_value DOUBLE PRECISION NOT NULL DEFAULT 0,
matches_considered INTEGER NOT NULL DEFAULT 0,
kills BIGINT NOT NULL DEFAULT 0,
deaths BIGINT NOT NULL DEFAULT 0,
@@ -379,12 +379,49 @@ CREATE INDEX IF NOT EXISTS idx_rcon_scoreboard_candidates_server_end
ON rcon_scoreboard_match_candidates(server_slug, ended_at DESC, started_at DESC);
"""
POSTGRES_ANNUAL_RANKING_SCHEMA_MIGRATION_SQL = """
ALTER TABLE rcon_annual_ranking_snapshot_items
ALTER COLUMN metric_value TYPE DOUBLE PRECISION USING metric_value::double precision;
DO $$
DECLARE
constraint_record record;
BEGIN
FOR constraint_record IN
SELECT con.conname
FROM pg_constraint AS con
JOIN pg_class AS rel ON rel.oid = con.conrelid
JOIN pg_namespace AS nsp ON nsp.oid = rel.relnamespace
WHERE rel.relname = 'rcon_annual_ranking_snapshots'
AND con.contype = 'c'
AND pg_get_constraintdef(con.oid) LIKE '%metric%'
LOOP
EXECUTE format(
'ALTER TABLE rcon_annual_ranking_snapshots DROP CONSTRAINT %I',
constraint_record.conname
);
END LOOP;
ALTER TABLE rcon_annual_ranking_snapshots
ADD CONSTRAINT rcon_annual_ranking_snapshots_metric_check
CHECK (metric IN (
'kills',
'deaths',
'teamkills',
'matches_considered',
'kd_ratio',
'kills_per_match'
));
END $$;
"""
def initialize_postgres_rcon_storage() -> None:
"""Create deterministic PostgreSQL schema for migrated RCON domains."""
with connect_postgres() as connection:
with connection.cursor() as cursor:
cursor.execute(RCON_SCHEMA_SQL)
cursor.execute(POSTGRES_ANNUAL_RANKING_SCHEMA_MIGRATION_SQL)
@contextmanager

View File

@@ -120,7 +120,7 @@ def initialize_rcon_materialized_storage(*, db_path: Path | None = None) -> Path
status TEXT NOT NULL DEFAULT 'ready',
source_matches_count INTEGER NOT NULL DEFAULT 0,
CHECK (limit_size > 0),
CHECK (metric IN ('kills', 'deaths', 'matches_over_100_kills', 'support')),
CHECK (metric IN ('kills', 'deaths', 'teamkills', 'matches_considered', 'kd_ratio', 'kills_per_match')),
UNIQUE (year, server_key, metric)
);
@@ -136,7 +136,7 @@ def initialize_rcon_materialized_storage(*, db_path: Path | None = None) -> Path
ranking_position INTEGER NOT NULL,
player_id TEXT NOT NULL,
player_name TEXT NOT NULL,
metric_value INTEGER NOT NULL DEFAULT 0,
metric_value REAL NOT NULL DEFAULT 0,
matches_considered INTEGER NOT NULL DEFAULT 0,
kills INTEGER NOT NULL DEFAULT 0,
deaths INTEGER NOT NULL DEFAULT 0,

View File

@@ -7,6 +7,7 @@ import json
import sqlite3
from contextlib import closing
from contextlib import contextmanager
from contextlib import nullcontext
from datetime import date, datetime, timezone
from pathlib import Path
@@ -16,6 +17,16 @@ from .rcon_admin_log_materialization import MATCH_RESULT_SOURCE, initialize_rcon
from .sqlite_utils import connect_sqlite_readonly, connect_sqlite_writer
SUPPORTED_ANNUAL_RANKING_METRICS = (
"kills",
"deaths",
"teamkills",
"matches_considered",
"kd_ratio",
"kills_per_match",
)
def generate_annual_ranking_snapshot(
*,
year: int,
@@ -34,75 +45,78 @@ def generate_annual_ranking_snapshot(
resolved_path = initialize_rcon_materialized_storage(db_path=db_path)
scope_sql, scope_params = _build_scope_sql(normalized_server_key)
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
postgres_enabled = use_postgres_rcon_storage(explicit_sqlite_path=db_path)
if postgres_enabled:
from .postgres_rcon_storage import connect_postgres_compat
connection_scope = connect_postgres_compat()
else:
connection_scope = connect_sqlite_writer(resolved_path)
connection_scope = closing(connect_sqlite_writer(resolved_path))
with connection_scope as connection:
source_matches_count = _count_matches_in_window(
connection=connection,
start=window_start,
end=window_end,
scope_sql=scope_sql,
scope_params=scope_params,
)
existing_snapshot_id = _find_existing_snapshot(
connection=connection,
year=normalized_year,
server_key=normalized_server_key,
metric=normalized_metric,
)
if existing_snapshot_id is not None and not replace_existing:
snapshot = _get_snapshot(connection=connection, snapshot_id=existing_snapshot_id)
items = _list_items(connection=connection, snapshot_id=existing_snapshot_id)
return {
"status": "ok",
"snapshot": snapshot,
"items": items,
"source_matches_count": source_matches_count,
"ranked_players": len(items),
"skipped_regeneration": True,
}
transaction_scope = nullcontext() if postgres_enabled else connection
with transaction_scope:
source_matches_count = _count_matches_in_window(
connection=connection,
start=window_start,
end=window_end,
scope_sql=scope_sql,
scope_params=scope_params,
)
existing_snapshot_id = _find_existing_snapshot(
connection=connection,
year=normalized_year,
server_key=normalized_server_key,
metric=normalized_metric,
)
if existing_snapshot_id is not None and not replace_existing:
snapshot = _get_snapshot(connection=connection, snapshot_id=existing_snapshot_id)
items = _list_items(connection=connection, snapshot_id=existing_snapshot_id)
return {
"status": "ok",
"snapshot": snapshot,
"items": items,
"source_matches_count": source_matches_count,
"ranked_players": len(items),
"skipped_regeneration": True,
}
ranking_rows = _fetch_annual_ranking_rows(
connection=connection,
start=window_start,
end=window_end,
metric=normalized_metric,
limit=normalized_limit,
scope_sql=scope_sql,
scope_params=scope_params,
)
ranking_rows = _fetch_annual_ranking_rows(
connection=connection,
start=window_start,
end=window_end,
metric=normalized_metric,
limit=normalized_limit,
scope_sql=scope_sql,
scope_params=scope_params,
)
_delete_existing_snapshot(
connection=connection,
year=normalized_year,
server_key=normalized_server_key,
metric=normalized_metric,
)
snapshot_id = _insert_snapshot(
connection=connection,
year=normalized_year,
server_key=normalized_server_key,
metric=normalized_metric,
limit=normalized_limit,
source_matches_count=source_matches_count,
window_start=window_start,
window_end=window_end,
)
_delete_existing_snapshot(
connection=connection,
year=normalized_year,
server_key=normalized_server_key,
metric=normalized_metric,
)
snapshot_id = _insert_snapshot(
connection=connection,
year=normalized_year,
server_key=normalized_server_key,
metric=normalized_metric,
limit=normalized_limit,
source_matches_count=source_matches_count,
window_start=window_start,
window_end=window_end,
)
_insert_items(
connection=connection,
snapshot_id=snapshot_id,
rows=ranking_rows,
limit=normalized_limit,
)
_insert_items(
connection=connection,
snapshot_id=snapshot_id,
rows=ranking_rows,
limit=normalized_limit,
)
snapshot = _get_snapshot(connection=connection, snapshot_id=snapshot_id)
items = _list_items(connection=connection, snapshot_id=snapshot_id)
snapshot = _get_snapshot(connection=connection, snapshot_id=snapshot_id)
items = _list_items(connection=connection, snapshot_id=snapshot_id)
return {
"status": "ok",
@@ -241,7 +255,7 @@ def _normalize_server_key(server_key: str | None) -> str:
def _normalize_metric(metric: str) -> str:
normalized = str(metric or "kills").strip().lower()
if normalized != "kills":
if normalized not in SUPPORTED_ANNUAL_RANKING_METRICS:
raise ValueError(
f"Metric '{normalized}' is not supported for annual ranking snapshots."
)
@@ -287,20 +301,18 @@ def _fetch_annual_ranking_rows(
scope_sql: str,
scope_params: list[object],
) -> list[dict[str, object]]:
# For now metric support is intentionally narrowed to kills only.
if metric != "kills":
return []
metric_sql, having_sql = _resolve_metric_sql(metric)
rows = connection.execute(
f"""
SELECT
stats.player_id,
COALESCE(MAX(stats.player_name), stats.player_id) AS player_name,
{metric_sql} AS metric_value,
SUM(COALESCE(stats.kills, 0)) AS kills,
SUM(COALESCE(stats.deaths, 0)) AS deaths,
SUM(COALESCE(stats.teamkills, 0)) AS teamkills,
COUNT(DISTINCT stats.match_key) AS matches_considered,
SUM(COALESCE(stats.kills, 0)) AS metric_value
COUNT(DISTINCT stats.match_key) AS matches_considered
FROM rcon_match_player_stats AS stats
INNER JOIN rcon_materialized_matches AS matches
ON matches.target_key = stats.target_key
@@ -311,8 +323,8 @@ def _fetch_annual_ranking_rows(
{scope_sql}
AND TRIM(COALESCE(stats.player_name, '')) != ''
GROUP BY stats.player_id
HAVING SUM(COALESCE(stats.kills, 0)) > 0
ORDER BY metric_value DESC, matches_considered DESC, player_name ASC
{having_sql}
ORDER BY metric_value DESC, matches_considered DESC, kills DESC, player_name ASC
LIMIT ?
""",
[MATCH_RESULT_SOURCE, start, end, *scope_params, limit],
@@ -320,6 +332,43 @@ def _fetch_annual_ranking_rows(
return [dict(row) for row in rows]
def _resolve_metric_sql(metric: str) -> tuple[str, str]:
metric_sql_by_metric = {
"kills": "SUM(COALESCE(stats.kills, 0))",
"deaths": "SUM(COALESCE(stats.deaths, 0))",
"teamkills": "SUM(COALESCE(stats.teamkills, 0))",
"matches_considered": "COUNT(DISTINCT stats.match_key)",
"kd_ratio": (
"CASE "
"WHEN SUM(COALESCE(stats.deaths, 0)) > 0 "
"THEN ROUND(CAST(SUM(COALESCE(stats.kills, 0)) AS NUMERIC) / "
"CAST(SUM(COALESCE(stats.deaths, 0)) AS NUMERIC), 2) "
"ELSE CAST(SUM(COALESCE(stats.kills, 0)) AS NUMERIC) "
"END"
),
"kills_per_match": (
"CASE "
"WHEN COUNT(DISTINCT stats.match_key) > 0 "
"THEN ROUND(CAST(SUM(COALESCE(stats.kills, 0)) AS NUMERIC) / "
"CAST(COUNT(DISTINCT stats.match_key) AS NUMERIC), 2) "
"ELSE CAST(0 AS NUMERIC) "
"END"
),
}
having_sql_by_metric = {
"kills": "HAVING SUM(COALESCE(stats.kills, 0)) > 0",
"deaths": "HAVING SUM(COALESCE(stats.deaths, 0)) > 0",
"teamkills": "HAVING SUM(COALESCE(stats.teamkills, 0)) > 0",
"matches_considered": "HAVING COUNT(DISTINCT stats.match_key) > 0",
"kd_ratio": "HAVING SUM(COALESCE(stats.kills, 0)) > 0",
"kills_per_match": (
"HAVING COUNT(DISTINCT stats.match_key) > 0 "
"AND SUM(COALESCE(stats.kills, 0)) > 0"
),
}
return metric_sql_by_metric[metric], having_sql_by_metric[metric]
def _count_matches_in_window(
*,
connection: object,
@@ -457,7 +506,10 @@ def _insert_snapshot(
source_matches_count,
],
)
row = cursor.fetchone()
try:
row = cursor.fetchone()
finally:
cursor.close()
if row is not None and row["id"] is not None:
return int(row["id"])
@@ -482,7 +534,7 @@ def _insert_items(
for index, row in enumerate(rows[:limit], start=1):
kills = int(row.get("kills") or 0)
deaths = int(row.get("deaths") or 0)
metric_value = int(row.get("metric_value") or 0)
metric_value = _coerce_metric_value(row.get("metric_value"))
connection.execute(
"""
INSERT INTO rcon_annual_ranking_snapshot_items (
@@ -563,6 +615,16 @@ def _list_items(*, connection: object, snapshot_id: int, limit: int | None = Non
return [dict(row) for row in rows]
def _coerce_metric_value(value: object) -> int | float:
try:
numeric = float(value or 0)
except (TypeError, ValueError):
return 0
if numeric.is_integer():
return int(numeric)
return round(numeric, 2)
def _count_items(*, connection: object, snapshot_id: int) -> int:
row = connection.execute(
"""

View File

@@ -1,8 +1,23 @@
import gc
import unittest
from contextlib import nullcontext
import warnings
from contextlib import closing, nullcontext
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
from app.rcon_annual_rankings import get_annual_ranking_snapshot
from app.historical_storage import ALL_SERVERS_SLUG
from app.rcon_admin_log_materialization import (
MATCH_RESULT_SOURCE,
initialize_rcon_materialized_storage,
)
from app.rcon_annual_rankings import (
SUPPORTED_ANNUAL_RANKING_METRICS,
_normalize_metric,
generate_annual_ranking_snapshot,
get_annual_ranking_snapshot,
)
from app.sqlite_utils import connect_sqlite_writer
class AnnualRankingPayloadTests(unittest.TestCase):
@@ -28,6 +43,94 @@ class AnnualRankingPayloadTests(unittest.TestCase):
self.assertEqual(result["source"], "rcon-annual-ranking-snapshot")
self.assertEqual(result["requested_limit"], 30)
def test_normalize_metric_accepts_supported_annual_metrics(self):
for metric in SUPPORTED_ANNUAL_RANKING_METRICS:
with self.subTest(metric=metric):
self.assertEqual(_normalize_metric(metric), metric)
def test_normalize_metric_rejects_unsupported_annual_metrics(self):
with self.assertRaises(ValueError):
_normalize_metric("kills_per_minute")
def test_generate_annual_snapshot_orders_kd_ratio_and_kills_per_match(self):
with TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "annual-ranking.sqlite3"
self._seed_materialized_stats(db_path)
kd_result = generate_annual_ranking_snapshot(
year=2026,
server_key=ALL_SERVERS_SLUG,
metric="kd_ratio",
limit=10,
db_path=db_path,
)
kpp_result = generate_annual_ranking_snapshot(
year=2026,
server_key=ALL_SERVERS_SLUG,
metric="kills_per_match",
limit=10,
db_path=db_path,
)
kd_items = kd_result["items"]
kpp_items = kpp_result["items"]
self.assertEqual(kd_items[0]["player_id"], "player-bravo")
self.assertEqual(kd_items[0]["metric_value"], 10)
self.assertEqual(kpp_items[0]["player_id"], "player-bravo")
self.assertEqual(kpp_items[0]["metric_value"], 20)
self.assertEqual(kpp_items[1]["player_id"], "player-alpha")
self.assertEqual(kpp_items[1]["metric_value"], 15)
with warnings.catch_warnings():
warnings.simplefilter("ignore", ResourceWarning)
gc.collect()
def _seed_materialized_stats(self, db_path: Path) -> None:
initialize_rcon_materialized_storage(db_path=db_path)
with closing(connect_sqlite_writer(db_path)) as connection:
with connection:
for match_key in ("match-1", "match-2", "match-3"):
connection.execute(
"""
INSERT INTO rcon_materialized_matches (
target_key,
external_server_id,
match_key,
started_at,
ended_at,
confidence_mode,
source_basis
) VALUES (?, ?, ?, ?, ?, ?, ?)
""",
[
"target-1",
"comunidad-hispana-01",
match_key,
"2026-01-01T19:00:00Z",
"2026-01-01T20:00:00Z",
"exact",
MATCH_RESULT_SOURCE,
],
)
for row in (
("match-1", "player-alpha", "Alpha", 12, 4, 0),
("match-2", "player-alpha", "Alpha", 18, 6, 1),
("match-3", "player-bravo", "Bravo", 20, 2, 0),
):
connection.execute(
"""
INSERT INTO rcon_match_player_stats (
target_key,
match_key,
player_id,
player_name,
kills,
deaths,
teamkills
) VALUES (?, ?, ?, ?, ?, ?, ?)
""",
["target-1", *row],
)
if __name__ == "__main__":
unittest.main()