Add monthly MVP backend calculation

This commit is contained in:
devRaGonSa
2026-03-24 09:52:12 +01:00
parent 26b0590696
commit 1933aeeb8d
4 changed files with 376 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
# TASK-067-monthly-mvp-ranking-backend-calculation
## Goal
Implementar en backend el cálculo base de la V1 del ranking mensual MVP usando la fórmula y reglas definidas en docs/monthly-mvp-ranking-scoring-design.md, apoyándose únicamente en métricas ya persistidas y fiables.
## Context
La auditoría y el diseño de scoring ya están cerrados. La V1 del ranking mensual MVP debe construirse con:
- kills
- support
- time played
- KPM derivado
- KDA derivado
- umbrales de elegibilidad
- penalización por teamkills opcional
- desempates deterministas
Antes de exponerlo en snapshots o UI, hace falta implementar el cálculo mensual real en backend de forma clara, trazable y compatible con servidor individual y all-servers.
## Steps
1. Revisar docs/monthly-player-ranking-data-audit.md y docs/monthly-mvp-ranking-scoring-design.md.
2. Implementar la lógica de cálculo del ranking mensual MVP según la fórmula aprobada.
3. Aplicar correctamente:
- métricas incluidas
- pesos
- normalización
- mínimos de elegibilidad
- penalización por teamkills si así quedó definida
- desempates
4. Soportar cálculo para:
- un servidor concreto
- all-servers
5. Dejar el resultado estructurado para poder serializarlo después en snapshots o payloads.
6. Mantener clara la separación entre:
- ranking mensual MVP V1
- leaderboards mensuales simples por métrica ya existentes
7. No exponer todavía UI nueva en esta task.
8. Documentar brevemente la parte backend necesaria.
9. Al completar la implementación:
- dejar el repositorio consistente
- hacer commit
- hacer push al remoto si el entorno lo permite
## Files to Read First
- AGENTS.md
- docs/monthly-player-ranking-data-audit.md
- docs/monthly-mvp-ranking-scoring-design.md
- backend/README.md
- backend/app/historical_storage.py
- backend/app/historical_models.py
- backend/app/payloads.py
- backend/app/routes.py
- backend/app/historical_snapshots.py
## Expected Files to Modify
- backend/app/historical_storage.py
- backend/app/payloads.py
- opcionalmente nuevos módulos si mejoran claridad, por ejemplo:
- backend/app/monthly_mvp.py
- backend/app/monthly_mvp_scoring.py
- backend/README.md
- opcionalmente docs/decisions.md si hace falta fijar una decisión técnica menor
## Constraints
- No incluir métricas no persistidas o no confirmadas.
- No romper los rankings mensuales ya existentes por kills, muertes, soporte y 100+ kills.
- No crear todavía UI en esta task.
- No hacer cambios destructivos.
- Mantener el trabajo centrado en el cálculo backend del monthly MVP V1.
## Validation
- Existe un cálculo mensual MVP funcional en backend.
- Soporta servidor individual y all-servers.
- Respeta la fórmula y elegibilidad definidas en el diseño.
- No rompe los leaderboards mensuales existentes.
- Los cambios quedan committeados y se hace push si el entorno lo permite.
## Change Budget
- Preferir menos de 6 archivos modificados o creados.
- Preferir menos de 260 líneas cambiadas.

View File

@@ -650,6 +650,20 @@ La misma capa de snapshots guarda tambien `monthly-leaderboard` por servidor y
por agregado `all-servers`, con archivos como `monthly-kills.json` y por agregado `all-servers`, con archivos como `monthly-kills.json` y
`monthly-support.json`. `monthly-support.json`.
El backend incluye ademas el calculo interno de `monthly MVP V1` en
`app/monthly_mvp.py`, separado de los leaderboards mensuales simples por
metrica. Ese calculo:
- usa solo `kills`, `support`, `time_seconds`, `deaths` y `teamkills`
persistidos
- recompone `KPM` y `KDA` desde totales mensuales
- aplica elegibilidad minima de `6` partidas cerradas y `6` horas
- soporta servidor individual y el agregado logico `all-servers`
En esta fase el ranking MVP queda listo para serializar en snapshots o payloads
sin reemplazar los leaderboards mensuales ya existentes por `kills`, `deaths`,
`support` y `matches_over_100_kills`.
## Ingesta historica CRCON ## Ingesta historica CRCON
La ingesta historica no usa A2S ni scraping del HTML de `/games`. Consume la La ingesta historica no usa A2S ni scraping del HTML de `/games`. Consume la

View File

@@ -13,6 +13,7 @@ from .config import (
get_storage_path, get_storage_path,
) )
from .historical_models import HistoricalServerDefinition from .historical_models import HistoricalServerDefinition
from .monthly_mvp import build_monthly_mvp_rankings
DEFAULT_HISTORICAL_SERVERS = ( DEFAULT_HISTORICAL_SERVERS = (
@@ -1449,6 +1450,125 @@ def list_monthly_leaderboard(
} }
def list_monthly_mvp_ranking(
*,
limit: int = 10,
server_id: str | None = None,
db_path: Path | None = None,
) -> dict[str, object]:
"""Return the monthly MVP V1 ranking built from persisted historical totals."""
resolved_path = initialize_historical_storage(db_path=db_path)
aggregate_all_servers = _is_all_servers_selector(server_id)
current_time = datetime.now(timezone.utc)
current_month_start = _start_of_month(current_time)
previous_month_start = _start_of_previous_month(current_month_start)
monthly_window = _select_monthly_window(
server_id=server_id,
current_time=current_time,
current_month_start=current_month_start,
previous_month_start=previous_month_start,
db_path=resolved_path,
)
window_start = monthly_window["window_start"]
window_end = monthly_window["window_end"]
where_clauses = [
"historical_matches.ended_at IS NOT NULL",
"historical_matches.ended_at >= ?",
"historical_matches.ended_at < ?",
]
params: list[object] = [
window_start.isoformat().replace("+00:00", "Z"),
window_end.isoformat().replace("+00:00", "Z"),
]
if server_id and not aggregate_all_servers:
normalized_server_id = server_id.strip()
where_clauses.append(
"(historical_servers.slug = ? OR CAST(historical_servers.server_number AS TEXT) = ?)"
)
params.extend([normalized_server_id, normalized_server_id])
server_slug_expression = (
f"'{ALL_SERVERS_SLUG}'"
if aggregate_all_servers
else "historical_servers.slug"
)
server_name_expression = (
f"'{ALL_SERVERS_DISPLAY_NAME}'"
if aggregate_all_servers
else "historical_servers.display_name"
)
group_by_expression = (
"historical_players.id"
if aggregate_all_servers
else "historical_servers.slug, historical_players.id"
)
with _connect(resolved_path) as connection:
rows = connection.execute(
f"""
SELECT
{server_slug_expression} AS server_slug,
{server_name_expression} AS server_name,
historical_players.stable_player_key,
historical_players.display_name AS player_name,
historical_players.steam_id,
COUNT(DISTINCT historical_matches.id) AS matches_count,
COALESCE(SUM(historical_player_match_stats.kills), 0) AS total_kills,
COALESCE(SUM(historical_player_match_stats.deaths), 0) AS total_deaths,
COALESCE(SUM(historical_player_match_stats.support), 0) AS total_support,
COALESCE(SUM(historical_player_match_stats.teamkills), 0) AS total_teamkills,
COALESCE(SUM(historical_player_match_stats.time_seconds), 0) AS total_time_seconds
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 {" AND ".join(where_clauses)}
GROUP BY {group_by_expression}
""",
params,
).fetchall()
ranking_result = build_monthly_mvp_rankings(
[dict(row) for row in rows],
limit=limit,
)
window_days = _calculate_window_days(window_start=window_start, window_end=window_end)
for item in ranking_result["items"]:
item["time_range"] = {
"start": window_start.isoformat().replace("+00:00", "Z"),
"end": window_end.isoformat().replace("+00:00", "Z"),
"window_days": window_days,
}
return {
"timeframe": "monthly",
"metric": "mvp",
"ranking_version": ranking_result["ranking_version"],
"window_start": window_start.isoformat().replace("+00:00", "Z"),
"window_end": window_end.isoformat().replace("+00:00", "Z"),
"window_days": window_days,
"window_kind": monthly_window["window_kind"],
"window_label": monthly_window["window_label"],
"uses_fallback": monthly_window["uses_fallback"],
"selection_reason": monthly_window["selection_reason"],
"current_month_start": current_month_start.isoformat().replace("+00:00", "Z"),
"current_month_closed_matches": monthly_window["current_month_closed_matches"],
"previous_month_closed_matches": monthly_window["previous_month_closed_matches"],
"sufficient_sample": {
"minimum_closed_matches": monthly_window["minimum_closed_matches"],
"current_month_closed_matches": monthly_window["current_month_closed_matches"],
"current_month_has_sufficient_sample": monthly_window["current_month_has_sufficient_sample"],
"is_early_month": monthly_window["is_early_month"],
},
"eligibility": ranking_result["eligibility"],
"eligible_players_count": ranking_result["eligible_players_count"],
"items": ranking_result["items"],
}
def _connect(db_path: Path) -> sqlite3.Connection: def _connect(db_path: Path) -> sqlite3.Connection:
connection = sqlite3.connect(db_path) connection = sqlite3.connect(db_path)
connection.row_factory = sqlite3.Row connection.row_factory = sqlite3.Row

163
backend/app/monthly_mvp.py Normal file
View File

@@ -0,0 +1,163 @@
"""Monthly MVP V1 scoring helpers."""
from __future__ import annotations
import math
from typing import Mapping
MONTHLY_MVP_VERSION = "v1"
MONTHLY_MVP_MIN_MATCHES = 6
MONTHLY_MVP_MIN_TIME_SECONDS = 21600
MONTHLY_MVP_FULL_PARTICIPATION_SECONDS = 28800
MONTHLY_MVP_TEAMKILL_PENALTY_CAP = 6.0
MONTHLY_MVP_TEAMKILL_PENALTY_PER_KILL = 0.5
def build_monthly_mvp_rankings(
aggregated_rows: list[Mapping[str, object]],
*,
limit: int,
) -> dict[str, object]:
"""Transform aggregated monthly totals into ranked MVP V1 items."""
eligible_rows = [
_build_eligible_player_summary(row)
for row in aggregated_rows
if _is_eligible_player_row(row)
]
if not eligible_rows:
return {
"ranking_version": MONTHLY_MVP_VERSION,
"eligibility": _build_eligibility_metadata(),
"items": [],
"eligible_players_count": 0,
}
max_total_kills = max(item["totals"]["kills"] for item in eligible_rows)
max_total_support = max(item["totals"]["support"] for item in eligible_rows)
max_kpm = max(item["derived"]["kpm"] for item in eligible_rows)
max_kda = max(item["derived"]["kda"] for item in eligible_rows)
for item in eligible_rows:
component_scores = {
"kills_score": _log_normalized_score(item["totals"]["kills"], max_total_kills),
"support_score": _log_normalized_score(item["totals"]["support"], max_total_support),
"kpm_score": _log_normalized_score(item["derived"]["kpm"], max_kpm),
"kda_score": _log_normalized_score(item["derived"]["kda"], max_kda),
"participation_score": round(
100
* min(
1.0,
item["totals"]["time_seconds"] / MONTHLY_MVP_FULL_PARTICIPATION_SECONDS,
),
3,
),
}
teamkill_penalty = round(
min(
MONTHLY_MVP_TEAMKILL_PENALTY_CAP,
item["totals"]["teamkills"] * MONTHLY_MVP_TEAMKILL_PENALTY_PER_KILL,
),
3,
)
item["component_scores"] = component_scores
item["teamkill_penalty"] = teamkill_penalty
item["mvp_score"] = round(
(0.35 * component_scores["kills_score"])
+ (0.20 * component_scores["support_score"])
+ (0.20 * component_scores["kpm_score"])
+ (0.15 * component_scores["kda_score"])
+ (0.10 * component_scores["participation_score"])
- teamkill_penalty,
3,
)
ranked_items = sorted(
eligible_rows,
key=lambda item: (
-item["mvp_score"],
-item["component_scores"]["participation_score"],
-item["component_scores"]["kills_score"],
-item["component_scores"]["support_score"],
item["totals"]["teamkills"],
str(item["player"]["name"]).casefold(),
str(item["player"]["stable_player_key"]),
),
)
for position, item in enumerate(ranked_items[:limit], start=1):
item["ranking_position"] = position
return {
"ranking_version": MONTHLY_MVP_VERSION,
"eligibility": _build_eligibility_metadata(),
"eligible_players_count": len(eligible_rows),
"items": ranked_items[:limit],
}
def _is_eligible_player_row(row: Mapping[str, object]) -> bool:
matches_count = int(row.get("matches_count") or 0)
time_seconds = int(row.get("total_time_seconds") or 0)
has_required_fields = all(
row.get(field_name) is not None
for field_name in ("total_kills", "total_deaths", "total_support", "total_time_seconds")
)
return (
has_required_fields
and matches_count >= MONTHLY_MVP_MIN_MATCHES
and time_seconds >= MONTHLY_MVP_MIN_TIME_SECONDS
)
def _build_eligible_player_summary(row: Mapping[str, object]) -> dict[str, object]:
total_kills = int(row.get("total_kills") or 0)
total_deaths = int(row.get("total_deaths") or 0)
total_support = int(row.get("total_support") or 0)
total_teamkills = int(row.get("total_teamkills") or 0)
total_time_seconds = int(row.get("total_time_seconds") or 0)
total_time_minutes = max(total_time_seconds / 60.0, 1.0)
kpm = round(total_kills / total_time_minutes, 6)
kda = round(total_kills / max(total_deaths, 1), 6)
return {
"server": {
"slug": row.get("server_slug"),
"name": row.get("server_name"),
},
"player": {
"stable_player_key": row.get("stable_player_key"),
"name": row.get("player_name"),
"steam_id": row.get("steam_id"),
},
"matches_considered": int(row.get("matches_count") or 0),
"totals": {
"kills": total_kills,
"deaths": total_deaths,
"support": total_support,
"teamkills": total_teamkills,
"time_seconds": total_time_seconds,
"time_minutes": round(total_time_seconds / 60.0, 2),
},
"derived": {
"kpm": kpm,
"kda": kda,
},
}
def _log_normalized_score(value: float | int, max_value: float | int) -> float:
if value <= 0 or max_value <= 0:
return 0.0
return round((100 * math.log1p(value)) / math.log1p(max_value), 3)
def _build_eligibility_metadata() -> dict[str, object]:
return {
"minimum_matches": MONTHLY_MVP_MIN_MATCHES,
"minimum_time_seconds": MONTHLY_MVP_MIN_TIME_SECONDS,
"minimum_time_hours": round(MONTHLY_MVP_MIN_TIME_SECONDS / 3600, 1),
"full_participation_seconds": MONTHLY_MVP_FULL_PARTICIPATION_SECONDS,
"full_participation_hours": round(MONTHLY_MVP_FULL_PARTICIPATION_SECONDS / 3600, 1),
"teamkill_penalty_per_kill": MONTHLY_MVP_TEAMKILL_PENALTY_PER_KILL,
"teamkill_penalty_cap": MONTHLY_MVP_TEAMKILL_PENALTY_CAP,
}