diff --git a/ai/tasks/done/TASK-067-monthly-mvp-ranking-backend-calculation.md b/ai/tasks/done/TASK-067-monthly-mvp-ranking-backend-calculation.md new file mode 100644 index 0000000..c55d0dd --- /dev/null +++ b/ai/tasks/done/TASK-067-monthly-mvp-ranking-backend-calculation.md @@ -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. diff --git a/backend/README.md b/backend/README.md index 2031da0..38d4c00 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 `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 La ingesta historica no usa A2S ni scraping del HTML de `/games`. Consume la diff --git a/backend/app/historical_storage.py b/backend/app/historical_storage.py index d3c4a1f..e206c4a 100644 --- a/backend/app/historical_storage.py +++ b/backend/app/historical_storage.py @@ -13,6 +13,7 @@ from .config import ( get_storage_path, ) from .historical_models import HistoricalServerDefinition +from .monthly_mvp import build_monthly_mvp_rankings 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: connection = sqlite3.connect(db_path) connection.row_factory = sqlite3.Row diff --git a/backend/app/monthly_mvp.py b/backend/app/monthly_mvp.py new file mode 100644 index 0000000..d58681b --- /dev/null +++ b/backend/app/monthly_mvp.py @@ -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, + }