Files
comunidadhll/backend/app/rcon_annual_rankings.py
2026-06-10 08:52:15 +02:00

638 lines
20 KiB
Python

"""Annual ranking snapshot generator and reader over materialized RCON match stats."""
from __future__ import annotations
import argparse
import json
import sqlite3
from contextlib import closing
from contextlib import contextmanager
from datetime import date, datetime, timezone
from pathlib import Path
from .config import get_storage_path, use_postgres_rcon_storage
from .historical_storage import ALL_SERVERS_SLUG
from .rcon_admin_log_materialization import MATCH_RESULT_SOURCE, initialize_rcon_materialized_storage
from .sqlite_utils import connect_sqlite_readonly, connect_sqlite_writer
def generate_annual_ranking_snapshot(
*,
year: int,
server_key: str | None = None,
metric: str = "kills",
limit: int = 20,
replace_existing: bool = True,
db_path: Path | None = None,
) -> dict[str, object]:
"""Generate and persist an annual top-k ranking snapshot for materialized RCON data."""
normalized_year = _normalize_year(year)
normalized_server_key = _normalize_server_key(server_key)
normalized_metric = _normalize_metric(metric)
normalized_limit = _normalize_limit(limit)
window_start, window_end = _annual_window(normalized_year)
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):
from .postgres_rcon_storage import connect_postgres_compat
connection_scope = connect_postgres_compat()
else:
connection_scope = 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,
}
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,
)
_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)
return {
"status": "ok",
"snapshot": snapshot,
"items": items,
"source_matches_count": source_matches_count,
"ranked_players": len(items),
}
def get_annual_ranking_snapshot(
*,
year: int,
server_key: str | None = None,
metric: str = "kills",
limit: int = 20,
db_path: Path | None = None,
) -> dict[str, object]:
"""Load one annual ranking snapshot without recalculating the ranking."""
normalized_year = _normalize_year(year)
normalized_server_key = _normalize_server_key(server_key)
normalized_metric = _normalize_metric(metric)
normalized_limit = _normalize_limit(limit)
try:
with _open_annual_snapshot_read_connection(db_path=db_path) as connection:
snapshot = _find_snapshot(
connection=connection,
year=normalized_year,
server_key=normalized_server_key,
metric=normalized_metric,
)
if snapshot is None:
return _build_missing_snapshot_result(
year=normalized_year,
server_key=normalized_server_key,
metric=normalized_metric,
limit=normalized_limit,
)
snapshot_limit = _normalize_limit(snapshot.get("limit_size") or normalized_limit)
item_count = _count_items(
connection=connection,
snapshot_id=int(snapshot["id"]),
)
effective_limit = _resolve_effective_limit(
requested_limit=normalized_limit,
snapshot_limit=snapshot_limit,
item_count=item_count,
)
items = _list_items(
connection=connection,
snapshot_id=int(snapshot["id"]),
limit=effective_limit if effective_limit > 0 else None,
)
except (FileNotFoundError, sqlite3.OperationalError):
return _build_missing_snapshot_result(
year=normalized_year,
server_key=normalized_server_key,
metric=normalized_metric,
limit=normalized_limit,
)
return {
"snapshot_status": "ready",
"year": normalized_year,
"server_id": normalized_server_key,
"metric": normalized_metric,
"limit": effective_limit,
"requested_limit": normalized_limit,
"effective_limit": effective_limit,
"snapshot_limit": snapshot_limit,
"item_count": item_count,
"source": "rcon-annual-ranking-snapshot",
"generated_at": snapshot.get("generated_at"),
"window_start": snapshot.get("window_start"),
"window_end": snapshot.get("window_end"),
"source_matches_count": int(snapshot.get("source_matches_count") or 0),
"items": items,
}
@contextmanager
def _open_annual_snapshot_read_connection(*, db_path: Path | None = None):
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
from .postgres_rcon_storage import PostgresCompatConnection, connect_postgres
with connect_postgres() as connection:
yield PostgresCompatConnection(connection)
return
resolved_path = _resolve_annual_snapshot_sqlite_path(db_path=db_path)
if not resolved_path.exists():
raise FileNotFoundError(resolved_path)
with closing(connect_sqlite_readonly(resolved_path)) as connection:
yield connection
def _resolve_annual_snapshot_sqlite_path(*, db_path: Path | None = None) -> Path:
return db_path if db_path is not None else get_storage_path()
def _build_missing_snapshot_result(
*,
year: int,
server_key: str,
metric: str,
limit: int,
) -> dict[str, object]:
return {
"snapshot_status": "missing",
"year": year,
"server_id": server_key,
"metric": metric,
"limit": limit,
"requested_limit": limit,
"effective_limit": 0,
"snapshot_limit": None,
"item_count": 0,
"source": "rcon-annual-ranking-snapshot",
"generated_at": None,
"window_start": None,
"window_end": None,
"source_matches_count": 0,
"items": [],
}
def _normalize_server_key(server_key: str | None) -> str:
normalized = str(server_key or "").strip()
normalized_lower = normalized.lower()
if not normalized or normalized_lower in {ALL_SERVERS_SLUG, "all"}:
return ALL_SERVERS_SLUG
return normalized
def _normalize_metric(metric: str) -> str:
normalized = str(metric or "kills").strip().lower()
if normalized != "kills":
raise ValueError(
f"Metric '{normalized}' is not supported for annual ranking snapshots."
)
return normalized
def _normalize_year(year: int) -> int:
normalized_year = int(year)
if normalized_year < 1 or normalized_year > 9999:
raise ValueError("year must be between 1 and 9999")
return normalized_year
def _normalize_limit(limit: object, *, maximum: int = 100) -> int:
normalized_limit = int(limit or 1)
if normalized_limit < 1:
raise ValueError("limit must be greater than zero")
return min(normalized_limit, maximum)
def _resolve_effective_limit(
*,
requested_limit: int,
snapshot_limit: int,
item_count: int,
) -> int:
return max(0, min(requested_limit, snapshot_limit, item_count))
def _annual_window(year: int) -> tuple[str, str]:
start = datetime(year, 1, 1, 0, 0, 0, tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
return start, end
def _fetch_annual_ranking_rows(
*,
connection: object,
start: str,
end: str,
metric: str,
limit: int,
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 []
rows = connection.execute(
f"""
SELECT
stats.player_id,
COALESCE(MAX(stats.player_name), stats.player_id) AS player_name,
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
FROM rcon_match_player_stats AS stats
INNER JOIN rcon_materialized_matches AS matches
ON matches.target_key = stats.target_key
AND matches.match_key = stats.match_key
WHERE matches.source_basis = ?
AND COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) >= ?
AND COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) < ?
{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
LIMIT ?
""",
[MATCH_RESULT_SOURCE, start, end, *scope_params, limit],
).fetchall()
return [dict(row) for row in rows]
def _count_matches_in_window(
*,
connection: object,
start: str,
end: str,
scope_sql: str,
scope_params: list[object],
) -> int:
row = connection.execute(
f"""
SELECT COUNT(*) AS source_matches_count
FROM (
SELECT matches.target_key, matches.match_key
FROM rcon_materialized_matches AS matches
WHERE matches.source_basis = ?
AND COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) >= ?
AND COALESCE(CAST(matches.ended_at AS TEXT), CAST(matches.started_at AS TEXT)) < ?
{scope_sql}
GROUP BY matches.target_key, matches.match_key
) AS source_matches
""",
[MATCH_RESULT_SOURCE, start, end, *scope_params],
).fetchone()
return int(row["source_matches_count"] or 0) if row else 0
def _find_snapshot(
*,
connection: object,
year: int,
server_key: str,
metric: str,
) -> dict[str, object] | None:
row = connection.execute(
"""
SELECT
id,
year,
server_key,
metric,
limit_size,
source_basis,
window_start,
window_end,
status,
source_matches_count,
generated_at
FROM rcon_annual_ranking_snapshots
WHERE year = ? AND server_key = ? AND metric = ?
LIMIT 1
""",
[year, server_key, metric],
).fetchone()
return dict(row) if row else None
def _find_existing_snapshot(
*,
connection: object,
year: int,
server_key: str,
metric: str,
) -> int | None:
row = connection.execute(
"""
SELECT id
FROM rcon_annual_ranking_snapshots
WHERE year = ? AND server_key = ? AND metric = ?
LIMIT 1
""",
[year, server_key, metric],
).fetchone()
return int(row["id"]) if row else None
def _delete_existing_snapshot(
*,
connection: object,
year: int,
server_key: str,
metric: str,
) -> int | None:
existing_id = _find_existing_snapshot(
connection=connection,
year=year,
server_key=server_key,
metric=metric,
)
if existing_id is None:
return None
connection.execute(
"DELETE FROM rcon_annual_ranking_snapshot_items WHERE snapshot_id = ?",
(existing_id,),
)
connection.execute(
"DELETE FROM rcon_annual_ranking_snapshots WHERE id = ?",
(existing_id,),
)
return existing_id
def _insert_snapshot(
*,
connection: object,
year: int,
server_key: str,
metric: str,
limit: int,
source_matches_count: int,
window_start: str,
window_end: str,
) -> int:
cursor = connection.execute(
"""
INSERT INTO rcon_annual_ranking_snapshots (
year,
server_key,
metric,
limit_size,
source_basis,
window_start,
window_end,
source_matches_count
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id
""",
[
year,
server_key,
metric,
limit,
MATCH_RESULT_SOURCE,
window_start,
window_end,
source_matches_count,
],
)
row = cursor.fetchone()
if row is not None and row["id"] is not None:
return int(row["id"])
existing = _find_existing_snapshot(
connection=connection,
year=year,
server_key=server_key,
metric=metric,
)
if existing is None:
raise RuntimeError("Unable to resolve annual snapshot id after insert.")
return existing
def _insert_items(
*,
connection: object,
snapshot_id: int,
rows: list[dict[str, object]],
limit: int,
) -> None:
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)
connection.execute(
"""
INSERT INTO rcon_annual_ranking_snapshot_items (
snapshot_id,
ranking_position,
player_id,
player_name,
metric_value,
matches_considered,
kills,
deaths,
teamkills,
kd_ratio
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
snapshot_id,
index,
str(row.get("player_id")),
str(row.get("player_name")),
metric_value,
int(row.get("matches_considered") or 0),
kills,
deaths,
int(row.get("teamkills") or 0),
float(kills / deaths) if deaths else float(kills),
],
)
def _get_snapshot(*, connection: object, snapshot_id: int) -> dict[str, object] | None:
row = connection.execute(
"""
SELECT
id,
year,
server_key,
metric,
limit_size,
source_basis,
window_start,
window_end,
status,
source_matches_count,
generated_at
FROM rcon_annual_ranking_snapshots
WHERE id = ?
LIMIT 1
""",
[snapshot_id],
).fetchone()
if not row:
return None
return dict(row)
def _list_items(*, connection: object, snapshot_id: int, limit: int | None = None) -> list[dict[str, object]]:
params: list[object] = [snapshot_id]
query = """
SELECT
ranking_position,
player_id,
player_name,
metric_value,
matches_considered,
kills,
deaths,
teamkills,
kd_ratio
FROM rcon_annual_ranking_snapshot_items
WHERE snapshot_id = ?
ORDER BY ranking_position ASC
"""
if limit is not None:
query = f"{query}\n LIMIT ?"
params.append(limit)
rows = connection.execute(query, params).fetchall()
return [dict(row) for row in rows]
def _count_items(*, connection: object, snapshot_id: int) -> int:
row = connection.execute(
"""
SELECT COUNT(*) AS item_count
FROM rcon_annual_ranking_snapshot_items
WHERE snapshot_id = ?
""",
[snapshot_id],
).fetchone()
return int(row["item_count"] or 0) if row else 0
def _build_scope_sql(server_key: str, *, table_alias: str = "matches") -> tuple[str, list[object]]:
if server_key == ALL_SERVERS_SLUG:
return "", []
return (
f"AND ({table_alias}.target_key = ? OR {table_alias}.external_server_id = ?)",
[server_key, server_key],
)
def _json_default(value: object) -> str:
if isinstance(value, datetime):
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
if isinstance(value, date):
return value.isoformat()
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
def _main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Generate annual ranking snapshots.")
subparsers = parser.add_subparsers(dest="command")
generate_parser = subparsers.add_parser("generate")
generate_parser.add_argument("--year", type=int, required=True)
generate_parser.add_argument("--server-key", default=None)
generate_parser.add_argument("--metric", default="kills")
generate_parser.add_argument("--limit", type=int, default=20)
generate_parser.add_argument(
"--sqlite-path",
type=Path,
default=None,
help="explicit local SQLite override; default operational mode uses PostgreSQL when configured",
)
generate_parser.add_argument("--replace-existing", action="store_true", default=True)
parser.set_defaults(command="generate")
args = parser.parse_args(argv)
if args.command == "generate":
payload = generate_annual_ranking_snapshot(
year=args.year,
server_key=args.server_key,
metric=args.metric,
limit=args.limit,
replace_existing=args.replace_existing,
db_path=args.sqlite_path,
)
print(
json.dumps(
{"status": "ok", "data": payload},
ensure_ascii=True,
indent=2,
default=_json_default,
)
)
return 0
parser.print_help()
return 2
if __name__ == "__main__":
raise SystemExit(_main())