Add ranking metric expansion

This commit is contained in:
devRaGonSa
2026-06-09 07:32:29 +02:00
parent f74ac17f48
commit 12a215d11a
13 changed files with 869 additions and 94 deletions

View File

@@ -500,22 +500,39 @@ def _normalize_global_ranking_items(items: object) -> list[dict[str, object]]:
for item in items if isinstance(items, list) else []:
if not isinstance(item, dict):
continue
matches_considered = int(item.get("matches_considered") or 0)
kills = int(item.get("kills") or 0)
normalized_items.append(
{
"ranking_position": int(item.get("ranking_position") or 0),
"player_id": item.get("player_id"),
"player_name": item.get("player_name"),
"metric_value": int(item.get("metric_value") or 0),
"matches_considered": int(item.get("matches_considered") or 0),
"kills": int(item.get("kills") or 0),
"metric_value": _coerce_public_metric_value(item.get("metric_value")),
"matches_considered": matches_considered,
"kills": kills,
"deaths": int(item.get("deaths") or 0),
"teamkills": int(item.get("teamkills") or 0),
"kd_ratio": float(item.get("kd_ratio") or 0.0),
"kills_per_match": float(
item.get("kills_per_match")
if item.get("kills_per_match") is not None
else round(kills / matches_considered, 2) if matches_considered else 0.0
),
}
)
return normalized_items
def _coerce_public_metric_value(value: object) -> int | float:
try:
numeric = float(value or 0)
except (TypeError, ValueError):
return 0
if numeric.is_integer():
return int(numeric)
return round(numeric, 2)
def _source_when_present(*values: object, source: str) -> str | None:
return source if any(value is not None for value in values) else None
@@ -735,12 +752,25 @@ def build_annual_ranking_snapshot_payload(
"ranking_position": int(item.get("ranking_position") or 0),
"player_id": item.get("player_id"),
"player_name": item.get("player_name"),
"metric_value": int(item.get("metric_value") or 0),
"metric_value": _coerce_public_metric_value(item.get("metric_value")),
"matches_considered": int(item.get("matches_considered") or 0),
"kills": int(item.get("kills") or 0),
"deaths": int(item.get("deaths") or 0),
"teamkills": int(item.get("teamkills") or 0),
"kd_ratio": float(item.get("kd_ratio") or 0.0),
"kills_per_match": (
float(item.get("kills_per_match"))
if item.get("kills_per_match") is not None
else (
round(
int(item.get("kills") or 0)
/ int(item.get("matches_considered") or 0),
2,
)
if int(item.get("matches_considered") or 0) > 0
else 0.0
)
),
}
for item in items
if isinstance(item, dict)

View File

@@ -205,7 +205,9 @@ def _normalize_server_key(server_key: str | None) -> str:
def _normalize_metric(metric: str) -> str:
normalized = str(metric or "kills").strip().lower()
if normalized != "kills":
raise ValueError("Only metric 'kills' is supported for annual ranking endpoints.")
raise ValueError(
f"Metric '{normalized}' is not supported for annual ranking snapshots."
)
return normalized

View File

@@ -17,7 +17,16 @@ from .rcon_admin_log_materialization import (
from .sqlite_utils import connect_sqlite_readonly
LeaderboardTimeframe = Literal["weekly", "monthly"]
LeaderboardMetric = Literal["kills", "deaths", "matches_over_100_kills", "support"]
LeaderboardMetric = Literal[
"kills",
"deaths",
"teamkills",
"matches_considered",
"kd_ratio",
"kills_per_match",
"matches_over_100_kills",
"support",
]
def build_rcon_materialized_leaderboard_snapshot_payload(
@@ -197,12 +206,7 @@ def _fetch_leaderboard_rows(
window_end: datetime,
) -> list[dict[str, object]]:
scope_sql, scope_params = _build_scope_sql(server_key)
metric_sql = {
"kills": "SUM(COALESCE(stats.kills, 0))",
"deaths": "SUM(COALESCE(stats.deaths, 0))",
"matches_over_100_kills": "SUM(CASE WHEN COALESCE(stats.kills, 0) >= 100 THEN 1 ELSE 0 END)",
}[metric]
having_sql = f"HAVING {metric_sql} > 0"
metric_sql, having_sql, order_by_sql = _resolve_metric_sql(metric)
params: list[object] = [
_to_iso(window_start),
_to_iso(window_end),
@@ -230,7 +234,7 @@ def _fetch_leaderboard_rows(
AND TRIM(COALESCE(stats.player_name, '')) != ''
GROUP BY stats.player_id, stats.player_name
{having_sql}
ORDER BY metric_value DESC, matches_considered DESC, stats.player_name ASC
ORDER BY {order_by_sql}
LIMIT ?
""",
[MATCH_RESULT_SOURCE, *params],
@@ -431,6 +435,9 @@ def _count_matches(
def _build_item(row: dict[str, object], *, index: int) -> dict[str, object]:
kills = _coerce_int(row.get("kills"))
deaths = _coerce_int(row.get("deaths"))
matches_considered = _coerce_int(row.get("matches_considered"))
kd_ratio = round(kills / deaths, 2) if deaths else float(kills)
kills_per_match = round(kills / matches_considered, 2) if matches_considered else 0.0
return {
"ranking_position": index,
"player": {
@@ -439,12 +446,13 @@ def _build_item(row: dict[str, object], *, index: int) -> dict[str, object]:
},
"player_id": row.get("player_id"),
"player_name": row.get("player_name"),
"metric_value": _coerce_int(row.get("metric_value")),
"matches_considered": _coerce_int(row.get("matches_considered")),
"metric_value": _coerce_metric_value(row.get("metric_value")),
"matches_considered": matches_considered,
"kills": kills,
"deaths": deaths,
"teamkills": _coerce_int(row.get("teamkills")),
"kd_ratio": round(kills / deaths, 2) if deaths else float(kills),
"kd_ratio": kd_ratio,
"kills_per_match": kills_per_match,
}
@@ -554,7 +562,16 @@ def _normalize_timeframe(value: str) -> LeaderboardTimeframe:
def _normalize_metric(value: str) -> LeaderboardMetric:
normalized = str(value or "kills").strip().lower()
if normalized in {"kills", "deaths", "matches_over_100_kills", "support"}:
if normalized in {
"kills",
"deaths",
"teamkills",
"matches_considered",
"kd_ratio",
"kills_per_match",
"matches_over_100_kills",
"support",
}:
return normalized # type: ignore[return-value]
return "kills"
@@ -565,6 +582,10 @@ def _build_title(*, metric: str, timeframe: str, server_id: str | None) -> str:
metric_label = {
"kills": "Top kills",
"deaths": "Top muertes",
"teamkills": "Top teamkills",
"matches_considered": "Top partidas jugadas",
"kd_ratio": "Top K/D",
"kills_per_match": "Top kills por partida",
"matches_over_100_kills": "Partidas 100+ kills",
"support": "Top soporte",
}.get(metric, "Top kills")
@@ -578,6 +599,69 @@ def _coerce_int(value: object) -> int:
return 0
def _coerce_metric_value(value: object) -> int | float:
numeric = _coerce_float(value)
if numeric.is_integer():
return int(numeric)
return round(numeric, 2)
def _coerce_float(value: object) -> float:
try:
return float(value or 0)
except (TypeError, ValueError):
return 0.0
def _resolve_metric_sql(metric: str) -> tuple[str, str, str]:
metric_sql_by_metric = {
"kills": "SUM(COALESCE(stats.kills, 0))",
"deaths": "SUM(COALESCE(stats.deaths, 0))",
"teamkills": "SUM(COALESCE(stats.teamkills, 0))",
"matches_considered": "COUNT(DISTINCT stats.match_key)",
"kd_ratio": (
"CASE "
"WHEN SUM(COALESCE(stats.deaths, 0)) > 0 "
"THEN ROUND(CAST(SUM(COALESCE(stats.kills, 0)) AS REAL) / SUM(COALESCE(stats.deaths, 0)), 2) "
"ELSE CAST(SUM(COALESCE(stats.kills, 0)) AS REAL) "
"END"
),
"kills_per_match": (
"CASE "
"WHEN COUNT(DISTINCT stats.match_key) > 0 "
"THEN ROUND(CAST(SUM(COALESCE(stats.kills, 0)) AS REAL) / COUNT(DISTINCT stats.match_key), 2) "
"ELSE 0.0 "
"END"
),
"matches_over_100_kills": "SUM(CASE WHEN COALESCE(stats.kills, 0) >= 100 THEN 1 ELSE 0 END)",
}
having_sql_by_metric = {
"kills": "HAVING SUM(COALESCE(stats.kills, 0)) > 0",
"deaths": "HAVING SUM(COALESCE(stats.deaths, 0)) > 0",
"teamkills": "HAVING SUM(COALESCE(stats.teamkills, 0)) > 0",
"matches_considered": "HAVING COUNT(DISTINCT stats.match_key) > 0",
"kd_ratio": "HAVING SUM(COALESCE(stats.kills, 0)) > 0",
"kills_per_match": "HAVING COUNT(DISTINCT stats.match_key) > 0 AND SUM(COALESCE(stats.kills, 0)) > 0",
"matches_over_100_kills": "HAVING SUM(CASE WHEN COALESCE(stats.kills, 0) >= 100 THEN 1 ELSE 0 END) > 0",
}
order_by_sql_by_metric = {
"kills": "metric_value DESC, matches_considered DESC, stats.player_name ASC",
"deaths": "metric_value DESC, matches_considered DESC, stats.player_name ASC",
"teamkills": "metric_value DESC, matches_considered DESC, stats.player_name ASC",
"matches_considered": "metric_value DESC, kills DESC, stats.player_name ASC",
"kd_ratio": "metric_value DESC, kills DESC, matches_considered DESC, stats.player_name ASC",
"kills_per_match": "metric_value DESC, kills DESC, matches_considered DESC, stats.player_name ASC",
"matches_over_100_kills": "metric_value DESC, matches_considered DESC, stats.player_name ASC",
}
if metric == "support":
raise ValueError("support is handled separately")
return (
metric_sql_by_metric[metric],
having_sql_by_metric[metric],
order_by_sql_by_metric[metric],
)
def _parse_datetime(value: object) -> datetime | None:
if isinstance(value, datetime):
parsed = value

View File

@@ -49,6 +49,15 @@ from .payloads import (
from .rcon_historical_leaderboards import build_rcon_materialized_leaderboard_snapshot_payload
from .scoreboard_origins import get_trusted_public_scoreboard_origin
RANKING_METRICS = {
"kills",
"deaths",
"teamkills",
"matches_considered",
"kd_ratio",
"kills_per_match",
}
GET_ROUTES = {
"/health": build_health_payload,
@@ -118,7 +127,7 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
if timeframe not in {"weekly", "monthly", "annual"}:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid timeframe parameter")
metric = params.get("metric", ["kills"])[0]
if metric != "kills":
if metric not in RANKING_METRICS:
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid metric parameter")
limit = _parse_limit(parsed.query)
if limit is None: