Add ranking metric expansion
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user