Add rcon-first elo mmr foundation

This commit is contained in:
devRaGonSa
2026-03-25 17:29:29 +01:00
parent 787e753f77
commit 6ed416f79a
18 changed files with 2282 additions and 33 deletions

View File

@@ -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

View File

@@ -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`

View File

@@ -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`

View File

@@ -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

View File

@@ -1384,6 +1384,63 @@ Limitaciones actuales de esta fase:
resumen disponible por jugador en CRCON y no de todos los encounters del match resumen disponible por jugador en CRCON y no de todos los encounters del match
- la V2 no expone aun endpoints HTTP ni snapshots propios - 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 ## Alcance
Esta fase no implementa: Esta fase no implementa:

View File

@@ -244,6 +244,88 @@ def get_rcon_historical_read_model() -> RconHistoricalDataSource | None:
return RconHistoricalDataSource() 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]]: def resolve_historical_ingestion_data_source() -> tuple[HistoricalDataSource, dict[str, object]]:
"""Resolve the writer-oriented historical provider with safe fallback semantics.""" """Resolve the writer-oriented historical provider with safe fallback semantics."""
configured_kind = get_historical_data_source_kind() configured_kind = get_historical_data_source_kind()

View 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())

View 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),
}

View 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()

View File

@@ -13,6 +13,7 @@ from .config import (
get_historical_refresh_overlap_hours, get_historical_refresh_overlap_hours,
) )
from .data_sources import HistoricalDataSource, resolve_historical_ingestion_data_source 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_snapshots import generate_and_persist_historical_snapshots
from .historical_storage import ( from .historical_storage import (
finalize_backfill_progress, finalize_backfill_progress,
@@ -187,12 +188,17 @@ def _run_ingestion(
active_runs.pop(str(server["slug"]), None) active_runs.pop(str(server["slug"]), None)
if rebuild_snapshots: if rebuild_snapshots:
snapshot_result = generate_and_persist_historical_snapshots(server_key=server_slug) snapshot_result = generate_and_persist_historical_snapshots(server_key=server_slug)
elo_mmr_result = rebuild_elo_mmr_models()
else: else:
snapshot_result = { snapshot_result = {
"status": "skipped", "status": "skipped",
"reason": "snapshot-rebuild-disabled", "reason": "snapshot-rebuild-disabled",
"generation_policy": "handled-by-caller", "generation_policy": "handled-by-caller",
} }
elo_mmr_result = {
"status": "skipped",
"reason": "snapshot-rebuild-disabled",
}
except Exception as exc: except Exception as exc:
for active_server_slug, run_id in active_runs.items(): for active_server_slug, run_id in active_runs.items():
finalize_ingestion_run( finalize_ingestion_run(
@@ -227,6 +233,7 @@ def _run_ingestion(
"servers": processed_servers, "servers": processed_servers,
"coverage": list_historical_coverage_report(server_slug=server_slug), "coverage": list_historical_coverage_report(server_slug=server_slug),
"snapshot_result": snapshot_result, "snapshot_result": snapshot_result,
"elo_mmr_result": elo_mmr_result,
"totals": { "totals": {
"pages_processed": stats.pages_processed, "pages_processed": stats.pages_processed,
"matches_seen": stats.matches_seen, "matches_seen": stats.matches_seen,

View File

@@ -15,6 +15,7 @@ from .config import (
get_historical_refresh_retry_delay_seconds, get_historical_refresh_retry_delay_seconds,
get_historical_data_source_kind, get_historical_data_source_kind,
) )
from .elo_mmr_engine import rebuild_elo_mmr_models
from .historical_ingestion import run_incremental_refresh from .historical_ingestion import run_incremental_refresh
from .historical_snapshots import ( from .historical_snapshots import (
generate_and_persist_historical_snapshots, generate_and_persist_historical_snapshots,
@@ -116,6 +117,7 @@ def _run_refresh_with_retries(
server_slug=server_slug, server_slug=server_slug,
run_number=run_number, run_number=run_number,
) )
elo_mmr_result = rebuild_elo_mmr_models()
else: else:
refresh_result = { refresh_result = {
"status": "skipped", "status": "skipped",
@@ -126,6 +128,10 @@ def _run_refresh_with_retries(
"reason": "rcon-primary-cycle-no-classic-fallback-needed", "reason": "rcon-primary-cycle-no-classic-fallback-needed",
"generation_policy": "classic-historical-fallback-only", "generation_policy": "classic-historical-fallback-only",
} }
elo_mmr_result = {
"status": "skipped",
"reason": "rcon-primary-cycle-no-classic-fallback-needed",
}
return { return {
"status": "ok", "status": "ok",
"attempts_used": attempt, "attempts_used": attempt,
@@ -135,6 +141,7 @@ def _run_refresh_with_retries(
"classic_fallback_reason": classic_fallback_reason, "classic_fallback_reason": classic_fallback_reason,
"refresh_result": refresh_result, "refresh_result": refresh_result,
"snapshot_result": snapshot_result, "snapshot_result": snapshot_result,
"elo_mmr_result": elo_mmr_result,
} }
except Exception as exc: except Exception as exc:
if attempt > max_retries: if attempt > max_retries:

View File

@@ -15,9 +15,15 @@ from .data_sources import (
SOURCE_KIND_RCON, SOURCE_KIND_RCON,
build_source_attempt, build_source_attempt,
build_source_policy, build_source_policy,
build_historical_runtime_source_policy,
describe_historical_runtime_policy,
get_live_data_source, get_live_data_source,
get_rcon_historical_read_model, 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_snapshot_storage import get_historical_snapshot
from .historical_snapshots import ( from .historical_snapshots import (
DEFAULT_MONTHLY_SNAPSHOT_WINDOW, DEFAULT_MONTHLY_SNAPSHOT_WINDOW,
@@ -57,6 +63,12 @@ def build_health_payload() -> dict[str, str]:
"phase": "bootstrap", "phase": "bootstrap",
"live_data_source": get_live_data_source_kind(), "live_data_source": get_live_data_source_kind(),
"historical_data_source": get_historical_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( def _get_historical_snapshot_record(
*, *,
server_key: str | None, 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 return snapshot_age_seconds // 60
def _resolve_historical_fallback_policy(*, fallback_reason: str) -> dict[str, object]: def _resolve_historical_fallback_policy(
if get_historical_data_source_kind() != SOURCE_KIND_RCON: *,
return build_source_policy( fallback_reason: str,
primary_source=SOURCE_KIND_PUBLIC_SCOREBOARD, operation: str = "historical-read",
selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD, ) -> dict[str, object]:
source_attempts=[ return build_historical_runtime_source_policy(
build_source_attempt( operation=operation,
source=SOURCE_KIND_PUBLIC_SCOREBOARD, rcon_status="unsupported",
role="primary",
status="success",
)
],
)
return build_source_policy(
primary_source=SOURCE_KIND_RCON,
selected_source=SOURCE_KIND_PUBLIC_SCOREBOARD,
fallback_used=True,
fallback_reason=fallback_reason, 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",
),
],
) )

View File

@@ -64,6 +64,8 @@ def describe_rcon_historical_read_model() -> dict[str, object]:
"/api/historical/leaderboard", "/api/historical/leaderboard",
"/api/historical/monthly-mvp", "/api/historical/monthly-mvp",
"/api/historical/monthly-mvp-v2", "/api/historical/monthly-mvp-v2",
"/api/historical/elo-mmr/leaderboard",
"/api/historical/elo-mmr/player",
"/api/historical/player-events", "/api/historical/player-events",
"/api/historical/player-profile", "/api/historical/player-profile",
"/api/historical/snapshots/*", "/api/historical/snapshots/*",

View File

@@ -8,6 +8,8 @@ from urllib.parse import parse_qs, urlparse
from .payloads import ( from .payloads import (
build_community_payload, build_community_payload,
build_discord_payload, build_discord_payload,
build_elo_mmr_leaderboard_payload,
build_elo_mmr_player_payload,
build_error_payload, build_error_payload,
build_health_payload, build_health_payload,
build_historical_leaderboard_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.BAD_REQUEST, build_error_payload("Player parameter is required")
return HTTPStatus.OK, build_historical_player_profile_payload(player_id) 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) builder = GET_ROUTES.get(parsed.path)
if builder is None: if builder is None:
if parsed.path.startswith("/api/servers/") and parsed.path.endswith("/history"): if parsed.path.startswith("/api/servers/") and parsed.path.endswith("/history"):

View 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

View File

@@ -158,9 +158,16 @@
gap: 14px; gap: 14px;
} }
.historical-elo-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
gap: 14px;
}
.historical-stat-card, .historical-stat-card,
.historical-match-card, .historical-match-card,
.historical-mvp-card { .historical-mvp-card,
.historical-elo-card {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
padding: 18px; padding: 18px;
@@ -177,7 +184,10 @@
.historical-match-card strong, .historical-match-card strong,
.historical-mvp-card p, .historical-mvp-card p,
.historical-mvp-card strong, .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; margin: 0;
} }
@@ -298,6 +308,91 @@
gap: 10px; 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 { .historical-mvp-card__signal-summary {
color: var(--text-soft); color: var(--text-soft);
font-size: 0.9rem; font-size: 0.9rem;

View File

@@ -110,6 +110,10 @@ document.addEventListener("DOMContentLoaded", () => {
const comparisonStateNode = document.getElementById("mvp-comparison-state"); const comparisonStateNode = document.getElementById("mvp-comparison-state");
const comparisonListNode = document.getElementById("mvp-comparison-list"); const comparisonListNode = document.getElementById("mvp-comparison-list");
const comparisonNoteNode = document.getElementById("mvp-comparison-note"); 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 weeklyValueHeadingNode = document.getElementById("weekly-leaderboard-value-heading");
const weeklyWindowNoteNode = document.getElementById("weekly-window-note"); const weeklyWindowNoteNode = document.getElementById("weekly-window-note");
const weeklySnapshotMetaNode = document.getElementById( const weeklySnapshotMetaNode = document.getElementById(
@@ -134,6 +138,7 @@ document.addEventListener("DOMContentLoaded", () => {
const leaderboardCache = new Map(); const leaderboardCache = new Map();
const monthlyMvpCache = new Map(); const monthlyMvpCache = new Map();
const monthlyMvpV2Cache = new Map(); const monthlyMvpV2Cache = new Map();
const eloMmrCache = new Map();
const pendingRequestCache = new Map(); const pendingRequestCache = new Map();
let latestMonthlyMvpResult = null; let latestMonthlyMvpResult = null;
let latestMonthlyMvpV2Result = null; let latestMonthlyMvpV2Result = null;
@@ -178,6 +183,14 @@ document.addEventListener("DOMContentLoaded", () => {
`${backendBaseUrl}/api/historical/snapshots/monthly-mvp-v2?server=${encodeURIComponent(serverSlug)}&limit=3`, `${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 refreshServerContent = async () => {
const requestId = activeServerRequestId + 1; const requestId = activeServerRequestId + 1;
const leaderboardRequestId = activeLeaderboardRequestId + 1; const leaderboardRequestId = activeLeaderboardRequestId + 1;
@@ -227,6 +240,10 @@ document.addEventListener("DOMContentLoaded", () => {
comparisonNoteNode.textContent = comparisonNoteNode.textContent =
"Cargando comparativa entre los rankings mensuales V1 y V2..."; "Cargando comparativa entre los rankings mensuales V1 y V2...";
setState(comparisonStateNode, "Preparando comparativa V1 vs 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..."; weeklyWindowNoteNode.textContent = "Cargando snapshot del ranking activo...";
setSnapshotMeta( setSnapshotMeta(
weeklySnapshotMetaNode, 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( const cachedLeaderboardPayload = readCachedPayload(
leaderboardCache, leaderboardCache,
buildLeaderboardSnapshotKey( buildLeaderboardSnapshotKey(
@@ -471,6 +502,27 @@ document.addEventListener("DOMContentLoaded", () => {
comparisonNoteNode, comparisonNoteNode,
); );
}); });
void settlePromise(getEloMmrLeaderboard(targetServerSlug)).then((eloMmrResult) => {
if (
!isActiveServerRequest(
requestId,
targetServerSlug,
targetTimeframe,
targetMetric,
)
) {
return;
}
hydrateEloMmr(
eloMmrResult,
eloMmrStateNode,
eloMmrListNode,
eloMmrNoteNode,
eloMmrMetaNode,
);
});
}; };
const refreshLeaderboardContent = async () => { 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) { function setState(node, message, isError = false) {
node.textContent = message; node.textContent = message;
node.hidden = false; node.hidden = false;
@@ -1275,6 +1410,10 @@ function buildMonthlyMvpV2SnapshotKey(serverSlug) {
return `monthly-mvp-v2:${serverSlug}`; return `monthly-mvp-v2:${serverSlug}`;
} }
function buildEloMmrSnapshotKey(serverSlug) {
return `elo-mmr:${serverSlug}`;
}
function buildRangeLabel(start, end) { function buildRangeLabel(start, end) {
if (!start && !end) { if (!start && !end) {
return ""; return "";
@@ -1414,6 +1553,31 @@ function buildMonthlyMvpV2Footer(item, payload) {
)} partidas · ${formatDecimal(hoursPlayed, 1)} h jugadas`; )} 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) { function buildMvpComparisonItems(v1Items, v2Items) {
const v1TopItems = v1Items.slice(0, 3); const v1TopItems = v1Items.slice(0, 3);
const v2TopItems = v2Items.slice(0, 3); const v2TopItems = v2Items.slice(0, 3);
@@ -1612,6 +1776,28 @@ function formatPercent(value) {
}).format(parsedValue * 100)} %`; }).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) { function formatMonthKey(monthKey) {
if (!monthKey) { if (!monthKey) {
return ""; return "";

View File

@@ -167,6 +167,27 @@
</div> </div>
</section> </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"> <section class="panel historical-panel" aria-labelledby="weekly-ranking-title">
<div class="panel__shell"> <div class="panel__shell">
<div class="panel__header historical-panel__header"> <div class="panel__header historical-panel__header">