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

@@ -0,0 +1,198 @@
---
id: TASK-217
title: Add annual ranking snapshots for supported metrics
status: done
type: backend
team: Backend Senior
supporting_teams:
- Frontend Senior
- Arquitecto de Base de Datos
- Arquitecto Python
roadmap_item: foundation
priority: high
---
# TASK-217 - Add annual ranking snapshots for supported metrics
## Goal
Permitir ranking anual para metricas adicionales usando un snapshot anual independiente por metrica, sin runtime publico pesado ni reutilizacion del snapshot top kills para otras metricas.
## Context
`TASK-211` dejo la lectura publica anual rapida y basada en `rcon_annual_ranking_snapshots` y `rcon_annual_ranking_snapshot_items`. `TASK-216` mantuvo annual limitado a `kills` porque no habia snapshot propio para otras metricas y corrigio el falso KPM: `kills_per_match` debe mostrarse como `Kills/partida`, no como kills por minuto.
Esta task amplia la generacion annual para metricas que ya se pueden calcular de forma segura desde los read models materializados durante un proceso interno/CLI. La lectura publica sigue leyendo solo snapshots anuales ya generados.
Preserve the current product identity: Spanish-speaking HLL Vietnam community, military/Vietnam/tactical/sober visual direction and controlled repository evolution.
## Steps
1. Revisar la lectura y generacion anual actual.
2. Confirmar que el esquema permite `metric_value` decimal para ratios.
3. Actualizar la normalizacion de metricas annual soportadas.
4. Generar snapshots independientes por metrica:
- `kills`
- `deaths`
- `teamkills`
- `matches_considered`
- `kd_ratio`
- `kills_per_match`
5. Mantener la lectura publica anual sobre tablas de snapshot, sin fallback runtime.
6. Actualizar frontend para permitir metricas annual soportadas y mostrar `kills_per_match` como `Kills/partida`.
7. Añadir tests de normalizacion, lectura publica y calculo/orden de `kd_ratio` y `kills_per_match`.
## Files to Read First
- `AGENTS.md`
- `ai/repo-context.md`
- `ai/architecture-index.md`
- `backend/app/rcon_annual_rankings.py`
- `backend/app/payloads.py`
- `frontend/assets/js/ranking.js`
## Expected Files to Modify
- `backend/app/rcon_annual_rankings.py`
- `backend/app/rcon_admin_log_materialization.py`
- `backend/app/postgres_rcon_storage.py`
- `backend/tests/test_annual_ranking_payload.py`
- `frontend/ranking.html`
- `frontend/assets/js/ranking.js`
- `ai/tasks/done/TASK-217-add-annual-ranking-snapshots-for-supported-metrics.md`
## Constraints
- Keep the change minimal.
- Preserve HLL Vietnam project identity.
- Do not introduce unnecessary frameworks or dependencies.
- No ejecutar `ai-platform run`.
- No hacer push.
- No tocar `frontend/assets/img/weapons/`.
- No tocar SVGs.
- No modificar imagenes fisicas.
- No tocar `ai/system-metrics.md`.
- No reactivar Elo/MMR.
- No reintroducir Comunidad Hispana #03.
- No implementar KPM real en esta task.
- No mostrar `kills_per_match` como KPM.
- No exponer metricas annual sin snapshot anual propio.
- No usar el snapshot top kills para representar otras metricas.
- La lectura publica annual debe leer solo `rcon_annual_ranking_snapshots` y `rcon_annual_ranking_snapshot_items`.
## Validation
Before completing the task ensure:
- `node --check frontend/assets/js/ranking.js`
- `python -m compileall backend\app\rcon_annual_rankings.py backend\app\payloads.py backend\tests\test_annual_ranking_payload.py`
- `python -m unittest backend.tests.test_annual_ranking_payload`
- Medicion directa de `build_global_ranking_payload` annual para metricas soportadas si el entorno local tiene datos.
- `git diff --name-only` matches the expected scope.
- No unrelated files were modified.
## Outcome
Archivos modificados por esta task:
- `backend/app/rcon_annual_rankings.py`
- `backend/app/rcon_admin_log_materialization.py`
- `backend/app/postgres_rcon_storage.py`
- `backend/tests/test_annual_ranking_payload.py`
- `frontend/ranking.html`
- `frontend/assets/js/ranking.js`
- `ai/tasks/done/TASK-217-add-annual-ranking-snapshots-for-supported-metrics.md`
Metricas anuales soportadas:
- `kills`
- `deaths`
- `teamkills`
- `matches_considered`
- `kd_ratio`
- `kills_per_match`
Cambios de backend:
- `_normalize_metric()` acepta solo las seis metricas anuales soportadas.
- La generacion anual calcula y ordena snapshots independientes por metrica real.
- `kd_ratio` se calcula como `kills / deaths` sobre agregados.
- `kills_per_match` se calcula como `kills / matches_considered` y sigue siendo `Kills/partida`, no KPM.
- La lectura publica anual no cambia de modelo: sigue usando `rcon_annual_ranking_snapshots` y `rcon_annual_ranking_snapshot_items`.
- No se introdujo fallback runtime ni lectura publica sobre `rcon_match_player_stats`.
- Se agregaron tests de normalizacion, rechazo de metrica no soportada, lectura publica sin inicializar storage y orden/calculo para `kd_ratio` y `kills_per_match`.
Cambios de esquema:
- Si hubo cambios de esquema.
- `rcon_annual_ranking_snapshots.metric` ahora permite:
- `kills`
- `deaths`
- `teamkills`
- `matches_considered`
- `kd_ratio`
- `kills_per_match`
- `rcon_annual_ranking_snapshot_items.metric_value` pasa de entero a valor decimal:
- SQLite: `REAL`
- PostgreSQL: `DOUBLE PRECISION`
- La inicializacion PostgreSQL incluye una migracion idempotente para ajustar `metric_value` y reemplazar el `CHECK` antiguo.
Cambios de frontend:
- Annual permite seleccionar las metricas soportadas.
- No se muestra KPM.
- `kills_per_match` se muestra como `Kills/partida`.
- Se renombraron identificadores internos heredados de KPM a KPP.
- Annual mantiene `2026` interno y no muestra input de año.
- Si falta un snapshot anual, la UI muestra `Snapshot anual no disponible para esta metrica.` sin fallback ni bloqueo.
Comandos de produccion para generar snapshots anuales 2026:
```bash
for server in all-servers comunidad-hispana-01 comunidad-hispana-02; do
for metric in kills deaths teamkills matches_considered kd_ratio kills_per_match; do
docker compose exec backend python -m app.rcon_annual_rankings generate \
--year 2026 \
--server-key "$server" \
--metric "$metric" \
--limit 30 \
--replace-existing
done
done
```
Validaciones ejecutadas:
- `node --check frontend/assets/js/ranking.js`
- `$env:PYTHONPATH='backend'; python -m compileall backend\app\rcon_annual_rankings.py backend\app\payloads.py backend\tests\test_annual_ranking_payload.py backend\app\postgres_rcon_storage.py backend\app\rcon_admin_log_materialization.py`
- `$env:PYTHONPATH='backend'; python -m unittest backend.tests.test_annual_ranking_payload`
- Busqueda en `frontend/ranking.html` y `frontend/assets/js/ranking.js` para confirmar que no aparecen `KPM`, `Actualizar ranking`, `ranking-year`, `El ranking expone`, `Kills listo`, `listo en` ni `annualMetric`.
- Medicion directa con `build_global_ranking_payload(timeframe="annual", year=2026)` para las seis metricas soportadas.
Tiempos obtenidos en lectura directa local:
- `kills`: `1.66 ms`, `snapshot_status=ready`, `read_model=rcon-annual-ranking-snapshot`, `fallback_used=False`, `items=20`.
- `deaths`: `1.17 ms`, `snapshot_status=missing`, `read_model=rcon-annual-ranking-snapshot`, `fallback_used=False`, `items=0`.
- `teamkills`: `1.10 ms`, `snapshot_status=missing`, `read_model=rcon-annual-ranking-snapshot`, `fallback_used=False`, `items=0`.
- `matches_considered`: `1.09 ms`, `snapshot_status=missing`, `read_model=rcon-annual-ranking-snapshot`, `fallback_used=False`, `items=0`.
- `kd_ratio`: `1.11 ms`, `snapshot_status=missing`, `read_model=rcon-annual-ranking-snapshot`, `fallback_used=False`, `items=0`.
- `kills_per_match`: `1.10 ms`, `snapshot_status=missing`, `read_model=rcon-annual-ranking-snapshot`, `fallback_used=False`, `items=0`.
Confirmacion de exclusiones:
- No se ejecuto `ai-platform run`.
- No se hizo push.
- No se hizo commit.
- No se tocaron assets de armas.
- No se tocaron SVGs.
- No se modificaron imagenes fisicas.
- No se toco `ai/system-metrics.md`.
- No se reactivo Elo/MMR.
- No se reintrodujo Comunidad Hispana #03.
- No se incluyeron cambios previos no relacionados.
## Change Budget
- Archivos modificados por la task: 7.
- El cambio supera el objetivo preferente de 5 archivos porque incluye schema SQLite/PostgreSQL, generador, test, frontend y documentacion de task.
- No se implemento KPM real; queda fuera de alcance hasta materializar tiempo jugado real por jugador.

View File

@@ -201,7 +201,7 @@ CREATE TABLE IF NOT EXISTS rcon_annual_ranking_snapshots (
status TEXT NOT NULL DEFAULT 'ready', status TEXT NOT NULL DEFAULT 'ready',
source_matches_count INTEGER NOT NULL DEFAULT 0, source_matches_count INTEGER NOT NULL DEFAULT 0,
CHECK (limit_size > 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) UNIQUE (year, server_key, metric)
); );
@@ -211,7 +211,7 @@ CREATE TABLE IF NOT EXISTS rcon_annual_ranking_snapshot_items (
ranking_position INTEGER NOT NULL, ranking_position INTEGER NOT NULL,
player_id TEXT NOT NULL, player_id TEXT NOT NULL,
player_name 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, matches_considered INTEGER NOT NULL DEFAULT 0,
kills BIGINT NOT NULL DEFAULT 0, kills BIGINT NOT NULL DEFAULT 0,
deaths 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); 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: def initialize_postgres_rcon_storage() -> None:
"""Create deterministic PostgreSQL schema for migrated RCON domains.""" """Create deterministic PostgreSQL schema for migrated RCON domains."""
with connect_postgres() as connection: with connect_postgres() as connection:
with connection.cursor() as cursor: with connection.cursor() as cursor:
cursor.execute(RCON_SCHEMA_SQL) cursor.execute(RCON_SCHEMA_SQL)
cursor.execute(POSTGRES_ANNUAL_RANKING_SCHEMA_MIGRATION_SQL)
@contextmanager @contextmanager

View File

@@ -120,7 +120,7 @@ def initialize_rcon_materialized_storage(*, db_path: Path | None = None) -> Path
status TEXT NOT NULL DEFAULT 'ready', status TEXT NOT NULL DEFAULT 'ready',
source_matches_count INTEGER NOT NULL DEFAULT 0, source_matches_count INTEGER NOT NULL DEFAULT 0,
CHECK (limit_size > 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) 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, ranking_position INTEGER NOT NULL,
player_id TEXT NOT NULL, player_id TEXT NOT NULL,
player_name 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, matches_considered INTEGER NOT NULL DEFAULT 0,
kills INTEGER NOT NULL DEFAULT 0, kills INTEGER NOT NULL DEFAULT 0,
deaths INTEGER NOT NULL DEFAULT 0, deaths INTEGER NOT NULL DEFAULT 0,

View File

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

View File

@@ -1,8 +1,23 @@
import gc
import unittest 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 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): class AnnualRankingPayloadTests(unittest.TestCase):
@@ -28,6 +43,94 @@ class AnnualRankingPayloadTests(unittest.TestCase):
self.assertEqual(result["source"], "rcon-annual-ranking-snapshot") self.assertEqual(result["source"], "rcon-annual-ranking-snapshot")
self.assertEqual(result["requested_limit"], 30) 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -11,11 +11,10 @@
const tableNode = document.getElementById("ranking-table"); const tableNode = document.getElementById("ranking-table");
const tableBodyNode = document.getElementById("ranking-table-body"); const tableBodyNode = document.getElementById("ranking-table-body");
const metricHeadingNode = document.getElementById("ranking-metric-heading"); const metricHeadingNode = document.getElementById("ranking-metric-heading");
const kpmHeadingNode = document.getElementById("ranking-kpm-heading"); const kppHeadingNode = document.getElementById("ranking-kpp-heading");
const emptyNode = document.getElementById("ranking-empty"); const emptyNode = document.getElementById("ranking-empty");
const annualYear = 2026; const annualYear = 2026;
const annualMetric = "kills";
const defaultMetric = "kills"; const defaultMetric = "kills";
const defaultLimit = "20"; const defaultLimit = "20";
const defaultTimeframe = "weekly"; const defaultTimeframe = "weekly";
@@ -132,11 +131,11 @@
} }
Array.from(metricSelect.options).forEach((option) => { Array.from(metricSelect.options).forEach((option) => {
option.disabled = isAnnual && option.value !== annualMetric; option.disabled = isAnnual && !supportedMetrics.includes(option.value);
}); });
if (isAnnual && metricSelect.value !== annualMetric) { if (isAnnual && !supportedMetrics.includes(metricSelect.value)) {
metricSelect.value = annualMetric; metricSelect.value = defaultMetric;
} }
} }
@@ -313,9 +312,9 @@
} }
if (timeframe === "annual" && snapshotStatus === "missing") { if (timeframe === "annual" && snapshotStatus === "missing") {
setRankingState("warning", "El snapshot anual solicitado aun no fue generado."); setRankingState("warning", "Snapshot anual no disponible para esta metrica.");
renderEmptyState( renderEmptyState(
"No existe snapshot anual para el a\u00f1o y servidor elegidos. Este estado es informativo y no implica ca\u00edda del backend.", "Genera el snapshot anual para esta combinacion de metrica, servidor y a\u00f1o antes de publicarlo.",
); );
return; return;
} }
@@ -340,7 +339,7 @@
if (tableBodyNode) { if (tableBodyNode) {
tableBodyNode.innerHTML = items.map((item) => renderRow(item, metric)).join(""); tableBodyNode.innerHTML = items.map((item) => renderRow(item, metric)).join("");
} }
syncKpmColumn(metric); syncKppColumn(metric);
if (tableNode) { if (tableNode) {
tableNode.hidden = false; tableNode.hidden = false;
} }
@@ -387,7 +386,7 @@
item.kills, item.kills,
item.matches_considered, item.matches_considered,
); );
const hideKpmColumn = metric === "kills_per_match"; const hideKppColumn = metric === "kills_per_match";
return ` return `
<tr> <tr>
@@ -404,24 +403,24 @@
<td>${safeInt(item.teamkills, 0)}</td> <td>${safeInt(item.teamkills, 0)}</td>
<td>${safeInt(item.matches_considered, 0)}</td> <td>${safeInt(item.matches_considered, 0)}</td>
<td>${safeDecimal(item.kd_ratio, 2, "0.00")}</td> <td>${safeDecimal(item.kd_ratio, 2, "0.00")}</td>
${hideKpmColumn ? "" : `<td>${killsPerMatch}</td>`} ${hideKppColumn ? "" : `<td>${killsPerMatch}</td>`}
</tr> </tr>
`; `;
} }
function syncKpmColumn(metric) { function syncKppColumn(metric) {
if (!tableNode || !kpmHeadingNode) { if (!tableNode || !kppHeadingNode) {
return; return;
} }
const kpmColumnIndex = kpmHeadingNode.cellIndex + 1; const kppColumnIndex = kppHeadingNode.cellIndex + 1;
const hideKpmColumn = metric === "kills_per_match"; const hideKppColumn = metric === "kills_per_match";
kpmHeadingNode.hidden = hideKpmColumn; kppHeadingNode.hidden = hideKppColumn;
tableNode.querySelectorAll("tbody tr").forEach((row) => { tableNode.querySelectorAll("tbody tr").forEach((row) => {
const cell = row.children[kpmColumnIndex - 1]; const cell = row.children[kppColumnIndex - 1];
if (cell) { if (cell) {
cell.hidden = hideKpmColumn; cell.hidden = hideKppColumn;
} }
}); });
} }

View File

@@ -125,7 +125,7 @@
<th>Teamkills</th> <th>Teamkills</th>
<th>Partidas</th> <th>Partidas</th>
<th>K/D</th> <th>K/D</th>
<th id="ranking-kpm-heading">Kills/partida</th> <th id="ranking-kpp-heading">Kills/partida</th>
</tr> </tr>
</thead> </thead>
<tbody id="ranking-table-body"></tbody> <tbody id="ranking-table-body"></tbody>