Implement monthly MVP V2 workflow and snapshots
This commit is contained in:
81
ai/tasks/done/TASK-081-player-event-snapshots-and-api.md
Normal file
81
ai/tasks/done/TASK-081-player-event-snapshots-and-api.md
Normal 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.
|
||||||
@@ -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.
|
||||||
64
ai/tasks/done/TASK-083-monthly-mvp-v2-backend-calculation.md
Normal file
64
ai/tasks/done/TASK-083-monthly-mvp-v2-backend-calculation.md
Normal 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.
|
||||||
65
ai/tasks/done/TASK-084-monthly-mvp-v2-snapshots-and-api.md
Normal file
65
ai/tasks/done/TASK-084-monthly-mvp-v2-snapshots-and-api.md
Normal 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.
|
||||||
@@ -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/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/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?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/recent-matches?limit=20&server=comunidad-hispana-01`
|
||||||
- `GET /api/historical/server-summary?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/server-summary?server=comunidad-hispana-01`
|
||||||
- `GET /api/historical/snapshots/weekly-leaderboard?metric=kills&limit=10&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/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?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/snapshots/recent-matches?limit=6&server=comunidad-hispana-01`
|
||||||
- `GET /api/historical/player-profile?player=steam%3A76561198000000000`
|
- `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
|
para lectura rapida desde `/api/historical/monthly-mvp` y
|
||||||
`/api/historical/snapshots/monthly-mvp` sin recalculo pesado en request.
|
`/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
|
El backend incluye ademas el calculo interno de `monthly MVP V1` en
|
||||||
`app/monthly_mvp.py`, separado de los leaderboards mensuales simples por
|
`app/monthly_mvp.py`, separado de los leaderboards mensuales simples por
|
||||||
metrica. Ese calculo:
|
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`,
|
sin reemplazar los leaderboards mensuales ya existentes por `kills`, `deaths`,
|
||||||
`support` y `matches_over_100_kills`.
|
`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
|
## Ingesta historica CRCON
|
||||||
|
|
||||||
La ingesta historica no usa A2S ni scraping del HTML de `/games`. Consume la
|
La ingesta historica no usa A2S ni scraping del HTML de `/games`. Consume la
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ def generate_historical_snapshots(
|
|||||||
"full_snapshot_every_runs": full_snapshot_every_runs,
|
"full_snapshot_every_runs": full_snapshot_every_runs,
|
||||||
"prewarm_only": not should_run_full_refresh,
|
"prewarm_only": not should_run_full_refresh,
|
||||||
"refresh_interval_seconds": get_historical_refresh_interval_seconds(),
|
"refresh_interval_seconds": get_historical_refresh_interval_seconds(),
|
||||||
|
"includes_monthly_mvp_v2": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -255,7 +255,12 @@ def _is_effectively_empty_snapshot_payload(
|
|||||||
items = payload.get("items")
|
items = payload.get("items")
|
||||||
return not isinstance(items, list) or len(items) == 0
|
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")
|
items = payload.get("items")
|
||||||
return not isinstance(items, list) or len(items) == 0
|
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"
|
return "server-summary.json"
|
||||||
if snapshot_type == "recent-matches":
|
if snapshot_type == "recent-matches":
|
||||||
return "recent-matches.json"
|
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":
|
if snapshot_type == "weekly-leaderboard":
|
||||||
metric_suffix = "matches-over-100-kills" if metric == "matches_over_100_kills" else _slugify(metric or "unknown")
|
metric_suffix = "matches-over-100-kills" if metric == "matches_over_100_kills" else _slugify(metric or "unknown")
|
||||||
return f"weekly-{metric_suffix}.json"
|
return f"weekly-{metric_suffix}.json"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -11,15 +12,30 @@ from .historical_storage import (
|
|||||||
list_historical_servers,
|
list_historical_servers,
|
||||||
list_monthly_leaderboard,
|
list_monthly_leaderboard,
|
||||||
list_monthly_mvp_ranking,
|
list_monthly_mvp_ranking,
|
||||||
|
list_monthly_mvp_v2_ranking,
|
||||||
list_recent_historical_matches,
|
list_recent_historical_matches,
|
||||||
list_weekly_leaderboard,
|
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_SERVER_SUMMARY = "server-summary"
|
||||||
SNAPSHOT_TYPE_WEEKLY_LEADERBOARD = "weekly-leaderboard"
|
SNAPSHOT_TYPE_WEEKLY_LEADERBOARD = "weekly-leaderboard"
|
||||||
SNAPSHOT_TYPE_MONTHLY_LEADERBOARD = "monthly-leaderboard"
|
SNAPSHOT_TYPE_MONTHLY_LEADERBOARD = "monthly-leaderboard"
|
||||||
SNAPSHOT_TYPE_MONTHLY_MVP = "monthly-mvp"
|
SNAPSHOT_TYPE_MONTHLY_MVP = "monthly-mvp"
|
||||||
|
SNAPSHOT_TYPE_MONTHLY_MVP_V2 = "monthly-mvp-v2"
|
||||||
SNAPSHOT_TYPE_RECENT_MATCHES = "recent-matches"
|
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(
|
SUPPORTED_SNAPSHOT_TYPES = frozenset(
|
||||||
{
|
{
|
||||||
@@ -27,7 +43,13 @@ SUPPORTED_SNAPSHOT_TYPES = frozenset(
|
|||||||
SNAPSHOT_TYPE_WEEKLY_LEADERBOARD,
|
SNAPSHOT_TYPE_WEEKLY_LEADERBOARD,
|
||||||
SNAPSHOT_TYPE_MONTHLY_LEADERBOARD,
|
SNAPSHOT_TYPE_MONTHLY_LEADERBOARD,
|
||||||
SNAPSHOT_TYPE_MONTHLY_MVP,
|
SNAPSHOT_TYPE_MONTHLY_MVP,
|
||||||
|
SNAPSHOT_TYPE_MONTHLY_MVP_V2,
|
||||||
SNAPSHOT_TYPE_RECENT_MATCHES,
|
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",
|
"matches_over_100_kills",
|
||||||
"support",
|
"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_SNAPSHOT_WINDOW = "all-time"
|
||||||
DEFAULT_WEEKLY_SNAPSHOT_WINDOW = "7d"
|
DEFAULT_WEEKLY_SNAPSHOT_WINDOW = "7d"
|
||||||
@@ -134,6 +163,24 @@ def build_historical_server_snapshots(
|
|||||||
db_path=db_path,
|
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(
|
snapshots.append(
|
||||||
_build_recent_matches_snapshot(
|
_build_recent_matches_snapshot(
|
||||||
@@ -188,6 +235,24 @@ def build_priority_historical_snapshots(
|
|||||||
db_path=db_path,
|
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(
|
snapshots.append(
|
||||||
_build_recent_matches_snapshot(
|
_build_recent_matches_snapshot(
|
||||||
server_key,
|
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(
|
def _build_monthly_mvp_snapshot(
|
||||||
server_key: str,
|
server_key: str,
|
||||||
generated_at: datetime,
|
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(
|
def _resolve_snapshot_target_keys(
|
||||||
*,
|
*,
|
||||||
server_key: str | None,
|
server_key: str | None,
|
||||||
@@ -470,6 +626,93 @@ def _resolve_snapshot_target_keys(
|
|||||||
return [normalized_server_key, ALL_SERVERS_SLUG]
|
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:
|
def _parse_optional_timestamp(value: object) -> datetime | None:
|
||||||
if not isinstance(value, str) or not value.strip():
|
if not isinstance(value, str) or not value.strip():
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from .config import (
|
|||||||
)
|
)
|
||||||
from .historical_models import HistoricalServerDefinition
|
from .historical_models import HistoricalServerDefinition
|
||||||
from .monthly_mvp import build_monthly_mvp_rankings
|
from .monthly_mvp import build_monthly_mvp_rankings
|
||||||
|
from .monthly_mvp_v2 import build_monthly_mvp_v2_rankings
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_HISTORICAL_SERVERS = (
|
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:
|
def _connect(db_path: Path) -> sqlite3.Connection:
|
||||||
connection = sqlite3.connect(db_path, timeout=30.0)
|
connection = sqlite3.connect(db_path, timeout=30.0)
|
||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
|
|||||||
201
backend/app/monthly_mvp_v2.py
Normal file
201
backend/app/monthly_mvp_v2.py
Normal 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,
|
||||||
|
}
|
||||||
@@ -17,6 +17,12 @@ from .historical_snapshots import (
|
|||||||
DEFAULT_WEEKLY_SNAPSHOT_WINDOW,
|
DEFAULT_WEEKLY_SNAPSHOT_WINDOW,
|
||||||
SNAPSHOT_TYPE_MONTHLY_LEADERBOARD,
|
SNAPSHOT_TYPE_MONTHLY_LEADERBOARD,
|
||||||
SNAPSHOT_TYPE_MONTHLY_MVP,
|
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_RECENT_MATCHES,
|
||||||
SNAPSHOT_TYPE_SERVER_SUMMARY,
|
SNAPSHOT_TYPE_SERVER_SUMMARY,
|
||||||
SNAPSHOT_TYPE_WEEKLY_LEADERBOARD,
|
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(
|
def build_historical_server_summary_snapshot_payload(
|
||||||
*,
|
*,
|
||||||
server_slug: str | None = None,
|
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(
|
def build_historical_server_summary_payload(
|
||||||
*,
|
*,
|
||||||
server_slug: str | None = None,
|
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}"
|
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]]:
|
def _enrich_server_items(items: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||||
target_index = get_live_data_source().build_target_index()
|
target_index = get_live_data_source().build_target_index()
|
||||||
enriched_items: list[dict[str, object]] = []
|
enriched_items: list[dict[str, object]] = []
|
||||||
|
|||||||
@@ -12,9 +12,13 @@ from .payloads import (
|
|||||||
build_health_payload,
|
build_health_payload,
|
||||||
build_historical_leaderboard_payload,
|
build_historical_leaderboard_payload,
|
||||||
build_monthly_mvp_payload,
|
build_monthly_mvp_payload,
|
||||||
|
build_monthly_mvp_v2_payload,
|
||||||
build_monthly_leaderboard_payload,
|
build_monthly_leaderboard_payload,
|
||||||
build_monthly_leaderboard_snapshot_payload,
|
build_monthly_leaderboard_snapshot_payload,
|
||||||
build_monthly_mvp_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_server_summary_snapshot_payload,
|
||||||
build_historical_player_profile_payload,
|
build_historical_player_profile_payload,
|
||||||
build_historical_server_summary_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,
|
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":
|
if parsed.path == "/api/historical/snapshots/leaderboard":
|
||||||
limit = _parse_limit(parsed.query)
|
limit = _parse_limit(parsed.query)
|
||||||
if limit is None:
|
if limit is None:
|
||||||
@@ -163,6 +192,31 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
|
|||||||
server_id=server_id,
|
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":
|
if parsed.path == "/api/historical/snapshots/weekly-leaderboard":
|
||||||
limit = _parse_limit(parsed.query)
|
limit = _parse_limit(parsed.query)
|
||||||
if limit is None:
|
if limit is None:
|
||||||
|
|||||||
243
docs/monthly-mvp-v2-scoring-design.md
Normal file
243
docs/monthly-mvp-v2-scoring-design.md
Normal 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.
|
||||||
Reference in New Issue
Block a user