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',
|
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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,14 +45,17 @@ 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:
|
||||||
|
transaction_scope = nullcontext() if postgres_enabled else connection
|
||||||
|
with transaction_scope:
|
||||||
source_matches_count = _count_matches_in_window(
|
source_matches_count = _count_matches_in_window(
|
||||||
connection=connection,
|
connection=connection,
|
||||||
start=window_start,
|
start=window_start,
|
||||||
@@ -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,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
row = cursor.fetchone()
|
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(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user