DL-Broadcast-Tool/analysis/rookie_leaderboard.py

128 lines
4.5 KiB
Python

import os
import json
# Paths
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CAREER_DB_PATH = os.path.join(PROJECT_ROOT, "career_stats.json")
CONFIG_PATH = os.path.join(PROJECT_ROOT, "config.json")
ROOKIE_KILL_THRESHOLD = 1000
def load_career_db():
if not os.path.exists(CAREER_DB_PATH):
return None
with open(CAREER_DB_PATH, "r", encoding="utf-8") as f:
return json.load(f)
def load_current_teams():
"""
The set of teams currently competing, per config.json — the same
source of truth used for tiers. A team can be dissolved (e.g. EMU)
without the app ever being told individual players left; this list
is what actually reflects reality right now.
"""
if not os.path.exists(CONFIG_PATH):
return None
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
config = json.load(f)
teams = config.get("teams")
return set(teams) if teams else None
def get_rookies(db):
"""
Rookie definition: career kills < 1000, full stop — regardless of how
many seasons the player has been around, how many maps they've
played, or whether they've played this specific season. A veteran
who simply hasn't racked up kills yet is still a rookie by this
definition; maps/KD are shown alongside for context only, they don't
gate eligibility.
Falls back to the TOP-LEVEL "kills" field (populated by on_sync_stats
for anyone with live current-season activity) when "career" is
missing entirely — e.g. a player who isn't in the local
match_history.db cache that build_career_database depends on, which
only covers past seasons. Without this fallback, an established
veteran missing from that cache would incorrectly read as 0 kills
and show up here as a "rookie".
"""
rookies = {}
for pid, p in db.get("players", {}).items():
career = p.get("career")
if career:
kills = career.get("kills", 0) or 0
else:
kills = p.get("kills", 0) or 0
if kills < ROOKIE_KILL_THRESHOLD:
rookies[pid] = p
return rookies
def rank_rookies_by_tag(season, tag_name, limit=15):
db = load_career_db()
if db is None:
return []
rookies = get_rookies(db)
current_teams = load_current_teams()
rows = []
for pid, p in rookies.items():
career = p.get("career") or {}
# Pull core stats — fall back to top-level fields (populated by
# on_sync_stats) when "career" is missing, same reasoning as
# get_rookies() above.
kills_val = career.get("kills", p.get("kills", 0))
kd_val = career.get("KD", p.get("kd", 0))
score_val = career.get("score", p.get("score", 0))
maps_val = career.get("maps", 0)
# Team handling: use history, then registry, then Free Agent
history = p.get("team_history", [])
team_name = "Free Agent"
# Search backward through history for a non-UUID name
for t in reversed(history):
if t and not (len(str(t)) > 20 and "-" in str(t)):
team_name = str(t)
break
# If still Free Agent, check if the player object has a 'team' key
# (often populated during Sync)
if team_name == "Free Agent" and p.get("team"):
team_name = p.get("team")
# Free agents are excluded entirely — a player with no current
# team could simply have left the league a while back, and would
# otherwise sit stuck in this table forever with no way to ever
# graduate out of it.
if team_name == "Free Agent":
continue
# Also exclude anyone whose last known team has since been
# dissolved (e.g. EMU) — team_history never gets updated when a
# team folds, only when a player joins somewhere new, so without
# this a departed/dissolved-team player stays stuck in the
# rookie table forever just like a free agent would.
if current_teams is not None and team_name not in current_teams:
continue
rows.append({
"id": pid,
"name": p.get("name", "Unknown"),
"team": team_name,
"tier": p.get(tag_name, {}).get("tier", "D"),
"kd": kd_val,
"kills": kills_val,
"maps": maps_val,
"score": score_val
})
# Sort by Score instead of Rating
rows.sort(key=lambda x: x["score"], reverse=True)
for i, r in enumerate(rows, start=1):
r["rank"] = i
return rows[:limit]