Implement monthly MVP V2 workflow and snapshots

This commit is contained in:
devRaGonSa
2026-03-24 16:40:19 +01:00
parent c84ebad5af
commit af140d6f3e
13 changed files with 1620 additions and 1 deletions

View File

@@ -0,0 +1,81 @@
# TASK-081-player-event-snapshots-and-api
## Goal
Exponer por snapshots JSON y API propia del backend las nuevas métricas derivadas V2 de eventos de jugador, manteniendo la filosofía de lectura rápida y desacoplada del request path pesado.
## Context
La base V2 ya existe:
- source adapter
- raw ledger
- incremental worker
- derived aggregates
Ahora hace falta convertir esas métricas derivadas en una capa consumible por producto y por futuras fórmulas de MVP V2 sin depender de consultas pesadas on-demand.
Las primeras métricas que deben quedar listas para lectura rápida son:
- most_killed
- death_by
- duel summaries
- weapon kills
- teamkills derivados
## Steps
1. Revisar los agregados V2 ya implementados.
2. Diseñar snapshots JSON adecuados para estas métricas avanzadas por:
- servidor
- all-servers cuando aplique
- periodo mensual si corresponde
3. Exponer endpoints claros y consistentes para leer esas métricas.
4. Mantener la misma filosofía que el histórico actual:
- lectura rápida
- sin cálculos pesados en el request path
5. Incluir metadatos claros en los snapshots cuando aplique:
- generated_at
- period/month_key
- source_range_start
- source_range_end
- found / is_stale si aplica
6. Integrar la generación de estos snapshots con la operativa existente o con una vía equivalente razonable.
7. No implementar todavía la UI V2 final.
8. No romper el MVP V1 ni los snapshots actuales.
9. Al completar la implementación:
- dejar el repositorio consistente
- hacer commit
- hacer push al remoto si el entorno lo permite
## Files to Read First
- AGENTS.md
- backend/README.md
- backend/app/historical_snapshots.py
- backend/app/historical_snapshot_storage.py
- backend/app/routes.py
- backend/app/payloads.py
- backend/app/player_event_aggregates.py
- backend/app/player_event_storage.py
- docs/player-event-pipeline-v2-design.md
## Expected Files to Modify
- backend/app/routes.py
- backend/app/payloads.py
- backend/app/historical_snapshots.py
- backend/app/historical_snapshot_storage.py
- backend/README.md
- opcionalmente nuevos módulos auxiliares si mejoran claridad
## Constraints
- No recalcular estas métricas pesadas en cada request.
- No romper la API histórica existente.
- No tocar todavía la UI final del MVP V2.
- No hacer cambios destructivos.
- Mantener el trabajo centrado en snapshots/API V2.
## Validation
- Existen snapshots JSON de métricas V2.
- Existen endpoints backend para leerlas.
- La lectura es rápida y coherente con la arquitectura actual.
- No se rompe el MVP V1 ni el histórico actual.
- Los cambios quedan committeados y se hace push si el entorno lo permite.
## Change Budget
- Preferir menos de 6 archivos modificados o creados.
- Preferir menos de 240 líneas cambiadas.

View File

@@ -0,0 +1,69 @@
# TASK-082-monthly-mvp-v2-scoring-design-adjustment
## Goal
Diseñar o ajustar de forma precisa la fórmula del MVP mensual V2 incorporando las nuevas métricas derivadas de eventos de jugador ya disponibles.
## Context
La V1 del MVP mensual ya existe y se apoya en métricas persistidas básicas. Con la nueva capa V2 de eventos y agregados, ya se pueden plantear señales más ricas como:
- kills por arma
- teamkills derivados
- duelos y rivalidades
- most_killed / death_by
- posible ponderación por tipo de kill si la señal es suficientemente fiable
Antes de implementar el backend del MVP V2, hace falta cerrar la fórmula concreta y sus pesos.
## Steps
1. Revisar el diseño de scoring V1 y la nueva disponibilidad de métricas V2.
2. Decidir qué métricas avanzadas entran realmente en la V2.
3. Diseñar una fórmula concreta para el MVP mensual V2, incluyendo:
- pesos
- elegibilidad
- penalizaciones
- tratamiento de muestras pequeñas
- desempates
4. Decidir si:
- las kills deben ponderarse por arma/tipo
- cómo influye teamkill
- cómo influye duelo/rivalidad
- qué parte de la V1 se mantiene
5. Mantener la fórmula defendible y explicable, evitando complejidad artificial si la señal aún no es robusta.
6. Dejar clara la compatibilidad o convivencia entre:
- MVP V1
- MVP V2
7. No implementar todavía la UI.
8. Al completar la implementación:
- dejar el repositorio consistente
- hacer commit
- hacer push al remoto si el entorno lo permite
## Files to Read First
- AGENTS.md
- docs/monthly-mvp-ranking-scoring-design.md
- docs/player-event-pipeline-v2-design.md
- docs/monthly-player-ranking-data-audit.md
- backend/README.md
- backend/app/player_event_aggregates.py
- cualquier snapshot/API V2 disponible tras la task previa
## Expected Files to Modify
- docs/monthly-mvp-v2-scoring-design.md
- opcionalmente docs/decisions.md
- opcionalmente ai/architecture-index.md
## Constraints
- No implementar todavía el cálculo backend del MVP V2.
- No tocar producto visible.
- No depender de métricas no confirmadas.
- No hacer cambios destructivos.
- Mantener el trabajo centrado en diseño de scoring V2.
## Validation
- Existe un documento claro con la fórmula V2 propuesta.
- Quedan definidos pesos, elegibilidad, penalizaciones y desempates.
- Queda claro qué señales entran realmente en V2.
- Los cambios quedan committeados y se hace push si el entorno lo permite.
## Change Budget
- Preferir menos de 4 archivos modificados o creados.
- Preferir menos de 220 líneas cambiadas.

View File

@@ -0,0 +1,64 @@
# TASK-083-monthly-mvp-v2-backend-calculation
## Goal
Implementar en backend el cálculo mensual del MVP V2 a partir de la fórmula aprobada y de las métricas derivadas V2 ya disponibles por snapshots/agregados.
## Context
La base V2 ya existe y, tras cerrar el diseño de scoring, hace falta materializar el ranking mensual MVP V2 en backend. Esta versión debe poder convivir con la V1 mientras se valida en producto.
## Steps
1. Revisar:
- scoring V2 aprobado
- agregados V2 disponibles
- snapshots/API de métricas avanzadas
2. Implementar el cálculo del ranking mensual MVP V2 por:
- servidor
- all-servers cuando aplique
3. Aplicar correctamente:
- pesos
- elegibilidad
- penalizaciones
- desempates
4. Mantenerlo separado del MVP V1.
5. Preparar el resultado para poder exponerlo después por snapshot/API y, más tarde, por UI.
6. Documentar la nueva capacidad backend.
7. No implementar todavía la UI final.
8. Al completar la implementación:
- dejar el repositorio consistente
- hacer commit
- hacer push al remoto si el entorno lo permite
## Files to Read First
- AGENTS.md
- docs/monthly-mvp-v2-scoring-design.md
- backend/README.md
- backend/app/player_event_aggregates.py
- backend/app/historical_storage.py
- backend/app/payloads.py
- backend/app/routes.py
- backend/app/historical_snapshots.py
## Expected Files to Modify
- backend/README.md
- backend/app/payloads.py
- backend/app/routes.py
- backend/app/historical_storage.py
- opcionalmente nuevos módulos, por ejemplo:
- backend/app/monthly_mvp_v2.py
- backend/app/monthly_mvp_v2_scoring.py
## Constraints
- No romper el MVP V1.
- No tocar todavía la UI final.
- No hacer cambios destructivos.
- Mantener el trabajo centrado en el cálculo backend del MVP V2.
## Validation
- Existe un cálculo backend funcional del monthly MVP V2.
- Soporta servidor y/o all-servers según diseño.
- Convive con la V1.
- Los cambios quedan committeados y se hace push si el entorno lo permite.
## Change Budget
- Preferir menos de 6 archivos modificados o creados.
- Preferir menos de 240 líneas cambiadas.

View File

@@ -0,0 +1,65 @@
# TASK-084-monthly-mvp-v2-snapshots-and-api
## Goal
Exponer el ranking mensual MVP V2 mediante snapshots JSON y API propia del backend, manteniéndolo separado del MVP V1 y preparado para una futura UI.
## Context
Una vez exista el cálculo backend del MVP V2, hace falta integrarlo en la capa de snapshots y API para lectura rápida, sin añadir cálculos pesados al request path.
## Steps
1. Revisar el cálculo backend del MVP V2.
2. Diseñar snapshots adecuados para:
- MVP V2 por servidor
- MVP V2 all-servers si aplica
3. Exponer endpoints claros y consistentes para leer estos snapshots.
4. Incluir metadatos útiles:
- generated_at
- month_key / periodo
- found
- source_range_start / source_range_end si aplica
5. Integrar la generación en la operativa existente de snapshots si corresponde.
6. Mantener la separación explícita entre:
- MVP V1
- MVP V2
7. No implementar todavía la UI V2 final en esta task.
8. Al completar la implementación:
- dejar el repositorio consistente
- hacer commit
- hacer push al remoto si el entorno lo permite
## Files to Read First
- AGENTS.md
- backend/README.md
- backend/app/historical_snapshots.py
- backend/app/historical_snapshot_storage.py
- backend/app/routes.py
- backend/app/payloads.py
- backend/app/historical_runner.py
- backend/app/config.py
## Expected Files to Modify
- backend/app/routes.py
- backend/app/payloads.py
- backend/app/historical_snapshots.py
- backend/app/historical_snapshot_storage.py
- backend/app/historical_runner.py
- backend/README.md
- opcionalmente nuevos módulos auxiliares si mejoran claridad
## Constraints
- No romper snapshots existentes.
- No mezclar MVP V1 y V2.
- No tocar todavía la UI final.
- No hacer cambios destructivos.
- Mantener el trabajo centrado en snapshots/API del MVP V2.
## Validation
- Existen snapshots JSON del MVP V2.
- Existen endpoints backend para leerlos.
- La lectura es rápida.
- MVP V1 y V2 conviven claramente.
- Los cambios quedan committeados y se hace push si el entorno lo permite.
## Change Budget
- Preferir menos de 6 archivos modificados o creados.
- Preferir menos de 240 líneas cambiadas.

View File

@@ -191,12 +191,16 @@ normaliza espacios y barras finales para mantener la comparacion con el header
- `GET /api/historical/weekly-leaderboard?metric=kills&limit=10&server=comunidad-hispana-01`
- `GET /api/historical/leaderboard?timeframe=monthly&metric=kills&limit=10&server=comunidad-hispana-01`
- `GET /api/historical/monthly-mvp?limit=10&server=comunidad-hispana-01`
- `GET /api/historical/monthly-mvp-v2?limit=10&server=comunidad-hispana-01`
- `GET /api/historical/player-events?view=most-killed&limit=10&server=comunidad-hispana-01`
- `GET /api/historical/recent-matches?limit=20&server=comunidad-hispana-01`
- `GET /api/historical/server-summary?server=comunidad-hispana-01`
- `GET /api/historical/snapshots/server-summary?server=comunidad-hispana-01`
- `GET /api/historical/snapshots/weekly-leaderboard?metric=kills&limit=10&server=comunidad-hispana-01`
- `GET /api/historical/snapshots/leaderboard?timeframe=monthly&metric=kills&limit=10&server=comunidad-hispana-01`
- `GET /api/historical/snapshots/monthly-mvp?limit=10&server=comunidad-hispana-01`
- `GET /api/historical/snapshots/monthly-mvp-v2?limit=10&server=comunidad-hispana-01`
- `GET /api/historical/snapshots/player-events?view=most-killed&limit=10&server=comunidad-hispana-01`
- `GET /api/historical/snapshots/recent-matches?limit=6&server=comunidad-hispana-01`
- `GET /api/historical/player-profile?player=steam%3A76561198000000000`
@@ -779,6 +783,34 @@ Tambien persiste `monthly-mvp.json` por servidor y para `all-servers`, listo
para lectura rapida desde `/api/historical/monthly-mvp` y
`/api/historical/snapshots/monthly-mvp` sin recalculo pesado en request.
La misma operativa persiste tambien snapshots V2 de eventos de jugador para el
ultimo mes con datos disponible por servidor y para `all-servers`, listos para
lectura rapida sin consultas pesadas on-demand:
- `player-events-most-killed.json`
- `player-events-death-by.json`
- `player-events-duels.json`
- `player-events-weapon-kills.json`
- `player-events-teamkills.json`
Los endpoints `/api/historical/player-events` y
`/api/historical/snapshots/player-events` aceptan:
- `view=most-killed`
- `view=death-by`
- `view=duels`
- `view=weapon-kills`
- `view=teamkills`
La respuesta expone metadata operativa alineada con el resto de snapshots:
- `generated_at`
- `month_key`
- `source_range_start`
- `source_range_end`
- `found`
- `is_stale`
El backend incluye ademas el calculo interno de `monthly MVP V1` en
`app/monthly_mvp.py`, separado de los leaderboards mensuales simples por
metrica. Ese calculo:
@@ -793,6 +825,35 @@ En esta fase el ranking MVP queda listo para serializar en snapshots o payloads
sin reemplazar los leaderboards mensuales ya existentes por `kills`, `deaths`,
`support` y `matches_over_100_kills`.
La repo incluye tambien un calculo backend separado de `monthly MVP V2` en
`app/monthly_mvp_v2.py`, expuesto de momento por
`/api/historical/monthly-mvp-v2`.
Esta V2:
- convive sin reemplazar `monthly MVP V1`
- reusa la misma ventana mensual y la misma elegibilidad base
- anade `rivalry_edge` y `duel_control` derivados del ledger V2 de eventos
- aplica una penalizacion de teamkills mas estricta
- mantiene fuera del score el peso por arma o tipo de kill hasta validar mejor
esas senales
Esa capacidad V2 se persiste tambien en snapshots dedicados
`monthly-mvp-v2.json` por servidor y para `all-servers`, leidos por:
- `/api/historical/monthly-mvp-v2`
- `/api/historical/snapshots/monthly-mvp-v2`
La lectura HTTP de V2 sigue asi la misma politica de fast path de solo lectura
que el resto de snapshots historicos, con metadata util como:
- `generated_at`
- `month_key`
- `found`
- `source_range_start`
- `source_range_end`
- `event_coverage`
## Ingesta historica CRCON
La ingesta historica no usa A2S ni scraping del HTML de `/games`. Consume la

View File

@@ -142,6 +142,7 @@ def generate_historical_snapshots(
"full_snapshot_every_runs": full_snapshot_every_runs,
"prewarm_only": not should_run_full_refresh,
"refresh_interval_seconds": get_historical_refresh_interval_seconds(),
"includes_monthly_mvp_v2": True,
}

View File

@@ -255,7 +255,12 @@ def _is_effectively_empty_snapshot_payload(
items = payload.get("items")
return not isinstance(items, list) or len(items) == 0
if snapshot_type in {"weekly-leaderboard", "monthly-leaderboard", "monthly-mvp"}:
if snapshot_type in {
"weekly-leaderboard",
"monthly-leaderboard",
"monthly-mvp",
"monthly-mvp-v2",
}:
items = payload.get("items")
return not isinstance(items, list) or len(items) == 0
@@ -287,6 +292,18 @@ def _build_snapshot_filename(*, snapshot_type: str, metric: str | None) -> str:
return "server-summary.json"
if snapshot_type == "recent-matches":
return "recent-matches.json"
if snapshot_type == "monthly-mvp-v2":
return "monthly-mvp-v2.json"
if snapshot_type == "player-event-most-killed":
return "player-events-most-killed.json"
if snapshot_type == "player-event-death-by":
return "player-events-death-by.json"
if snapshot_type == "player-event-duels":
return "player-events-duels.json"
if snapshot_type == "player-event-weapon-kills":
return "player-events-weapon-kills.json"
if snapshot_type == "player-event-teamkills":
return "player-events-teamkills.json"
if snapshot_type == "weekly-leaderboard":
metric_suffix = "matches-over-100-kills" if metric == "matches_over_100_kills" else _slugify(metric or "unknown")
return f"weekly-{metric_suffix}.json"

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
@@ -11,15 +12,30 @@ from .historical_storage import (
list_historical_servers,
list_monthly_leaderboard,
list_monthly_mvp_ranking,
list_monthly_mvp_v2_ranking,
list_recent_historical_matches,
list_weekly_leaderboard,
)
from .player_event_aggregates import (
list_death_by,
list_most_killed,
list_net_duel_summaries,
list_teamkill_summaries,
list_weapon_kills,
)
from .player_event_storage import initialize_player_event_storage
SNAPSHOT_TYPE_SERVER_SUMMARY = "server-summary"
SNAPSHOT_TYPE_WEEKLY_LEADERBOARD = "weekly-leaderboard"
SNAPSHOT_TYPE_MONTHLY_LEADERBOARD = "monthly-leaderboard"
SNAPSHOT_TYPE_MONTHLY_MVP = "monthly-mvp"
SNAPSHOT_TYPE_MONTHLY_MVP_V2 = "monthly-mvp-v2"
SNAPSHOT_TYPE_RECENT_MATCHES = "recent-matches"
SNAPSHOT_TYPE_PLAYER_EVENT_MOST_KILLED = "player-event-most-killed"
SNAPSHOT_TYPE_PLAYER_EVENT_DEATH_BY = "player-event-death-by"
SNAPSHOT_TYPE_PLAYER_EVENT_DUELS = "player-event-duels"
SNAPSHOT_TYPE_PLAYER_EVENT_WEAPON_KILLS = "player-event-weapon-kills"
SNAPSHOT_TYPE_PLAYER_EVENT_TEAMKILLS = "player-event-teamkills"
SUPPORTED_SNAPSHOT_TYPES = frozenset(
{
@@ -27,7 +43,13 @@ SUPPORTED_SNAPSHOT_TYPES = frozenset(
SNAPSHOT_TYPE_WEEKLY_LEADERBOARD,
SNAPSHOT_TYPE_MONTHLY_LEADERBOARD,
SNAPSHOT_TYPE_MONTHLY_MVP,
SNAPSHOT_TYPE_MONTHLY_MVP_V2,
SNAPSHOT_TYPE_RECENT_MATCHES,
SNAPSHOT_TYPE_PLAYER_EVENT_MOST_KILLED,
SNAPSHOT_TYPE_PLAYER_EVENT_DEATH_BY,
SNAPSHOT_TYPE_PLAYER_EVENT_DUELS,
SNAPSHOT_TYPE_PLAYER_EVENT_WEAPON_KILLS,
SNAPSHOT_TYPE_PLAYER_EVENT_TEAMKILLS,
}
)
@@ -52,6 +74,13 @@ SNAPSHOT_LEADERBOARD_METRICS = (
"matches_over_100_kills",
"support",
)
PLAYER_EVENT_SNAPSHOT_TYPES = (
SNAPSHOT_TYPE_PLAYER_EVENT_MOST_KILLED,
SNAPSHOT_TYPE_PLAYER_EVENT_DEATH_BY,
SNAPSHOT_TYPE_PLAYER_EVENT_DUELS,
SNAPSHOT_TYPE_PLAYER_EVENT_WEAPON_KILLS,
SNAPSHOT_TYPE_PLAYER_EVENT_TEAMKILLS,
)
DEFAULT_SNAPSHOT_WINDOW = "all-time"
DEFAULT_WEEKLY_SNAPSHOT_WINDOW = "7d"
@@ -134,6 +163,24 @@ def build_historical_server_snapshots(
db_path=db_path,
)
)
snapshots.append(
_build_monthly_mvp_v2_snapshot(
server_key,
generated_at_value,
limit=leaderboard_limit,
db_path=db_path,
)
)
for snapshot_type in PLAYER_EVENT_SNAPSHOT_TYPES:
snapshots.append(
_build_player_event_snapshot(
server_key,
snapshot_type,
generated_at_value,
limit=leaderboard_limit,
db_path=db_path,
)
)
snapshots.append(
_build_recent_matches_snapshot(
@@ -188,6 +235,24 @@ def build_priority_historical_snapshots(
db_path=db_path,
)
)
snapshots.append(
_build_monthly_mvp_v2_snapshot(
server_key,
generated_at_value,
limit=leaderboard_limit,
db_path=db_path,
)
)
for snapshot_type in PLAYER_EVENT_SNAPSHOT_TYPES:
snapshots.append(
_build_player_event_snapshot(
server_key,
snapshot_type,
generated_at_value,
limit=leaderboard_limit,
db_path=db_path,
)
)
snapshots.append(
_build_recent_matches_snapshot(
server_key,
@@ -420,6 +485,58 @@ def _build_recent_matches_snapshot(
}
def _build_player_event_snapshot(
server_key: str,
snapshot_type: str,
generated_at: datetime,
*,
limit: int,
db_path: Path | None = None,
) -> dict[str, object]:
month_key = _get_latest_player_event_month_key(server_key=server_key, db_path=db_path)
source_range_start = None
source_range_end = None
items: list[dict[str, object]] = []
found = False
if month_key:
source_range_start, source_range_end = _get_player_event_source_range(
server_key=server_key,
month_key=month_key,
db_path=db_path,
)
items = _list_player_event_snapshot_items(
snapshot_type=snapshot_type,
server_key=server_key,
month_key=month_key,
limit=limit,
db_path=db_path,
)
found = bool(items or source_range_start or source_range_end)
return {
"server_key": server_key,
"snapshot_type": snapshot_type,
"metric": None,
"window": DEFAULT_MONTHLY_SNAPSHOT_WINDOW,
"generated_at": generated_at,
"source_range_start": source_range_start,
"source_range_end": source_range_end,
"is_stale": False,
"payload": {
"server_key": server_key,
"period": "monthly",
"month_key": month_key,
"limit": limit,
"found": found,
"generated_at": _to_iso(generated_at),
"source_range_start": _to_iso(source_range_start) if source_range_start else None,
"source_range_end": _to_iso(source_range_end) if source_range_end else None,
"items": items,
},
}
def _build_monthly_mvp_snapshot(
server_key: str,
generated_at: datetime,
@@ -452,6 +569,45 @@ def _build_monthly_mvp_snapshot(
}
def _build_monthly_mvp_v2_snapshot(
server_key: str,
generated_at: datetime,
*,
limit: int,
db_path: Path | None = None,
) -> dict[str, object]:
ranking_result = list_monthly_mvp_v2_ranking(
limit=limit,
server_id=server_key,
db_path=db_path,
)
month_key = str(ranking_result.get("window_start") or "")[:7] or None
event_coverage = ranking_result.get("event_coverage")
source_range_start = None
source_range_end = None
if isinstance(event_coverage, dict):
source_range_start = _parse_optional_timestamp(event_coverage.get("source_range_start"))
source_range_end = _parse_optional_timestamp(event_coverage.get("source_range_end"))
return {
"server_key": server_key,
"snapshot_type": SNAPSHOT_TYPE_MONTHLY_MVP_V2,
"metric": None,
"window": DEFAULT_MONTHLY_SNAPSHOT_WINDOW,
"generated_at": generated_at,
"source_range_start": source_range_start,
"source_range_end": source_range_end,
"is_stale": False,
"payload": {
"server_key": server_key,
"limit": limit,
"month_key": month_key,
"found": bool(event_coverage.get("ready")) if isinstance(event_coverage, dict) else False,
"generated_at": _to_iso(generated_at),
**ranking_result,
},
}
def _resolve_snapshot_target_keys(
*,
server_key: str | None,
@@ -470,6 +626,93 @@ def _resolve_snapshot_target_keys(
return [normalized_server_key, ALL_SERVERS_SLUG]
def _list_player_event_snapshot_items(
*,
snapshot_type: str,
server_key: str,
month_key: str,
limit: int,
db_path: Path | None,
) -> list[dict[str, object]]:
aggregator_by_snapshot_type = {
SNAPSHOT_TYPE_PLAYER_EVENT_MOST_KILLED: list_most_killed,
SNAPSHOT_TYPE_PLAYER_EVENT_DEATH_BY: list_death_by,
SNAPSHOT_TYPE_PLAYER_EVENT_DUELS: list_net_duel_summaries,
SNAPSHOT_TYPE_PLAYER_EVENT_WEAPON_KILLS: list_weapon_kills,
SNAPSHOT_TYPE_PLAYER_EVENT_TEAMKILLS: list_teamkill_summaries,
}
aggregator = aggregator_by_snapshot_type[snapshot_type]
return aggregator(
server_slug=server_key,
month=month_key,
limit=limit,
db_path=db_path,
)
def _get_latest_player_event_month_key(
*,
server_key: str,
db_path: Path | None = None,
) -> str | None:
resolved_path = initialize_player_event_storage(db_path=db_path)
where_sql, params = _build_player_event_scope_where(server_key=server_key)
with _connect(resolved_path) as connection:
row = connection.execute(
f"""
SELECT MAX(substr(occurred_at, 1, 7)) AS latest_month
FROM player_event_raw_ledger
WHERE occurred_at IS NOT NULL
AND {where_sql}
""",
params,
).fetchone()
if not row or not row["latest_month"]:
return None
return str(row["latest_month"])
def _get_player_event_source_range(
*,
server_key: str,
month_key: str,
db_path: Path | None = None,
) -> tuple[datetime | None, datetime | None]:
resolved_path = initialize_player_event_storage(db_path=db_path)
where_sql, params = _build_player_event_scope_where(server_key=server_key)
with _connect(resolved_path) as connection:
row = connection.execute(
f"""
SELECT
MIN(occurred_at) AS source_range_start,
MAX(occurred_at) AS source_range_end
FROM player_event_raw_ledger
WHERE occurred_at IS NOT NULL
AND substr(occurred_at, 1, 7) = ?
AND {where_sql}
""",
[month_key, *params],
).fetchone()
if not row:
return None, None
return (
_parse_optional_timestamp(row["source_range_start"]),
_parse_optional_timestamp(row["source_range_end"]),
)
def _build_player_event_scope_where(*, server_key: str) -> tuple[str, list[object]]:
if server_key == ALL_SERVERS_SLUG:
return "1 = 1", []
return "server_slug = ?", [server_key]
def _connect(db_path: Path) -> sqlite3.Connection:
connection = sqlite3.connect(db_path)
connection.row_factory = sqlite3.Row
return connection
def _parse_optional_timestamp(value: object) -> datetime | None:
if not isinstance(value, str) or not value.strip():
return None

View File

@@ -14,6 +14,7 @@ from .config import (
)
from .historical_models import HistoricalServerDefinition
from .monthly_mvp import build_monthly_mvp_rankings
from .monthly_mvp_v2 import build_monthly_mvp_v2_rankings
DEFAULT_HISTORICAL_SERVERS = (
@@ -1569,6 +1570,331 @@ def list_monthly_mvp_ranking(
}
def list_monthly_mvp_v2_ranking(
*,
limit: int = 10,
server_id: str | None = None,
db_path: Path | None = None,
) -> dict[str, object]:
"""Return the monthly MVP V2 ranking built from monthly totals plus V2 signals."""
resolved_path = initialize_historical_storage(db_path=db_path)
aggregate_all_servers = _is_all_servers_selector(server_id)
current_time = datetime.now(timezone.utc)
current_month_start = _start_of_month(current_time)
previous_month_start = _start_of_previous_month(current_month_start)
monthly_window = _select_monthly_window(
server_id=server_id,
current_time=current_time,
current_month_start=current_month_start,
previous_month_start=previous_month_start,
db_path=resolved_path,
)
window_start = monthly_window["window_start"]
window_end = monthly_window["window_end"]
month_key = window_start.strftime("%Y-%m")
event_coverage = _get_monthly_player_event_coverage(
server_id=server_id,
month_key=month_key,
db_path=resolved_path,
)
window_days = _calculate_window_days(window_start=window_start, window_end=window_end)
empty_result = {
"timeframe": "monthly",
"metric": "mvp-v2",
"ranking_version": "v2",
"window_start": window_start.isoformat().replace("+00:00", "Z"),
"window_end": window_end.isoformat().replace("+00:00", "Z"),
"window_days": window_days,
"window_kind": monthly_window["window_kind"],
"window_label": monthly_window["window_label"],
"uses_fallback": monthly_window["uses_fallback"],
"selection_reason": monthly_window["selection_reason"],
"current_month_start": current_month_start.isoformat().replace("+00:00", "Z"),
"current_month_closed_matches": monthly_window["current_month_closed_matches"],
"previous_month_closed_matches": monthly_window["previous_month_closed_matches"],
"sufficient_sample": {
"minimum_closed_matches": monthly_window["minimum_closed_matches"],
"current_month_closed_matches": monthly_window["current_month_closed_matches"],
"current_month_has_sufficient_sample": monthly_window["current_month_has_sufficient_sample"],
"is_early_month": monthly_window["is_early_month"],
},
"event_coverage": event_coverage,
}
if not bool(event_coverage["ready"]):
return {
**empty_result,
"eligibility": None,
"eligible_players_count": 0,
"items": [],
}
where_clauses = [
"historical_matches.ended_at IS NOT NULL",
"historical_matches.ended_at >= ?",
"historical_matches.ended_at < ?",
]
params: list[object] = [
window_start.isoformat().replace("+00:00", "Z"),
window_end.isoformat().replace("+00:00", "Z"),
]
if server_id and not aggregate_all_servers:
normalized_server_id = server_id.strip()
where_clauses.append(
"(historical_servers.slug = ? OR CAST(historical_servers.server_number AS TEXT) = ?)"
)
params.extend([normalized_server_id, normalized_server_id])
event_scope_sql, event_scope_params = _build_player_event_scope_sql(server_id)
server_slug_expression = (
f"'{ALL_SERVERS_SLUG}'"
if aggregate_all_servers
else "historical_servers.slug"
)
server_name_expression = (
f"'{ALL_SERVERS_DISPLAY_NAME}'"
if aggregate_all_servers
else "historical_servers.display_name"
)
group_by_expression = (
"historical_players.id"
if aggregate_all_servers
else "historical_servers.slug, historical_players.id"
)
with _connect(resolved_path) as connection:
rows = connection.execute(
f"""
WITH most_killed_pairs AS (
SELECT
killer_player_key AS stable_player_key,
victim_player_key,
COALESCE(SUM(event_value), 0) AS total_kills
FROM player_event_raw_ledger
WHERE event_type = 'player_kill_summary'
AND occurred_at IS NOT NULL
AND substr(occurred_at, 1, 7) = ?
AND {event_scope_sql}
AND killer_player_key IS NOT NULL
AND victim_player_key IS NOT NULL
GROUP BY killer_player_key, victim_player_key
),
most_killed_by_player AS (
SELECT
stable_player_key,
MAX(total_kills) AS most_killed_count
FROM most_killed_pairs
GROUP BY stable_player_key
),
death_by_pairs AS (
SELECT
victim_player_key AS stable_player_key,
killer_player_key,
COALESCE(SUM(event_value), 0) AS total_kills
FROM player_event_raw_ledger
WHERE event_type = 'player_death_summary'
AND occurred_at IS NOT NULL
AND substr(occurred_at, 1, 7) = ?
AND {event_scope_sql}
AND killer_player_key IS NOT NULL
AND victim_player_key IS NOT NULL
GROUP BY victim_player_key, killer_player_key
),
death_by_player AS (
SELECT
stable_player_key,
MAX(total_kills) AS death_by_count
FROM death_by_pairs
GROUP BY stable_player_key
),
duel_pairs AS (
SELECT
CASE
WHEN COALESCE(killer_player_key, '') <= COALESCE(victim_player_key, '')
THEN killer_player_key
ELSE victim_player_key
END AS player_a_key,
CASE
WHEN COALESCE(killer_player_key, '') <= COALESCE(victim_player_key, '')
THEN victim_player_key
ELSE killer_player_key
END AS player_b_key,
COALESCE(
SUM(
CASE
WHEN COALESCE(killer_player_key, '') <= COALESCE(victim_player_key, '')
THEN event_value
ELSE -event_value
END
),
0
) AS net_duel_value
FROM player_event_raw_ledger
WHERE event_type = 'player_kill_summary'
AND occurred_at IS NOT NULL
AND substr(occurred_at, 1, 7) = ?
AND {event_scope_sql}
AND killer_player_key IS NOT NULL
AND victim_player_key IS NOT NULL
GROUP BY
CASE
WHEN COALESCE(killer_player_key, '') <= COALESCE(victim_player_key, '')
THEN killer_player_key
ELSE victim_player_key
END,
CASE
WHEN COALESCE(killer_player_key, '') <= COALESCE(victim_player_key, '')
THEN victim_player_key
ELSE killer_player_key
END
),
duel_player_values AS (
SELECT
player_a_key AS stable_player_key,
CASE WHEN net_duel_value > 0 THEN net_duel_value ELSE 0 END AS positive_duel_value
FROM duel_pairs
UNION ALL
SELECT
player_b_key AS stable_player_key,
CASE WHEN net_duel_value < 0 THEN -net_duel_value ELSE 0 END AS positive_duel_value
FROM duel_pairs
),
ranked_duel_values AS (
SELECT
stable_player_key,
positive_duel_value,
ROW_NUMBER() OVER (
PARTITION BY stable_player_key
ORDER BY positive_duel_value DESC
) AS duel_rank
FROM duel_player_values
WHERE stable_player_key IS NOT NULL
AND positive_duel_value > 0
),
duel_control_by_player AS (
SELECT
stable_player_key,
COALESCE(SUM(positive_duel_value), 0) AS duel_control_raw
FROM ranked_duel_values
WHERE duel_rank <= 3
GROUP BY stable_player_key
)
SELECT
{server_slug_expression} AS server_slug,
{server_name_expression} AS server_name,
historical_players.stable_player_key,
historical_players.display_name AS player_name,
historical_players.steam_id,
COUNT(DISTINCT historical_matches.id) AS matches_count,
COALESCE(SUM(historical_player_match_stats.kills), 0) AS total_kills,
COALESCE(SUM(historical_player_match_stats.deaths), 0) AS total_deaths,
COALESCE(SUM(historical_player_match_stats.support), 0) AS total_support,
COALESCE(SUM(historical_player_match_stats.teamkills), 0) AS total_teamkills,
COALESCE(SUM(historical_player_match_stats.time_seconds), 0) AS total_time_seconds,
COALESCE(MAX(most_killed_by_player.most_killed_count), 0) AS most_killed_count,
COALESCE(MAX(death_by_player.death_by_count), 0) AS death_by_count,
COALESCE(MAX(duel_control_by_player.duel_control_raw), 0) AS duel_control_raw
FROM historical_player_match_stats
INNER JOIN historical_matches
ON historical_matches.id = historical_player_match_stats.historical_match_id
INNER JOIN historical_servers
ON historical_servers.id = historical_matches.historical_server_id
INNER JOIN historical_players
ON historical_players.id = historical_player_match_stats.historical_player_id
LEFT JOIN most_killed_by_player
ON most_killed_by_player.stable_player_key = historical_players.stable_player_key
LEFT JOIN death_by_player
ON death_by_player.stable_player_key = historical_players.stable_player_key
LEFT JOIN duel_control_by_player
ON duel_control_by_player.stable_player_key = historical_players.stable_player_key
WHERE {" AND ".join(where_clauses)}
GROUP BY {group_by_expression}
""",
[
month_key,
*event_scope_params,
month_key,
*event_scope_params,
month_key,
*event_scope_params,
*params,
],
).fetchall()
ranking_result = build_monthly_mvp_v2_rankings(
[dict(row) for row in rows],
limit=limit,
)
for item in ranking_result["items"]:
item["time_range"] = {
"start": window_start.isoformat().replace("+00:00", "Z"),
"end": window_end.isoformat().replace("+00:00", "Z"),
"window_days": window_days,
}
return {
**empty_result,
"ranking_version": ranking_result["ranking_version"],
"eligibility": ranking_result["eligibility"],
"eligible_players_count": ranking_result["eligible_players_count"],
"items": ranking_result["items"],
}
def _get_monthly_player_event_coverage(
*,
server_id: str | None,
month_key: str,
db_path: Path,
) -> dict[str, object]:
scope_sql, scope_params = _build_player_event_scope_sql(server_id)
with _connect(db_path) as connection:
latest_row = connection.execute(
f"""
SELECT MAX(substr(occurred_at, 1, 7)) AS latest_month_key
FROM player_event_raw_ledger
WHERE occurred_at IS NOT NULL
AND {scope_sql}
""",
scope_params,
).fetchone()
month_row = connection.execute(
f"""
SELECT
COUNT(*) AS event_count,
MIN(occurred_at) AS source_range_start,
MAX(occurred_at) AS source_range_end
FROM player_event_raw_ledger
WHERE occurred_at IS NOT NULL
AND substr(occurred_at, 1, 7) = ?
AND {scope_sql}
""",
[month_key, *scope_params],
).fetchone()
latest_month_key = str(latest_row["latest_month_key"]) if latest_row and latest_row["latest_month_key"] else None
event_count = int(month_row["event_count"] or 0) if month_row else 0
return {
"month_key": month_key,
"latest_month_key": latest_month_key,
"ready": bool(event_count > 0 and latest_month_key == month_key),
"event_count": event_count,
"source_range_start": month_row["source_range_start"] if month_row else None,
"source_range_end": month_row["source_range_end"] if month_row else None,
"selection_reason": (
"month-key-aligned"
if event_count > 0 and latest_month_key == month_key
else "player-event-month-mismatch-or-missing"
),
}
def _build_player_event_scope_sql(server_id: str | None) -> tuple[str, list[object]]:
if not server_id or _is_all_servers_selector(server_id):
return "1 = 1", []
normalized_server_id = server_id.strip()
return "server_slug = ?", [normalized_server_id]
def _connect(db_path: Path) -> sqlite3.Connection:
connection = sqlite3.connect(db_path, timeout=30.0)
connection.row_factory = sqlite3.Row

View File

@@ -0,0 +1,201 @@
"""Monthly MVP V2 scoring helpers."""
from __future__ import annotations
import math
from typing import Mapping
MONTHLY_MVP_V2_VERSION = "v2"
MONTHLY_MVP_V2_MIN_MATCHES = 6
MONTHLY_MVP_V2_MIN_TIME_SECONDS = 21600
MONTHLY_MVP_V2_FULL_PARTICIPATION_SECONDS = 28800
MONTHLY_MVP_V2_ADVANCED_CONFIDENCE_KILLS = 35
MONTHLY_MVP_V2_TEAMKILL_PENALTY_CAP = 8.0
MONTHLY_MVP_V2_TEAMKILL_PENALTY_PER_KILL = 0.75
def build_monthly_mvp_v2_rankings(
aggregated_rows: list[Mapping[str, object]],
*,
limit: int,
) -> dict[str, object]:
"""Transform aggregated monthly totals plus V2 event signals into rankings."""
eligible_rows = [
_build_eligible_player_summary(row)
for row in aggregated_rows
if _is_eligible_player_row(row)
]
if not eligible_rows:
return {
"ranking_version": MONTHLY_MVP_V2_VERSION,
"eligibility": _build_eligibility_metadata(),
"eligible_players_count": 0,
"items": [],
}
max_total_kills = max(item["totals"]["kills"] for item in eligible_rows)
max_total_support = max(item["totals"]["support"] for item in eligible_rows)
max_kpm = max(item["derived"]["kpm"] for item in eligible_rows)
max_kda = max(item["derived"]["kda"] for item in eligible_rows)
max_rivalry_edge = max(item["advanced"]["rivalry_edge_raw"] for item in eligible_rows)
max_duel_control = max(item["advanced"]["duel_control_raw"] for item in eligible_rows)
for item in eligible_rows:
component_scores = {
"kills_score": _log_normalized_score(item["totals"]["kills"], max_total_kills),
"support_score": _log_normalized_score(item["totals"]["support"], max_total_support),
"kpm_score": _log_normalized_score(item["derived"]["kpm"], max_kpm),
"kda_score": _log_normalized_score(item["derived"]["kda"], max_kda),
"participation_score": round(
100
* min(
1.0,
item["totals"]["time_seconds"] / MONTHLY_MVP_V2_FULL_PARTICIPATION_SECONDS,
),
3,
),
"rivalry_edge_score": _log_normalized_score(
item["advanced"]["rivalry_edge_raw"],
max_rivalry_edge,
),
"duel_control_score": _log_normalized_score(
item["advanced"]["duel_control_raw"],
max_duel_control,
),
}
advanced_confidence = round(
min(
1.0,
item["totals"]["kills"] / MONTHLY_MVP_V2_ADVANCED_CONFIDENCE_KILLS,
),
3,
)
teamkill_penalty_v2 = round(
min(
MONTHLY_MVP_V2_TEAMKILL_PENALTY_CAP,
item["totals"]["teamkills"] * MONTHLY_MVP_V2_TEAMKILL_PENALTY_PER_KILL,
),
3,
)
item["component_scores"] = component_scores
item["advanced_confidence"] = advanced_confidence
item["teamkill_penalty_v2"] = teamkill_penalty_v2
item["mvp_v2_score"] = round(
(0.30 * component_scores["kills_score"])
+ (0.18 * component_scores["support_score"])
+ (0.18 * component_scores["kpm_score"])
+ (0.12 * component_scores["kda_score"])
+ (0.10 * component_scores["participation_score"])
+ advanced_confidence
* (
(0.07 * component_scores["rivalry_edge_score"])
+ (0.05 * component_scores["duel_control_score"])
)
- teamkill_penalty_v2,
3,
)
ranked_items = sorted(
eligible_rows,
key=lambda item: (
-item["mvp_v2_score"],
-item["advanced_confidence"],
-item["component_scores"]["participation_score"],
-item["component_scores"]["kills_score"],
-item["component_scores"]["rivalry_edge_score"],
item["totals"]["teamkills"],
str(item["player"]["name"]).casefold(),
str(item["player"]["stable_player_key"]),
),
)
for position, item in enumerate(ranked_items[:limit], start=1):
item["ranking_position"] = position
return {
"ranking_version": MONTHLY_MVP_V2_VERSION,
"eligibility": _build_eligibility_metadata(),
"eligible_players_count": len(eligible_rows),
"items": ranked_items[:limit],
}
def _is_eligible_player_row(row: Mapping[str, object]) -> bool:
matches_count = int(row.get("matches_count") or 0)
time_seconds = int(row.get("total_time_seconds") or 0)
has_required_fields = all(
row.get(field_name) is not None
for field_name in ("total_kills", "total_deaths", "total_support", "total_time_seconds")
)
return (
has_required_fields
and matches_count >= MONTHLY_MVP_V2_MIN_MATCHES
and time_seconds >= MONTHLY_MVP_V2_MIN_TIME_SECONDS
)
def _build_eligible_player_summary(row: Mapping[str, object]) -> dict[str, object]:
total_kills = int(row.get("total_kills") or 0)
total_deaths = int(row.get("total_deaths") or 0)
total_support = int(row.get("total_support") or 0)
total_teamkills = int(row.get("total_teamkills") or 0)
total_time_seconds = int(row.get("total_time_seconds") or 0)
total_time_minutes = max(total_time_seconds / 60.0, 1.0)
most_killed_count = int(row.get("most_killed_count") or 0)
death_by_count = int(row.get("death_by_count") or 0)
duel_control_raw = int(row.get("duel_control_raw") or 0)
kpm = round(total_kills / total_time_minutes, 6)
kda = round(total_kills / max(total_deaths, 1), 6)
return {
"server": {
"slug": row.get("server_slug"),
"name": row.get("server_name"),
},
"player": {
"stable_player_key": row.get("stable_player_key"),
"name": row.get("player_name"),
"steam_id": row.get("steam_id"),
},
"matches_considered": int(row.get("matches_count") or 0),
"totals": {
"kills": total_kills,
"deaths": total_deaths,
"support": total_support,
"teamkills": total_teamkills,
"time_seconds": total_time_seconds,
"time_minutes": round(total_time_seconds / 60.0, 2),
},
"derived": {
"kpm": kpm,
"kda": kda,
},
"advanced": {
"most_killed_count": most_killed_count,
"death_by_count": death_by_count,
"rivalry_edge_raw": max(0, most_killed_count - death_by_count),
"duel_control_raw": duel_control_raw,
},
}
def _log_normalized_score(value: float | int, max_value: float | int) -> float:
if value <= 0 or max_value <= 0:
return 0.0
return round((100 * math.log1p(value)) / math.log1p(max_value), 3)
def _build_eligibility_metadata() -> dict[str, object]:
return {
"minimum_matches": MONTHLY_MVP_V2_MIN_MATCHES,
"minimum_time_seconds": MONTHLY_MVP_V2_MIN_TIME_SECONDS,
"minimum_time_hours": round(MONTHLY_MVP_V2_MIN_TIME_SECONDS / 3600, 1),
"full_participation_seconds": MONTHLY_MVP_V2_FULL_PARTICIPATION_SECONDS,
"full_participation_hours": round(
MONTHLY_MVP_V2_FULL_PARTICIPATION_SECONDS / 3600,
1,
),
"advanced_confidence_kills": MONTHLY_MVP_V2_ADVANCED_CONFIDENCE_KILLS,
"teamkill_penalty_per_kill": MONTHLY_MVP_V2_TEAMKILL_PENALTY_PER_KILL,
"teamkill_penalty_cap": MONTHLY_MVP_V2_TEAMKILL_PENALTY_CAP,
}

View File

@@ -17,6 +17,12 @@ from .historical_snapshots import (
DEFAULT_WEEKLY_SNAPSHOT_WINDOW,
SNAPSHOT_TYPE_MONTHLY_LEADERBOARD,
SNAPSHOT_TYPE_MONTHLY_MVP,
SNAPSHOT_TYPE_MONTHLY_MVP_V2,
SNAPSHOT_TYPE_PLAYER_EVENT_DEATH_BY,
SNAPSHOT_TYPE_PLAYER_EVENT_DUELS,
SNAPSHOT_TYPE_PLAYER_EVENT_MOST_KILLED,
SNAPSHOT_TYPE_PLAYER_EVENT_TEAMKILLS,
SNAPSHOT_TYPE_PLAYER_EVENT_WEAPON_KILLS,
SNAPSHOT_TYPE_RECENT_MATCHES,
SNAPSHOT_TYPE_SERVER_SUMMARY,
SNAPSHOT_TYPE_WEEKLY_LEADERBOARD,
@@ -357,6 +363,59 @@ def build_monthly_mvp_payload(
}
def build_player_event_payload(
*,
limit: int = 10,
server_id: str | None = None,
view: str = "most-killed",
) -> dict[str, object]:
"""Return one V2 player-event payload through the stable API surface."""
snapshot_payload = build_player_event_snapshot_payload(
limit=limit,
server_id=server_id,
view=view,
)
data = snapshot_payload["data"]
return {
"status": "ok",
"data": {
**data,
"title": _build_player_event_title(
view=view,
is_all_servers=server_id == ALL_SERVERS_SLUG,
snapshot=False,
),
"context": "historical-player-events",
"source": "historical-precomputed-player-event-snapshots",
},
}
def build_monthly_mvp_v2_payload(
*,
limit: int = 10,
server_id: str | None = None,
) -> dict[str, object]:
"""Return the precomputed monthly MVP V2 payload through the stable API surface."""
snapshot_payload = build_monthly_mvp_v2_snapshot_payload(
limit=limit,
server_id=server_id,
)
data = snapshot_payload["data"]
return {
"status": "ok",
"data": {
**data,
"title": _build_monthly_mvp_v2_title(
is_all_servers=server_id == ALL_SERVERS_SLUG,
snapshot=False,
),
"context": "historical-monthly-mvp-v2",
"source": "historical-precomputed-snapshots",
},
}
def build_historical_server_summary_snapshot_payload(
*,
server_slug: str | None = None,
@@ -573,6 +632,105 @@ def build_monthly_mvp_snapshot_payload(
}
def build_monthly_mvp_v2_snapshot_payload(
*,
limit: int = 10,
server_id: str | None = None,
) -> dict[str, object]:
"""Return one precomputed monthly MVP V2 snapshot."""
snapshot = _get_historical_snapshot_record(
server_key=server_id,
snapshot_type=SNAPSHOT_TYPE_MONTHLY_MVP_V2,
window=DEFAULT_MONTHLY_SNAPSHOT_WINDOW,
)
payload = snapshot.get("payload") if snapshot else {}
items = payload.get("items") if isinstance(payload, dict) else None
sliced_items = list(items[:limit]) if isinstance(items, list) else []
found = bool(payload.get("found")) if isinstance(payload, dict) else False
return {
"status": "ok",
"data": {
"title": _build_monthly_mvp_v2_title(
is_all_servers=server_id == ALL_SERVERS_SLUG,
snapshot=True,
),
"context": "historical-monthly-mvp-v2-snapshot",
"source": "historical-precomputed-snapshots",
"server_slug": server_id,
"timeframe": "monthly",
"metric": "mvp-v2",
"found": snapshot is not None and found,
**_build_historical_snapshot_metadata(snapshot),
"month_key": payload.get("month_key") if isinstance(payload, dict) else None,
"window_days": payload.get("window_days") if isinstance(payload, dict) else None,
"window_start": payload.get("window_start") if isinstance(payload, dict) else None,
"window_end": payload.get("window_end") if isinstance(payload, dict) else None,
"window_kind": payload.get("window_kind") if isinstance(payload, dict) else None,
"window_label": payload.get("window_label") if isinstance(payload, dict) else None,
"uses_fallback": bool(payload.get("uses_fallback")) if isinstance(payload, dict) else False,
"selection_reason": payload.get("selection_reason") if isinstance(payload, dict) else None,
"current_month_start": payload.get("current_month_start") if isinstance(payload, dict) else None,
"current_month_closed_matches": (
payload.get("current_month_closed_matches") if isinstance(payload, dict) else None
),
"previous_month_closed_matches": (
payload.get("previous_month_closed_matches") if isinstance(payload, dict) else None
),
"sufficient_sample": payload.get("sufficient_sample") if isinstance(payload, dict) else None,
"eligibility": payload.get("eligibility") if isinstance(payload, dict) else None,
"ranking_version": payload.get("ranking_version") if isinstance(payload, dict) else None,
"event_coverage": payload.get("event_coverage") if isinstance(payload, dict) else None,
"eligible_players_count": (
payload.get("eligible_players_count") if isinstance(payload, dict) else 0
),
"snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None,
"limit": limit,
"items": sliced_items,
},
}
def build_player_event_snapshot_payload(
*,
limit: int = 10,
server_id: str | None = None,
view: str = "most-killed",
) -> dict[str, object]:
"""Return one precomputed V2 player-event snapshot."""
snapshot_type = _resolve_player_event_snapshot_type(view)
snapshot = _get_historical_snapshot_record(
server_key=server_id,
snapshot_type=snapshot_type,
window=DEFAULT_MONTHLY_SNAPSHOT_WINDOW,
)
payload = snapshot.get("payload") if snapshot else {}
items = payload.get("items") if isinstance(payload, dict) else None
sliced_items = list(items[:limit]) if isinstance(items, list) else []
found = bool(payload.get("found")) if isinstance(payload, dict) else False
return {
"status": "ok",
"data": {
"title": _build_player_event_title(
view=view,
is_all_servers=server_id == ALL_SERVERS_SLUG,
snapshot=True,
),
"context": "historical-player-events-snapshot",
"source": "historical-precomputed-player-event-snapshots",
"server_slug": server_id,
"timeframe": "monthly",
"metric": view,
"found": snapshot is not None and found,
**_build_historical_snapshot_metadata(snapshot),
"period": payload.get("period") if isinstance(payload, dict) else "monthly",
"month_key": payload.get("month_key") if isinstance(payload, dict) else None,
"snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None,
"limit": limit,
"items": sliced_items,
},
}
def build_historical_server_summary_payload(
*,
server_slug: str | None = None,
@@ -683,6 +841,42 @@ def _build_monthly_mvp_title(*, is_all_servers: bool, snapshot: bool = False) ->
return f"{prefix}Top MVP mensual {scope_label}"
def _build_monthly_mvp_v2_title(*, is_all_servers: bool, snapshot: bool = False) -> str:
prefix = "Snapshot " if snapshot else ""
scope_label = "global" if is_all_servers else "por servidor"
return f"{prefix}Top MVP mensual V2 {scope_label}"
def _build_player_event_title(
*,
view: str,
is_all_servers: bool,
snapshot: bool = False,
) -> str:
prefix = "Snapshot " if snapshot else ""
scope_label = "global" if is_all_servers else "por servidor"
title_by_view = {
"most-killed": f"{prefix}Most killed mensual {scope_label}",
"death-by": f"{prefix}Death by mensual {scope_label}",
"duels": f"{prefix}Duelos netos mensuales {scope_label}",
"weapon-kills": f"{prefix}Kills por arma mensuales {scope_label}",
"teamkills": f"{prefix}Teamkills mensuales {scope_label}",
}
return title_by_view.get(view, f"{prefix}Metricas V2 mensuales {scope_label}")
def _resolve_player_event_snapshot_type(view: str) -> str:
normalized_view = view.strip().lower() if isinstance(view, str) else "most-killed"
snapshot_type_by_view = {
"most-killed": SNAPSHOT_TYPE_PLAYER_EVENT_MOST_KILLED,
"death-by": SNAPSHOT_TYPE_PLAYER_EVENT_DEATH_BY,
"duels": SNAPSHOT_TYPE_PLAYER_EVENT_DUELS,
"weapon-kills": SNAPSHOT_TYPE_PLAYER_EVENT_WEAPON_KILLS,
"teamkills": SNAPSHOT_TYPE_PLAYER_EVENT_TEAMKILLS,
}
return snapshot_type_by_view.get(normalized_view, SNAPSHOT_TYPE_PLAYER_EVENT_MOST_KILLED)
def _enrich_server_items(items: list[dict[str, object]]) -> list[dict[str, object]]:
target_index = get_live_data_source().build_target_index()
enriched_items: list[dict[str, object]] = []

View File

@@ -12,9 +12,13 @@ from .payloads import (
build_health_payload,
build_historical_leaderboard_payload,
build_monthly_mvp_payload,
build_monthly_mvp_v2_payload,
build_monthly_leaderboard_payload,
build_monthly_leaderboard_snapshot_payload,
build_monthly_mvp_snapshot_payload,
build_monthly_mvp_v2_snapshot_payload,
build_player_event_payload,
build_player_event_snapshot_payload,
build_historical_server_summary_snapshot_payload,
build_historical_player_profile_payload,
build_historical_server_summary_payload,
@@ -119,6 +123,31 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
server_id=server_id,
)
if parsed.path == "/api/historical/monthly-mvp-v2":
limit = _parse_limit(parsed.query)
if limit is None:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
server_id = parse_qs(parsed.query).get("server", [None])[0]
return HTTPStatus.OK, build_monthly_mvp_v2_payload(
limit=limit,
server_id=server_id,
)
if parsed.path == "/api/historical/player-events":
limit = _parse_limit(parsed.query)
if limit is None:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
params = parse_qs(parsed.query)
server_id = params.get("server", [None])[0]
view = params.get("view", ["most-killed"])[0]
if view not in {"most-killed", "death-by", "duels", "weapon-kills", "teamkills"}:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid view parameter")
return HTTPStatus.OK, build_player_event_payload(
limit=limit,
server_id=server_id,
view=view,
)
if parsed.path == "/api/historical/snapshots/leaderboard":
limit = _parse_limit(parsed.query)
if limit is None:
@@ -163,6 +192,31 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
server_id=server_id,
)
if parsed.path == "/api/historical/snapshots/monthly-mvp-v2":
limit = _parse_limit(parsed.query)
if limit is None:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
server_id = parse_qs(parsed.query).get("server", [None])[0]
return HTTPStatus.OK, build_monthly_mvp_v2_snapshot_payload(
limit=limit,
server_id=server_id,
)
if parsed.path == "/api/historical/snapshots/player-events":
limit = _parse_limit(parsed.query)
if limit is None:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
params = parse_qs(parsed.query)
server_id = params.get("server", [None])[0]
view = params.get("view", ["most-killed"])[0]
if view not in {"most-killed", "death-by", "duels", "weapon-kills", "teamkills"}:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid view parameter")
return HTTPStatus.OK, build_player_event_snapshot_payload(
limit=limit,
server_id=server_id,
view=view,
)
if parsed.path == "/api/historical/snapshots/weekly-leaderboard":
limit = _parse_limit(parsed.query)
if limit is None:

View File

@@ -0,0 +1,243 @@
# Monthly MVP V2 Scoring Design
## Validation Date
- 2026-03-24
## Objective
Definir una formula V2 precisa, explicable e implementable para el MVP mensual
usando la base V1 ya aprobada y solo las senales avanzadas V2 que hoy tienen
soporte real en la repo.
## Evidence Base
This proposal is based on:
- `docs/monthly-mvp-ranking-scoring-design.md`
- `docs/monthly-player-ranking-data-audit.md`
- `docs/player-event-pipeline-v2-design.md`
- `backend/app/monthly_mvp.py`
- `backend/app/player_event_aggregates.py`
- `backend/app/historical_snapshots.py`
- `backend/app/payloads.py`
## Design Position
V2 should not replace the V1 logic with a radically different opaque model.
The correct direction is:
- keep V1 as the stable baseline
- preserve the same monthly UTC window and closed-match policy
- add a small set of advanced event-derived signals with limited weight
- avoid weapon-type or kill-type complexity until the source is richer
## Meaning Of MVP In V2
V2 still means "best monthly player", not "best fragger only".
Compared with V1, V2 should reward:
- sustained offensive output
- team contribution
- efficiency over the month
- cleaner player-vs-player control in repeated encounters
- better discipline through a stricter teamkill penalty
## Signals Included In V2
V2 keeps these V1 signals:
- total kills
- total support
- KPM recomputed from monthly totals
- KDA recomputed from monthly totals
- participation based on monthly time played
- monthly teamkills as penalty input
V2 adds these advanced signals:
- `most_killed`
- `death_by`
- net duel summaries
These signals are used only as modest scoring components, not as the core of
the ranking.
## Signals Explicitly Excluded From V2 Formula
Do not score these yet:
- weapon-type weighting
- kill-category weighting
- weapon variety bonus
- `death_by_weapons`
- combat, offense and defense
- win/loss context
Reason:
- the current CRCON-derived V2 layer is partial and summary-based
- weapon and type semantics are not robust enough for a serious weighted score
- adding too many low-confidence knobs would make V2 harder to defend than V1
Weapon kills remain useful for product readouts and future analysis, but not as
a weighted scoring factor in this phase.
## Eligibility Rules
Player eligibility for V2 should remain identical to V1:
- at least `6` closed matches in the selected month and scope
- at least `21600` seconds (`6` hours) played in the selected month and scope
- non-null monthly totals for kills, deaths, support and time
Additional publication gate for the ranking itself:
- publish V2 only when the selected month and scope have matching player-event
coverage for that same `month_key`
This avoids ranking a month with V1 totals but missing V2 event coverage.
## Derived Advanced Metrics
For each eligible player-month, derive:
- `most_killed_count`
- kills against the player most often killed by this player in the month
- `death_by_count`
- deaths suffered from the player that killed this player most often in the
month
- `rivalry_edge_raw = max(0, most_killed_count - death_by_count)`
- `duel_control_raw`
- sum of positive `net_duel_value` across the player's top `3` duel pairs in
the selected month and scope
Then normalize:
- `rivalry_edge_score = 100 * ln(1 + rivalry_edge_raw) / ln(1 + max_rivalry_edge_raw_eligible)`
- `duel_control_score = 100 * ln(1 + duel_control_raw) / ln(1 + max_duel_control_raw_eligible)`
## Small-Sample Treatment
Advanced event signals should be damped on low-volume months.
Use:
- `advanced_confidence = min(1, total_kills / 35)`
Effect:
- under `35` kills, advanced components contribute only partially
- at `35+` kills, the full advanced weight is available
This keeps V2 from overreacting to tiny rivalry samples.
## Normalized Core Component Scores
V2 keeps the same V1 normalization style on a `0..100` scale:
- `kills_score = 100 * ln(1 + total_kills) / ln(1 + max_total_kills_eligible)`
- `support_score = 100 * ln(1 + total_support) / ln(1 + max_total_support_eligible)`
- `kpm_score = 100 * ln(1 + kpm) / ln(1 + max_kpm_eligible)`
- `kda_score = 100 * ln(1 + kda) / ln(1 + max_kda_eligible)`
- `participation_score = 100 * min(1, total_time_seconds / 28800)`
## V2 Teamkill Penalty
V2 should be slightly stricter than V1 on discipline.
Use:
- `teamkill_penalty_v2 = min(8, total_teamkills * 0.75)`
Effect:
- `1` teamkill subtracts `0.75`
- `4` teamkills subtract `3`
- penalty caps at `8`
## V2 Scoring Formula
Recommended V2 monthly MVP score:
`mvp_v2_score = 0.30 * kills_score + 0.18 * support_score + 0.18 * kpm_score + 0.12 * kda_score + 0.10 * participation_score + advanced_confidence * (0.07 * rivalry_edge_score + 0.05 * duel_control_score) - teamkill_penalty_v2`
Weight rationale:
- `30%` kills keeps offense as the main visible driver
- `18%` support preserves MVP rather than pure frag logic
- `18%` KPM rewards productive time
- `12%` KDA rewards cleaner performance without dominating the table
- `10%` participation keeps monthly presence relevant
- `7%` rivalry edge rewards players who repeatedly finish ahead in their
strongest recurring encounter
- `5%` duel control adds a second advanced signal but keeps it clearly bounded
## Why Weapon Kills Are Not Weighted Yet
The repository can already expose kills by weapon, but the current source layer:
- is summary-based, not a full raw kill feed
- does not yet prove a stable weapon taxonomy for competitive weighting
- would invite fragile distinctions such as tank vs infantry vs artillery too
early
Decision:
- do not weight kills by weapon in V2
- do not assign bonus or penalty by weapon type
- keep weapon-kill outputs as audit and UI-facing data only
## Tie-Break Rules
If two players have the same `mvp_v2_score`, resolve ties in this order:
1. higher `advanced_confidence`
2. higher `participation_score`
3. higher `kills_score`
4. higher `rivalry_edge_score`
5. lower `total_teamkills`
6. alphabetical `display_name`
7. stable player key as final deterministic fallback
## Coexistence With V1
V1 and V2 should coexist explicitly:
- `V1` remains the stable default ranking
- `V2` is a separate ranking version with its own `ranking_version`
- both versions should use the same month and scope selectors
- V2 should never overwrite or reinterpret the V1 payload contract
## Implementation Guidance For Next Task
The backend task should compute V2 from:
- the same monthly player totals already used by V1
- direct player-event monthly aggregates derived from the raw ledger
Required per-player V2 outputs:
- `mvp_v2_score`
- `advanced_confidence`
- `rivalry_edge_raw`
- `duel_control_raw`
- `component_scores`
- `teamkill_penalty_v2`
Recommended `ranking_version`:
- `v2`
## Final Recommendation
The correct V2 for the current repository is an incremental evolution of V1:
- keep the same explainable weighted-score structure
- add only `most_killed` / `death_by` / duel-derived pressure signals
- make discipline stricter
- refuse weapon-type weighting until the signal quality improves
This yields a V2 that is materially richer than V1 without becoming speculative.