Implement monthly MVP V2 workflow and snapshots
This commit is contained in:
@@ -191,12 +191,16 @@ normaliza espacios y barras finales para mantener la comparacion con el header
|
||||
- `GET /api/historical/weekly-leaderboard?metric=kills&limit=10&server=comunidad-hispana-01`
|
||||
- `GET /api/historical/leaderboard?timeframe=monthly&metric=kills&limit=10&server=comunidad-hispana-01`
|
||||
- `GET /api/historical/monthly-mvp?limit=10&server=comunidad-hispana-01`
|
||||
- `GET /api/historical/monthly-mvp-v2?limit=10&server=comunidad-hispana-01`
|
||||
- `GET /api/historical/player-events?view=most-killed&limit=10&server=comunidad-hispana-01`
|
||||
- `GET /api/historical/recent-matches?limit=20&server=comunidad-hispana-01`
|
||||
- `GET /api/historical/server-summary?server=comunidad-hispana-01`
|
||||
- `GET /api/historical/snapshots/server-summary?server=comunidad-hispana-01`
|
||||
- `GET /api/historical/snapshots/weekly-leaderboard?metric=kills&limit=10&server=comunidad-hispana-01`
|
||||
- `GET /api/historical/snapshots/leaderboard?timeframe=monthly&metric=kills&limit=10&server=comunidad-hispana-01`
|
||||
- `GET /api/historical/snapshots/monthly-mvp?limit=10&server=comunidad-hispana-01`
|
||||
- `GET /api/historical/snapshots/monthly-mvp-v2?limit=10&server=comunidad-hispana-01`
|
||||
- `GET /api/historical/snapshots/player-events?view=most-killed&limit=10&server=comunidad-hispana-01`
|
||||
- `GET /api/historical/snapshots/recent-matches?limit=6&server=comunidad-hispana-01`
|
||||
- `GET /api/historical/player-profile?player=steam%3A76561198000000000`
|
||||
|
||||
@@ -779,6 +783,34 @@ Tambien persiste `monthly-mvp.json` por servidor y para `all-servers`, listo
|
||||
para lectura rapida desde `/api/historical/monthly-mvp` y
|
||||
`/api/historical/snapshots/monthly-mvp` sin recalculo pesado en request.
|
||||
|
||||
La misma operativa persiste tambien snapshots V2 de eventos de jugador para el
|
||||
ultimo mes con datos disponible por servidor y para `all-servers`, listos para
|
||||
lectura rapida sin consultas pesadas on-demand:
|
||||
|
||||
- `player-events-most-killed.json`
|
||||
- `player-events-death-by.json`
|
||||
- `player-events-duels.json`
|
||||
- `player-events-weapon-kills.json`
|
||||
- `player-events-teamkills.json`
|
||||
|
||||
Los endpoints `/api/historical/player-events` y
|
||||
`/api/historical/snapshots/player-events` aceptan:
|
||||
|
||||
- `view=most-killed`
|
||||
- `view=death-by`
|
||||
- `view=duels`
|
||||
- `view=weapon-kills`
|
||||
- `view=teamkills`
|
||||
|
||||
La respuesta expone metadata operativa alineada con el resto de snapshots:
|
||||
|
||||
- `generated_at`
|
||||
- `month_key`
|
||||
- `source_range_start`
|
||||
- `source_range_end`
|
||||
- `found`
|
||||
- `is_stale`
|
||||
|
||||
El backend incluye ademas el calculo interno de `monthly MVP V1` en
|
||||
`app/monthly_mvp.py`, separado de los leaderboards mensuales simples por
|
||||
metrica. Ese calculo:
|
||||
@@ -793,6 +825,35 @@ En esta fase el ranking MVP queda listo para serializar en snapshots o payloads
|
||||
sin reemplazar los leaderboards mensuales ya existentes por `kills`, `deaths`,
|
||||
`support` y `matches_over_100_kills`.
|
||||
|
||||
La repo incluye tambien un calculo backend separado de `monthly MVP V2` en
|
||||
`app/monthly_mvp_v2.py`, expuesto de momento por
|
||||
`/api/historical/monthly-mvp-v2`.
|
||||
|
||||
Esta V2:
|
||||
|
||||
- convive sin reemplazar `monthly MVP V1`
|
||||
- reusa la misma ventana mensual y la misma elegibilidad base
|
||||
- anade `rivalry_edge` y `duel_control` derivados del ledger V2 de eventos
|
||||
- aplica una penalizacion de teamkills mas estricta
|
||||
- mantiene fuera del score el peso por arma o tipo de kill hasta validar mejor
|
||||
esas senales
|
||||
|
||||
Esa capacidad V2 se persiste tambien en snapshots dedicados
|
||||
`monthly-mvp-v2.json` por servidor y para `all-servers`, leidos por:
|
||||
|
||||
- `/api/historical/monthly-mvp-v2`
|
||||
- `/api/historical/snapshots/monthly-mvp-v2`
|
||||
|
||||
La lectura HTTP de V2 sigue asi la misma politica de fast path de solo lectura
|
||||
que el resto de snapshots historicos, con metadata util como:
|
||||
|
||||
- `generated_at`
|
||||
- `month_key`
|
||||
- `found`
|
||||
- `source_range_start`
|
||||
- `source_range_end`
|
||||
- `event_coverage`
|
||||
|
||||
## Ingesta historica CRCON
|
||||
|
||||
La ingesta historica no usa A2S ni scraping del HTML de `/games`. Consume la
|
||||
|
||||
@@ -142,6 +142,7 @@ def generate_historical_snapshots(
|
||||
"full_snapshot_every_runs": full_snapshot_every_runs,
|
||||
"prewarm_only": not should_run_full_refresh,
|
||||
"refresh_interval_seconds": get_historical_refresh_interval_seconds(),
|
||||
"includes_monthly_mvp_v2": True,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -255,7 +255,12 @@ def _is_effectively_empty_snapshot_payload(
|
||||
items = payload.get("items")
|
||||
return not isinstance(items, list) or len(items) == 0
|
||||
|
||||
if snapshot_type in {"weekly-leaderboard", "monthly-leaderboard", "monthly-mvp"}:
|
||||
if snapshot_type in {
|
||||
"weekly-leaderboard",
|
||||
"monthly-leaderboard",
|
||||
"monthly-mvp",
|
||||
"monthly-mvp-v2",
|
||||
}:
|
||||
items = payload.get("items")
|
||||
return not isinstance(items, list) or len(items) == 0
|
||||
|
||||
@@ -287,6 +292,18 @@ def _build_snapshot_filename(*, snapshot_type: str, metric: str | None) -> str:
|
||||
return "server-summary.json"
|
||||
if snapshot_type == "recent-matches":
|
||||
return "recent-matches.json"
|
||||
if snapshot_type == "monthly-mvp-v2":
|
||||
return "monthly-mvp-v2.json"
|
||||
if snapshot_type == "player-event-most-killed":
|
||||
return "player-events-most-killed.json"
|
||||
if snapshot_type == "player-event-death-by":
|
||||
return "player-events-death-by.json"
|
||||
if snapshot_type == "player-event-duels":
|
||||
return "player-events-duels.json"
|
||||
if snapshot_type == "player-event-weapon-kills":
|
||||
return "player-events-weapon-kills.json"
|
||||
if snapshot_type == "player-event-teamkills":
|
||||
return "player-events-teamkills.json"
|
||||
if snapshot_type == "weekly-leaderboard":
|
||||
metric_suffix = "matches-over-100-kills" if metric == "matches_over_100_kills" else _slugify(metric or "unknown")
|
||||
return f"weekly-{metric_suffix}.json"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -11,15 +12,30 @@ from .historical_storage import (
|
||||
list_historical_servers,
|
||||
list_monthly_leaderboard,
|
||||
list_monthly_mvp_ranking,
|
||||
list_monthly_mvp_v2_ranking,
|
||||
list_recent_historical_matches,
|
||||
list_weekly_leaderboard,
|
||||
)
|
||||
from .player_event_aggregates import (
|
||||
list_death_by,
|
||||
list_most_killed,
|
||||
list_net_duel_summaries,
|
||||
list_teamkill_summaries,
|
||||
list_weapon_kills,
|
||||
)
|
||||
from .player_event_storage import initialize_player_event_storage
|
||||
|
||||
SNAPSHOT_TYPE_SERVER_SUMMARY = "server-summary"
|
||||
SNAPSHOT_TYPE_WEEKLY_LEADERBOARD = "weekly-leaderboard"
|
||||
SNAPSHOT_TYPE_MONTHLY_LEADERBOARD = "monthly-leaderboard"
|
||||
SNAPSHOT_TYPE_MONTHLY_MVP = "monthly-mvp"
|
||||
SNAPSHOT_TYPE_MONTHLY_MVP_V2 = "monthly-mvp-v2"
|
||||
SNAPSHOT_TYPE_RECENT_MATCHES = "recent-matches"
|
||||
SNAPSHOT_TYPE_PLAYER_EVENT_MOST_KILLED = "player-event-most-killed"
|
||||
SNAPSHOT_TYPE_PLAYER_EVENT_DEATH_BY = "player-event-death-by"
|
||||
SNAPSHOT_TYPE_PLAYER_EVENT_DUELS = "player-event-duels"
|
||||
SNAPSHOT_TYPE_PLAYER_EVENT_WEAPON_KILLS = "player-event-weapon-kills"
|
||||
SNAPSHOT_TYPE_PLAYER_EVENT_TEAMKILLS = "player-event-teamkills"
|
||||
|
||||
SUPPORTED_SNAPSHOT_TYPES = frozenset(
|
||||
{
|
||||
@@ -27,7 +43,13 @@ SUPPORTED_SNAPSHOT_TYPES = frozenset(
|
||||
SNAPSHOT_TYPE_WEEKLY_LEADERBOARD,
|
||||
SNAPSHOT_TYPE_MONTHLY_LEADERBOARD,
|
||||
SNAPSHOT_TYPE_MONTHLY_MVP,
|
||||
SNAPSHOT_TYPE_MONTHLY_MVP_V2,
|
||||
SNAPSHOT_TYPE_RECENT_MATCHES,
|
||||
SNAPSHOT_TYPE_PLAYER_EVENT_MOST_KILLED,
|
||||
SNAPSHOT_TYPE_PLAYER_EVENT_DEATH_BY,
|
||||
SNAPSHOT_TYPE_PLAYER_EVENT_DUELS,
|
||||
SNAPSHOT_TYPE_PLAYER_EVENT_WEAPON_KILLS,
|
||||
SNAPSHOT_TYPE_PLAYER_EVENT_TEAMKILLS,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -52,6 +74,13 @@ SNAPSHOT_LEADERBOARD_METRICS = (
|
||||
"matches_over_100_kills",
|
||||
"support",
|
||||
)
|
||||
PLAYER_EVENT_SNAPSHOT_TYPES = (
|
||||
SNAPSHOT_TYPE_PLAYER_EVENT_MOST_KILLED,
|
||||
SNAPSHOT_TYPE_PLAYER_EVENT_DEATH_BY,
|
||||
SNAPSHOT_TYPE_PLAYER_EVENT_DUELS,
|
||||
SNAPSHOT_TYPE_PLAYER_EVENT_WEAPON_KILLS,
|
||||
SNAPSHOT_TYPE_PLAYER_EVENT_TEAMKILLS,
|
||||
)
|
||||
|
||||
DEFAULT_SNAPSHOT_WINDOW = "all-time"
|
||||
DEFAULT_WEEKLY_SNAPSHOT_WINDOW = "7d"
|
||||
@@ -134,6 +163,24 @@ def build_historical_server_snapshots(
|
||||
db_path=db_path,
|
||||
)
|
||||
)
|
||||
snapshots.append(
|
||||
_build_monthly_mvp_v2_snapshot(
|
||||
server_key,
|
||||
generated_at_value,
|
||||
limit=leaderboard_limit,
|
||||
db_path=db_path,
|
||||
)
|
||||
)
|
||||
for snapshot_type in PLAYER_EVENT_SNAPSHOT_TYPES:
|
||||
snapshots.append(
|
||||
_build_player_event_snapshot(
|
||||
server_key,
|
||||
snapshot_type,
|
||||
generated_at_value,
|
||||
limit=leaderboard_limit,
|
||||
db_path=db_path,
|
||||
)
|
||||
)
|
||||
|
||||
snapshots.append(
|
||||
_build_recent_matches_snapshot(
|
||||
@@ -188,6 +235,24 @@ def build_priority_historical_snapshots(
|
||||
db_path=db_path,
|
||||
)
|
||||
)
|
||||
snapshots.append(
|
||||
_build_monthly_mvp_v2_snapshot(
|
||||
server_key,
|
||||
generated_at_value,
|
||||
limit=leaderboard_limit,
|
||||
db_path=db_path,
|
||||
)
|
||||
)
|
||||
for snapshot_type in PLAYER_EVENT_SNAPSHOT_TYPES:
|
||||
snapshots.append(
|
||||
_build_player_event_snapshot(
|
||||
server_key,
|
||||
snapshot_type,
|
||||
generated_at_value,
|
||||
limit=leaderboard_limit,
|
||||
db_path=db_path,
|
||||
)
|
||||
)
|
||||
snapshots.append(
|
||||
_build_recent_matches_snapshot(
|
||||
server_key,
|
||||
@@ -420,6 +485,58 @@ def _build_recent_matches_snapshot(
|
||||
}
|
||||
|
||||
|
||||
def _build_player_event_snapshot(
|
||||
server_key: str,
|
||||
snapshot_type: str,
|
||||
generated_at: datetime,
|
||||
*,
|
||||
limit: int,
|
||||
db_path: Path | None = None,
|
||||
) -> dict[str, object]:
|
||||
month_key = _get_latest_player_event_month_key(server_key=server_key, db_path=db_path)
|
||||
source_range_start = None
|
||||
source_range_end = None
|
||||
items: list[dict[str, object]] = []
|
||||
found = False
|
||||
|
||||
if month_key:
|
||||
source_range_start, source_range_end = _get_player_event_source_range(
|
||||
server_key=server_key,
|
||||
month_key=month_key,
|
||||
db_path=db_path,
|
||||
)
|
||||
items = _list_player_event_snapshot_items(
|
||||
snapshot_type=snapshot_type,
|
||||
server_key=server_key,
|
||||
month_key=month_key,
|
||||
limit=limit,
|
||||
db_path=db_path,
|
||||
)
|
||||
found = bool(items or source_range_start or source_range_end)
|
||||
|
||||
return {
|
||||
"server_key": server_key,
|
||||
"snapshot_type": snapshot_type,
|
||||
"metric": None,
|
||||
"window": DEFAULT_MONTHLY_SNAPSHOT_WINDOW,
|
||||
"generated_at": generated_at,
|
||||
"source_range_start": source_range_start,
|
||||
"source_range_end": source_range_end,
|
||||
"is_stale": False,
|
||||
"payload": {
|
||||
"server_key": server_key,
|
||||
"period": "monthly",
|
||||
"month_key": month_key,
|
||||
"limit": limit,
|
||||
"found": found,
|
||||
"generated_at": _to_iso(generated_at),
|
||||
"source_range_start": _to_iso(source_range_start) if source_range_start else None,
|
||||
"source_range_end": _to_iso(source_range_end) if source_range_end else None,
|
||||
"items": items,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_monthly_mvp_snapshot(
|
||||
server_key: str,
|
||||
generated_at: datetime,
|
||||
@@ -452,6 +569,45 @@ def _build_monthly_mvp_snapshot(
|
||||
}
|
||||
|
||||
|
||||
def _build_monthly_mvp_v2_snapshot(
|
||||
server_key: str,
|
||||
generated_at: datetime,
|
||||
*,
|
||||
limit: int,
|
||||
db_path: Path | None = None,
|
||||
) -> dict[str, object]:
|
||||
ranking_result = list_monthly_mvp_v2_ranking(
|
||||
limit=limit,
|
||||
server_id=server_key,
|
||||
db_path=db_path,
|
||||
)
|
||||
month_key = str(ranking_result.get("window_start") or "")[:7] or None
|
||||
event_coverage = ranking_result.get("event_coverage")
|
||||
source_range_start = None
|
||||
source_range_end = None
|
||||
if isinstance(event_coverage, dict):
|
||||
source_range_start = _parse_optional_timestamp(event_coverage.get("source_range_start"))
|
||||
source_range_end = _parse_optional_timestamp(event_coverage.get("source_range_end"))
|
||||
return {
|
||||
"server_key": server_key,
|
||||
"snapshot_type": SNAPSHOT_TYPE_MONTHLY_MVP_V2,
|
||||
"metric": None,
|
||||
"window": DEFAULT_MONTHLY_SNAPSHOT_WINDOW,
|
||||
"generated_at": generated_at,
|
||||
"source_range_start": source_range_start,
|
||||
"source_range_end": source_range_end,
|
||||
"is_stale": False,
|
||||
"payload": {
|
||||
"server_key": server_key,
|
||||
"limit": limit,
|
||||
"month_key": month_key,
|
||||
"found": bool(event_coverage.get("ready")) if isinstance(event_coverage, dict) else False,
|
||||
"generated_at": _to_iso(generated_at),
|
||||
**ranking_result,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _resolve_snapshot_target_keys(
|
||||
*,
|
||||
server_key: str | None,
|
||||
@@ -470,6 +626,93 @@ def _resolve_snapshot_target_keys(
|
||||
return [normalized_server_key, ALL_SERVERS_SLUG]
|
||||
|
||||
|
||||
def _list_player_event_snapshot_items(
|
||||
*,
|
||||
snapshot_type: str,
|
||||
server_key: str,
|
||||
month_key: str,
|
||||
limit: int,
|
||||
db_path: Path | None,
|
||||
) -> list[dict[str, object]]:
|
||||
aggregator_by_snapshot_type = {
|
||||
SNAPSHOT_TYPE_PLAYER_EVENT_MOST_KILLED: list_most_killed,
|
||||
SNAPSHOT_TYPE_PLAYER_EVENT_DEATH_BY: list_death_by,
|
||||
SNAPSHOT_TYPE_PLAYER_EVENT_DUELS: list_net_duel_summaries,
|
||||
SNAPSHOT_TYPE_PLAYER_EVENT_WEAPON_KILLS: list_weapon_kills,
|
||||
SNAPSHOT_TYPE_PLAYER_EVENT_TEAMKILLS: list_teamkill_summaries,
|
||||
}
|
||||
aggregator = aggregator_by_snapshot_type[snapshot_type]
|
||||
return aggregator(
|
||||
server_slug=server_key,
|
||||
month=month_key,
|
||||
limit=limit,
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
|
||||
def _get_latest_player_event_month_key(
|
||||
*,
|
||||
server_key: str,
|
||||
db_path: Path | None = None,
|
||||
) -> str | None:
|
||||
resolved_path = initialize_player_event_storage(db_path=db_path)
|
||||
where_sql, params = _build_player_event_scope_where(server_key=server_key)
|
||||
with _connect(resolved_path) as connection:
|
||||
row = connection.execute(
|
||||
f"""
|
||||
SELECT MAX(substr(occurred_at, 1, 7)) AS latest_month
|
||||
FROM player_event_raw_ledger
|
||||
WHERE occurred_at IS NOT NULL
|
||||
AND {where_sql}
|
||||
""",
|
||||
params,
|
||||
).fetchone()
|
||||
if not row or not row["latest_month"]:
|
||||
return None
|
||||
return str(row["latest_month"])
|
||||
|
||||
|
||||
def _get_player_event_source_range(
|
||||
*,
|
||||
server_key: str,
|
||||
month_key: str,
|
||||
db_path: Path | None = None,
|
||||
) -> tuple[datetime | None, datetime | None]:
|
||||
resolved_path = initialize_player_event_storage(db_path=db_path)
|
||||
where_sql, params = _build_player_event_scope_where(server_key=server_key)
|
||||
with _connect(resolved_path) as connection:
|
||||
row = connection.execute(
|
||||
f"""
|
||||
SELECT
|
||||
MIN(occurred_at) AS source_range_start,
|
||||
MAX(occurred_at) AS source_range_end
|
||||
FROM player_event_raw_ledger
|
||||
WHERE occurred_at IS NOT NULL
|
||||
AND substr(occurred_at, 1, 7) = ?
|
||||
AND {where_sql}
|
||||
""",
|
||||
[month_key, *params],
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None, None
|
||||
return (
|
||||
_parse_optional_timestamp(row["source_range_start"]),
|
||||
_parse_optional_timestamp(row["source_range_end"]),
|
||||
)
|
||||
|
||||
|
||||
def _build_player_event_scope_where(*, server_key: str) -> tuple[str, list[object]]:
|
||||
if server_key == ALL_SERVERS_SLUG:
|
||||
return "1 = 1", []
|
||||
return "server_slug = ?", [server_key]
|
||||
|
||||
|
||||
def _connect(db_path: Path) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(db_path)
|
||||
connection.row_factory = sqlite3.Row
|
||||
return connection
|
||||
|
||||
|
||||
def _parse_optional_timestamp(value: object) -> datetime | None:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
|
||||
@@ -14,6 +14,7 @@ from .config import (
|
||||
)
|
||||
from .historical_models import HistoricalServerDefinition
|
||||
from .monthly_mvp import build_monthly_mvp_rankings
|
||||
from .monthly_mvp_v2 import build_monthly_mvp_v2_rankings
|
||||
|
||||
|
||||
DEFAULT_HISTORICAL_SERVERS = (
|
||||
@@ -1569,6 +1570,331 @@ def list_monthly_mvp_ranking(
|
||||
}
|
||||
|
||||
|
||||
def list_monthly_mvp_v2_ranking(
|
||||
*,
|
||||
limit: int = 10,
|
||||
server_id: str | None = None,
|
||||
db_path: Path | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Return the monthly MVP V2 ranking built from monthly totals plus V2 signals."""
|
||||
resolved_path = initialize_historical_storage(db_path=db_path)
|
||||
aggregate_all_servers = _is_all_servers_selector(server_id)
|
||||
current_time = datetime.now(timezone.utc)
|
||||
current_month_start = _start_of_month(current_time)
|
||||
previous_month_start = _start_of_previous_month(current_month_start)
|
||||
monthly_window = _select_monthly_window(
|
||||
server_id=server_id,
|
||||
current_time=current_time,
|
||||
current_month_start=current_month_start,
|
||||
previous_month_start=previous_month_start,
|
||||
db_path=resolved_path,
|
||||
)
|
||||
window_start = monthly_window["window_start"]
|
||||
window_end = monthly_window["window_end"]
|
||||
month_key = window_start.strftime("%Y-%m")
|
||||
event_coverage = _get_monthly_player_event_coverage(
|
||||
server_id=server_id,
|
||||
month_key=month_key,
|
||||
db_path=resolved_path,
|
||||
)
|
||||
window_days = _calculate_window_days(window_start=window_start, window_end=window_end)
|
||||
|
||||
empty_result = {
|
||||
"timeframe": "monthly",
|
||||
"metric": "mvp-v2",
|
||||
"ranking_version": "v2",
|
||||
"window_start": window_start.isoformat().replace("+00:00", "Z"),
|
||||
"window_end": window_end.isoformat().replace("+00:00", "Z"),
|
||||
"window_days": window_days,
|
||||
"window_kind": monthly_window["window_kind"],
|
||||
"window_label": monthly_window["window_label"],
|
||||
"uses_fallback": monthly_window["uses_fallback"],
|
||||
"selection_reason": monthly_window["selection_reason"],
|
||||
"current_month_start": current_month_start.isoformat().replace("+00:00", "Z"),
|
||||
"current_month_closed_matches": monthly_window["current_month_closed_matches"],
|
||||
"previous_month_closed_matches": monthly_window["previous_month_closed_matches"],
|
||||
"sufficient_sample": {
|
||||
"minimum_closed_matches": monthly_window["minimum_closed_matches"],
|
||||
"current_month_closed_matches": monthly_window["current_month_closed_matches"],
|
||||
"current_month_has_sufficient_sample": monthly_window["current_month_has_sufficient_sample"],
|
||||
"is_early_month": monthly_window["is_early_month"],
|
||||
},
|
||||
"event_coverage": event_coverage,
|
||||
}
|
||||
if not bool(event_coverage["ready"]):
|
||||
return {
|
||||
**empty_result,
|
||||
"eligibility": None,
|
||||
"eligible_players_count": 0,
|
||||
"items": [],
|
||||
}
|
||||
|
||||
where_clauses = [
|
||||
"historical_matches.ended_at IS NOT NULL",
|
||||
"historical_matches.ended_at >= ?",
|
||||
"historical_matches.ended_at < ?",
|
||||
]
|
||||
params: list[object] = [
|
||||
window_start.isoformat().replace("+00:00", "Z"),
|
||||
window_end.isoformat().replace("+00:00", "Z"),
|
||||
]
|
||||
if server_id and not aggregate_all_servers:
|
||||
normalized_server_id = server_id.strip()
|
||||
where_clauses.append(
|
||||
"(historical_servers.slug = ? OR CAST(historical_servers.server_number AS TEXT) = ?)"
|
||||
)
|
||||
params.extend([normalized_server_id, normalized_server_id])
|
||||
|
||||
event_scope_sql, event_scope_params = _build_player_event_scope_sql(server_id)
|
||||
server_slug_expression = (
|
||||
f"'{ALL_SERVERS_SLUG}'"
|
||||
if aggregate_all_servers
|
||||
else "historical_servers.slug"
|
||||
)
|
||||
server_name_expression = (
|
||||
f"'{ALL_SERVERS_DISPLAY_NAME}'"
|
||||
if aggregate_all_servers
|
||||
else "historical_servers.display_name"
|
||||
)
|
||||
group_by_expression = (
|
||||
"historical_players.id"
|
||||
if aggregate_all_servers
|
||||
else "historical_servers.slug, historical_players.id"
|
||||
)
|
||||
|
||||
with _connect(resolved_path) as connection:
|
||||
rows = connection.execute(
|
||||
f"""
|
||||
WITH most_killed_pairs AS (
|
||||
SELECT
|
||||
killer_player_key AS stable_player_key,
|
||||
victim_player_key,
|
||||
COALESCE(SUM(event_value), 0) AS total_kills
|
||||
FROM player_event_raw_ledger
|
||||
WHERE event_type = 'player_kill_summary'
|
||||
AND occurred_at IS NOT NULL
|
||||
AND substr(occurred_at, 1, 7) = ?
|
||||
AND {event_scope_sql}
|
||||
AND killer_player_key IS NOT NULL
|
||||
AND victim_player_key IS NOT NULL
|
||||
GROUP BY killer_player_key, victim_player_key
|
||||
),
|
||||
most_killed_by_player AS (
|
||||
SELECT
|
||||
stable_player_key,
|
||||
MAX(total_kills) AS most_killed_count
|
||||
FROM most_killed_pairs
|
||||
GROUP BY stable_player_key
|
||||
),
|
||||
death_by_pairs AS (
|
||||
SELECT
|
||||
victim_player_key AS stable_player_key,
|
||||
killer_player_key,
|
||||
COALESCE(SUM(event_value), 0) AS total_kills
|
||||
FROM player_event_raw_ledger
|
||||
WHERE event_type = 'player_death_summary'
|
||||
AND occurred_at IS NOT NULL
|
||||
AND substr(occurred_at, 1, 7) = ?
|
||||
AND {event_scope_sql}
|
||||
AND killer_player_key IS NOT NULL
|
||||
AND victim_player_key IS NOT NULL
|
||||
GROUP BY victim_player_key, killer_player_key
|
||||
),
|
||||
death_by_player AS (
|
||||
SELECT
|
||||
stable_player_key,
|
||||
MAX(total_kills) AS death_by_count
|
||||
FROM death_by_pairs
|
||||
GROUP BY stable_player_key
|
||||
),
|
||||
duel_pairs AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN COALESCE(killer_player_key, '') <= COALESCE(victim_player_key, '')
|
||||
THEN killer_player_key
|
||||
ELSE victim_player_key
|
||||
END AS player_a_key,
|
||||
CASE
|
||||
WHEN COALESCE(killer_player_key, '') <= COALESCE(victim_player_key, '')
|
||||
THEN victim_player_key
|
||||
ELSE killer_player_key
|
||||
END AS player_b_key,
|
||||
COALESCE(
|
||||
SUM(
|
||||
CASE
|
||||
WHEN COALESCE(killer_player_key, '') <= COALESCE(victim_player_key, '')
|
||||
THEN event_value
|
||||
ELSE -event_value
|
||||
END
|
||||
),
|
||||
0
|
||||
) AS net_duel_value
|
||||
FROM player_event_raw_ledger
|
||||
WHERE event_type = 'player_kill_summary'
|
||||
AND occurred_at IS NOT NULL
|
||||
AND substr(occurred_at, 1, 7) = ?
|
||||
AND {event_scope_sql}
|
||||
AND killer_player_key IS NOT NULL
|
||||
AND victim_player_key IS NOT NULL
|
||||
GROUP BY
|
||||
CASE
|
||||
WHEN COALESCE(killer_player_key, '') <= COALESCE(victim_player_key, '')
|
||||
THEN killer_player_key
|
||||
ELSE victim_player_key
|
||||
END,
|
||||
CASE
|
||||
WHEN COALESCE(killer_player_key, '') <= COALESCE(victim_player_key, '')
|
||||
THEN victim_player_key
|
||||
ELSE killer_player_key
|
||||
END
|
||||
),
|
||||
duel_player_values AS (
|
||||
SELECT
|
||||
player_a_key AS stable_player_key,
|
||||
CASE WHEN net_duel_value > 0 THEN net_duel_value ELSE 0 END AS positive_duel_value
|
||||
FROM duel_pairs
|
||||
UNION ALL
|
||||
SELECT
|
||||
player_b_key AS stable_player_key,
|
||||
CASE WHEN net_duel_value < 0 THEN -net_duel_value ELSE 0 END AS positive_duel_value
|
||||
FROM duel_pairs
|
||||
),
|
||||
ranked_duel_values AS (
|
||||
SELECT
|
||||
stable_player_key,
|
||||
positive_duel_value,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY stable_player_key
|
||||
ORDER BY positive_duel_value DESC
|
||||
) AS duel_rank
|
||||
FROM duel_player_values
|
||||
WHERE stable_player_key IS NOT NULL
|
||||
AND positive_duel_value > 0
|
||||
),
|
||||
duel_control_by_player AS (
|
||||
SELECT
|
||||
stable_player_key,
|
||||
COALESCE(SUM(positive_duel_value), 0) AS duel_control_raw
|
||||
FROM ranked_duel_values
|
||||
WHERE duel_rank <= 3
|
||||
GROUP BY stable_player_key
|
||||
)
|
||||
SELECT
|
||||
{server_slug_expression} AS server_slug,
|
||||
{server_name_expression} AS server_name,
|
||||
historical_players.stable_player_key,
|
||||
historical_players.display_name AS player_name,
|
||||
historical_players.steam_id,
|
||||
COUNT(DISTINCT historical_matches.id) AS matches_count,
|
||||
COALESCE(SUM(historical_player_match_stats.kills), 0) AS total_kills,
|
||||
COALESCE(SUM(historical_player_match_stats.deaths), 0) AS total_deaths,
|
||||
COALESCE(SUM(historical_player_match_stats.support), 0) AS total_support,
|
||||
COALESCE(SUM(historical_player_match_stats.teamkills), 0) AS total_teamkills,
|
||||
COALESCE(SUM(historical_player_match_stats.time_seconds), 0) AS total_time_seconds,
|
||||
COALESCE(MAX(most_killed_by_player.most_killed_count), 0) AS most_killed_count,
|
||||
COALESCE(MAX(death_by_player.death_by_count), 0) AS death_by_count,
|
||||
COALESCE(MAX(duel_control_by_player.duel_control_raw), 0) AS duel_control_raw
|
||||
FROM historical_player_match_stats
|
||||
INNER JOIN historical_matches
|
||||
ON historical_matches.id = historical_player_match_stats.historical_match_id
|
||||
INNER JOIN historical_servers
|
||||
ON historical_servers.id = historical_matches.historical_server_id
|
||||
INNER JOIN historical_players
|
||||
ON historical_players.id = historical_player_match_stats.historical_player_id
|
||||
LEFT JOIN most_killed_by_player
|
||||
ON most_killed_by_player.stable_player_key = historical_players.stable_player_key
|
||||
LEFT JOIN death_by_player
|
||||
ON death_by_player.stable_player_key = historical_players.stable_player_key
|
||||
LEFT JOIN duel_control_by_player
|
||||
ON duel_control_by_player.stable_player_key = historical_players.stable_player_key
|
||||
WHERE {" AND ".join(where_clauses)}
|
||||
GROUP BY {group_by_expression}
|
||||
""",
|
||||
[
|
||||
month_key,
|
||||
*event_scope_params,
|
||||
month_key,
|
||||
*event_scope_params,
|
||||
month_key,
|
||||
*event_scope_params,
|
||||
*params,
|
||||
],
|
||||
).fetchall()
|
||||
|
||||
ranking_result = build_monthly_mvp_v2_rankings(
|
||||
[dict(row) for row in rows],
|
||||
limit=limit,
|
||||
)
|
||||
for item in ranking_result["items"]:
|
||||
item["time_range"] = {
|
||||
"start": window_start.isoformat().replace("+00:00", "Z"),
|
||||
"end": window_end.isoformat().replace("+00:00", "Z"),
|
||||
"window_days": window_days,
|
||||
}
|
||||
|
||||
return {
|
||||
**empty_result,
|
||||
"ranking_version": ranking_result["ranking_version"],
|
||||
"eligibility": ranking_result["eligibility"],
|
||||
"eligible_players_count": ranking_result["eligible_players_count"],
|
||||
"items": ranking_result["items"],
|
||||
}
|
||||
|
||||
|
||||
def _get_monthly_player_event_coverage(
|
||||
*,
|
||||
server_id: str | None,
|
||||
month_key: str,
|
||||
db_path: Path,
|
||||
) -> dict[str, object]:
|
||||
scope_sql, scope_params = _build_player_event_scope_sql(server_id)
|
||||
with _connect(db_path) as connection:
|
||||
latest_row = connection.execute(
|
||||
f"""
|
||||
SELECT MAX(substr(occurred_at, 1, 7)) AS latest_month_key
|
||||
FROM player_event_raw_ledger
|
||||
WHERE occurred_at IS NOT NULL
|
||||
AND {scope_sql}
|
||||
""",
|
||||
scope_params,
|
||||
).fetchone()
|
||||
month_row = connection.execute(
|
||||
f"""
|
||||
SELECT
|
||||
COUNT(*) AS event_count,
|
||||
MIN(occurred_at) AS source_range_start,
|
||||
MAX(occurred_at) AS source_range_end
|
||||
FROM player_event_raw_ledger
|
||||
WHERE occurred_at IS NOT NULL
|
||||
AND substr(occurred_at, 1, 7) = ?
|
||||
AND {scope_sql}
|
||||
""",
|
||||
[month_key, *scope_params],
|
||||
).fetchone()
|
||||
latest_month_key = str(latest_row["latest_month_key"]) if latest_row and latest_row["latest_month_key"] else None
|
||||
event_count = int(month_row["event_count"] or 0) if month_row else 0
|
||||
return {
|
||||
"month_key": month_key,
|
||||
"latest_month_key": latest_month_key,
|
||||
"ready": bool(event_count > 0 and latest_month_key == month_key),
|
||||
"event_count": event_count,
|
||||
"source_range_start": month_row["source_range_start"] if month_row else None,
|
||||
"source_range_end": month_row["source_range_end"] if month_row else None,
|
||||
"selection_reason": (
|
||||
"month-key-aligned"
|
||||
if event_count > 0 and latest_month_key == month_key
|
||||
else "player-event-month-mismatch-or-missing"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _build_player_event_scope_sql(server_id: str | None) -> tuple[str, list[object]]:
|
||||
if not server_id or _is_all_servers_selector(server_id):
|
||||
return "1 = 1", []
|
||||
normalized_server_id = server_id.strip()
|
||||
return "server_slug = ?", [normalized_server_id]
|
||||
|
||||
|
||||
def _connect(db_path: Path) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(db_path, timeout=30.0)
|
||||
connection.row_factory = sqlite3.Row
|
||||
|
||||
201
backend/app/monthly_mvp_v2.py
Normal file
201
backend/app/monthly_mvp_v2.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""Monthly MVP V2 scoring helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Mapping
|
||||
|
||||
|
||||
MONTHLY_MVP_V2_VERSION = "v2"
|
||||
MONTHLY_MVP_V2_MIN_MATCHES = 6
|
||||
MONTHLY_MVP_V2_MIN_TIME_SECONDS = 21600
|
||||
MONTHLY_MVP_V2_FULL_PARTICIPATION_SECONDS = 28800
|
||||
MONTHLY_MVP_V2_ADVANCED_CONFIDENCE_KILLS = 35
|
||||
MONTHLY_MVP_V2_TEAMKILL_PENALTY_CAP = 8.0
|
||||
MONTHLY_MVP_V2_TEAMKILL_PENALTY_PER_KILL = 0.75
|
||||
|
||||
|
||||
def build_monthly_mvp_v2_rankings(
|
||||
aggregated_rows: list[Mapping[str, object]],
|
||||
*,
|
||||
limit: int,
|
||||
) -> dict[str, object]:
|
||||
"""Transform aggregated monthly totals plus V2 event signals into rankings."""
|
||||
eligible_rows = [
|
||||
_build_eligible_player_summary(row)
|
||||
for row in aggregated_rows
|
||||
if _is_eligible_player_row(row)
|
||||
]
|
||||
|
||||
if not eligible_rows:
|
||||
return {
|
||||
"ranking_version": MONTHLY_MVP_V2_VERSION,
|
||||
"eligibility": _build_eligibility_metadata(),
|
||||
"eligible_players_count": 0,
|
||||
"items": [],
|
||||
}
|
||||
|
||||
max_total_kills = max(item["totals"]["kills"] for item in eligible_rows)
|
||||
max_total_support = max(item["totals"]["support"] for item in eligible_rows)
|
||||
max_kpm = max(item["derived"]["kpm"] for item in eligible_rows)
|
||||
max_kda = max(item["derived"]["kda"] for item in eligible_rows)
|
||||
max_rivalry_edge = max(item["advanced"]["rivalry_edge_raw"] for item in eligible_rows)
|
||||
max_duel_control = max(item["advanced"]["duel_control_raw"] for item in eligible_rows)
|
||||
|
||||
for item in eligible_rows:
|
||||
component_scores = {
|
||||
"kills_score": _log_normalized_score(item["totals"]["kills"], max_total_kills),
|
||||
"support_score": _log_normalized_score(item["totals"]["support"], max_total_support),
|
||||
"kpm_score": _log_normalized_score(item["derived"]["kpm"], max_kpm),
|
||||
"kda_score": _log_normalized_score(item["derived"]["kda"], max_kda),
|
||||
"participation_score": round(
|
||||
100
|
||||
* min(
|
||||
1.0,
|
||||
item["totals"]["time_seconds"] / MONTHLY_MVP_V2_FULL_PARTICIPATION_SECONDS,
|
||||
),
|
||||
3,
|
||||
),
|
||||
"rivalry_edge_score": _log_normalized_score(
|
||||
item["advanced"]["rivalry_edge_raw"],
|
||||
max_rivalry_edge,
|
||||
),
|
||||
"duel_control_score": _log_normalized_score(
|
||||
item["advanced"]["duel_control_raw"],
|
||||
max_duel_control,
|
||||
),
|
||||
}
|
||||
advanced_confidence = round(
|
||||
min(
|
||||
1.0,
|
||||
item["totals"]["kills"] / MONTHLY_MVP_V2_ADVANCED_CONFIDENCE_KILLS,
|
||||
),
|
||||
3,
|
||||
)
|
||||
teamkill_penalty_v2 = round(
|
||||
min(
|
||||
MONTHLY_MVP_V2_TEAMKILL_PENALTY_CAP,
|
||||
item["totals"]["teamkills"] * MONTHLY_MVP_V2_TEAMKILL_PENALTY_PER_KILL,
|
||||
),
|
||||
3,
|
||||
)
|
||||
item["component_scores"] = component_scores
|
||||
item["advanced_confidence"] = advanced_confidence
|
||||
item["teamkill_penalty_v2"] = teamkill_penalty_v2
|
||||
item["mvp_v2_score"] = round(
|
||||
(0.30 * component_scores["kills_score"])
|
||||
+ (0.18 * component_scores["support_score"])
|
||||
+ (0.18 * component_scores["kpm_score"])
|
||||
+ (0.12 * component_scores["kda_score"])
|
||||
+ (0.10 * component_scores["participation_score"])
|
||||
+ advanced_confidence
|
||||
* (
|
||||
(0.07 * component_scores["rivalry_edge_score"])
|
||||
+ (0.05 * component_scores["duel_control_score"])
|
||||
)
|
||||
- teamkill_penalty_v2,
|
||||
3,
|
||||
)
|
||||
|
||||
ranked_items = sorted(
|
||||
eligible_rows,
|
||||
key=lambda item: (
|
||||
-item["mvp_v2_score"],
|
||||
-item["advanced_confidence"],
|
||||
-item["component_scores"]["participation_score"],
|
||||
-item["component_scores"]["kills_score"],
|
||||
-item["component_scores"]["rivalry_edge_score"],
|
||||
item["totals"]["teamkills"],
|
||||
str(item["player"]["name"]).casefold(),
|
||||
str(item["player"]["stable_player_key"]),
|
||||
),
|
||||
)
|
||||
for position, item in enumerate(ranked_items[:limit], start=1):
|
||||
item["ranking_position"] = position
|
||||
|
||||
return {
|
||||
"ranking_version": MONTHLY_MVP_V2_VERSION,
|
||||
"eligibility": _build_eligibility_metadata(),
|
||||
"eligible_players_count": len(eligible_rows),
|
||||
"items": ranked_items[:limit],
|
||||
}
|
||||
|
||||
|
||||
def _is_eligible_player_row(row: Mapping[str, object]) -> bool:
|
||||
matches_count = int(row.get("matches_count") or 0)
|
||||
time_seconds = int(row.get("total_time_seconds") or 0)
|
||||
has_required_fields = all(
|
||||
row.get(field_name) is not None
|
||||
for field_name in ("total_kills", "total_deaths", "total_support", "total_time_seconds")
|
||||
)
|
||||
return (
|
||||
has_required_fields
|
||||
and matches_count >= MONTHLY_MVP_V2_MIN_MATCHES
|
||||
and time_seconds >= MONTHLY_MVP_V2_MIN_TIME_SECONDS
|
||||
)
|
||||
|
||||
|
||||
def _build_eligible_player_summary(row: Mapping[str, object]) -> dict[str, object]:
|
||||
total_kills = int(row.get("total_kills") or 0)
|
||||
total_deaths = int(row.get("total_deaths") or 0)
|
||||
total_support = int(row.get("total_support") or 0)
|
||||
total_teamkills = int(row.get("total_teamkills") or 0)
|
||||
total_time_seconds = int(row.get("total_time_seconds") or 0)
|
||||
total_time_minutes = max(total_time_seconds / 60.0, 1.0)
|
||||
most_killed_count = int(row.get("most_killed_count") or 0)
|
||||
death_by_count = int(row.get("death_by_count") or 0)
|
||||
duel_control_raw = int(row.get("duel_control_raw") or 0)
|
||||
kpm = round(total_kills / total_time_minutes, 6)
|
||||
kda = round(total_kills / max(total_deaths, 1), 6)
|
||||
return {
|
||||
"server": {
|
||||
"slug": row.get("server_slug"),
|
||||
"name": row.get("server_name"),
|
||||
},
|
||||
"player": {
|
||||
"stable_player_key": row.get("stable_player_key"),
|
||||
"name": row.get("player_name"),
|
||||
"steam_id": row.get("steam_id"),
|
||||
},
|
||||
"matches_considered": int(row.get("matches_count") or 0),
|
||||
"totals": {
|
||||
"kills": total_kills,
|
||||
"deaths": total_deaths,
|
||||
"support": total_support,
|
||||
"teamkills": total_teamkills,
|
||||
"time_seconds": total_time_seconds,
|
||||
"time_minutes": round(total_time_seconds / 60.0, 2),
|
||||
},
|
||||
"derived": {
|
||||
"kpm": kpm,
|
||||
"kda": kda,
|
||||
},
|
||||
"advanced": {
|
||||
"most_killed_count": most_killed_count,
|
||||
"death_by_count": death_by_count,
|
||||
"rivalry_edge_raw": max(0, most_killed_count - death_by_count),
|
||||
"duel_control_raw": duel_control_raw,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _log_normalized_score(value: float | int, max_value: float | int) -> float:
|
||||
if value <= 0 or max_value <= 0:
|
||||
return 0.0
|
||||
return round((100 * math.log1p(value)) / math.log1p(max_value), 3)
|
||||
|
||||
|
||||
def _build_eligibility_metadata() -> dict[str, object]:
|
||||
return {
|
||||
"minimum_matches": MONTHLY_MVP_V2_MIN_MATCHES,
|
||||
"minimum_time_seconds": MONTHLY_MVP_V2_MIN_TIME_SECONDS,
|
||||
"minimum_time_hours": round(MONTHLY_MVP_V2_MIN_TIME_SECONDS / 3600, 1),
|
||||
"full_participation_seconds": MONTHLY_MVP_V2_FULL_PARTICIPATION_SECONDS,
|
||||
"full_participation_hours": round(
|
||||
MONTHLY_MVP_V2_FULL_PARTICIPATION_SECONDS / 3600,
|
||||
1,
|
||||
),
|
||||
"advanced_confidence_kills": MONTHLY_MVP_V2_ADVANCED_CONFIDENCE_KILLS,
|
||||
"teamkill_penalty_per_kill": MONTHLY_MVP_V2_TEAMKILL_PENALTY_PER_KILL,
|
||||
"teamkill_penalty_cap": MONTHLY_MVP_V2_TEAMKILL_PENALTY_CAP,
|
||||
}
|
||||
@@ -17,6 +17,12 @@ from .historical_snapshots import (
|
||||
DEFAULT_WEEKLY_SNAPSHOT_WINDOW,
|
||||
SNAPSHOT_TYPE_MONTHLY_LEADERBOARD,
|
||||
SNAPSHOT_TYPE_MONTHLY_MVP,
|
||||
SNAPSHOT_TYPE_MONTHLY_MVP_V2,
|
||||
SNAPSHOT_TYPE_PLAYER_EVENT_DEATH_BY,
|
||||
SNAPSHOT_TYPE_PLAYER_EVENT_DUELS,
|
||||
SNAPSHOT_TYPE_PLAYER_EVENT_MOST_KILLED,
|
||||
SNAPSHOT_TYPE_PLAYER_EVENT_TEAMKILLS,
|
||||
SNAPSHOT_TYPE_PLAYER_EVENT_WEAPON_KILLS,
|
||||
SNAPSHOT_TYPE_RECENT_MATCHES,
|
||||
SNAPSHOT_TYPE_SERVER_SUMMARY,
|
||||
SNAPSHOT_TYPE_WEEKLY_LEADERBOARD,
|
||||
@@ -357,6 +363,59 @@ def build_monthly_mvp_payload(
|
||||
}
|
||||
|
||||
|
||||
def build_player_event_payload(
|
||||
*,
|
||||
limit: int = 10,
|
||||
server_id: str | None = None,
|
||||
view: str = "most-killed",
|
||||
) -> dict[str, object]:
|
||||
"""Return one V2 player-event payload through the stable API surface."""
|
||||
snapshot_payload = build_player_event_snapshot_payload(
|
||||
limit=limit,
|
||||
server_id=server_id,
|
||||
view=view,
|
||||
)
|
||||
data = snapshot_payload["data"]
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
**data,
|
||||
"title": _build_player_event_title(
|
||||
view=view,
|
||||
is_all_servers=server_id == ALL_SERVERS_SLUG,
|
||||
snapshot=False,
|
||||
),
|
||||
"context": "historical-player-events",
|
||||
"source": "historical-precomputed-player-event-snapshots",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_monthly_mvp_v2_payload(
|
||||
*,
|
||||
limit: int = 10,
|
||||
server_id: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Return the precomputed monthly MVP V2 payload through the stable API surface."""
|
||||
snapshot_payload = build_monthly_mvp_v2_snapshot_payload(
|
||||
limit=limit,
|
||||
server_id=server_id,
|
||||
)
|
||||
data = snapshot_payload["data"]
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
**data,
|
||||
"title": _build_monthly_mvp_v2_title(
|
||||
is_all_servers=server_id == ALL_SERVERS_SLUG,
|
||||
snapshot=False,
|
||||
),
|
||||
"context": "historical-monthly-mvp-v2",
|
||||
"source": "historical-precomputed-snapshots",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_historical_server_summary_snapshot_payload(
|
||||
*,
|
||||
server_slug: str | None = None,
|
||||
@@ -573,6 +632,105 @@ def build_monthly_mvp_snapshot_payload(
|
||||
}
|
||||
|
||||
|
||||
def build_monthly_mvp_v2_snapshot_payload(
|
||||
*,
|
||||
limit: int = 10,
|
||||
server_id: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Return one precomputed monthly MVP V2 snapshot."""
|
||||
snapshot = _get_historical_snapshot_record(
|
||||
server_key=server_id,
|
||||
snapshot_type=SNAPSHOT_TYPE_MONTHLY_MVP_V2,
|
||||
window=DEFAULT_MONTHLY_SNAPSHOT_WINDOW,
|
||||
)
|
||||
payload = snapshot.get("payload") if snapshot else {}
|
||||
items = payload.get("items") if isinstance(payload, dict) else None
|
||||
sliced_items = list(items[:limit]) if isinstance(items, list) else []
|
||||
found = bool(payload.get("found")) if isinstance(payload, dict) else False
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"title": _build_monthly_mvp_v2_title(
|
||||
is_all_servers=server_id == ALL_SERVERS_SLUG,
|
||||
snapshot=True,
|
||||
),
|
||||
"context": "historical-monthly-mvp-v2-snapshot",
|
||||
"source": "historical-precomputed-snapshots",
|
||||
"server_slug": server_id,
|
||||
"timeframe": "monthly",
|
||||
"metric": "mvp-v2",
|
||||
"found": snapshot is not None and found,
|
||||
**_build_historical_snapshot_metadata(snapshot),
|
||||
"month_key": payload.get("month_key") if isinstance(payload, dict) else None,
|
||||
"window_days": payload.get("window_days") if isinstance(payload, dict) else None,
|
||||
"window_start": payload.get("window_start") if isinstance(payload, dict) else None,
|
||||
"window_end": payload.get("window_end") if isinstance(payload, dict) else None,
|
||||
"window_kind": payload.get("window_kind") if isinstance(payload, dict) else None,
|
||||
"window_label": payload.get("window_label") if isinstance(payload, dict) else None,
|
||||
"uses_fallback": bool(payload.get("uses_fallback")) if isinstance(payload, dict) else False,
|
||||
"selection_reason": payload.get("selection_reason") if isinstance(payload, dict) else None,
|
||||
"current_month_start": payload.get("current_month_start") if isinstance(payload, dict) else None,
|
||||
"current_month_closed_matches": (
|
||||
payload.get("current_month_closed_matches") if isinstance(payload, dict) else None
|
||||
),
|
||||
"previous_month_closed_matches": (
|
||||
payload.get("previous_month_closed_matches") if isinstance(payload, dict) else None
|
||||
),
|
||||
"sufficient_sample": payload.get("sufficient_sample") if isinstance(payload, dict) else None,
|
||||
"eligibility": payload.get("eligibility") if isinstance(payload, dict) else None,
|
||||
"ranking_version": payload.get("ranking_version") if isinstance(payload, dict) else None,
|
||||
"event_coverage": payload.get("event_coverage") if isinstance(payload, dict) else None,
|
||||
"eligible_players_count": (
|
||||
payload.get("eligible_players_count") if isinstance(payload, dict) else 0
|
||||
),
|
||||
"snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None,
|
||||
"limit": limit,
|
||||
"items": sliced_items,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_player_event_snapshot_payload(
|
||||
*,
|
||||
limit: int = 10,
|
||||
server_id: str | None = None,
|
||||
view: str = "most-killed",
|
||||
) -> dict[str, object]:
|
||||
"""Return one precomputed V2 player-event snapshot."""
|
||||
snapshot_type = _resolve_player_event_snapshot_type(view)
|
||||
snapshot = _get_historical_snapshot_record(
|
||||
server_key=server_id,
|
||||
snapshot_type=snapshot_type,
|
||||
window=DEFAULT_MONTHLY_SNAPSHOT_WINDOW,
|
||||
)
|
||||
payload = snapshot.get("payload") if snapshot else {}
|
||||
items = payload.get("items") if isinstance(payload, dict) else None
|
||||
sliced_items = list(items[:limit]) if isinstance(items, list) else []
|
||||
found = bool(payload.get("found")) if isinstance(payload, dict) else False
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"title": _build_player_event_title(
|
||||
view=view,
|
||||
is_all_servers=server_id == ALL_SERVERS_SLUG,
|
||||
snapshot=True,
|
||||
),
|
||||
"context": "historical-player-events-snapshot",
|
||||
"source": "historical-precomputed-player-event-snapshots",
|
||||
"server_slug": server_id,
|
||||
"timeframe": "monthly",
|
||||
"metric": view,
|
||||
"found": snapshot is not None and found,
|
||||
**_build_historical_snapshot_metadata(snapshot),
|
||||
"period": payload.get("period") if isinstance(payload, dict) else "monthly",
|
||||
"month_key": payload.get("month_key") if isinstance(payload, dict) else None,
|
||||
"snapshot_limit": payload.get("limit") if isinstance(payload, dict) else None,
|
||||
"limit": limit,
|
||||
"items": sliced_items,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_historical_server_summary_payload(
|
||||
*,
|
||||
server_slug: str | None = None,
|
||||
@@ -683,6 +841,42 @@ def _build_monthly_mvp_title(*, is_all_servers: bool, snapshot: bool = False) ->
|
||||
return f"{prefix}Top MVP mensual {scope_label}"
|
||||
|
||||
|
||||
def _build_monthly_mvp_v2_title(*, is_all_servers: bool, snapshot: bool = False) -> str:
|
||||
prefix = "Snapshot " if snapshot else ""
|
||||
scope_label = "global" if is_all_servers else "por servidor"
|
||||
return f"{prefix}Top MVP mensual V2 {scope_label}"
|
||||
|
||||
|
||||
def _build_player_event_title(
|
||||
*,
|
||||
view: str,
|
||||
is_all_servers: bool,
|
||||
snapshot: bool = False,
|
||||
) -> str:
|
||||
prefix = "Snapshot " if snapshot else ""
|
||||
scope_label = "global" if is_all_servers else "por servidor"
|
||||
title_by_view = {
|
||||
"most-killed": f"{prefix}Most killed mensual {scope_label}",
|
||||
"death-by": f"{prefix}Death by mensual {scope_label}",
|
||||
"duels": f"{prefix}Duelos netos mensuales {scope_label}",
|
||||
"weapon-kills": f"{prefix}Kills por arma mensuales {scope_label}",
|
||||
"teamkills": f"{prefix}Teamkills mensuales {scope_label}",
|
||||
}
|
||||
return title_by_view.get(view, f"{prefix}Metricas V2 mensuales {scope_label}")
|
||||
|
||||
|
||||
def _resolve_player_event_snapshot_type(view: str) -> str:
|
||||
normalized_view = view.strip().lower() if isinstance(view, str) else "most-killed"
|
||||
snapshot_type_by_view = {
|
||||
"most-killed": SNAPSHOT_TYPE_PLAYER_EVENT_MOST_KILLED,
|
||||
"death-by": SNAPSHOT_TYPE_PLAYER_EVENT_DEATH_BY,
|
||||
"duels": SNAPSHOT_TYPE_PLAYER_EVENT_DUELS,
|
||||
"weapon-kills": SNAPSHOT_TYPE_PLAYER_EVENT_WEAPON_KILLS,
|
||||
"teamkills": SNAPSHOT_TYPE_PLAYER_EVENT_TEAMKILLS,
|
||||
}
|
||||
return snapshot_type_by_view.get(normalized_view, SNAPSHOT_TYPE_PLAYER_EVENT_MOST_KILLED)
|
||||
|
||||
|
||||
def _enrich_server_items(items: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||
target_index = get_live_data_source().build_target_index()
|
||||
enriched_items: list[dict[str, object]] = []
|
||||
|
||||
@@ -12,9 +12,13 @@ from .payloads import (
|
||||
build_health_payload,
|
||||
build_historical_leaderboard_payload,
|
||||
build_monthly_mvp_payload,
|
||||
build_monthly_mvp_v2_payload,
|
||||
build_monthly_leaderboard_payload,
|
||||
build_monthly_leaderboard_snapshot_payload,
|
||||
build_monthly_mvp_snapshot_payload,
|
||||
build_monthly_mvp_v2_snapshot_payload,
|
||||
build_player_event_payload,
|
||||
build_player_event_snapshot_payload,
|
||||
build_historical_server_summary_snapshot_payload,
|
||||
build_historical_player_profile_payload,
|
||||
build_historical_server_summary_payload,
|
||||
@@ -119,6 +123,31 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
|
||||
server_id=server_id,
|
||||
)
|
||||
|
||||
if parsed.path == "/api/historical/monthly-mvp-v2":
|
||||
limit = _parse_limit(parsed.query)
|
||||
if limit is None:
|
||||
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
|
||||
server_id = parse_qs(parsed.query).get("server", [None])[0]
|
||||
return HTTPStatus.OK, build_monthly_mvp_v2_payload(
|
||||
limit=limit,
|
||||
server_id=server_id,
|
||||
)
|
||||
|
||||
if parsed.path == "/api/historical/player-events":
|
||||
limit = _parse_limit(parsed.query)
|
||||
if limit is None:
|
||||
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
|
||||
params = parse_qs(parsed.query)
|
||||
server_id = params.get("server", [None])[0]
|
||||
view = params.get("view", ["most-killed"])[0]
|
||||
if view not in {"most-killed", "death-by", "duels", "weapon-kills", "teamkills"}:
|
||||
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid view parameter")
|
||||
return HTTPStatus.OK, build_player_event_payload(
|
||||
limit=limit,
|
||||
server_id=server_id,
|
||||
view=view,
|
||||
)
|
||||
|
||||
if parsed.path == "/api/historical/snapshots/leaderboard":
|
||||
limit = _parse_limit(parsed.query)
|
||||
if limit is None:
|
||||
@@ -163,6 +192,31 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
|
||||
server_id=server_id,
|
||||
)
|
||||
|
||||
if parsed.path == "/api/historical/snapshots/monthly-mvp-v2":
|
||||
limit = _parse_limit(parsed.query)
|
||||
if limit is None:
|
||||
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
|
||||
server_id = parse_qs(parsed.query).get("server", [None])[0]
|
||||
return HTTPStatus.OK, build_monthly_mvp_v2_snapshot_payload(
|
||||
limit=limit,
|
||||
server_id=server_id,
|
||||
)
|
||||
|
||||
if parsed.path == "/api/historical/snapshots/player-events":
|
||||
limit = _parse_limit(parsed.query)
|
||||
if limit is None:
|
||||
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
|
||||
params = parse_qs(parsed.query)
|
||||
server_id = params.get("server", [None])[0]
|
||||
view = params.get("view", ["most-killed"])[0]
|
||||
if view not in {"most-killed", "death-by", "duels", "weapon-kills", "teamkills"}:
|
||||
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid view parameter")
|
||||
return HTTPStatus.OK, build_player_event_snapshot_payload(
|
||||
limit=limit,
|
||||
server_id=server_id,
|
||||
view=view,
|
||||
)
|
||||
|
||||
if parsed.path == "/api/historical/snapshots/weekly-leaderboard":
|
||||
limit = _parse_limit(parsed.query)
|
||||
if limit is None:
|
||||
|
||||
Reference in New Issue
Block a user