Add annual ranking snapshots for supported metrics
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,14 +45,17 @@ 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:
|
||||
transaction_scope = nullcontext() if postgres_enabled else connection
|
||||
with transaction_scope:
|
||||
source_matches_count = _count_matches_in_window(
|
||||
connection=connection,
|
||||
start=window_start,
|
||||
@@ -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,
|
||||
],
|
||||
)
|
||||
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(
|
||||
"""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 `
|
||||
<tr>
|
||||
@@ -404,24 +403,24 @@
|
||||
<td>${safeInt(item.teamkills, 0)}</td>
|
||||
<td>${safeInt(item.matches_considered, 0)}</td>
|
||||
<td>${safeDecimal(item.kd_ratio, 2, "0.00")}</td>
|
||||
${hideKpmColumn ? "" : `<td>${killsPerMatch}</td>`}
|
||||
${hideKppColumn ? "" : `<td>${killsPerMatch}</td>`}
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
<th>Teamkills</th>
|
||||
<th>Partidas</th>
|
||||
<th>K/D</th>
|
||||
<th id="ranking-kpm-heading">Kills/partida</th>
|
||||
<th id="ranking-kpp-heading">Kills/partida</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ranking-table-body"></tbody>
|
||||
|
||||
Reference in New Issue
Block a user