Files
comunidadhll/backend/app/monthly_mvp_v2.py
2026-06-02 16:29:53 +02:00

202 lines
7.5 KiB
Python

"""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,
}