Add rcon-first elo mmr foundation
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
# TASK-096-rcon-first-historical-runtime-selection
|
||||
|
||||
## Goal
|
||||
Hacer que el histórico backend funcione realmente en modo RCON-first en runtime, con fallback automático, observable y seguro a public-scoreboard/CRCON cuando RCON falle o no soporte una operación concreta.
|
||||
|
||||
## Context
|
||||
Hoy la repo ya tiene:
|
||||
- live RCON-first con fallback a A2S
|
||||
- captura prospectiva RCON
|
||||
- read model histórico RCON parcial
|
||||
- histórico clásico por public-scoreboard/CRCON
|
||||
Pero el runtime histórico aún no se comporta como una política RCON-first completa.
|
||||
|
||||
## Steps
|
||||
1. Auditar la selección histórica actual en:
|
||||
- `backend/app/data_sources.py`
|
||||
- `backend/app/payloads.py`
|
||||
- `backend/app/historical_ingestion.py`
|
||||
- `backend/app/historical_runner.py`
|
||||
- `backend/app/rcon_historical_read_model.py`
|
||||
2. Introducir arbitraje histórico explícito:
|
||||
- intento primario por RCON
|
||||
- fallback a public-scoreboard/CRCON si:
|
||||
- RCON falla
|
||||
- RCON no tiene cobertura
|
||||
- RCON no soporta esa operación concreta
|
||||
3. Asegurar trazabilidad en payloads:
|
||||
- `primary_source`
|
||||
- `selected_source`
|
||||
- `fallback_used`
|
||||
- `fallback_reason`
|
||||
- `source_attempts`
|
||||
4. Ajustar el runtime y los defaults para que la política efectiva del stack sea coherente con histórico RCON-first.
|
||||
5. Mantener compatibilidad con el histórico clásico sin romper snapshots ni workers existentes.
|
||||
6. Actualizar `backend/README.md` y runbook para que el comportamiento real quede claro.
|
||||
|
||||
## Constraints
|
||||
- No romper live RCON-first ya existente.
|
||||
- No eliminar public-scoreboard/CRCON.
|
||||
- No degradar el request path HTTP en latencia o estabilidad de forma evitable.
|
||||
- No fingir soporte RCON en operaciones que todavía no están cubiertas.
|
||||
|
||||
## Validation
|
||||
- `/health` y/o la metadata funcional relevante reflejan una política histórica RCON-first coherente.
|
||||
- Los endpoints históricos compatibles intentan RCON primero.
|
||||
- Cuando RCON no sirve, el fallback a public-scoreboard/CRCON es observable y claro.
|
||||
- La repo queda consistente.
|
||||
|
||||
## Expected Files
|
||||
- `backend/app/data_sources.py`
|
||||
- `backend/app/payloads.py`
|
||||
- `backend/app/historical_ingestion.py`
|
||||
- `backend/app/historical_runner.py`
|
||||
- `backend/app/rcon_historical_read_model.py`
|
||||
- `backend/app/config.py`
|
||||
- `backend/README.md`
|
||||
- `docker-compose.yml` si hace falta
|
||||
@@ -0,0 +1,65 @@
|
||||
# TASK-097-elo-mmr-capabilities-and-data-contract
|
||||
|
||||
## Goal
|
||||
Definir e implementar la base de contrato de datos, capabilities y storage para un sistema de MMR persistente + MonthlyRankScore mensual inspirado en `sistema_elo_mensual_hll.pdf`, adaptado a la telemetría real disponible hoy.
|
||||
|
||||
## Context
|
||||
El PDF define una arquitectura con:
|
||||
- MMR persistente
|
||||
- ranking mensual visible
|
||||
- calidad de match
|
||||
- subíndices por rol
|
||||
- ImpactScore
|
||||
- MatchScore
|
||||
- MonthlyRankScore
|
||||
Pero la repo actual no dispone de todas las métricas del documento con precisión total.
|
||||
|
||||
Hace falta una base sólida que no mezcle:
|
||||
- lo que el PDF ideal propone
|
||||
- lo que hoy se puede calcular de verdad
|
||||
|
||||
## Steps
|
||||
1. Leer el PDF `sistema_elo_mensual_hll.pdf` y descomponerlo en:
|
||||
- inputs obligatorios
|
||||
- inputs opcionales
|
||||
- inputs no disponibles todavía
|
||||
2. Auditar las fuentes reales actuales:
|
||||
- live RCON
|
||||
- read model histórico RCON
|
||||
- player-event V2
|
||||
- histórico clásico CRCON/public-scoreboard
|
||||
3. Crear una especificación operativa dentro del repo para el sistema Elo/MMR:
|
||||
- qué campos existen
|
||||
- cuáles son exactos
|
||||
- cuáles son aproximados
|
||||
- cuáles no están disponibles
|
||||
4. Diseñar e implementar storage mínimo para:
|
||||
- snapshots o checkpoints mensuales de rating
|
||||
- MMR persistente por jugador
|
||||
- MatchScore / impacto por match cuando proceda
|
||||
- metadata de capabilities por cálculo
|
||||
5. Dejar una capa de contrato o modelos Python claros para:
|
||||
- match validity
|
||||
- quality factor Q
|
||||
- role bucket
|
||||
- subindices
|
||||
- monthly eligibility
|
||||
- penalties
|
||||
6. No cerrar todavía las fórmulas finales si falta señal; primero dejar bien cerrada la base de datos y contrato semántico.
|
||||
|
||||
## Constraints
|
||||
- No inventar métricas inexistentes.
|
||||
- No mezclar cálculo final con datos no soportados.
|
||||
- No romper MVP V1/V2 actuales.
|
||||
- La documentación debe dejar cristalino qué parte del PDF está soportada hoy y cuál no.
|
||||
|
||||
## Validation
|
||||
- Existe documentación técnica concreta de capabilities del sistema Elo/MMR.
|
||||
- Existen modelos/contratos/backend storage listos para cálculo incremental.
|
||||
- La repo deja clara la diferencia entre exacto, aproximado y no disponible.
|
||||
- La repo queda consistente.
|
||||
|
||||
## Expected Files
|
||||
- `docs/elo-mmr-monthly-ranking-design.md`
|
||||
- uno o varios archivos nuevos bajo `backend/app/` para modelos/contratos/storage Elo
|
||||
- `backend/README.md`
|
||||
@@ -0,0 +1,67 @@
|
||||
# TASK-098-elo-mmr-core-engine-v1-backed-by-real-signals
|
||||
|
||||
## Goal
|
||||
Implementar una primera versión operativa del motor de MMR persistente + MonthlyRankScore mensual usando únicamente señales reales o aproximaciones justificadas por la telemetría disponible hoy.
|
||||
|
||||
## Context
|
||||
Esta task debe apoyarse en la especificación y capabilities de la task anterior.
|
||||
|
||||
La implementación debe seguir el espíritu del PDF:
|
||||
- validar matches
|
||||
- factor de calidad Q
|
||||
- OutcomeScore
|
||||
- ImpactScore por rol
|
||||
- DeltaMMR
|
||||
- MatchScore
|
||||
- MonthlyRankScore
|
||||
Pero sin fingir precisión donde no existe.
|
||||
|
||||
## Steps
|
||||
1. Implementar la validación de partida y el factor de calidad Q con las señales disponibles reales.
|
||||
2. Implementar la lógica de bucket mínima viable:
|
||||
- rol principal
|
||||
- modo si está disponible
|
||||
- tramo de duración
|
||||
3. Implementar los subíndices soportables hoy:
|
||||
- OutcomeScore
|
||||
- CombatIndex
|
||||
- UtilityIndex cuando haya señal
|
||||
- LeadershipIndex cuando haya señal
|
||||
- DisciplineIndex con teamkills / abandonos / AFK si la fuente real lo permite
|
||||
- ObjectiveIndex exacto o aproximado solo si está realmente soportado
|
||||
4. Implementar `ImpactScore` con pesos por rol inspirados en el PDF.
|
||||
5. Implementar actualización de `MMR` persistente.
|
||||
6. Implementar `MatchScore` mensual.
|
||||
7. Implementar agregación mensual de `MonthlyRankScore` con:
|
||||
- Confidence
|
||||
- Activity
|
||||
- Consistency
|
||||
- StrengthOfSchedule
|
||||
- PenaltyPoints
|
||||
8. Marcar explícitamente en el resultado de cada cálculo:
|
||||
- qué partes se calcularon con señal exacta
|
||||
- qué partes fueron aproximadas
|
||||
- qué partes quedaron no disponibles
|
||||
9. Exponer una API o endpoints internos/backend claros para consultar:
|
||||
- rating persistente del jugador
|
||||
- leaderboard mensual Elo/MMR
|
||||
- metadata de cálculo/capabilities
|
||||
10. Mantener MVP V1/V2 existentes sin romperse.
|
||||
|
||||
## Constraints
|
||||
- No vender esta V1 del motor como “idéntica al PDF” si hay aproximaciones.
|
||||
- No usar valores mágicos sin documentarlos.
|
||||
- No romper endpoints históricos actuales.
|
||||
- No rehacer toda la UI en esta task.
|
||||
|
||||
## Validation
|
||||
- Existe cálculo persistente de MMR para jugadores soportados.
|
||||
- Existe cálculo mensual visible de ranking/score.
|
||||
- El resultado expone metadata de exactitud/aproximación.
|
||||
- La repo queda consistente.
|
||||
- La documentación queda alineada con lo realmente implementado.
|
||||
|
||||
## Expected Files
|
||||
- archivos backend nuevos o modificados bajo `backend/app/` para engine/storage/routes/payloads Elo
|
||||
- `backend/README.md`
|
||||
- `docs/elo-mmr-monthly-ranking-design.md`
|
||||
@@ -0,0 +1,46 @@
|
||||
# TASK-099-elo-mmr-product-exposure-and-ui-minimal
|
||||
|
||||
## Goal
|
||||
Exponer de forma mínima y útil en producto el nuevo sistema de rating/MMR mensual sin romper el histórico actual ni mezclarlo de forma confusa con MVP V1/V2.
|
||||
|
||||
## Context
|
||||
Una vez exista el motor base, hace falta hacer visible el sistema:
|
||||
- leaderboard mensual Elo/MMR
|
||||
- score visible del mes
|
||||
- rating persistente o skill score
|
||||
- metadata suficiente para no confundir al usuario
|
||||
|
||||
## Steps
|
||||
1. Auditar la UI histórica actual y decidir el punto de exposición mínimo más claro.
|
||||
2. Añadir un bloque o vista mínima para:
|
||||
- rating persistente
|
||||
- score mensual
|
||||
- elegibilidad mensual
|
||||
- indicación básica de si el cálculo es exacto / aproximado / parcial
|
||||
3. Mantener separación clara respecto a:
|
||||
- MVP V1
|
||||
- MVP V2
|
||||
- player-events V2
|
||||
4. No saturar la UI.
|
||||
5. Si procede, añadir copy/tooltip/legend breve explicando:
|
||||
- que el sistema prioriza señales reales
|
||||
- que algunas métricas avanzadas pueden estar en modo parcial según cobertura
|
||||
6. Validar que los enlaces/histórico existentes no se rompen.
|
||||
|
||||
## Constraints
|
||||
- No rehacer toda la página histórica.
|
||||
- No borrar MVP V1/V2.
|
||||
- No esconder la naturaleza parcial si el cálculo aún no es completo.
|
||||
- Mantener UX legible y estable.
|
||||
|
||||
## Validation
|
||||
- El sistema Elo/MMR aparece en producto de forma entendible.
|
||||
- No rompe bloques existentes.
|
||||
- La UI deja claro qué representa cada score.
|
||||
- La repo queda consistente.
|
||||
|
||||
## Expected Files
|
||||
- `frontend/historico.html`
|
||||
- `frontend/assets/js/historico.js`
|
||||
- `frontend/assets/css/historico.css`
|
||||
- backend solo si hace falta ajustar payloads expuestos
|
||||
@@ -1384,6 +1384,63 @@ Limitaciones actuales de esta fase:
|
||||
resumen disponible por jugador en CRCON y no de todos los encounters del match
|
||||
- la V2 no expone aun endpoints HTTP ni snapshots propios
|
||||
|
||||
## Historical Runtime Policy
|
||||
|
||||
El backend queda orientado a `RCON-first` tambien para historico:
|
||||
|
||||
- live:
|
||||
- `rcon` primero
|
||||
- `a2s` solo como fallback
|
||||
- historico:
|
||||
- `rcon` primero para la capa de lectura/cobertura soportada hoy
|
||||
- `public-scoreboard` solo como fallback cuando RCON no cubre una operacion
|
||||
competitiva concreta o no tiene cobertura suficiente
|
||||
|
||||
Metadata observable en payloads historicos:
|
||||
|
||||
- `primary_source`
|
||||
- `selected_source`
|
||||
- `fallback_used`
|
||||
- `fallback_reason`
|
||||
- `source_attempts`
|
||||
|
||||
Estado real a fecha de esta fase:
|
||||
|
||||
- el read model historico RCON soporta cobertura y actividad reciente
|
||||
- rankings competitivos, MVP y Elo/MMR siguen necesitando fallback a
|
||||
`public-scoreboard` porque el read model RCON actual no expone aun detalle
|
||||
historico competitivo suficiente
|
||||
|
||||
## Elo/MMR Monthly Ranking
|
||||
|
||||
Se añade una primera base operativa inspirada en el documento
|
||||
`sistema_elo_mensual_hll.pdf`, pero adaptada a la telemetria real disponible.
|
||||
|
||||
Superficies nuevas:
|
||||
|
||||
- `python -m app.elo_mmr_engine rebuild`
|
||||
- `python -m app.elo_mmr_engine leaderboard --server all-servers --limit 10`
|
||||
- `python -m app.elo_mmr_engine player --server all-servers --player <stable_player_key>`
|
||||
- `/api/historical/elo-mmr/leaderboard`
|
||||
- `/api/historical/elo-mmr/player`
|
||||
|
||||
Persistencia nueva en SQLite:
|
||||
|
||||
- `elo_mmr_player_ratings`
|
||||
- `elo_mmr_match_results`
|
||||
- `elo_mmr_monthly_rankings`
|
||||
- `elo_mmr_monthly_checkpoints`
|
||||
|
||||
Politica de exactitud:
|
||||
|
||||
- `exact`: outcome, combat, utility, disciplina por teamkills, MMR persistente
|
||||
- `approximate`: role bucket, objective index, strength of schedule
|
||||
- `not_available`: leadership y tacticas finas no persistidas
|
||||
|
||||
La especificacion detallada y el mapa de capabilities quedan en:
|
||||
|
||||
- `docs/elo-mmr-monthly-ranking-design.md`
|
||||
|
||||
## Alcance
|
||||
|
||||
Esta fase no implementa:
|
||||
|
||||
@@ -244,6 +244,88 @@ def get_rcon_historical_read_model() -> RconHistoricalDataSource | None:
|
||||
return RconHistoricalDataSource()
|
||||
|
||||
|
||||
def describe_historical_runtime_policy() -> dict[str, object]:
|
||||
"""Describe the effective historical runtime policy for the current environment."""
|
||||
if get_historical_data_source_kind() != SOURCE_KIND_RCON:
|
||||
return {
|
||||
"mode": "public-scoreboard-primary",
|
||||
"primary_source": SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||
"fallback_source": None,
|
||||
"summary": "Historical runtime uses public-scoreboard directly.",
|
||||
}
|
||||
return {
|
||||
"mode": "rcon-first-with-public-scoreboard-fallback",
|
||||
"primary_source": SOURCE_KIND_RCON,
|
||||
"fallback_source": SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||
"summary": (
|
||||
"Historical runtime attempts the persisted RCON read model first and falls "
|
||||
"back to public-scoreboard when the requested operation is unsupported, has "
|
||||
"no coverage yet, or the primary path fails."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def build_historical_runtime_source_policy(
|
||||
*,
|
||||
operation: str,
|
||||
rcon_status: str,
|
||||
fallback_reason: str | None = None,
|
||||
selected_source: str | None = None,
|
||||
rcon_message: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Build one normalized source-policy block for historical runtime reads."""
|
||||
configured_kind = get_historical_data_source_kind()
|
||||
if configured_kind != SOURCE_KIND_RCON:
|
||||
return build_source_policy(
|
||||
primary_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||
selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||
source_attempts=[
|
||||
build_source_attempt(
|
||||
source=SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||
role="primary",
|
||||
status="success",
|
||||
reason=f"{operation}-served-by-public-scoreboard",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
if rcon_status == "success":
|
||||
return build_source_policy(
|
||||
primary_source=SOURCE_KIND_RCON,
|
||||
selected_source=selected_source or SOURCE_KIND_RCON,
|
||||
source_attempts=[
|
||||
build_source_attempt(
|
||||
source=SOURCE_KIND_RCON,
|
||||
role="primary",
|
||||
status="success",
|
||||
reason=f"{operation}-served-by-rcon",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
return build_source_policy(
|
||||
primary_source=SOURCE_KIND_RCON,
|
||||
selected_source=selected_source or SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||
fallback_used=True,
|
||||
fallback_reason=fallback_reason,
|
||||
source_attempts=[
|
||||
build_source_attempt(
|
||||
source=SOURCE_KIND_RCON,
|
||||
role="primary",
|
||||
status=rcon_status,
|
||||
reason=fallback_reason,
|
||||
message=rcon_message,
|
||||
),
|
||||
build_source_attempt(
|
||||
source=SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||
role="fallback",
|
||||
status="success",
|
||||
reason=f"{operation}-served-by-public-scoreboard-fallback",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def resolve_historical_ingestion_data_source() -> tuple[HistoricalDataSource, dict[str, object]]:
|
||||
"""Resolve the writer-oriented historical provider with safe fallback semantics."""
|
||||
configured_kind = get_historical_data_source_kind()
|
||||
|
||||
644
backend/app/elo_mmr_engine.py
Normal file
644
backend/app/elo_mmr_engine.py
Normal file
@@ -0,0 +1,644 @@
|
||||
"""Core Elo/MMR rebuild engine backed by real historical signals."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from statistics import pstdev
|
||||
from typing import Iterable
|
||||
|
||||
from .config import get_historical_data_source_kind
|
||||
from .data_sources import (
|
||||
SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||
SOURCE_KIND_RCON,
|
||||
build_source_attempt,
|
||||
build_source_policy,
|
||||
)
|
||||
from .elo_mmr_models import (
|
||||
CAPABILITY_APPROXIMATE,
|
||||
CAPABILITY_EXACT,
|
||||
CAPABILITY_UNAVAILABLE,
|
||||
DEFAULT_BASE_MMR,
|
||||
FULL_QUALITY_DURATION_SECONDS,
|
||||
FULL_QUALITY_PLAYER_COUNT,
|
||||
MIN_VALID_MATCH_DURATION_SECONDS,
|
||||
MIN_VALID_MATCH_PLAYERS,
|
||||
MONTHLY_ACTIVITY_TARGET_HOURS,
|
||||
MONTHLY_ACTIVITY_TARGET_MATCHES,
|
||||
MONTHLY_MIN_TIME_SECONDS,
|
||||
MONTHLY_MIN_VALID_MATCHES,
|
||||
build_signal,
|
||||
summarize_accuracy,
|
||||
)
|
||||
from .elo_mmr_storage import (
|
||||
get_elo_mmr_player_profile,
|
||||
initialize_elo_mmr_storage,
|
||||
list_elo_mmr_monthly_rankings,
|
||||
replace_elo_mmr_state,
|
||||
)
|
||||
from .historical_storage import ALL_SERVERS_SLUG, initialize_historical_storage
|
||||
from .sqlite_utils import connect_sqlite_readonly
|
||||
from .writer_lock import backend_writer_lock, build_writer_lock_holder
|
||||
|
||||
|
||||
SCOPE_ALL_SERVERS = ALL_SERVERS_SLUG
|
||||
QUALITY_BUCKET_HIGH = "high"
|
||||
QUALITY_BUCKET_MEDIUM = "medium"
|
||||
QUALITY_BUCKET_LOW = "low"
|
||||
ROLE_BUCKET_SUPPORT = "support"
|
||||
ROLE_BUCKET_OFFENSE = "offense"
|
||||
ROLE_BUCKET_DEFENSE = "defense"
|
||||
ROLE_BUCKET_COMBAT = "combat"
|
||||
ROLE_BUCKET_GENERALIST = "generalist"
|
||||
|
||||
ROLE_WEIGHTS = {
|
||||
ROLE_BUCKET_SUPPORT: {"combat": 0.18, "objective": 0.18, "utility": 0.42, "discipline": 0.22},
|
||||
ROLE_BUCKET_OFFENSE: {"combat": 0.38, "objective": 0.30, "utility": 0.10, "discipline": 0.22},
|
||||
ROLE_BUCKET_DEFENSE: {"combat": 0.26, "objective": 0.34, "utility": 0.16, "discipline": 0.24},
|
||||
ROLE_BUCKET_COMBAT: {"combat": 0.48, "objective": 0.14, "utility": 0.14, "discipline": 0.24},
|
||||
ROLE_BUCKET_GENERALIST: {"combat": 0.34, "objective": 0.22, "utility": 0.20, "discipline": 0.24},
|
||||
}
|
||||
|
||||
|
||||
def rebuild_elo_mmr_models(*, db_path=None) -> dict[str, object]:
|
||||
"""Rebuild persistent player ratings and monthly rankings from scratch."""
|
||||
with backend_writer_lock(holder=build_writer_lock_holder("app.elo_mmr_engine rebuild")):
|
||||
resolved_path = initialize_historical_storage(db_path=db_path)
|
||||
initialize_elo_mmr_storage(db_path=resolved_path)
|
||||
historical_source_policy = _build_historical_source_policy_for_elo()
|
||||
match_rows = _load_closed_match_rows(db_path=resolved_path)
|
||||
grouped_matches = _group_match_rows(match_rows)
|
||||
|
||||
ratings_by_scope: dict[str, dict[str, dict[str, object]]] = {SCOPE_ALL_SERVERS: {}}
|
||||
player_ratings: list[dict[str, object]] = []
|
||||
match_results: list[dict[str, object]] = []
|
||||
monthly_checkpoints: list[dict[str, object]] = []
|
||||
|
||||
for match_group in grouped_matches:
|
||||
server_scope = match_group["server_slug"]
|
||||
ratings_by_scope.setdefault(server_scope, {})
|
||||
for scope_key in (server_scope, SCOPE_ALL_SERVERS):
|
||||
match_results.extend(
|
||||
_score_match_for_scope(
|
||||
match_group=match_group,
|
||||
scope_key=scope_key,
|
||||
ratings_by_scope=ratings_by_scope[scope_key],
|
||||
)
|
||||
)
|
||||
|
||||
for scope_ratings in ratings_by_scope.values():
|
||||
player_ratings.extend(scope_ratings.values())
|
||||
|
||||
monthly_rankings = _build_monthly_rankings(match_results)
|
||||
checkpoint_groups: dict[tuple[str, str], list[dict[str, object]]] = defaultdict(list)
|
||||
for row in monthly_rankings:
|
||||
checkpoint_groups[(row["scope_key"], row["month_key"])].append(row)
|
||||
generated_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
for (scope_key, month_key), rows in checkpoint_groups.items():
|
||||
eligible_count = sum(1 for row in rows if row["eligible"])
|
||||
exact_ratio = round(
|
||||
sum(float(row["capabilities"]["exact_ratio"]) for row in rows) / max(1, len(rows)),
|
||||
3,
|
||||
)
|
||||
approximate_ratio = round(
|
||||
sum(float(row["capabilities"]["approximate_ratio"]) for row in rows) / max(1, len(rows)),
|
||||
3,
|
||||
)
|
||||
partial_count = sum(1 for row in rows if row["accuracy_mode"] == "partial")
|
||||
monthly_checkpoints.append(
|
||||
{
|
||||
"scope_key": scope_key,
|
||||
"month_key": month_key,
|
||||
"generated_at": generated_at,
|
||||
"player_count": len(rows),
|
||||
"eligible_player_count": eligible_count,
|
||||
"source_policy": historical_source_policy,
|
||||
"capabilities_summary": {
|
||||
"exact_ratio": exact_ratio,
|
||||
"approximate_ratio": approximate_ratio,
|
||||
"partial_count": partial_count,
|
||||
"notes": [
|
||||
"Outcome, combat, utility, discipline and persistent MMR use real stored signals.",
|
||||
"ObjectiveIndex and role bucket are approximate proxies based on offense/defense/support/combat scores.",
|
||||
"LeadershipIndex is not available with the current repository telemetry.",
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
replace_elo_mmr_state(
|
||||
player_ratings=player_ratings,
|
||||
match_results=match_results,
|
||||
monthly_rankings=monthly_rankings,
|
||||
monthly_checkpoints=monthly_checkpoints,
|
||||
db_path=resolved_path,
|
||||
)
|
||||
latest_month_by_scope = {
|
||||
checkpoint["scope_key"]: checkpoint["month_key"] for checkpoint in monthly_checkpoints
|
||||
}
|
||||
return {
|
||||
"status": "ok",
|
||||
"historical_source_policy": historical_source_policy,
|
||||
"totals": {
|
||||
"matches_scored": len({(row["scope_key"], row["external_match_id"]) for row in match_results}),
|
||||
"player_ratings": len(player_ratings),
|
||||
"match_results": len(match_results),
|
||||
"monthly_rankings": len(monthly_rankings),
|
||||
"monthly_checkpoints": len(monthly_checkpoints),
|
||||
},
|
||||
"latest_month_by_scope": latest_month_by_scope,
|
||||
}
|
||||
|
||||
|
||||
def list_elo_mmr_leaderboard_payload(*, server_id: str | None, limit: int) -> dict[str, object]:
|
||||
"""Return the current monthly Elo/MMR leaderboard for one scope."""
|
||||
scope_key = _normalize_scope_key(server_id)
|
||||
result = list_elo_mmr_monthly_rankings(scope_key=scope_key, limit=limit)
|
||||
return {
|
||||
"scope_key": scope_key,
|
||||
"month_key": result["month_key"],
|
||||
"found": result["found"],
|
||||
"generated_at": result["generated_at"],
|
||||
"items": result["items"],
|
||||
"source_policy": result["source_policy"] or _build_historical_source_policy_for_elo(),
|
||||
"capabilities_summary": result["capabilities_summary"],
|
||||
}
|
||||
|
||||
|
||||
def get_elo_mmr_player_payload(*, player_id: str, server_id: str | None) -> dict[str, object] | None:
|
||||
"""Return one Elo/MMR player profile."""
|
||||
return get_elo_mmr_player_profile(
|
||||
player_id=player_id,
|
||||
scope_key=_normalize_scope_key(server_id),
|
||||
)
|
||||
|
||||
|
||||
def build_arg_parser() -> argparse.ArgumentParser:
|
||||
"""Build the CLI parser for Elo/MMR maintenance."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Rebuild or inspect the Elo/MMR monthly ranking system.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"mode",
|
||||
choices=("rebuild", "leaderboard", "player"),
|
||||
help="rebuild recomputes all persisted Elo/MMR state; leaderboard and player inspect the read model",
|
||||
)
|
||||
parser.add_argument("--server", dest="server_id", help="optional server scope")
|
||||
parser.add_argument("--limit", type=int, default=10, help="max rows for leaderboard mode")
|
||||
parser.add_argument("--player", dest="player_id", help="player id or steam id for player mode")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Iterable[str] | None = None) -> int:
|
||||
"""Run the Elo/MMR CLI."""
|
||||
parser = build_arg_parser()
|
||||
args = parser.parse_args(list(argv) if argv is not None else None)
|
||||
if args.mode == "rebuild":
|
||||
print(json.dumps(rebuild_elo_mmr_models(), indent=2))
|
||||
return 0
|
||||
if args.mode == "leaderboard":
|
||||
print(json.dumps(list_elo_mmr_leaderboard_payload(server_id=args.server_id, limit=args.limit), indent=2))
|
||||
return 0
|
||||
if not args.player_id:
|
||||
parser.error("--player is required in player mode")
|
||||
print(json.dumps(get_elo_mmr_player_payload(player_id=args.player_id, server_id=args.server_id), indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def _load_closed_match_rows(*, db_path) -> list[dict[str, object]]:
|
||||
with connect_sqlite_readonly(db_path) as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT
|
||||
historical_servers.slug AS server_slug,
|
||||
historical_servers.display_name AS server_name,
|
||||
historical_matches.external_match_id,
|
||||
historical_matches.started_at,
|
||||
historical_matches.ended_at,
|
||||
historical_matches.game_mode,
|
||||
historical_matches.allied_score,
|
||||
historical_matches.axis_score,
|
||||
historical_players.stable_player_key,
|
||||
historical_players.display_name AS player_name,
|
||||
historical_players.steam_id,
|
||||
historical_player_match_stats.team_side,
|
||||
historical_player_match_stats.kills,
|
||||
historical_player_match_stats.deaths,
|
||||
historical_player_match_stats.teamkills,
|
||||
historical_player_match_stats.time_seconds,
|
||||
historical_player_match_stats.combat,
|
||||
historical_player_match_stats.offense,
|
||||
historical_player_match_stats.defense,
|
||||
historical_player_match_stats.support
|
||||
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
|
||||
WHERE historical_matches.ended_at IS NOT NULL
|
||||
ORDER BY historical_matches.ended_at ASC, historical_matches.id ASC, historical_players.id ASC
|
||||
"""
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def _group_match_rows(rows: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||
grouped: dict[tuple[str, str], list[dict[str, object]]] = defaultdict(list)
|
||||
for row in rows:
|
||||
grouped[(str(row["server_slug"]), str(row["external_match_id"]))].append(row)
|
||||
items: list[dict[str, object]] = []
|
||||
for (server_slug, match_id), players in grouped.items():
|
||||
first = players[0]
|
||||
items.append(
|
||||
{
|
||||
"server_slug": server_slug,
|
||||
"server_name": first["server_name"],
|
||||
"external_match_id": match_id,
|
||||
"started_at": first["started_at"],
|
||||
"ended_at": first["ended_at"],
|
||||
"game_mode": first["game_mode"],
|
||||
"allied_score": _safe_int(first["allied_score"]),
|
||||
"axis_score": _safe_int(first["axis_score"]),
|
||||
"players": players,
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def _score_match_for_scope(
|
||||
*,
|
||||
match_group: dict[str, object],
|
||||
scope_key: str,
|
||||
ratings_by_scope: dict[str, dict[str, object]],
|
||||
) -> list[dict[str, object]]:
|
||||
players = list(match_group["players"])
|
||||
duration_seconds, duration_mode = _resolve_match_duration(match_group, players)
|
||||
quality_factor = _build_quality_factor(
|
||||
player_count=len(players),
|
||||
duration_seconds=duration_seconds,
|
||||
has_score=match_group.get("allied_score") is not None and match_group.get("axis_score") is not None,
|
||||
)
|
||||
quality_bucket = _classify_quality_bucket(quality_factor)
|
||||
match_valid = duration_seconds >= MIN_VALID_MATCH_DURATION_SECONDS and len(players) >= MIN_VALID_MATCH_PLAYERS
|
||||
month_key = str(match_group["ended_at"])[:7]
|
||||
max_kills = max(max(_safe_int(player.get("kills")), 0) for player in players) or 1
|
||||
max_support = max(max(_safe_int(player.get("support")), 0) for player in players) or 1
|
||||
max_combat = max(max(_safe_int(player.get("combat")), 0) for player in players) or 1
|
||||
max_objective = max(
|
||||
max(_safe_int(player.get("offense")) + _safe_int(player.get("defense")), 0)
|
||||
for player in players
|
||||
) or 1
|
||||
results: list[dict[str, object]] = []
|
||||
|
||||
for player in players:
|
||||
stable_player_key = str(player["stable_player_key"])
|
||||
rating_row = ratings_by_scope.setdefault(
|
||||
stable_player_key,
|
||||
{
|
||||
"scope_key": scope_key,
|
||||
"stable_player_key": stable_player_key,
|
||||
"player_name": player["player_name"],
|
||||
"steam_id": player.get("steam_id"),
|
||||
"current_mmr": DEFAULT_BASE_MMR,
|
||||
"matches_processed": 0,
|
||||
"wins": 0,
|
||||
"draws": 0,
|
||||
"losses": 0,
|
||||
"last_match_id": None,
|
||||
"last_match_ended_at": None,
|
||||
"accuracy_mode": "partial",
|
||||
"capabilities": summarize_accuracy([]),
|
||||
},
|
||||
)
|
||||
signals: list[dict[str, object]] = []
|
||||
team_outcome = _resolve_team_outcome(
|
||||
team_side=str(player.get("team_side") or ""),
|
||||
allied_score=_safe_int(match_group.get("allied_score")),
|
||||
axis_score=_safe_int(match_group.get("axis_score")),
|
||||
)
|
||||
outcome_score = 100.0 if team_outcome == "win" else 50.0 if team_outcome == "draw" else 0.0
|
||||
signals.append(build_signal("OutcomeScore", CAPABILITY_EXACT, "Derived from team side and final match score."))
|
||||
|
||||
kills = _safe_int(player.get("kills"))
|
||||
deaths = max(1, _safe_int(player.get("deaths")))
|
||||
combat_raw = _safe_int(player.get("combat"))
|
||||
combat_index = round(
|
||||
(40.0 * (kills / max_kills))
|
||||
+ (35.0 * min(1.0, (kills / deaths) / 3.0))
|
||||
+ (25.0 * (combat_raw / max_combat)),
|
||||
3,
|
||||
)
|
||||
signals.append(build_signal("CombatIndex", CAPABILITY_EXACT, "Uses kills, KDA proxy and persisted combat score."))
|
||||
|
||||
support = _safe_int(player.get("support"))
|
||||
utility_index = round(100.0 * (support / max_support), 3) if max_support > 0 else 0.0
|
||||
signals.append(build_signal("UtilityIndex", CAPABILITY_EXACT, "Uses persisted support points."))
|
||||
|
||||
objective_proxy = _safe_int(player.get("offense")) + _safe_int(player.get("defense"))
|
||||
objective_index = round(100.0 * (objective_proxy / max_objective), 3) if max_objective > 0 else 0.0
|
||||
signals.append(build_signal("ObjectiveIndex", CAPABILITY_APPROXIMATE, "Approximated from offense and defense scoreboard points because no tactical event feed exists yet."))
|
||||
|
||||
teamkills = _safe_int(player.get("teamkills"))
|
||||
discipline_index = round(max(0.0, 100.0 - (teamkills * 25.0)), 3)
|
||||
signals.append(build_signal("DisciplineIndex", CAPABILITY_EXACT, "Uses persisted teamkills. AFK and leave events are not available yet."))
|
||||
leadership_index = None
|
||||
signals.append(build_signal("LeadershipIndex", CAPABILITY_UNAVAILABLE, "No leadership-specific telemetry is stored in the repository yet."))
|
||||
|
||||
role_bucket = _resolve_role_bucket(player)
|
||||
signals.append(build_signal("role_bucket", CAPABILITY_APPROXIMATE, "Inferred from the dominant combat/offense/defense/support axis because literal player role is unavailable."))
|
||||
if duration_mode == CAPABILITY_EXACT:
|
||||
signals.append(build_signal("quality_duration", CAPABILITY_EXACT, "Duration computed from match timestamps."))
|
||||
else:
|
||||
signals.append(build_signal("quality_duration", CAPABILITY_APPROXIMATE, "Duration approximated from the maximum persisted player time."))
|
||||
|
||||
weights = ROLE_WEIGHTS.get(role_bucket, ROLE_WEIGHTS[ROLE_BUCKET_GENERALIST])
|
||||
impact_score = round(
|
||||
sum(
|
||||
{
|
||||
"combat": combat_index,
|
||||
"objective": objective_index,
|
||||
"utility": utility_index,
|
||||
"discipline": discipline_index,
|
||||
}[key]
|
||||
* weight
|
||||
for key, weight in weights.items()
|
||||
),
|
||||
3,
|
||||
)
|
||||
combined_score = round((0.55 * outcome_score) + (0.45 * impact_score), 3)
|
||||
if not match_valid:
|
||||
delta_mmr = 0.0
|
||||
match_score = 0.0
|
||||
else:
|
||||
delta_mmr = round(((combined_score - 50.0) * quality_factor) * 0.6, 3)
|
||||
match_score = round(combined_score * quality_factor, 3)
|
||||
capability_summary = summarize_accuracy(signals)
|
||||
rating_before = float(rating_row["current_mmr"])
|
||||
rating_after = round(rating_before + delta_mmr, 3)
|
||||
results.append(
|
||||
{
|
||||
"scope_key": scope_key,
|
||||
"month_key": month_key,
|
||||
"external_match_id": match_group["external_match_id"],
|
||||
"stable_player_key": stable_player_key,
|
||||
"player_name": player["player_name"],
|
||||
"steam_id": player.get("steam_id"),
|
||||
"server_slug": match_group["server_slug"],
|
||||
"server_name": match_group["server_name"],
|
||||
"match_ended_at": match_group["ended_at"],
|
||||
"match_valid": match_valid,
|
||||
"quality_factor": quality_factor,
|
||||
"quality_bucket": quality_bucket,
|
||||
"role_bucket": role_bucket,
|
||||
"role_bucket_mode": CAPABILITY_APPROXIMATE,
|
||||
"outcome_score": outcome_score,
|
||||
"combat_index": combat_index,
|
||||
"objective_index": objective_index,
|
||||
"objective_index_mode": CAPABILITY_APPROXIMATE,
|
||||
"utility_index": utility_index,
|
||||
"utility_index_mode": CAPABILITY_EXACT,
|
||||
"leadership_index": leadership_index,
|
||||
"leadership_index_mode": CAPABILITY_UNAVAILABLE,
|
||||
"discipline_index": discipline_index,
|
||||
"discipline_index_mode": CAPABILITY_EXACT,
|
||||
"impact_score": impact_score,
|
||||
"delta_mmr": delta_mmr,
|
||||
"mmr_before": rating_before,
|
||||
"mmr_after": rating_after,
|
||||
"match_score": match_score,
|
||||
"penalty_points": round(teamkills * 2.0, 3),
|
||||
"capabilities": capability_summary,
|
||||
"time_seconds": _safe_int(player.get("time_seconds")),
|
||||
"team_outcome": team_outcome,
|
||||
}
|
||||
)
|
||||
rating_row["current_mmr"] = rating_after
|
||||
rating_row["matches_processed"] = int(rating_row["matches_processed"]) + 1
|
||||
rating_row["last_match_id"] = match_group["external_match_id"]
|
||||
rating_row["last_match_ended_at"] = match_group["ended_at"]
|
||||
rating_row["accuracy_mode"] = capability_summary["accuracy_mode"]
|
||||
rating_row["capabilities"] = capability_summary
|
||||
if team_outcome == "win":
|
||||
rating_row["wins"] = int(rating_row["wins"]) + 1
|
||||
elif team_outcome == "draw":
|
||||
rating_row["draws"] = int(rating_row["draws"]) + 1
|
||||
else:
|
||||
rating_row["losses"] = int(rating_row["losses"]) + 1
|
||||
return results
|
||||
|
||||
|
||||
def _build_monthly_rankings(match_results: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||
grouped: dict[tuple[str, str, str], list[dict[str, object]]] = defaultdict(list)
|
||||
for row in match_results:
|
||||
grouped[(row["scope_key"], row["month_key"], row["stable_player_key"])].append(row)
|
||||
|
||||
rankings: list[dict[str, object]] = []
|
||||
grouped_by_scope_month: dict[tuple[str, str], list[dict[str, object]]] = defaultdict(list)
|
||||
for (scope_key, month_key, stable_player_key), rows in grouped.items():
|
||||
rows.sort(key=lambda item: (item["match_ended_at"], item["external_match_id"]))
|
||||
valid_rows = [row for row in rows if row["match_valid"]]
|
||||
total_time_seconds = sum(int(row["time_seconds"] or 0) for row in rows)
|
||||
penalty_points = round(sum(float(row["penalty_points"]) for row in rows), 3)
|
||||
capability_rows = [row["capabilities"] for row in rows]
|
||||
exact_ratio = round(sum(float(item["exact_ratio"]) for item in capability_rows) / max(1, len(capability_rows)), 3)
|
||||
approximate_ratio = round(sum(float(item["approximate_ratio"]) for item in capability_rows) / max(1, len(capability_rows)), 3)
|
||||
unavailable_ratio = round(sum(float(item["unavailable_ratio"]) for item in capability_rows) / max(1, len(capability_rows)), 3)
|
||||
accuracy_mode = "partial" if unavailable_ratio > 0 else "approximate" if approximate_ratio > 0 else "exact"
|
||||
avg_match_score = round(sum(float(row["match_score"]) for row in valid_rows) / max(1, len(valid_rows)), 3)
|
||||
baseline_mmr = round(float(rows[0]["mmr_before"]), 3)
|
||||
current_mmr = round(float(rows[-1]["mmr_after"]), 3)
|
||||
mmr_gain = round(current_mmr - baseline_mmr, 3)
|
||||
strength_of_schedule = round(sum(float(row["quality_factor"]) for row in valid_rows) * 100.0 / max(1, len(valid_rows)), 3)
|
||||
consistency = _build_consistency_score(valid_rows)
|
||||
activity = _build_activity_score(valid_rows, total_time_seconds)
|
||||
confidence = round(min(100.0, (len(valid_rows) / MONTHLY_MIN_VALID_MATCHES) * 40.0 + (total_time_seconds / MONTHLY_MIN_TIME_SECONDS) * 35.0 + (exact_ratio * 25.0)), 3)
|
||||
eligible = len(valid_rows) >= MONTHLY_MIN_VALID_MATCHES and total_time_seconds >= MONTHLY_MIN_TIME_SECONDS
|
||||
eligibility_reason = None if eligible else "minimum-valid-matches-not-met" if len(valid_rows) < MONTHLY_MIN_VALID_MATCHES else "minimum-playtime-not-met"
|
||||
grouped_by_scope_month[(scope_key, month_key)].append(
|
||||
{
|
||||
"scope_key": scope_key,
|
||||
"month_key": month_key,
|
||||
"stable_player_key": stable_player_key,
|
||||
"player_name": rows[-1]["player_name"],
|
||||
"steam_id": rows[-1].get("steam_id"),
|
||||
"current_mmr": current_mmr,
|
||||
"baseline_mmr": baseline_mmr,
|
||||
"mmr_gain": mmr_gain,
|
||||
"avg_match_score": avg_match_score,
|
||||
"strength_of_schedule": strength_of_schedule,
|
||||
"consistency": consistency,
|
||||
"activity": activity,
|
||||
"confidence": confidence,
|
||||
"penalty_points": penalty_points,
|
||||
"monthly_rank_score": 0.0,
|
||||
"valid_matches": len(valid_rows),
|
||||
"total_matches": len(rows),
|
||||
"total_time_seconds": total_time_seconds,
|
||||
"eligible": eligible,
|
||||
"eligibility_reason": eligibility_reason,
|
||||
"accuracy_mode": accuracy_mode,
|
||||
"capabilities": {
|
||||
"accuracy_mode": accuracy_mode,
|
||||
"exact_ratio": exact_ratio,
|
||||
"approximate_ratio": approximate_ratio,
|
||||
"unavailable_ratio": unavailable_ratio,
|
||||
"signals": [
|
||||
build_signal("OutcomeScore", CAPABILITY_EXACT, "Uses final scores and team side."),
|
||||
build_signal("CombatIndex", CAPABILITY_EXACT, "Uses historical player stats."),
|
||||
build_signal("ObjectiveIndex", CAPABILITY_APPROXIMATE, "Uses offense and defense scores as a tactical proxy."),
|
||||
build_signal("UtilityIndex", CAPABILITY_EXACT, "Uses support points."),
|
||||
build_signal("LeadershipIndex", CAPABILITY_UNAVAILABLE, "No leadership telemetry exists yet."),
|
||||
build_signal("DisciplineIndex", CAPABILITY_EXACT, "Uses teamkills; no AFK or leave telemetry exists yet."),
|
||||
build_signal("StrengthOfSchedule", CAPABILITY_APPROXIMATE, "Currently approximated from match quality and lobby density, not opponent MMR."),
|
||||
],
|
||||
},
|
||||
"component_scores": {
|
||||
"avg_match_score": avg_match_score,
|
||||
"mmr_gain_raw": mmr_gain,
|
||||
"strength_of_schedule": strength_of_schedule,
|
||||
"consistency": consistency,
|
||||
"activity": activity,
|
||||
"confidence": confidence,
|
||||
"penalty_points": penalty_points,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for rows in grouped_by_scope_month.values():
|
||||
max_avg = max((row["avg_match_score"] for row in rows), default=1.0) or 1.0
|
||||
max_gain = max((max(0.0, row["mmr_gain"]) for row in rows), default=1.0) or 1.0
|
||||
max_sos = max((row["strength_of_schedule"] for row in rows), default=1.0) or 1.0
|
||||
max_consistency = max((row["consistency"] for row in rows), default=1.0) or 1.0
|
||||
max_activity = max((row["activity"] for row in rows), default=1.0) or 1.0
|
||||
max_confidence = max((row["confidence"] for row in rows), default=1.0) or 1.0
|
||||
for row in rows:
|
||||
normalized_gain = max(0.0, row["mmr_gain"]) / max_gain if max_gain > 0 else 0.0
|
||||
row["component_scores"]["normalized_mmr_gain"] = round(normalized_gain * 100.0, 3)
|
||||
row["monthly_rank_score"] = round(
|
||||
(0.38 * (row["avg_match_score"] / max_avg) * 100.0)
|
||||
+ (0.22 * normalized_gain * 100.0)
|
||||
+ (0.10 * (row["strength_of_schedule"] / max_sos) * 100.0)
|
||||
+ (0.12 * (row["consistency"] / max_consistency) * 100.0)
|
||||
+ (0.10 * (row["activity"] / max_activity) * 100.0)
|
||||
+ (0.08 * (row["confidence"] / max_confidence) * 100.0)
|
||||
- row["penalty_points"],
|
||||
3,
|
||||
)
|
||||
rankings.append(row)
|
||||
return rankings
|
||||
|
||||
|
||||
def _build_historical_source_policy_for_elo() -> dict[str, object]:
|
||||
if get_historical_data_source_kind() != SOURCE_KIND_RCON:
|
||||
return build_source_policy(
|
||||
primary_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||
selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||
source_attempts=[build_source_attempt(source=SOURCE_KIND_PUBLIC_SCOREBOARD, role="primary", status="success")],
|
||||
)
|
||||
return build_source_policy(
|
||||
primary_source=SOURCE_KIND_RCON,
|
||||
selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||
fallback_used=True,
|
||||
fallback_reason="rcon-historical-read-model-does-not-support-elo-mmr-competitive-calculations-yet",
|
||||
source_attempts=[
|
||||
build_source_attempt(source=SOURCE_KIND_RCON, role="primary", status="unsupported", reason="rcon-historical-read-model-does-not-support-elo-mmr-competitive-calculations-yet"),
|
||||
build_source_attempt(source=SOURCE_KIND_PUBLIC_SCOREBOARD, role="fallback", status="success"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _resolve_match_duration(match_group: dict[str, object], players: list[dict[str, object]]) -> tuple[int, str]:
|
||||
started_at = _parse_optional_timestamp(match_group.get("started_at"))
|
||||
ended_at = _parse_optional_timestamp(match_group.get("ended_at"))
|
||||
if started_at and ended_at and ended_at >= started_at:
|
||||
return int((ended_at - started_at).total_seconds()), CAPABILITY_EXACT
|
||||
return max((_safe_int(player.get("time_seconds")) for player in players), default=0), CAPABILITY_APPROXIMATE
|
||||
|
||||
|
||||
def _build_quality_factor(*, player_count: int, duration_seconds: int, has_score: bool) -> float:
|
||||
player_component = min(1.0, player_count / FULL_QUALITY_PLAYER_COUNT)
|
||||
duration_component = min(1.0, duration_seconds / FULL_QUALITY_DURATION_SECONDS)
|
||||
score_component = 1.0 if has_score else 0.7
|
||||
return round((0.4 * player_component) + (0.4 * duration_component) + (0.2 * score_component), 3)
|
||||
|
||||
|
||||
def _classify_quality_bucket(quality_factor: float) -> str:
|
||||
if quality_factor >= 0.8:
|
||||
return QUALITY_BUCKET_HIGH
|
||||
if quality_factor >= 0.55:
|
||||
return QUALITY_BUCKET_MEDIUM
|
||||
return QUALITY_BUCKET_LOW
|
||||
|
||||
|
||||
def _resolve_team_outcome(*, team_side: str, allied_score: int | None, axis_score: int | None) -> str:
|
||||
if allied_score is None or axis_score is None or allied_score == axis_score:
|
||||
return "draw"
|
||||
normalized = team_side.strip().lower()
|
||||
allied_won = allied_score > axis_score
|
||||
if normalized.startswith("all"):
|
||||
return "win" if allied_won else "loss"
|
||||
if normalized.startswith("ax"):
|
||||
return "win" if not allied_won else "loss"
|
||||
return "draw"
|
||||
|
||||
|
||||
def _resolve_role_bucket(player: dict[str, object]) -> str:
|
||||
axes = {
|
||||
ROLE_BUCKET_SUPPORT: _safe_int(player.get("support")),
|
||||
ROLE_BUCKET_OFFENSE: _safe_int(player.get("offense")),
|
||||
ROLE_BUCKET_DEFENSE: _safe_int(player.get("defense")),
|
||||
ROLE_BUCKET_COMBAT: _safe_int(player.get("combat")),
|
||||
}
|
||||
top_bucket, top_value = max(axes.items(), key=lambda item: item[1])
|
||||
sorted_values = sorted(axes.values(), reverse=True)
|
||||
if top_value <= 0 or (len(sorted_values) >= 2 and sorted_values[0] == sorted_values[1]):
|
||||
return ROLE_BUCKET_GENERALIST
|
||||
return top_bucket
|
||||
|
||||
|
||||
def _build_consistency_score(rows: list[dict[str, object]]) -> float:
|
||||
if len(rows) <= 1:
|
||||
return 100.0 if rows else 0.0
|
||||
values = [float(row["match_score"]) for row in rows]
|
||||
average = sum(values) / len(values)
|
||||
if average <= 0:
|
||||
return 0.0
|
||||
return round(100.0 * (1.0 - min(1.0, pstdev(values) / max(average, 1.0))), 3)
|
||||
|
||||
|
||||
def _build_activity_score(rows: list[dict[str, object]], total_time_seconds: int) -> float:
|
||||
match_component = min(1.0, len(rows) / MONTHLY_ACTIVITY_TARGET_MATCHES)
|
||||
hour_component = min(1.0, (total_time_seconds / 3600.0) / MONTHLY_ACTIVITY_TARGET_HOURS)
|
||||
return round(((0.6 * match_component) + (0.4 * hour_component)) * 100.0, 3)
|
||||
|
||||
|
||||
def _normalize_scope_key(server_id: str | None) -> str:
|
||||
normalized = str(server_id or SCOPE_ALL_SERVERS).strip()
|
||||
return normalized or SCOPE_ALL_SERVERS
|
||||
|
||||
|
||||
def _parse_optional_timestamp(value: object) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _safe_int(value: object) -> int:
|
||||
try:
|
||||
return int(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
71
backend/app/elo_mmr_models.py
Normal file
71
backend/app/elo_mmr_models.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Contracts and capability helpers for the Elo/MMR monthly ranking system."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
|
||||
CAPABILITY_EXACT = "exact"
|
||||
CAPABILITY_APPROXIMATE = "approximate"
|
||||
CAPABILITY_UNAVAILABLE = "not_available"
|
||||
|
||||
ACCURACY_EXACT = "exact"
|
||||
ACCURACY_APPROXIMATE = "approximate"
|
||||
ACCURACY_PARTIAL = "partial"
|
||||
|
||||
DEFAULT_BASE_MMR = 1000.0
|
||||
MIN_VALID_MATCH_DURATION_SECONDS = 900
|
||||
MIN_VALID_MATCH_PLAYERS = 20
|
||||
FULL_QUALITY_PLAYER_COUNT = 70
|
||||
FULL_QUALITY_DURATION_SECONDS = 3600
|
||||
MONTHLY_MIN_VALID_MATCHES = 5
|
||||
MONTHLY_MIN_TIME_SECONDS = 21600
|
||||
MONTHLY_ACTIVITY_TARGET_MATCHES = 12
|
||||
MONTHLY_ACTIVITY_TARGET_HOURS = 20.0
|
||||
DEFAULT_MONTHLY_SCOREBOARD_MIN_MATCHES = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EloSignalAvailability:
|
||||
"""Normalized availability state for one scoring input."""
|
||||
|
||||
name: str
|
||||
status: str
|
||||
detail: str
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
"""Return the availability entry as a serializable mapping."""
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def build_signal(name: str, status: str, detail: str) -> dict[str, object]:
|
||||
"""Create a normalized availability block for one signal."""
|
||||
return EloSignalAvailability(name=name, status=status, detail=detail).to_dict()
|
||||
|
||||
|
||||
def summarize_accuracy(signals: list[dict[str, object]]) -> dict[str, object]:
|
||||
"""Summarize exact, approximate and unavailable signals for one calculation."""
|
||||
exact_count = sum(1 for signal in signals if signal.get("status") == CAPABILITY_EXACT)
|
||||
approximate_count = sum(
|
||||
1 for signal in signals if signal.get("status") == CAPABILITY_APPROXIMATE
|
||||
)
|
||||
unavailable_count = sum(
|
||||
1 for signal in signals if signal.get("status") == CAPABILITY_UNAVAILABLE
|
||||
)
|
||||
if unavailable_count > 0:
|
||||
accuracy_mode = ACCURACY_PARTIAL
|
||||
elif approximate_count > 0:
|
||||
accuracy_mode = ACCURACY_APPROXIMATE
|
||||
else:
|
||||
accuracy_mode = ACCURACY_EXACT
|
||||
total = max(1, len(signals))
|
||||
return {
|
||||
"accuracy_mode": accuracy_mode,
|
||||
"exact_count": exact_count,
|
||||
"approximate_count": approximate_count,
|
||||
"unavailable_count": unavailable_count,
|
||||
"exact_ratio": round(exact_count / total, 3),
|
||||
"approximate_ratio": round(approximate_count / total, 3),
|
||||
"unavailable_ratio": round(unavailable_count / total, 3),
|
||||
"signals": list(signals),
|
||||
}
|
||||
558
backend/app/elo_mmr_storage.py
Normal file
558
backend/app/elo_mmr_storage.py
Normal file
@@ -0,0 +1,558 @@
|
||||
"""SQLite storage for persistent Elo/MMR and monthly ranking results."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from .config import get_storage_path
|
||||
from .sqlite_utils import connect_sqlite_readonly, connect_sqlite_writer
|
||||
|
||||
|
||||
def initialize_elo_mmr_storage(*, db_path: Path | None = None) -> Path:
|
||||
"""Create the Elo/MMR persistence tables in the shared backend SQLite."""
|
||||
resolved_path = _resolve_db_path(db_path)
|
||||
resolved_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with _connect_writer(resolved_path) as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS elo_mmr_player_ratings (
|
||||
scope_key TEXT NOT NULL,
|
||||
stable_player_key TEXT NOT NULL,
|
||||
player_name TEXT NOT NULL,
|
||||
steam_id TEXT,
|
||||
current_mmr REAL NOT NULL,
|
||||
matches_processed INTEGER NOT NULL DEFAULT 0,
|
||||
wins INTEGER NOT NULL DEFAULT 0,
|
||||
draws INTEGER NOT NULL DEFAULT 0,
|
||||
losses INTEGER NOT NULL DEFAULT 0,
|
||||
last_match_id TEXT,
|
||||
last_match_ended_at TEXT,
|
||||
accuracy_mode TEXT NOT NULL,
|
||||
capabilities_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (scope_key, stable_player_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS elo_mmr_match_results (
|
||||
scope_key TEXT NOT NULL,
|
||||
month_key TEXT NOT NULL,
|
||||
external_match_id TEXT NOT NULL,
|
||||
stable_player_key TEXT NOT NULL,
|
||||
player_name TEXT NOT NULL,
|
||||
steam_id TEXT,
|
||||
server_slug TEXT NOT NULL,
|
||||
server_name TEXT NOT NULL,
|
||||
match_ended_at TEXT NOT NULL,
|
||||
match_valid INTEGER NOT NULL,
|
||||
quality_factor REAL NOT NULL,
|
||||
quality_bucket TEXT NOT NULL,
|
||||
role_bucket TEXT NOT NULL,
|
||||
role_bucket_mode TEXT NOT NULL,
|
||||
outcome_score REAL NOT NULL,
|
||||
combat_index REAL NOT NULL,
|
||||
objective_index REAL,
|
||||
objective_index_mode TEXT NOT NULL,
|
||||
utility_index REAL,
|
||||
utility_index_mode TEXT NOT NULL,
|
||||
leadership_index REAL,
|
||||
leadership_index_mode TEXT NOT NULL,
|
||||
discipline_index REAL,
|
||||
discipline_index_mode TEXT NOT NULL,
|
||||
impact_score REAL NOT NULL,
|
||||
delta_mmr REAL NOT NULL,
|
||||
mmr_before REAL NOT NULL,
|
||||
mmr_after REAL NOT NULL,
|
||||
match_score REAL NOT NULL,
|
||||
penalty_points REAL NOT NULL,
|
||||
capabilities_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (scope_key, external_match_id, stable_player_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS elo_mmr_monthly_rankings (
|
||||
scope_key TEXT NOT NULL,
|
||||
month_key TEXT NOT NULL,
|
||||
stable_player_key TEXT NOT NULL,
|
||||
player_name TEXT NOT NULL,
|
||||
steam_id TEXT,
|
||||
current_mmr REAL NOT NULL,
|
||||
baseline_mmr REAL NOT NULL,
|
||||
mmr_gain REAL NOT NULL,
|
||||
avg_match_score REAL NOT NULL,
|
||||
strength_of_schedule REAL NOT NULL,
|
||||
consistency REAL NOT NULL,
|
||||
activity REAL NOT NULL,
|
||||
confidence REAL NOT NULL,
|
||||
penalty_points REAL NOT NULL,
|
||||
monthly_rank_score REAL NOT NULL,
|
||||
valid_matches INTEGER NOT NULL,
|
||||
total_matches INTEGER NOT NULL,
|
||||
total_time_seconds INTEGER NOT NULL,
|
||||
eligible INTEGER NOT NULL,
|
||||
eligibility_reason TEXT,
|
||||
accuracy_mode TEXT NOT NULL,
|
||||
capabilities_json TEXT NOT NULL,
|
||||
component_scores_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (scope_key, month_key, stable_player_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS elo_mmr_monthly_checkpoints (
|
||||
scope_key TEXT NOT NULL,
|
||||
month_key TEXT NOT NULL,
|
||||
generated_at TEXT NOT NULL,
|
||||
player_count INTEGER NOT NULL,
|
||||
eligible_player_count INTEGER NOT NULL,
|
||||
source_policy_json TEXT NOT NULL,
|
||||
capabilities_summary_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (scope_key, month_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_elo_mmr_monthly_rankings_scope_month
|
||||
ON elo_mmr_monthly_rankings(scope_key, month_key, eligible, monthly_rank_score DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_elo_mmr_player_ratings_scope
|
||||
ON elo_mmr_player_ratings(scope_key, current_mmr DESC);
|
||||
"""
|
||||
)
|
||||
return resolved_path
|
||||
|
||||
|
||||
def replace_elo_mmr_state(
|
||||
*,
|
||||
player_ratings: list[dict[str, object]],
|
||||
match_results: list[dict[str, object]],
|
||||
monthly_rankings: list[dict[str, object]],
|
||||
monthly_checkpoints: list[dict[str, object]],
|
||||
db_path: Path | None = None,
|
||||
) -> Path:
|
||||
"""Replace the persisted Elo/MMR state with a freshly rebuilt dataset."""
|
||||
resolved_path = initialize_elo_mmr_storage(db_path=db_path)
|
||||
with _connect_writer(resolved_path) as connection:
|
||||
connection.execute("DELETE FROM elo_mmr_monthly_checkpoints")
|
||||
connection.execute("DELETE FROM elo_mmr_monthly_rankings")
|
||||
connection.execute("DELETE FROM elo_mmr_match_results")
|
||||
connection.execute("DELETE FROM elo_mmr_player_ratings")
|
||||
|
||||
connection.executemany(
|
||||
"""
|
||||
INSERT INTO elo_mmr_player_ratings (
|
||||
scope_key,
|
||||
stable_player_key,
|
||||
player_name,
|
||||
steam_id,
|
||||
current_mmr,
|
||||
matches_processed,
|
||||
wins,
|
||||
draws,
|
||||
losses,
|
||||
last_match_id,
|
||||
last_match_ended_at,
|
||||
accuracy_mode,
|
||||
capabilities_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
(
|
||||
row["scope_key"],
|
||||
row["stable_player_key"],
|
||||
row["player_name"],
|
||||
row.get("steam_id"),
|
||||
row["current_mmr"],
|
||||
row["matches_processed"],
|
||||
row["wins"],
|
||||
row["draws"],
|
||||
row["losses"],
|
||||
row.get("last_match_id"),
|
||||
row.get("last_match_ended_at"),
|
||||
row["accuracy_mode"],
|
||||
json.dumps(row["capabilities"], ensure_ascii=True, separators=(",", ":")),
|
||||
)
|
||||
for row in player_ratings
|
||||
],
|
||||
)
|
||||
|
||||
connection.executemany(
|
||||
"""
|
||||
INSERT INTO elo_mmr_match_results (
|
||||
scope_key,
|
||||
month_key,
|
||||
external_match_id,
|
||||
stable_player_key,
|
||||
player_name,
|
||||
steam_id,
|
||||
server_slug,
|
||||
server_name,
|
||||
match_ended_at,
|
||||
match_valid,
|
||||
quality_factor,
|
||||
quality_bucket,
|
||||
role_bucket,
|
||||
role_bucket_mode,
|
||||
outcome_score,
|
||||
combat_index,
|
||||
objective_index,
|
||||
objective_index_mode,
|
||||
utility_index,
|
||||
utility_index_mode,
|
||||
leadership_index,
|
||||
leadership_index_mode,
|
||||
discipline_index,
|
||||
discipline_index_mode,
|
||||
impact_score,
|
||||
delta_mmr,
|
||||
mmr_before,
|
||||
mmr_after,
|
||||
match_score,
|
||||
penalty_points,
|
||||
capabilities_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
(
|
||||
row["scope_key"],
|
||||
row["month_key"],
|
||||
row["external_match_id"],
|
||||
row["stable_player_key"],
|
||||
row["player_name"],
|
||||
row.get("steam_id"),
|
||||
row["server_slug"],
|
||||
row["server_name"],
|
||||
row["match_ended_at"],
|
||||
1 if row["match_valid"] else 0,
|
||||
row["quality_factor"],
|
||||
row["quality_bucket"],
|
||||
row["role_bucket"],
|
||||
row["role_bucket_mode"],
|
||||
row["outcome_score"],
|
||||
row["combat_index"],
|
||||
row.get("objective_index"),
|
||||
row["objective_index_mode"],
|
||||
row.get("utility_index"),
|
||||
row["utility_index_mode"],
|
||||
row.get("leadership_index"),
|
||||
row["leadership_index_mode"],
|
||||
row.get("discipline_index"),
|
||||
row["discipline_index_mode"],
|
||||
row["impact_score"],
|
||||
row["delta_mmr"],
|
||||
row["mmr_before"],
|
||||
row["mmr_after"],
|
||||
row["match_score"],
|
||||
row["penalty_points"],
|
||||
json.dumps(row["capabilities"], ensure_ascii=True, separators=(",", ":")),
|
||||
)
|
||||
for row in match_results
|
||||
],
|
||||
)
|
||||
|
||||
connection.executemany(
|
||||
"""
|
||||
INSERT INTO elo_mmr_monthly_rankings (
|
||||
scope_key,
|
||||
month_key,
|
||||
stable_player_key,
|
||||
player_name,
|
||||
steam_id,
|
||||
current_mmr,
|
||||
baseline_mmr,
|
||||
mmr_gain,
|
||||
avg_match_score,
|
||||
strength_of_schedule,
|
||||
consistency,
|
||||
activity,
|
||||
confidence,
|
||||
penalty_points,
|
||||
monthly_rank_score,
|
||||
valid_matches,
|
||||
total_matches,
|
||||
total_time_seconds,
|
||||
eligible,
|
||||
eligibility_reason,
|
||||
accuracy_mode,
|
||||
capabilities_json,
|
||||
component_scores_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
(
|
||||
row["scope_key"],
|
||||
row["month_key"],
|
||||
row["stable_player_key"],
|
||||
row["player_name"],
|
||||
row.get("steam_id"),
|
||||
row["current_mmr"],
|
||||
row["baseline_mmr"],
|
||||
row["mmr_gain"],
|
||||
row["avg_match_score"],
|
||||
row["strength_of_schedule"],
|
||||
row["consistency"],
|
||||
row["activity"],
|
||||
row["confidence"],
|
||||
row["penalty_points"],
|
||||
row["monthly_rank_score"],
|
||||
row["valid_matches"],
|
||||
row["total_matches"],
|
||||
row["total_time_seconds"],
|
||||
1 if row["eligible"] else 0,
|
||||
row.get("eligibility_reason"),
|
||||
row["accuracy_mode"],
|
||||
json.dumps(row["capabilities"], ensure_ascii=True, separators=(",", ":")),
|
||||
json.dumps(row["component_scores"], ensure_ascii=True, separators=(",", ":")),
|
||||
)
|
||||
for row in monthly_rankings
|
||||
],
|
||||
)
|
||||
|
||||
connection.executemany(
|
||||
"""
|
||||
INSERT INTO elo_mmr_monthly_checkpoints (
|
||||
scope_key,
|
||||
month_key,
|
||||
generated_at,
|
||||
player_count,
|
||||
eligible_player_count,
|
||||
source_policy_json,
|
||||
capabilities_summary_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
(
|
||||
row["scope_key"],
|
||||
row["month_key"],
|
||||
row["generated_at"],
|
||||
row["player_count"],
|
||||
row["eligible_player_count"],
|
||||
json.dumps(row["source_policy"], ensure_ascii=True, separators=(",", ":")),
|
||||
json.dumps(
|
||||
row["capabilities_summary"],
|
||||
ensure_ascii=True,
|
||||
separators=(",", ":"),
|
||||
),
|
||||
)
|
||||
for row in monthly_checkpoints
|
||||
],
|
||||
)
|
||||
return resolved_path
|
||||
|
||||
|
||||
def list_elo_mmr_monthly_rankings(
|
||||
*,
|
||||
scope_key: str,
|
||||
limit: int = 10,
|
||||
month_key: str | None = None,
|
||||
eligible_only: bool = True,
|
||||
db_path: Path | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Return the persisted monthly Elo/MMR leaderboard for one scope."""
|
||||
resolved_path = _resolve_db_path(db_path)
|
||||
resolved_month_key = month_key or get_latest_elo_mmr_month_key(scope_key=scope_key, db_path=resolved_path)
|
||||
if not resolved_month_key:
|
||||
return {
|
||||
"month_key": None,
|
||||
"found": False,
|
||||
"generated_at": None,
|
||||
"items": [],
|
||||
"source_policy": None,
|
||||
"capabilities_summary": None,
|
||||
}
|
||||
|
||||
where_clauses = ["scope_key = ?", "month_key = ?"]
|
||||
params: list[object] = [scope_key, resolved_month_key]
|
||||
if eligible_only:
|
||||
where_clauses.append("eligible = 1")
|
||||
params.append(limit)
|
||||
try:
|
||||
with _connect_readonly(resolved_path) as connection:
|
||||
checkpoint_row = connection.execute(
|
||||
"""
|
||||
SELECT generated_at, source_policy_json, capabilities_summary_json
|
||||
FROM elo_mmr_monthly_checkpoints
|
||||
WHERE scope_key = ? AND month_key = ?
|
||||
""",
|
||||
(scope_key, resolved_month_key),
|
||||
).fetchone()
|
||||
rows = connection.execute(
|
||||
f"""
|
||||
SELECT *
|
||||
FROM elo_mmr_monthly_rankings
|
||||
WHERE {" AND ".join(where_clauses)}
|
||||
ORDER BY monthly_rank_score DESC, current_mmr DESC, player_name COLLATE NOCASE ASC
|
||||
LIMIT ?
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
except sqlite3.OperationalError:
|
||||
return {
|
||||
"month_key": None,
|
||||
"found": False,
|
||||
"generated_at": None,
|
||||
"items": [],
|
||||
"source_policy": None,
|
||||
"capabilities_summary": None,
|
||||
}
|
||||
items = []
|
||||
for index, row in enumerate(rows, start=1):
|
||||
items.append(
|
||||
{
|
||||
"ranking_position": index,
|
||||
"player": {
|
||||
"stable_player_key": row["stable_player_key"],
|
||||
"name": row["player_name"],
|
||||
"steam_id": row["steam_id"],
|
||||
},
|
||||
"persistent_rating": {
|
||||
"mmr": round(float(row["current_mmr"] or 0.0), 3),
|
||||
"baseline_mmr": round(float(row["baseline_mmr"] or 0.0), 3),
|
||||
"mmr_gain": round(float(row["mmr_gain"] or 0.0), 3),
|
||||
},
|
||||
"monthly_rank_score": round(float(row["monthly_rank_score"] or 0.0), 3),
|
||||
"components": json.loads(row["component_scores_json"]),
|
||||
"valid_matches": int(row["valid_matches"] or 0),
|
||||
"total_matches": int(row["total_matches"] or 0),
|
||||
"total_time_seconds": int(row["total_time_seconds"] or 0),
|
||||
"eligible": bool(row["eligible"]),
|
||||
"eligibility_reason": row["eligibility_reason"],
|
||||
"accuracy_mode": row["accuracy_mode"],
|
||||
"capabilities": json.loads(row["capabilities_json"]),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"month_key": resolved_month_key,
|
||||
"found": bool(items),
|
||||
"generated_at": checkpoint_row["generated_at"] if checkpoint_row else None,
|
||||
"items": items,
|
||||
"source_policy": json.loads(checkpoint_row["source_policy_json"])
|
||||
if checkpoint_row
|
||||
else None,
|
||||
"capabilities_summary": json.loads(checkpoint_row["capabilities_summary_json"])
|
||||
if checkpoint_row
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
def get_elo_mmr_player_profile(
|
||||
*,
|
||||
player_id: str,
|
||||
scope_key: str,
|
||||
month_key: str | None = None,
|
||||
db_path: Path | None = None,
|
||||
) -> dict[str, object] | None:
|
||||
"""Return the persisted rating and monthly ranking profile for one player."""
|
||||
resolved_player_id = player_id.strip()
|
||||
if not resolved_player_id:
|
||||
return None
|
||||
resolved_path = _resolve_db_path(db_path)
|
||||
resolved_month_key = month_key or get_latest_elo_mmr_month_key(scope_key=scope_key, db_path=resolved_path)
|
||||
try:
|
||||
with _connect_readonly(resolved_path) as connection:
|
||||
rating_row = connection.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM elo_mmr_player_ratings
|
||||
WHERE scope_key = ?
|
||||
AND (stable_player_key = ? OR steam_id = ?)
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(scope_key, resolved_player_id, resolved_player_id),
|
||||
).fetchone()
|
||||
monthly_row = None
|
||||
if resolved_month_key:
|
||||
monthly_row = connection.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM elo_mmr_monthly_rankings
|
||||
WHERE scope_key = ?
|
||||
AND month_key = ?
|
||||
AND (stable_player_key = ? OR steam_id = ?)
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(scope_key, resolved_month_key, resolved_player_id, resolved_player_id),
|
||||
).fetchone()
|
||||
except sqlite3.OperationalError:
|
||||
return None
|
||||
if rating_row is None and monthly_row is None:
|
||||
return None
|
||||
return {
|
||||
"scope_key": scope_key,
|
||||
"month_key": resolved_month_key,
|
||||
"player": {
|
||||
"stable_player_key": (
|
||||
rating_row["stable_player_key"] if rating_row else monthly_row["stable_player_key"]
|
||||
),
|
||||
"name": rating_row["player_name"] if rating_row else monthly_row["player_name"],
|
||||
"steam_id": rating_row["steam_id"] if rating_row else monthly_row["steam_id"],
|
||||
},
|
||||
"persistent_rating": (
|
||||
{
|
||||
"mmr": round(float(rating_row["current_mmr"] or 0.0), 3),
|
||||
"matches_processed": int(rating_row["matches_processed"] or 0),
|
||||
"wins": int(rating_row["wins"] or 0),
|
||||
"draws": int(rating_row["draws"] or 0),
|
||||
"losses": int(rating_row["losses"] or 0),
|
||||
"last_match_id": rating_row["last_match_id"],
|
||||
"last_match_ended_at": rating_row["last_match_ended_at"],
|
||||
"accuracy_mode": rating_row["accuracy_mode"],
|
||||
"capabilities": json.loads(rating_row["capabilities_json"]),
|
||||
}
|
||||
if rating_row
|
||||
else None
|
||||
),
|
||||
"monthly_ranking": (
|
||||
{
|
||||
"monthly_rank_score": round(float(monthly_row["monthly_rank_score"] or 0.0), 3),
|
||||
"current_mmr": round(float(monthly_row["current_mmr"] or 0.0), 3),
|
||||
"baseline_mmr": round(float(monthly_row["baseline_mmr"] or 0.0), 3),
|
||||
"mmr_gain": round(float(monthly_row["mmr_gain"] or 0.0), 3),
|
||||
"valid_matches": int(monthly_row["valid_matches"] or 0),
|
||||
"total_matches": int(monthly_row["total_matches"] or 0),
|
||||
"total_time_seconds": int(monthly_row["total_time_seconds"] or 0),
|
||||
"eligible": bool(monthly_row["eligible"]),
|
||||
"eligibility_reason": monthly_row["eligibility_reason"],
|
||||
"accuracy_mode": monthly_row["accuracy_mode"],
|
||||
"components": json.loads(monthly_row["component_scores_json"]),
|
||||
"capabilities": json.loads(monthly_row["capabilities_json"]),
|
||||
}
|
||||
if monthly_row
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_latest_elo_mmr_month_key(
|
||||
*,
|
||||
scope_key: str,
|
||||
db_path: Path | None = None,
|
||||
) -> str | None:
|
||||
"""Return the latest month key available for one Elo/MMR scope."""
|
||||
resolved_path = _resolve_db_path(db_path)
|
||||
try:
|
||||
with _connect_readonly(resolved_path) as connection:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT MAX(month_key) AS latest_month_key
|
||||
FROM elo_mmr_monthly_checkpoints
|
||||
WHERE scope_key = ?
|
||||
""",
|
||||
(scope_key,),
|
||||
).fetchone()
|
||||
except sqlite3.OperationalError:
|
||||
return None
|
||||
return str(row["latest_month_key"]) if row and row["latest_month_key"] else None
|
||||
|
||||
|
||||
def _connect_writer(db_path: Path):
|
||||
return connect_sqlite_writer(db_path)
|
||||
|
||||
|
||||
def _connect_readonly(db_path: Path):
|
||||
return connect_sqlite_readonly(db_path)
|
||||
|
||||
|
||||
def _resolve_db_path(db_path: Path | None) -> Path:
|
||||
return db_path or get_storage_path()
|
||||
@@ -13,6 +13,7 @@ from .config import (
|
||||
get_historical_refresh_overlap_hours,
|
||||
)
|
||||
from .data_sources import HistoricalDataSource, resolve_historical_ingestion_data_source
|
||||
from .elo_mmr_engine import rebuild_elo_mmr_models
|
||||
from .historical_snapshots import generate_and_persist_historical_snapshots
|
||||
from .historical_storage import (
|
||||
finalize_backfill_progress,
|
||||
@@ -187,12 +188,17 @@ def _run_ingestion(
|
||||
active_runs.pop(str(server["slug"]), None)
|
||||
if rebuild_snapshots:
|
||||
snapshot_result = generate_and_persist_historical_snapshots(server_key=server_slug)
|
||||
elo_mmr_result = rebuild_elo_mmr_models()
|
||||
else:
|
||||
snapshot_result = {
|
||||
"status": "skipped",
|
||||
"reason": "snapshot-rebuild-disabled",
|
||||
"generation_policy": "handled-by-caller",
|
||||
}
|
||||
elo_mmr_result = {
|
||||
"status": "skipped",
|
||||
"reason": "snapshot-rebuild-disabled",
|
||||
}
|
||||
except Exception as exc:
|
||||
for active_server_slug, run_id in active_runs.items():
|
||||
finalize_ingestion_run(
|
||||
@@ -227,6 +233,7 @@ def _run_ingestion(
|
||||
"servers": processed_servers,
|
||||
"coverage": list_historical_coverage_report(server_slug=server_slug),
|
||||
"snapshot_result": snapshot_result,
|
||||
"elo_mmr_result": elo_mmr_result,
|
||||
"totals": {
|
||||
"pages_processed": stats.pages_processed,
|
||||
"matches_seen": stats.matches_seen,
|
||||
|
||||
@@ -15,6 +15,7 @@ from .config import (
|
||||
get_historical_refresh_retry_delay_seconds,
|
||||
get_historical_data_source_kind,
|
||||
)
|
||||
from .elo_mmr_engine import rebuild_elo_mmr_models
|
||||
from .historical_ingestion import run_incremental_refresh
|
||||
from .historical_snapshots import (
|
||||
generate_and_persist_historical_snapshots,
|
||||
@@ -116,6 +117,7 @@ def _run_refresh_with_retries(
|
||||
server_slug=server_slug,
|
||||
run_number=run_number,
|
||||
)
|
||||
elo_mmr_result = rebuild_elo_mmr_models()
|
||||
else:
|
||||
refresh_result = {
|
||||
"status": "skipped",
|
||||
@@ -126,6 +128,10 @@ def _run_refresh_with_retries(
|
||||
"reason": "rcon-primary-cycle-no-classic-fallback-needed",
|
||||
"generation_policy": "classic-historical-fallback-only",
|
||||
}
|
||||
elo_mmr_result = {
|
||||
"status": "skipped",
|
||||
"reason": "rcon-primary-cycle-no-classic-fallback-needed",
|
||||
}
|
||||
return {
|
||||
"status": "ok",
|
||||
"attempts_used": attempt,
|
||||
@@ -135,6 +141,7 @@ def _run_refresh_with_retries(
|
||||
"classic_fallback_reason": classic_fallback_reason,
|
||||
"refresh_result": refresh_result,
|
||||
"snapshot_result": snapshot_result,
|
||||
"elo_mmr_result": elo_mmr_result,
|
||||
}
|
||||
except Exception as exc:
|
||||
if attempt > max_retries:
|
||||
|
||||
@@ -15,9 +15,15 @@ from .data_sources import (
|
||||
SOURCE_KIND_RCON,
|
||||
build_source_attempt,
|
||||
build_source_policy,
|
||||
build_historical_runtime_source_policy,
|
||||
describe_historical_runtime_policy,
|
||||
get_live_data_source,
|
||||
get_rcon_historical_read_model,
|
||||
)
|
||||
from .elo_mmr_engine import (
|
||||
get_elo_mmr_player_payload,
|
||||
list_elo_mmr_leaderboard_payload,
|
||||
)
|
||||
from .historical_snapshot_storage import get_historical_snapshot
|
||||
from .historical_snapshots import (
|
||||
DEFAULT_MONTHLY_SNAPSHOT_WINDOW,
|
||||
@@ -57,6 +63,12 @@ def build_health_payload() -> dict[str, str]:
|
||||
"phase": "bootstrap",
|
||||
"live_data_source": get_live_data_source_kind(),
|
||||
"historical_data_source": get_historical_data_source_kind(),
|
||||
"historical_runtime_policy": describe_historical_runtime_policy()["mode"],
|
||||
"live_runtime_policy": (
|
||||
"rcon-first-with-a2s-fallback"
|
||||
if get_live_data_source_kind() == SOURCE_KIND_RCON
|
||||
else "a2s-primary"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -905,6 +917,64 @@ def build_historical_player_profile_payload(player_id: str) -> dict[str, object]
|
||||
}
|
||||
|
||||
|
||||
def build_elo_mmr_leaderboard_payload(
|
||||
*,
|
||||
limit: int = 10,
|
||||
server_id: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Return the current Elo/MMR monthly leaderboard."""
|
||||
payload = list_elo_mmr_leaderboard_payload(server_id=server_id, limit=limit)
|
||||
is_all_servers = server_id == ALL_SERVERS_SLUG
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"title": (
|
||||
"Leaderboard mensual Elo/MMR global"
|
||||
if is_all_servers
|
||||
else "Leaderboard mensual Elo/MMR por servidor"
|
||||
),
|
||||
"context": "historical-elo-mmr-leaderboard",
|
||||
"source": "elo-mmr-persisted-read-model",
|
||||
"server_slug": server_id,
|
||||
"month_key": payload.get("month_key"),
|
||||
"found": bool(payload.get("found")),
|
||||
"generated_at": payload.get("generated_at"),
|
||||
"limit": limit,
|
||||
**(payload.get("source_policy") or _resolve_historical_fallback_policy(
|
||||
operation="elo-mmr-leaderboard",
|
||||
fallback_reason="elo-mmr-source-policy-missing",
|
||||
)),
|
||||
"capabilities_summary": payload.get("capabilities_summary"),
|
||||
"items": payload.get("items") or [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_elo_mmr_player_payload(
|
||||
*,
|
||||
player_id: str,
|
||||
server_id: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Return one Elo/MMR player profile."""
|
||||
profile = get_elo_mmr_player_payload(player_id=player_id, server_id=server_id)
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"title": "Perfil Elo/MMR de jugador",
|
||||
"context": "historical-elo-mmr-player",
|
||||
"source": "elo-mmr-persisted-read-model",
|
||||
"player_id": player_id,
|
||||
"server_slug": server_id,
|
||||
"found": profile is not None,
|
||||
**_resolve_historical_fallback_policy(
|
||||
operation="elo-mmr-player",
|
||||
fallback_reason="rcon-historical-read-model-does-not-support-elo-mmr-competitive-calculations-yet",
|
||||
),
|
||||
"profile": profile,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _get_historical_snapshot_record(
|
||||
*,
|
||||
server_key: str | None,
|
||||
@@ -1164,38 +1234,15 @@ def _to_snapshot_age_minutes(snapshot_age_seconds: int | None) -> int | None:
|
||||
return snapshot_age_seconds // 60
|
||||
|
||||
|
||||
def _resolve_historical_fallback_policy(*, fallback_reason: str) -> dict[str, object]:
|
||||
if get_historical_data_source_kind() != SOURCE_KIND_RCON:
|
||||
return build_source_policy(
|
||||
primary_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||
selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||
source_attempts=[
|
||||
build_source_attempt(
|
||||
source=SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||
role="primary",
|
||||
status="success",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
return build_source_policy(
|
||||
primary_source=SOURCE_KIND_RCON,
|
||||
selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||
fallback_used=True,
|
||||
def _resolve_historical_fallback_policy(
|
||||
*,
|
||||
fallback_reason: str,
|
||||
operation: str = "historical-read",
|
||||
) -> dict[str, object]:
|
||||
return build_historical_runtime_source_policy(
|
||||
operation=operation,
|
||||
rcon_status="unsupported",
|
||||
fallback_reason=fallback_reason,
|
||||
source_attempts=[
|
||||
build_source_attempt(
|
||||
source=SOURCE_KIND_RCON,
|
||||
role="primary",
|
||||
status="unsupported",
|
||||
reason=fallback_reason,
|
||||
),
|
||||
build_source_attempt(
|
||||
source=SOURCE_KIND_PUBLIC_SCOREBOARD,
|
||||
role="fallback",
|
||||
status="success",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -64,6 +64,8 @@ def describe_rcon_historical_read_model() -> dict[str, object]:
|
||||
"/api/historical/leaderboard",
|
||||
"/api/historical/monthly-mvp",
|
||||
"/api/historical/monthly-mvp-v2",
|
||||
"/api/historical/elo-mmr/leaderboard",
|
||||
"/api/historical/elo-mmr/player",
|
||||
"/api/historical/player-events",
|
||||
"/api/historical/player-profile",
|
||||
"/api/historical/snapshots/*",
|
||||
|
||||
@@ -8,6 +8,8 @@ from urllib.parse import parse_qs, urlparse
|
||||
from .payloads import (
|
||||
build_community_payload,
|
||||
build_discord_payload,
|
||||
build_elo_mmr_leaderboard_payload,
|
||||
build_elo_mmr_player_payload,
|
||||
build_error_payload,
|
||||
build_health_payload,
|
||||
build_historical_leaderboard_payload,
|
||||
@@ -268,6 +270,27 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
|
||||
return HTTPStatus.BAD_REQUEST, build_error_payload("Player parameter is required")
|
||||
return HTTPStatus.OK, build_historical_player_profile_payload(player_id)
|
||||
|
||||
if parsed.path == "/api/historical/elo-mmr/leaderboard":
|
||||
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_elo_mmr_leaderboard_payload(
|
||||
limit=limit,
|
||||
server_id=server_id,
|
||||
)
|
||||
|
||||
if parsed.path == "/api/historical/elo-mmr/player":
|
||||
params = parse_qs(parsed.query)
|
||||
player_id = params.get("player", [None])[0]
|
||||
if not player_id:
|
||||
return HTTPStatus.BAD_REQUEST, build_error_payload("Player parameter is required")
|
||||
server_id = params.get("server", [None])[0]
|
||||
return HTTPStatus.OK, build_elo_mmr_player_payload(
|
||||
player_id=player_id,
|
||||
server_id=server_id,
|
||||
)
|
||||
|
||||
builder = GET_ROUTES.get(parsed.path)
|
||||
if builder is None:
|
||||
if parsed.path.startswith("/api/servers/") and parsed.path.endswith("/history"):
|
||||
|
||||
214
docs/elo-mmr-monthly-ranking-design.md
Normal file
214
docs/elo-mmr-monthly-ranking-design.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# Elo/MMR Monthly Ranking Design
|
||||
|
||||
## Scope
|
||||
|
||||
This repository now exposes a first operational Elo/MMR-like system inspired by
|
||||
`sistema_elo_mensual_hll.pdf`, but constrained to signals that are really
|
||||
available today.
|
||||
|
||||
The implementation keeps the same conceptual split:
|
||||
|
||||
- persistent `MMR`
|
||||
- monthly `MonthlyRankScore`
|
||||
|
||||
It does **not** claim full parity with the PDF. Every major signal is labeled as:
|
||||
|
||||
- `exact`
|
||||
- `approximate`
|
||||
- `not_available`
|
||||
|
||||
## Real Inputs Available Today
|
||||
|
||||
Exact today from persisted historical CRCON/public-scoreboard data:
|
||||
|
||||
- closed match identity
|
||||
- server scope
|
||||
- player identity
|
||||
- team side
|
||||
- kills
|
||||
- deaths
|
||||
- support
|
||||
- teamkills
|
||||
- combat score
|
||||
- offense score
|
||||
- defense score
|
||||
- match timestamps when present
|
||||
- final allied/axis score
|
||||
|
||||
Exact today from current product state but not required by the core engine:
|
||||
|
||||
- player-event V2 summaries for duels, most-killed, death-by and weapon summaries
|
||||
|
||||
Approximate only:
|
||||
|
||||
- `role_bucket`
|
||||
- inferred from the dominant scoreboard axis among `combat`, `offense`,
|
||||
`defense` and `support`
|
||||
- `ObjectiveIndex`
|
||||
- proxied with `offense + defense` because there is no tactical event feed
|
||||
- `StrengthOfSchedule`
|
||||
- proxied with match quality and lobby density because there is no opponent MMR
|
||||
model yet
|
||||
|
||||
Not available today:
|
||||
|
||||
- explicit squad role / commander / SL role
|
||||
- garrisons and OPs destroyed
|
||||
- revives
|
||||
- AFK and leave events
|
||||
- precise leadership telemetry
|
||||
- exact tactical objective event stream
|
||||
- exact opponent-strength graph by roster
|
||||
|
||||
## Current Capability Contract
|
||||
|
||||
### Match validity
|
||||
|
||||
Current rule:
|
||||
|
||||
- match must be closed
|
||||
- match duration must be at least `15` minutes
|
||||
- match must have at least `20` persisted player rows
|
||||
|
||||
Duration source:
|
||||
|
||||
- `exact` if `started_at` and `ended_at` exist
|
||||
- `approximate` if we must fall back to max player `time_seconds`
|
||||
|
||||
### Quality factor Q
|
||||
|
||||
Current `Q` is a bounded mix of:
|
||||
|
||||
- player density
|
||||
- match duration
|
||||
- score completeness
|
||||
|
||||
This is an operational approximation of the PDF quality factor and is labelled:
|
||||
|
||||
- `exact` for the density and score-completeness inputs
|
||||
- `exact` or `approximate` for duration depending on timestamp availability
|
||||
|
||||
### Buckets
|
||||
|
||||
Implemented:
|
||||
|
||||
- duration bucket
|
||||
- mode retention through `game_mode`
|
||||
- approximate `role_bucket`
|
||||
|
||||
Not implemented yet:
|
||||
|
||||
- literal class role bucket
|
||||
|
||||
### Subindices
|
||||
|
||||
Implemented now:
|
||||
|
||||
- `OutcomeScore`: `exact`
|
||||
- `CombatIndex`: `exact`
|
||||
- `ObjectiveIndex`: `approximate`
|
||||
- `UtilityIndex`: `exact`
|
||||
- `LeadershipIndex`: `not_available`
|
||||
- `DisciplineIndex`: `exact` for teamkills only
|
||||
|
||||
### ImpactScore
|
||||
|
||||
Implemented with role-inspired weights, but the role itself is approximate, so
|
||||
the final `ImpactScore` is operationally `approximate`.
|
||||
|
||||
### DeltaMMR
|
||||
|
||||
Implemented from:
|
||||
|
||||
- `OutcomeScore`
|
||||
- `ImpactScore`
|
||||
- quality factor `Q`
|
||||
|
||||
The resulting `DeltaMMR` is real and persisted, but inherits the mixed
|
||||
availability of the inputs above.
|
||||
|
||||
## Storage Model
|
||||
|
||||
Tables added in backend SQLite:
|
||||
|
||||
- `elo_mmr_player_ratings`
|
||||
- `elo_mmr_match_results`
|
||||
- `elo_mmr_monthly_rankings`
|
||||
- `elo_mmr_monthly_checkpoints`
|
||||
|
||||
Meaning:
|
||||
|
||||
- `elo_mmr_player_ratings`
|
||||
- current persistent rating per player and scope
|
||||
- `elo_mmr_match_results`
|
||||
- per-match scoring trace used to explain rating movement
|
||||
- `elo_mmr_monthly_rankings`
|
||||
- monthly ranking rows ready for product/API
|
||||
- `elo_mmr_monthly_checkpoints`
|
||||
- generated-at metadata plus source policy and capability summary
|
||||
|
||||
Scopes persisted:
|
||||
|
||||
- per historical server
|
||||
- `all-servers`
|
||||
|
||||
## Runtime Source Policy
|
||||
|
||||
The Elo/MMR engine follows the same historical policy as the rest of backend:
|
||||
|
||||
- primary intent: `rcon`
|
||||
- current competitive calculation fallback: `public-scoreboard`
|
||||
|
||||
Why fallback still exists here:
|
||||
|
||||
- the current RCON historical read model only supports coverage and recent
|
||||
activity
|
||||
- it does not yet expose enough competitive match detail to support this Elo/MMR
|
||||
engine directly
|
||||
|
||||
That fallback is exposed in API metadata through:
|
||||
|
||||
- `primary_source`
|
||||
- `selected_source`
|
||||
- `fallback_used`
|
||||
- `fallback_reason`
|
||||
- `source_attempts`
|
||||
|
||||
## Product Read Model
|
||||
|
||||
Current API surfaces:
|
||||
|
||||
- `/api/historical/elo-mmr/leaderboard`
|
||||
- `/api/historical/elo-mmr/player`
|
||||
|
||||
These payloads expose:
|
||||
|
||||
- persistent rating
|
||||
- monthly ranking score
|
||||
- eligibility
|
||||
- component breakdown
|
||||
- exact/approximate/partial capability metadata
|
||||
|
||||
## Important Limitations
|
||||
|
||||
This first version should be treated as:
|
||||
|
||||
- operational
|
||||
- honest about accuracy
|
||||
- compatible with future expansion
|
||||
|
||||
It should **not** be described as:
|
||||
|
||||
- a perfect Elo system
|
||||
- full parity with the PDF
|
||||
- a complete tactical rating model
|
||||
|
||||
## Planned Expansion Path
|
||||
|
||||
The current design is compatible with future upgrades once real telemetry exists:
|
||||
|
||||
- replace approximate `ObjectiveIndex` with event-driven tactical signals
|
||||
- add `LeadershipIndex` when squad/command telemetry exists
|
||||
- replace approximate `StrengthOfSchedule` with opponent MMR graph logic
|
||||
- feed V2 duels and weapon signals into richer combat weighting when their
|
||||
coverage is sufficient
|
||||
@@ -158,9 +158,16 @@
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.historical-elo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.historical-stat-card,
|
||||
.historical-match-card,
|
||||
.historical-mvp-card {
|
||||
.historical-mvp-card,
|
||||
.historical-elo-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 18px;
|
||||
@@ -177,7 +184,10 @@
|
||||
.historical-match-card strong,
|
||||
.historical-mvp-card p,
|
||||
.historical-mvp-card strong,
|
||||
.historical-mvp-card span {
|
||||
.historical-mvp-card span,
|
||||
.historical-elo-card p,
|
||||
.historical-elo-card strong,
|
||||
.historical-elo-card span {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -298,6 +308,91 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.historical-elo-card {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
border-color: rgba(96, 150, 124, 0.24);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(96, 150, 124, 0.12), transparent 40%),
|
||||
linear-gradient(180deg, rgba(21, 32, 27, 0.96), rgba(12, 15, 11, 0.98));
|
||||
}
|
||||
|
||||
.historical-elo-card__top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.historical-elo-card__rank {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 42px;
|
||||
min-height: 42px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(96, 150, 124, 0.28);
|
||||
border-radius: 999px;
|
||||
color: #a8d4bf;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.historical-elo-card__accuracy {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid rgba(96, 150, 124, 0.24);
|
||||
border-radius: 999px;
|
||||
color: #a8d4bf;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.historical-elo-card__meta,
|
||||
.historical-elo-card__scores {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.historical-elo-card__meta article,
|
||||
.historical-elo-card__scores article {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(96, 150, 124, 0.14);
|
||||
border-radius: 14px;
|
||||
background: rgba(13, 17, 12, 0.42);
|
||||
}
|
||||
|
||||
.historical-elo-card__meta span,
|
||||
.historical-elo-card__scores span {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.historical-elo-card__summary {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.historical-elo-card__footer {
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(159, 168, 141, 0.12);
|
||||
color: var(--text-soft);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.historical-mvp-card__signal-summary {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.9rem;
|
||||
|
||||
@@ -110,6 +110,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const comparisonStateNode = document.getElementById("mvp-comparison-state");
|
||||
const comparisonListNode = document.getElementById("mvp-comparison-list");
|
||||
const comparisonNoteNode = document.getElementById("mvp-comparison-note");
|
||||
const eloMmrStateNode = document.getElementById("elo-mmr-state");
|
||||
const eloMmrListNode = document.getElementById("elo-mmr-list");
|
||||
const eloMmrNoteNode = document.getElementById("elo-mmr-note");
|
||||
const eloMmrMetaNode = document.getElementById("elo-mmr-meta");
|
||||
const weeklyValueHeadingNode = document.getElementById("weekly-leaderboard-value-heading");
|
||||
const weeklyWindowNoteNode = document.getElementById("weekly-window-note");
|
||||
const weeklySnapshotMetaNode = document.getElementById(
|
||||
@@ -134,6 +138,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const leaderboardCache = new Map();
|
||||
const monthlyMvpCache = new Map();
|
||||
const monthlyMvpV2Cache = new Map();
|
||||
const eloMmrCache = new Map();
|
||||
const pendingRequestCache = new Map();
|
||||
let latestMonthlyMvpResult = null;
|
||||
let latestMonthlyMvpV2Result = null;
|
||||
@@ -178,6 +183,14 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
`${backendBaseUrl}/api/historical/snapshots/monthly-mvp-v2?server=${encodeURIComponent(serverSlug)}&limit=3`,
|
||||
);
|
||||
|
||||
const getEloMmrLeaderboard = (serverSlug) =>
|
||||
getCachedJson(
|
||||
eloMmrCache,
|
||||
pendingRequestCache,
|
||||
buildEloMmrSnapshotKey(serverSlug),
|
||||
`${backendBaseUrl}/api/historical/elo-mmr/leaderboard?server=${encodeURIComponent(serverSlug)}&limit=5`,
|
||||
);
|
||||
|
||||
const refreshServerContent = async () => {
|
||||
const requestId = activeServerRequestId + 1;
|
||||
const leaderboardRequestId = activeLeaderboardRequestId + 1;
|
||||
@@ -227,6 +240,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
comparisonNoteNode.textContent =
|
||||
"Cargando comparativa entre los rankings mensuales V1 y V2...";
|
||||
setState(comparisonStateNode, "Preparando comparativa V1 vs V2...");
|
||||
eloMmrListNode.innerHTML = "";
|
||||
eloMmrNoteNode.textContent = "Cargando lectura del rating persistente y score mensual...";
|
||||
setState(eloMmrStateNode, "Cargando leaderboard Elo/MMR...");
|
||||
setSnapshotMeta(eloMmrMetaNode, "Cargando metadata de Elo/MMR...");
|
||||
weeklyWindowNoteNode.textContent = "Cargando snapshot del ranking activo...";
|
||||
setSnapshotMeta(
|
||||
weeklySnapshotMetaNode,
|
||||
@@ -297,6 +314,20 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
);
|
||||
}
|
||||
|
||||
const cachedEloMmrPayload = readCachedPayload(
|
||||
eloMmrCache,
|
||||
buildEloMmrSnapshotKey(activeServerSlug),
|
||||
);
|
||||
if (cachedEloMmrPayload) {
|
||||
hydrateEloMmr(
|
||||
{ status: "fulfilled", value: cachedEloMmrPayload },
|
||||
eloMmrStateNode,
|
||||
eloMmrListNode,
|
||||
eloMmrNoteNode,
|
||||
eloMmrMetaNode,
|
||||
);
|
||||
}
|
||||
|
||||
const cachedLeaderboardPayload = readCachedPayload(
|
||||
leaderboardCache,
|
||||
buildLeaderboardSnapshotKey(
|
||||
@@ -471,6 +502,27 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
comparisonNoteNode,
|
||||
);
|
||||
});
|
||||
|
||||
void settlePromise(getEloMmrLeaderboard(targetServerSlug)).then((eloMmrResult) => {
|
||||
if (
|
||||
!isActiveServerRequest(
|
||||
requestId,
|
||||
targetServerSlug,
|
||||
targetTimeframe,
|
||||
targetMetric,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
hydrateEloMmr(
|
||||
eloMmrResult,
|
||||
eloMmrStateNode,
|
||||
eloMmrListNode,
|
||||
eloMmrNoteNode,
|
||||
eloMmrMetaNode,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const refreshLeaderboardContent = async () => {
|
||||
@@ -1166,6 +1218,89 @@ function renderMvpComparisonCard(item) {
|
||||
`;
|
||||
}
|
||||
|
||||
function hydrateEloMmr(result, stateNode, listNode, noteNode, metaNode) {
|
||||
if (result?.status !== "fulfilled") {
|
||||
setState(stateNode, "No se pudo cargar el leaderboard Elo/MMR.", true);
|
||||
listNode.innerHTML = "";
|
||||
noteNode.textContent =
|
||||
"El sistema Elo/MMR sigue disponible solo cuando existe rebuild persistido.";
|
||||
setSnapshotMeta(metaNode, "Error al cargar metadata de Elo/MMR.");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = result.value?.data;
|
||||
const items = Array.isArray(payload?.items) ? payload.items : [];
|
||||
if (!payload?.found || !items.length) {
|
||||
setState(
|
||||
stateNode,
|
||||
"Todavia no hay leaderboard Elo/MMR mensual listo para este alcance.",
|
||||
);
|
||||
listNode.innerHTML = "";
|
||||
noteNode.textContent =
|
||||
"El bloque aparece cuando existe un rebuild persistido con jugadores elegibles.";
|
||||
setSnapshotMeta(
|
||||
metaNode,
|
||||
buildEloMmrMeta(payload),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
stateNode.hidden = true;
|
||||
noteNode.textContent = buildEloMmrNote(payload);
|
||||
setSnapshotMeta(metaNode, buildEloMmrMeta(payload));
|
||||
listNode.innerHTML = items.map((item) => renderEloMmrCard(item, payload)).join("");
|
||||
}
|
||||
|
||||
function renderEloMmrCard(item, payload) {
|
||||
return `
|
||||
<article class="historical-elo-card">
|
||||
<div class="historical-elo-card__top">
|
||||
<div>
|
||||
<span class="historical-elo-card__rank">#${escapeHtml(item?.ranking_position || "-")}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="historical-elo-card__accuracy">${escapeHtml(formatAccuracyMode(item?.accuracy_mode))}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<strong class="historical-mvp-card__player">${escapeHtml(
|
||||
item?.player?.name || "Jugador no identificado",
|
||||
)}</strong>
|
||||
</div>
|
||||
<div class="historical-elo-card__scores">
|
||||
<article>
|
||||
<span>Score mensual</span>
|
||||
<strong>${escapeHtml(formatDecimal(item?.monthly_rank_score, 2))}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>MMR persistente</span>
|
||||
<strong>${escapeHtml(formatDecimal(item?.persistent_rating?.mmr, 1))}</strong>
|
||||
</article>
|
||||
</div>
|
||||
<div class="historical-elo-card__meta">
|
||||
<article>
|
||||
<span>MMR gain</span>
|
||||
<strong>${escapeHtml(formatSignedDecimal(item?.persistent_rating?.mmr_gain, 1))}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>Elegibilidad</span>
|
||||
<strong>${escapeHtml(item?.eligible ? "Elegible" : "Parcial")}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>Partidas validas</span>
|
||||
<strong>${escapeHtml(formatNumber(item?.valid_matches))}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>Confidence</span>
|
||||
<strong>${escapeHtml(formatDecimal(item?.components?.confidence, 1))}</strong>
|
||||
</article>
|
||||
</div>
|
||||
<p class="historical-elo-card__summary">${escapeHtml(buildEloMmrSummary(item))}</p>
|
||||
<p class="historical-elo-card__footer">${escapeHtml(buildEloMmrFooter(item, payload))}</p>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function setState(node, message, isError = false) {
|
||||
node.textContent = message;
|
||||
node.hidden = false;
|
||||
@@ -1275,6 +1410,10 @@ function buildMonthlyMvpV2SnapshotKey(serverSlug) {
|
||||
return `monthly-mvp-v2:${serverSlug}`;
|
||||
}
|
||||
|
||||
function buildEloMmrSnapshotKey(serverSlug) {
|
||||
return `elo-mmr:${serverSlug}`;
|
||||
}
|
||||
|
||||
function buildRangeLabel(start, end) {
|
||||
if (!start && !end) {
|
||||
return "";
|
||||
@@ -1414,6 +1553,31 @@ function buildMonthlyMvpV2Footer(item, payload) {
|
||||
)} partidas · ${formatDecimal(hoursPlayed, 1)} h jugadas`;
|
||||
}
|
||||
|
||||
function buildEloMmrNote(payload) {
|
||||
const monthLabel = formatMonthKey(payload?.month_key);
|
||||
const exactRatio = Number(payload?.capabilities_summary?.exact_ratio || 0);
|
||||
const approximateRatio = Number(payload?.capabilities_summary?.approximate_ratio || 0);
|
||||
return `${monthLabel || "Mes activo"}. Rating persistente + score mensual con ${formatPercent(exactRatio)} de senal exacta y ${formatPercent(approximateRatio)} de senal aproximada en este corte.`;
|
||||
}
|
||||
|
||||
function buildEloMmrMeta(payload) {
|
||||
const sourceLabel = payload?.selected_source || payload?.source || "origen no disponible";
|
||||
const fallbackLabel = payload?.fallback_used
|
||||
? `fallback ${payload?.fallback_reason || "activo"}`
|
||||
: "sin fallback";
|
||||
return `Generado ${formatTimestamp(payload?.generated_at)} · fuente ${sourceLabel} · ${fallbackLabel}`;
|
||||
}
|
||||
|
||||
function buildEloMmrSummary(item) {
|
||||
return `AvgMatchScore ${formatDecimal(item?.components?.avg_match_score, 1)}, actividad ${formatDecimal(item?.components?.activity, 1)} y strength-of-schedule ${formatDecimal(item?.components?.strength_of_schedule, 1)}.`;
|
||||
}
|
||||
|
||||
function buildEloMmrFooter(item, payload) {
|
||||
const monthLabel = formatMonthKey(payload?.month_key);
|
||||
const hoursPlayed = Number(item?.total_time_seconds) / 3600;
|
||||
return `${monthLabel || "Mes activo"} · ${formatNumber(item?.valid_matches)} validas / ${formatNumber(item?.total_matches)} totales · ${formatDecimal(hoursPlayed, 1)} h`;
|
||||
}
|
||||
|
||||
function buildMvpComparisonItems(v1Items, v2Items) {
|
||||
const v1TopItems = v1Items.slice(0, 3);
|
||||
const v2TopItems = v2Items.slice(0, 3);
|
||||
@@ -1612,6 +1776,28 @@ function formatPercent(value) {
|
||||
}).format(parsedValue * 100)} %`;
|
||||
}
|
||||
|
||||
function formatSignedDecimal(value, fractionDigits = 1) {
|
||||
const parsedValue = Number(value);
|
||||
if (!Number.isFinite(parsedValue)) {
|
||||
return "0";
|
||||
}
|
||||
const prefix = parsedValue > 0 ? "+" : "";
|
||||
return `${prefix}${formatDecimal(parsedValue, fractionDigits)}`;
|
||||
}
|
||||
|
||||
function formatAccuracyMode(mode) {
|
||||
if (mode === "exact") {
|
||||
return "Exacto";
|
||||
}
|
||||
if (mode === "approximate") {
|
||||
return "Aproximado";
|
||||
}
|
||||
if (mode === "partial") {
|
||||
return "Parcial";
|
||||
}
|
||||
return "Mixto";
|
||||
}
|
||||
|
||||
function formatMonthKey(monthKey) {
|
||||
if (!monthKey) {
|
||||
return "";
|
||||
|
||||
@@ -167,6 +167,27 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel historical-panel" aria-labelledby="elo-mmr-title">
|
||||
<div class="panel__shell">
|
||||
<div class="panel__header historical-panel__header">
|
||||
<div>
|
||||
<p class="eyebrow eyebrow--section">Elo/MMR mensual</p>
|
||||
<h2 id="elo-mmr-title">Rating persistente y ranking mensual del alcance activo</h2>
|
||||
<p class="historical-panel__note" id="elo-mmr-note">
|
||||
Cargando sistema mensual de rating...
|
||||
</p>
|
||||
<p class="historical-snapshot-meta" id="elo-mmr-meta">
|
||||
Cargando metadata de Elo/MMR...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="historical-state" id="elo-mmr-state" aria-live="polite">
|
||||
Cargando leaderboard Elo/MMR...
|
||||
</p>
|
||||
<div class="historical-elo-grid" id="elo-mmr-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel historical-panel" aria-labelledby="weekly-ranking-title">
|
||||
<div class="panel__shell">
|
||||
<div class="panel__header historical-panel__header">
|
||||
|
||||
Reference in New Issue
Block a user