diff --git a/ai/tasks/done/TASK-217-add-annual-ranking-snapshots-for-supported-metrics.md b/ai/tasks/done/TASK-217-add-annual-ranking-snapshots-for-supported-metrics.md new file mode 100644 index 0000000..4e0864e --- /dev/null +++ b/ai/tasks/done/TASK-217-add-annual-ranking-snapshots-for-supported-metrics.md @@ -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. diff --git a/backend/app/postgres_rcon_storage.py b/backend/app/postgres_rcon_storage.py index 313a118..87411a0 100644 --- a/backend/app/postgres_rcon_storage.py +++ b/backend/app/postgres_rcon_storage.py @@ -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 diff --git a/backend/app/rcon_admin_log_materialization.py b/backend/app/rcon_admin_log_materialization.py index ef030c5..6b5d90c 100644 --- a/backend/app/rcon_admin_log_materialization.py +++ b/backend/app/rcon_admin_log_materialization.py @@ -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, diff --git a/backend/app/rcon_annual_rankings.py b/backend/app/rcon_annual_rankings.py index 8530227..7b9720b 100644 --- a/backend/app/rcon_annual_rankings.py +++ b/backend/app/rcon_annual_rankings.py @@ -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( """ diff --git a/backend/tests/test_annual_ranking_payload.py b/backend/tests/test_annual_ranking_payload.py index e0aafbd..bfaa360 100644 --- a/backend/tests/test_annual_ranking_payload.py +++ b/backend/tests/test_annual_ranking_payload.py @@ -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() diff --git a/frontend/assets/js/ranking.js b/frontend/assets/js/ranking.js index 82a27a5..4df3ba3 100644 --- a/frontend/assets/js/ranking.js +++ b/frontend/assets/js/ranking.js @@ -11,11 +11,10 @@ const tableNode = document.getElementById("ranking-table"); const tableBodyNode = document.getElementById("ranking-table-body"); 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 annualYear = 2026; - const annualMetric = "kills"; const defaultMetric = "kills"; const defaultLimit = "20"; const defaultTimeframe = "weekly"; @@ -132,11 +131,11 @@ } Array.from(metricSelect.options).forEach((option) => { - option.disabled = isAnnual && option.value !== annualMetric; + option.disabled = isAnnual && !supportedMetrics.includes(option.value); }); - if (isAnnual && metricSelect.value !== annualMetric) { - metricSelect.value = annualMetric; + if (isAnnual && !supportedMetrics.includes(metricSelect.value)) { + metricSelect.value = defaultMetric; } } @@ -313,9 +312,9 @@ } 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( - "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; } @@ -340,7 +339,7 @@ if (tableBodyNode) { tableBodyNode.innerHTML = items.map((item) => renderRow(item, metric)).join(""); } - syncKpmColumn(metric); + syncKppColumn(metric); if (tableNode) { tableNode.hidden = false; } @@ -387,7 +386,7 @@ item.kills, item.matches_considered, ); - const hideKpmColumn = metric === "kills_per_match"; + const hideKppColumn = metric === "kills_per_match"; return ` @@ -404,24 +403,24 @@ ${safeInt(item.teamkills, 0)} ${safeInt(item.matches_considered, 0)} ${safeDecimal(item.kd_ratio, 2, "0.00")} - ${hideKpmColumn ? "" : `${killsPerMatch}`} + ${hideKppColumn ? "" : `${killsPerMatch}`} `; } - function syncKpmColumn(metric) { - if (!tableNode || !kpmHeadingNode) { + function syncKppColumn(metric) { + if (!tableNode || !kppHeadingNode) { return; } - const kpmColumnIndex = kpmHeadingNode.cellIndex + 1; - const hideKpmColumn = metric === "kills_per_match"; - kpmHeadingNode.hidden = hideKpmColumn; + const kppColumnIndex = kppHeadingNode.cellIndex + 1; + const hideKppColumn = metric === "kills_per_match"; + kppHeadingNode.hidden = hideKppColumn; tableNode.querySelectorAll("tbody tr").forEach((row) => { - const cell = row.children[kpmColumnIndex - 1]; + const cell = row.children[kppColumnIndex - 1]; if (cell) { - cell.hidden = hideKpmColumn; + cell.hidden = hideKppColumn; } }); } diff --git a/frontend/ranking.html b/frontend/ranking.html index aa64bc7..f1316bd 100644 --- a/frontend/ranking.html +++ b/frontend/ranking.html @@ -125,7 +125,7 @@ Teamkills Partidas K/D - Kills/partida + Kills/partida