Rookie system WIP: added seasons_played sync, career stat storage, and leaderboard fixes

This commit is contained in:
FireHorse 2026-04-23 19:24:48 +10:00
parent 2ff013da3b
commit acfc7d48a0
8 changed files with 3953 additions and 1114 deletions

File diff suppressed because it is too large Load diff

View file

@ -3,52 +3,91 @@ import os
PIR_PATH = os.path.join(os.path.dirname(__file__), "player_identity_registry.json") PIR_PATH = os.path.join(os.path.dirname(__file__), "player_identity_registry.json")
class PlayerIdentityRegistry: class PlayerIdentityRegistry:
def __init__(self): def __init__(self):
if os.path.exists(PIR_PATH): if os.path.exists(PIR_PATH):
with open(PIR_PATH, "r", encoding="utf-8") as f: with open(PIR_PATH, "r", encoding="utf-8") as f:
self.data = json.load(f) self.data = json.load(f)
else: else:
self.data = {"uuid_to_canonical": {}, "canonical": {}} self.data = {
"uuid_to_canonical": {},
"canonical": {}
}
def save(self): def save(self):
with open(PIR_PATH, "w", encoding="utf-8") as f: with open(PIR_PATH, "w", encoding="utf-8") as f:
json.dump(self.data, f, indent=2) json.dump(self.data, f, indent=2)
def resolve(self, uuid): # ---------------------------------------------------------
"""Return canonical ID for a UUID, or create one.""" # Resolve UUID → canonical ID (and create if needed)
# ---------------------------------------------------------
def resolve(self, uuid, season=None):
uuid = str(uuid) uuid = str(uuid)
# Known UUID
if uuid in self.data["uuid_to_canonical"]: if uuid in self.data["uuid_to_canonical"]:
return self.data["uuid_to_canonical"][uuid] cid = self.data["uuid_to_canonical"][uuid]
entry = self.data["canonical"][cid]
if season is not None:
entry["last_season"] = season
return cid
# Create new canonical ID # New UUID → new canonical ID
canonical = f"player_{len(self.data['canonical'])+1}" cid = f"player_{len(self.data['canonical']) + 1}"
self.data["uuid_to_canonical"][uuid] = canonical self.data["uuid_to_canonical"][uuid] = cid
self.data["canonical"][canonical] = {"uuids": [uuid], "names": []} self.data["canonical"][cid] = {
"uuids": [uuid],
"names": [],
"team_history": [],
"first_season": season,
"last_season": season,
}
self.save() self.save()
return canonical return cid
def add_name(self, canonical, name): # ---------------------------------------------------------
entry = self.data["canonical"].setdefault(canonical, {"uuids": [], "names": []}) # Track name changes
if name not in entry["names"]: # ---------------------------------------------------------
def update_name(self, canonical_id, name):
entry = self.data["canonical"].get(canonical_id)
if not entry:
return
if name and name not in entry["names"]:
entry["names"].append(name) entry["names"].append(name)
self.save() self.save()
def merge(self, uuid_a, uuid_b): # ---------------------------------------------------------
"""Merge two UUIDs into one canonical identity.""" # Track team history
ca = self.resolve(uuid_a) # ---------------------------------------------------------
cb = self.resolve(uuid_b) def update_team(self, canonical_id, team):
if ca == cb: entry = self.data["canonical"].get(canonical_id)
return ca if not entry:
return
# Merge cb into ca if team and (not entry["team_history"] or entry["team_history"][-1] != team):
self.data["canonical"][ca]["uuids"].extend(self.data["canonical"][cb]["uuids"]) entry["team_history"].append(team)
self.data["canonical"][ca]["names"].extend(self.data["canonical"][cb]["names"]) self.save()
# Update uuid_to_canonical # ---------------------------------------------------------
for u in self.data["canonical"][cb]["uuids"]: # Backwards compatibility: old method name
self.data["uuid_to_canonical"][u] = ca # ---------------------------------------------------------
def add_name(self, canonical_id, name):
"""Legacy alias for update_name()."""
self.update_name(canonical_id, name)
del self.data["canonical"][cb] def add_team(self, canonical_id, team):
self.save() """Legacy alias for update_team()."""
return ca self.update_team(canonical_id, team)
# ---------------------------------------------------------
# Rookie detection
# ---------------------------------------------------------
def get_first_season(self, canonical_id):
entry = self.data["canonical"].get(canonical_id)
if not entry:
return None
return entry.get("first_season")

View file

@ -1,24 +1,98 @@
def compute_rookie_leaderboard(career_db, tag="slayer", limit=10): import os
rookies = [] import json
for cid, pdata in career_db["players"].items(): PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
maps = pdata["career"].get("maps", 0) CAREER_DB_PATH = os.path.join(PROJECT_ROOT, "career_stats.json")
if maps >= 10:
continue # not a rookie
tag_data = pdata.get(tag, {})
rookies.append({ # ---------------------------------------------------------
"canonical_id": cid, # Load DB
"name": pdata["name"], # ---------------------------------------------------------
"team": pdata.get("team", ""),
"maps": maps, def load_career_db():
"kd": pdata["career"]["KD"], if not os.path.exists(CAREER_DB_PATH):
"kills": pdata["career"]["kills"], return None
"score": pdata["career"]["damage"], # or actual score if available with open(CAREER_DB_PATH, "r", encoding="utf-8") as f:
"tag_raw": tag_data.get("raw", 0.0), return json.load(f)
"tag_pct": tag_data.get("pct", 0.0),
"tag_tier": tag_data.get("tier", "D"),
# ---------------------------------------------------------
# Identify rookies
# ---------------------------------------------------------
def get_rookies(db, season):
rookies = {}
for pid, p in db.get("players", {}).items():
seasons_played = p.get("seasons_played") or ""
season_list = [int(s) for s in seasons_played.split(",") if s.isdigit()]
# True rookie = only played this season
if len(season_list) == 1 and season_list[0] == season:
rookies[pid] = p
return rookies
# ---------------------------------------------------------
# Rank rookies by a selected tag
# ---------------------------------------------------------
def rank_rookies_by_tag(season, tag_name, limit=10):
"""
Rank rookies using REAL career stats.
"""
db = load_career_db()
if db is None:
return []
rookies = get_rookies(db, season)
rows = []
for pid, p in rookies.items():
# Pull stats from career DB (correct source)
kills = p.get("kills", 0)
deaths = p.get("deaths", 0)
score = p.get("score", 0)
# KD
if deaths > 0:
kd = kills / deaths
else:
kd = kills
# Raw score = KD (for now)
raw = kd
# Percentile placeholder (we can compute real percentiles later)
pct = raw
# Team
team = "Unknown"
if p.get("team_history"):
team = p["team_history"][-1]
rows.append({
"id": pid,
"name": p.get("name", "Unknown"),
"team": team,
"pct": pct,
"tier": "C", # placeholder until rookie tiers are added
"raw": raw,
"kd": kd,
"kills": kills,
"deaths": deaths,
"score": score,
"summary": "",
}) })
rookies.sort(key=lambda r: r["tag_pct"], reverse=True) # Sort by raw score
return rookies[:limit] rows.sort(key=lambda x: x["raw"], reverse=True)
# Assign ranks
for i, r in enumerate(rows, start=1):
r["rank"] = i
return rows[:limit]

View file

@ -39,9 +39,15 @@ def summarize_team_tags(team_players):
pcts.append(safe_pct(tag_data.get("pct"))) pcts.append(safe_pct(tag_data.get("pct")))
tiers.append(safe_tier(tag_data.get("tier"))) tiers.append(safe_tier(tag_data.get("tier")))
# -----------------------------
# FIX: safe top-tier calculation
# -----------------------------
valid_tiers = [t for t in tiers if t in "SABCD"]
top_tier = max(valid_tiers, key=lambda t: "SABCD".index(t)) if valid_tiers else "-"
summary[tag] = { summary[tag] = {
"avg_pct": safe_avg(pcts), "avg_pct": safe_avg(pcts),
"top_tier": max(tiers, key=lambda t: "SABCD".index(t)), "top_tier": top_tier,
"count_S": tiers.count("S"), "count_S": tiers.count("S"),
"count_A": tiers.count("A"), "count_A": tiers.count("A"),
"count_B": tiers.count("B"), "count_B": tiers.count("B"),

View file

@ -26,6 +26,7 @@ from rankings import (
from analysis.team_identity_v2 import generate_team_identity_block from analysis.team_identity_v2 import generate_team_identity_block
from analysis.storylines import generate_storyline, matchup_storyline from analysis.storylines import generate_storyline, matchup_storyline
from analysis.predictions_v2 import generate_prediction from analysis.predictions_v2 import generate_prediction
from analysis.rookie_leaderboard import rank_rookies_by_tag
# OBS export # OBS export
@ -50,6 +51,8 @@ from match_engine import (
rank_match_players_clutch, rank_match_players_clutch,
compute_slayer_prediction, compute_slayer_prediction,
rank_match_players_consistency_career, rank_match_players_consistency_career,
load_career_db,
attach_career_tags_to_match_player,
) )
from data.tag_framework import percentile_rank, tier_from_percentile from data.tag_framework import percentile_rank, tier_from_percentile
@ -235,12 +238,15 @@ class DashLeagueGUI:
self.rankings_button = ttk.Button(top_frame, text="Rankings", command=self.on_rankings) self.rankings_button = ttk.Button(top_frame, text="Rankings", command=self.on_rankings)
self.rankings_button.grid(row=0, column=5, padx=(0, 10)) self.rankings_button.grid(row=0, column=5, padx=(0, 10))
self.rookie_button = ttk.Button(top_frame, text="Rookie Leaderboard", command=self.on_rookie_leaderboard)
self.rookie_button.grid(row=0, column=6, padx=(0, 10))
self.output_folder_button = ttk.Button( self.output_folder_button = ttk.Button(
top_frame, top_frame,
text="Select Output Folder", text="Select Output Folder",
command=self.choose_output_folder command=self.choose_output_folder
) )
self.output_folder_button.grid(row=0, column=6, padx=(0, 10)) self.output_folder_button.grid(row=0, column=7, padx=(0, 10))
self.map_var = tk.StringVar() self.map_var = tk.StringVar()
self.map_var.set("Payload") # default self.map_var.set("Payload") # default
@ -315,51 +321,144 @@ class DashLeagueGUI:
with open(career_path, "r", encoding="utf-8") as f: with open(career_path, "r", encoding="utf-8") as f:
career_db = json.load(f) career_db = json.load(f)
players_db = career_db.setdefault("players", {})
# --------------------------------------------------------- # ---------------------------------------------------------
# Rebuild stats_by_id keyed by canonical ID # BUILD stats_by_id (canonical → enriched player object)
# --------------------------------------------------------- # ---------------------------------------------------------
new_stats_by_id = {} new_stats_by_id = {}
for p in stats: for p in stats:
raw_id = p["id"] raw_id = p.get("id")
canonical = pir.resolve(raw_id) if not raw_id:
continue
# Start with API stats # Resolve canonical + stamp seasons
merged = dict(p) canonical = pir.resolve(raw_id, season=self.current_season)
if not canonical:
continue
# 🔥 Normalize team field BEFORE anything else # Ensure first_season is set if missing
team = merged.get("team") entry = pir.data["canonical"].get(canonical)
if team is None: #if entry is not None and entry.get("first_season") is None:
merged["team"] = "" # entry["first_season"] = self.current_season
# Normalize DOM fields from API (for DomObj)
p["DOM_Captures"] = p.get("DOM_captures", p.get("DOM_Captures", 0))
p["DOM_Counters"] = p.get("DOM_counters", p.get("DOM_Counters", 0))
merged = {
"canonical": canonical,
"name": p.get("name"),
"team": (p.get("team") or "").strip(),
"id": raw_id,
"kills": p.get("kills", 0),
"deaths": p.get("deaths", 0),
"damage": p.get("damage", 0),
"shots": p.get("shots", 0),
"shots_hit": p.get("shots_hit", 0),
"match_stats": p,
"seasons_played": p.get("seasons_played") or p.get("seasons") or "",
}
# Ensure player exists in career DB (so rookies are present)
if canonical not in players_db:
players_db[canonical] = {
"name": merged["name"],
"team_history": [merged["team"]] if merged["team"] else [],
"seasons_played": merged.get("seasons_played"),
"slayer": {},
"sharpshooter": {},
"objective_payload": {},
"objective_domination": {},
"consistency": {},
"clutch": {},
"anchor": {},
"breaker": {},
}
pdata = players_db.get(canonical, {})
# Store basic stats into career DB so rookies have real data
pdata["kills"] = merged.get("kills", 0)
pdata["deaths"] = merged.get("deaths", 0)
pdata["damage"] = merged.get("damage", 0)
pdata["shots"] = merged.get("shots", 0)
pdata["shots_hit"] = merged.get("shots_hit", 0)
# Score is inside match_stats
ms = merged.get("match_stats", {})
pdata["score"] = ms.get("score", 0)
# KD
if pdata["deaths"] > 0:
pdata["kd"] = pdata["kills"] / pdata["deaths"]
else: else:
merged["team"] = str(team).strip() pdata["kd"] = pdata["kills"]
# Attach canonical ID # Keep seasons_played updated
merged["canonical_id"] = canonical #if merged.get("seasons_played"):
# pdata["seasons_played"] = merged["seasons_played"]
pdata["seasons_played"] = merged.get("seasons_played")
# Attach career tags # Attach career tags
pdata = career_db["players"].get(canonical) for tag in (
if pdata: "slayer",
merged["slayer"] = pdata.get("slayer", {}) "sharpshooter",
merged["sharpshooter"] = pdata.get("sharpshooter", {}) "objective_payload",
merged["objective_payload"] = pdata.get("objective_payload", {}) "objective_domination",
merged["objective_domination"] = pdata.get("objective_domination", {}) "consistency",
merged["consistency"] = pdata.get("consistency", {}) "clutch",
merged["clutch"] = pdata.get("clutch", {}) "anchor",
merged["anchor"] = pdata.get("anchor", {}) "breaker",
merged["breaker"] = pdata.get("breaker", {}) ):
merged[tag] = pdata.get(tag, {})
new_stats_by_id[canonical] = merged new_stats_by_id[canonical] = merged
# 🔥 Now safe to assign # Write merged stats into career DB
players_db[canonical]["kills"] = merged["kills"]
players_db[canonical]["deaths"] = merged["deaths"]
players_db[canonical]["damage"] = merged["damage"]
players_db[canonical]["shots"] = merged["shots"]
players_db[canonical]["shots_hit"] = merged["shots_hit"]
players_db[canonical]["match_stats"] = merged["match_stats"]
players_db[canonical]["seasons_played"] = merged["seasons_played"]
# Compute KD
d = merged["deaths"]
players_db[canonical]["kd"] = merged["kills"] / d if d > 0 else merged["kills"]
# Score (inside match_stats)
players_db[canonical]["score"] = merged["match_stats"].get("score", 0)
# ---------------------------------------------------------
# Save updated career DB (rookies now included)
# ---------------------------------------------------------
with open(career_path, "w", encoding="utf-8") as f:
json.dump(career_db, f, indent=2)
# ---------------------------------------------------------
# Save PIR (first_season now persisted)
# ---------------------------------------------------------
pir.save()
# ---------------------------------------------------------
# Replace stats_by_id with enriched version
# ---------------------------------------------------------
self.stats_by_id = new_stats_by_id self.stats_by_id = new_stats_by_id
# 🔥 Now safe to inspect teams # ---------------------------------------------------------
# Debug: show teams detected
# ---------------------------------------------------------
teams_in_api = sorted({p["team"] for p in new_stats_by_id.values()}) teams_in_api = sorted({p["team"] for p in new_stats_by_id.values()})
print("TEAMS IN API:", teams_in_api) print("TEAMS IN API:", teams_in_api)
self.set_status("Stats synced.") self.set_status("Stats synced.")
messagebox.showinfo("Success", f"Synced {len(stats)} player stats.") messagebox.showinfo("Success", f"Synced {len(stats)} player stats.")
except Exception as e: except Exception as e:
messagebox.showerror("Error", f"Failed to sync stats:\n{e}") messagebox.showerror("Error", f"Failed to sync stats:\n{e}")
self.set_status("Sync failed.") self.set_status("Sync failed.")
@ -449,13 +548,41 @@ class DashLeagueGUI:
selected_b = selected_b[:5] selected_b = selected_b[:5]
all_players = selected_a + selected_b all_players = selected_a + selected_b
print("=== DEBUG MATCH_STATS SAMPLE ===")
print(all_players[0].get("match_stats", {}))
# --------------------------------------------------------- # ---------------------------------------------------------
# 2. Normalize stat keys BEFORE attaching tags # 2. Normalize stat keys BEFORE attaching tags
# --------------------------------------------------------- # ---------------------------------------------------------
for p in all_players: for p in all_players:
p["CP_Captures"] = p.get("CP_captures", 0) ms = p.get("match_stats", {})
p["DOM_Captures"] = p.get("DOM_captures", 0)
p["DOM_Counters"] = p.get("DOM_counters", 0) # CP
p["CP_Captures"] = (
p.get("CP_Captures")
or p.get("CP_captures")
or ms.get("CP_Captures")
or ms.get("CP_captures")
or 0
)
# DOM
p["DOM_Captures"] = (
p.get("DOM_Captures")
or p.get("DOM_captures")
or ms.get("DOM_Captures")
or ms.get("DOM_captures")
or 0
)
p["DOM_Counters"] = (
p.get("DOM_Counters")
or p.get("DOM_counters")
or ms.get("DOM_Counters")
or ms.get("DOM_counters")
or 0
)
# --------------------------------------------------------- # ---------------------------------------------------------
# 3. Resolve canonical IDs AND normalize player ID # 3. Resolve canonical IDs AND normalize player ID
@ -471,14 +598,14 @@ class DashLeagueGUI:
or p.get("uuid") or p.get("uuid")
) )
if raw_uuid: if raw_uuid:
p["canonical_id"] = pir.resolve(raw_uuid) p["canonical"] = pir.resolve(raw_uuid, season=self.current_season)
# Normalize ID (AFTER canonical resolution) # Normalize ID (AFTER canonical resolution)
pid = ( pid = (
p.get("id") p.get("id")
or p.get("PlayerUUID") or p.get("PlayerUUID")
or p.get("uuid") or p.get("uuid")
or p.get("canonical_id") or p.get("canonical")
or p.get("name") or p.get("name")
) )
p["id"] = pid p["id"] = pid
@ -502,6 +629,13 @@ class DashLeagueGUI:
# --------------------------------------------------------- # ---------------------------------------------------------
# 6. Compute match-based Consistency (raw only) # 6. Compute match-based Consistency (raw only)
# --------------------------------------------------------- # ---------------------------------------------------------
print("\n=== DEBUG: Player objects BEFORE consistency ===")
for p in all_players:
print(p.get("name"), "| id:", p.get("id"), "| uuid:", p.get("uuid"), "| PlayerUUID:", p.get("PlayerUUID"), "| canonical:", p.get("canonical_id"))
consistency_rows = rank_match_players_consistency_career(all_players) consistency_rows = rank_match_players_consistency_career(all_players)
consistency_map = {row["id"]: row["score_raw"] for row in consistency_rows} consistency_map = {row["id"]: row["score_raw"] for row in consistency_rows}
@ -533,7 +667,7 @@ class DashLeagueGUI:
# --------------------------------------------------------- # ---------------------------------------------------------
if career_db is not None: if career_db is not None:
for p in all_players: for p in all_players:
cid = p.get("canonical_id") cid = p.get("canonical")
if not cid: if not cid:
continue continue
@ -546,10 +680,10 @@ class DashLeagueGUI:
p["objective_payload"] = pdata.get("objective_payload", {}) p["objective_payload"] = pdata.get("objective_payload", {})
p["clutch"] = pdata.get("clutch", {}) p["clutch"] = pdata.get("clutch", {})
p["anchor"] = pdata.get("anchor", {}) p["anchor"] = pdata.get("anchor", {})
p["breaker_career"] = pdata.get("breaker", {}) p["breaker"] = pdata.get("breaker", {})
# Do NOT overwrite match-based DomObj # Do NOT overwrite match-based DomObj
if "objective_domination" not in p: if "objective_domination" not in p or not p["objective_domination"]:
p["objective_domination"] = pdata.get("objective_domination", {}) p["objective_domination"] = pdata.get("objective_domination", {})
# --------------------------------------------------------- # ---------------------------------------------------------
@ -561,17 +695,20 @@ class DashLeagueGUI:
counters = dom.get("counters", 0) counters = dom.get("counters", 0)
dom_score = caps * 0.4 + counters * 0.6 dom_score = caps * 0.4 + counters * 0.6
p["objective_domination_components"] = { print("DOM DEBUG:", p["name"], "caps=", caps, "counters=", counters)
p["objective_domination"] = {
"raw": dom_score, "raw": dom_score,
"pct": dom_score, # <- match panel can now read this
"tier": "-", # <- neutral tier for single-match view
"captures": caps, "captures": caps,
"counters": counters, "counters": counters,
"summary": "", # optional, keeps shape consistent
} }
# --------------------------------------------------------- # 11. Ensure stats_by_id contains all enriched tags
# 11. Merge enriched tag data into raw match players
# ---------------------------------------------------------
for p in all_players: for p in all_players:
cid = p.get("canonical_id") cid = p.get("canonical")
if not cid: if not cid:
continue continue
@ -579,17 +716,10 @@ class DashLeagueGUI:
if not enriched: if not enriched:
continue continue
for key in ( # Copy match fields into stats_by_id
"slayer", enriched["team"] = p.get("team")
"sharpshooter", enriched["name"] = p.get("name")
"objective_payload", enriched["id"] = p.get("id")
"objective_domination",
"clutch",
"anchor",
"breaker",
):
if key in enriched:
p[key] = enriched[key]
# --------------------------------------------------------- # ---------------------------------------------------------
# 12. Update current_match_players # 12. Update current_match_players
@ -825,13 +955,24 @@ Consistency Summary:
return return
# --------------------------------------------------------- # ---------------------------------------------------------
# Extract players for each team (correct filtering) # 1. Attach career tags to ALL match players (CRITICAL)
# ---------------------------------------------------------
from match_engine import load_career_db, attach_career_tags_to_match_player
career_db = load_career_db()
# self.stats_by_id keys ARE canonical IDs
for cid, p in self.stats_by_id.items():
if cid in career_db["players"]:
attach_career_tags_to_match_player(p, career_db["players"][cid])
# ---------------------------------------------------------
# 2. Extract players for each team (using enriched objects)
# --------------------------------------------------------- # ---------------------------------------------------------
teamA_players = [] teamA_players = []
teamB_players = [] teamB_players = []
for p in self.current_match_players: for p in self.current_match_players:
cid = p.get("canonical_id") cid = p.get("canonical") # <-- correct key
if not cid: if not cid:
continue continue
@ -844,19 +985,15 @@ Consistency Summary:
elif enriched.get("team") == team_b: elif enriched.get("team") == team_b:
teamB_players.append(enriched) teamB_players.append(enriched)
#print("TEAM A SAMPLE:", teamA_players[0])
#print("TEAM B SAMPLE:", teamB_players[0])
if not teamA_players or not teamB_players: if not teamA_players or not teamB_players:
messagebox.showerror("Error", "No players selected for one or both teams.") messagebox.showerror("Error", "No players selected for one or both teams.")
return return
# ----------------------------------------- # ---------------------------------------------------------
# 1. Generate storyline + identity block # 3. Generate storyline + identity blocks
# ----------------------------------------- # ---------------------------------------------------------
storyline_text = matchup_storyline(teamA_players, teamB_players) storyline_text = matchup_storyline(teamA_players, teamB_players)
# Team identity block (per team, using modern dict + formatter)
identity_data_a = generate_team_identity_block(teamA_players, team_a) identity_data_a = generate_team_identity_block(teamA_players, team_a)
identity_text_a = format_team_identity(identity_data_a) identity_text_a = format_team_identity(identity_data_a)
@ -871,9 +1008,9 @@ Consistency Summary:
+ identity_text_b + identity_text_b
) )
# ----------------------------------------- # ---------------------------------------------------------
# 2. Popup window # 4. Popup window
# ----------------------------------------- # ---------------------------------------------------------
win = tk.Toplevel(self.root) win = tk.Toplevel(self.root)
win.title("Match Storyline") win.title("Match Storyline")
win.geometry("600x700") win.geometry("600x700")
@ -882,9 +1019,9 @@ Consistency Summary:
output.pack(expand=True, fill="both", padx=10, pady=10) output.pack(expand=True, fill="both", padx=10, pady=10)
output.insert(tk.END, full_story) output.insert(tk.END, full_story)
# ----------------------------------------- # ---------------------------------------------------------
# 3. Export to OBS # 5. Export to OBS
# ----------------------------------------- # ---------------------------------------------------------
def export(): def export():
content = output.get("1.0", tk.END).strip() content = output.get("1.0", tk.END).strip()
if not content: if not content:
@ -925,7 +1062,6 @@ Consistency Summary:
"Clutch", "Clutch",
"Anchor", "Anchor",
"Breaker", "Breaker",
"Rookie",
] ]
ttk.Label(win, text="Select Tag:").pack(anchor="w", padx=10, pady=5) ttk.Label(win, text="Select Tag:").pack(anchor="w", padx=10, pady=5)
@ -1167,6 +1303,38 @@ Consistency Summary:
ttk.Button(win, text="Show Rankings", command=show).pack(pady=5) ttk.Button(win, text="Show Rankings", command=show).pack(pady=5)
def on_rookie_leaderboard(self):
from analysis.rookie_leaderboard import rank_rookies_by_tag
season = self.current_season
tag = "slayer" # default tag for rookie leaderboard
rookies = rank_rookies_by_tag(season, tag)
# Build text output
lines = []
lines.append(f"ROOKIE LEADERBOARD — {tag.upper()} — Season {season}")
lines.append("")
lines.append(f"{'Rank':<5} {'Name':<18} {'Team':<8} {'Tier':<6} {'Pct':<6} {'Raw':<6}")
lines.append("-" * 60)
for r in rookies:
pct = f"{r['pct']:.0f}%" if r["pct"] is not None else "-"
raw = f"{r['raw']:.2f}"
lines.append(f"{r['rank']:<5} {r['name']:<18} {r['team']:<8} {r['tier']:<6} {pct:<6} {raw:<6}")
text = "\n".join(lines)
# Popup window
win = tk.Toplevel(self.root)
win.title("Rookie Leaderboard")
win.geometry("600x500")
output = tk.Text(win, wrap="word")
output.pack(expand=True, fill="both", padx=10, pady=10)
output.insert(tk.END, text)
def export_all_obs_files(self): def export_all_obs_files(self):
""" """
Unified export function that writes all OBS text files Unified export function that writes all OBS text files

View file

@ -52,15 +52,18 @@ def build_career_database(output_path):
raw_uuid = str(row["PlayerUUID"]) raw_uuid = str(row["PlayerUUID"])
name = row["PlayerGameName"] or "Unknown" name = row["PlayerGameName"] or "Unknown"
# Resolve canonical identity # Get this player's full match history
canonical_id = pir.resolve(raw_uuid)
pir.add_name(canonical_id, name)
# Use RAW UUID for DB queries
matches = db_access.get_player_match_history(raw_uuid) matches = db_access.get_player_match_history(raw_uuid)
if not matches: if not matches:
continue continue
# Infer "season" from earliest CycleID (or whatever you treat as season)
first_cycle = min(m.get("CycleID", 0) for m in matches) or 0
# Resolve canonical identity AND stamp first_season
canonical_id = pir.resolve(raw_uuid, season=first_cycle)
pir.add_name(canonical_id, name)
# Use CANONICAL ID as the key in the career DB # Use CANONICAL ID as the key in the career DB
pid = canonical_id pid = canonical_id
@ -215,131 +218,50 @@ def build_career_database(output_path):
# --------------------------------------------------------- # ---------------------------------------------------------
for pid, pdata in final_db["players"].items(): for pid, pdata in final_db["players"].items():
# -----------------------------------------------------
# Slayer # Slayer
# -----------------------------------------------------
pdata["slayer"] = compute_slayer_for_career_player( pdata["slayer"] = compute_slayer_for_career_player(
pdata, pdata,
league_averages, league_averages,
distributions["slayer"] distributions["slayer"]
) )
# -----------------------------------------------------
# Sharpshooter # Sharpshooter
# -----------------------------------------------------
pdata["sharpshooter"] = compute_sharpshooter_for_career_player( pdata["sharpshooter"] = compute_sharpshooter_for_career_player(
pdata["career"], pdata["career"],
league_averages, league_averages,
distributions["sharpshooter"] distributions["sharpshooter"]
) )
# -----------------------------------------------------
# Payload Objective Specialist # Payload Objective Specialist
# -----------------------------------------------------
pdata["objective_payload"] = compute_payload_tag( pdata["objective_payload"] = compute_payload_tag(
pdata, pdata,
league_averages, league_averages,
distributions["payload"] distributions["payload"]
) )
# -----------------------------------------------------
# Domination Objective Specialist # Domination Objective Specialist
# -----------------------------------------------------
pdata["objective_domination"] = compute_dom_objective_for_career_player( pdata["objective_domination"] = compute_dom_objective_for_career_player(
pdata, pdata,
league_averages, league_averages,
distributions["domination"] distributions["domination"]
) )
# Consistency (legacy adapter)
consistency_result = build_consistency_tag(
floor_current=pdata.get("floor_current"),
floor_career=pdata.get("floor_career"),
floor_league=league_averages.get("consistency_floor", 0.0),
stability_current=pdata.get("stability_current"),
stability_career=pdata.get("stability_career"),
stability_league=league_averages.get("consistency_stability", 0.0),
average_current=pdata.get("average_current"),
average_career=pdata.get("average_career"),
average_league=league_averages.get("consistency_average", 0.0),
matches_played_current=pdata["career"].get("maps", 0),
percentile=None
)
pdata["consistency"] = {
"raw": consistency_result.normalized_score,
"summary": consistency_result.summary_short
}
# ---------------------------------------------------------
# Consistency Percentile + Tier (NEW)
# ---------------------------------------------------------
raw_cons = pdata["consistency"]["raw"]
# Build distribution if missing
if "consistency" not in distributions:
distributions["consistency"] = [
p["consistency"]["raw"]
for p in all_players
if "consistency" in p and isinstance(p["consistency"], dict)
]
# Percentile lookup
pct = percentile_rank(raw_cons, distributions["consistency"])
tier = tier_from_percentile(pct)
pdata["consistency"]["pct"] = pct
pdata["consistency"]["tier"] = tier
pdata["consistency"]["summary"] = (
f"{tier} Tier Consistency ({pct:.1f} percentile)"
)
# Anchor
consistency_norm = pdata["consistency"]["raw"] # normalized 0–1 score
pdata["anchor"] = compute_anchor_for_career_player(
pdata,
league_averages,
distributions["anchor"],
consistency_norm
)
# Clutch
pdata["clutch"] = compute_clutch(
pdata,
league_averages,
clutch_distribution
)
# Breaker
career = pdata["career"]
damage_per_map = career["damage"] / max(1, career["maps"])
kills_per_map = career["kills"] / max(1, career["maps"])
raw_breaker = compute_breaker_raw(damage_per_map, kills_per_map)
# Percentile + tier
pct = percentile_rank(raw_breaker, breaker_distribution)
tier = tier_from_percentile(pct)
pdata["breaker"] = {
"raw": raw_breaker,
"pct": pct,
"tier": tier,
"summary": f"Offensive disruptor ({tier} Tier, {pct:.1f} percentile)"
}
# --------------------------------------------------------- # ---------------------------------------------------------
# Consistency (Hybrid Career Tag - New System) # Consistency (Hybrid Career Tag - New System)
# --------------------------------------------------------- # ---------------------------------------------------------
matches = pdata["matches"] matches = pdata["matches"]
maps_played = pdata["career"].get("maps", 0) maps_played = pdata["career"].get("maps", 0)
# ---------------------------------------------------------
# Extract per-match performance components
# ---------------------------------------------------------
# NOTE:
# If you later add per-match slayer/objective/accuracy scores,
# this block will automatically support them.
# For now, we compute a simple per-match performance score
# using the same formula as the legacy system.
# ---------------------------------------------------------
per_match_scores = [] per_match_scores = []
for m in matches: for m in matches:
# Slayer-like component
dmg = m.get("Damage", 0) dmg = m.get("Damage", 0)
kills = m.get("Kills", 0) kills = m.get("Kills", 0)
deaths = m.get("Deaths", 0) deaths = m.get("Deaths", 0)
@ -351,7 +273,6 @@ def build_career_database(output_path):
(kd / 5) * 0.20 (kd / 5) * 0.20
) )
# Objective-like component
push = m.get("PAY_PushTime", 0) push = m.get("PAY_PushTime", 0)
caps = m.get("DOM_Captures", 0) caps = m.get("DOM_Captures", 0)
counters = m.get("DOM_Counters", 0) counters = m.get("DOM_Counters", 0)
@ -362,25 +283,18 @@ def build_career_database(output_path):
(counters / 20) * 0.30 (counters / 20) * 0.30
) )
# Accuracy-like component
shots = m.get("Shots", 0) shots = m.get("Shots", 0)
shots_hit = m.get("ShotsHit", 0) shots_hit = m.get("ShotsHit", 0)
accuracy = (shots_hit / shots) if shots > 0 else 0.0 accuracy = (shots_hit / shots) if shots > 0 else 0.0
accuracy_component = accuracy * 0.20 accuracy_component = accuracy * 0.20
# Final per-match performance score
perf = slayer_component + objective_component + accuracy_component perf = slayer_component + objective_component + accuracy_component
per_match_scores.append(perf) per_match_scores.append(perf)
# ---------------------------------------------------------
# Compute floor, stability, average
# ---------------------------------------------------------
if per_match_scores: if per_match_scores:
floor_value = min(per_match_scores) floor_value = min(per_match_scores)
avg_value = sum(per_match_scores) / len(per_match_scores) avg_value = sum(per_match_scores) / len(per_match_scores)
# Variance adjusted for match count
mean = avg_value mean = avg_value
variance = sum((x - mean) ** 2 for x in per_match_scores) / len(per_match_scores) variance = sum((x - mean) ** 2 for x in per_match_scores) / len(per_match_scores)
adjusted_variance = variance * (1 + 1 / max(1, len(per_match_scores))) adjusted_variance = variance * (1 + 1 / max(1, len(per_match_scores)))
@ -390,12 +304,9 @@ def build_career_database(output_path):
avg_value = 0.0 avg_value = 0.0
stability_value = 0.0 stability_value = 0.0
# ---------------------------------------------------------
# Build hybrid consistency tag
# ---------------------------------------------------------
consistency_result = build_consistency_tag( consistency_result = build_consistency_tag(
floor_current=floor_value, floor_current=floor_value,
floor_career=floor_value, # career = all matches floor_career=floor_value,
floor_league=league_averages.get("consistency_floor", 0.0), floor_league=league_averages.get("consistency_floor", 0.0),
stability_current=stability_value, stability_current=stability_value,
@ -410,12 +321,9 @@ def build_career_database(output_path):
percentile=None percentile=None
) )
# ---------------------------------------------------------
# Store in DB (GUI + Rankings + Spotlight compatible)
# ---------------------------------------------------------
pdata["consistency"] = { pdata["consistency"] = {
"raw": consistency_result.normalized_score, "raw": consistency_result.normalized_score,
"pct": consistency_result.percentile, "pct": None,
"tier": consistency_result.tier, "tier": consistency_result.tier,
"summary": consistency_result.summary_short, "summary": consistency_result.summary_short,
"components": { "components": {
@ -426,13 +334,62 @@ def build_career_database(output_path):
"extras": consistency_result.extras, "extras": consistency_result.extras,
} }
# Clutch (FINAL TAG) # ---------------------------------------------------------
# Career Consistency Percentile + Tier (NEW)
# ---------------------------------------------------------
raw_cons = pdata["consistency"]["raw"]
cons_dist = distributions.get("consistency", [])
pct = percentile_rank(raw_cons, cons_dist)
tier = tier_from_percentile(pct)
pdata["consistency"]["pct"] = pct
pdata["consistency"]["tier"] = tier
pdata["consistency"]["summary"] = (
f"{tier} Tier Consistency ({pct:.1f} percentile)"
)
# ---------------------------------------------------------
# Anchor (requires consistency)
# ---------------------------------------------------------
consistency_norm = pdata["consistency"]["raw"]
pdata["anchor"] = compute_anchor_for_career_player(
pdata,
league_averages,
distributions["anchor"],
consistency_norm
)
# ---------------------------------------------------------
# Clutch
# ---------------------------------------------------------
pdata["clutch"] = compute_clutch( pdata["clutch"] = compute_clutch(
pdata, pdata,
league_averages, league_averages,
clutch_distribution clutch_distribution
) )
# ---------------------------------------------------------
# Breaker
# ---------------------------------------------------------
career = pdata["career"]
damage_per_map = career["damage"] / max(1, career["maps"])
kills_per_map = career["kills"] / max(1, career["maps"])
raw_breaker = compute_breaker_raw(damage_per_map, kills_per_map)
pct = percentile_rank(raw_breaker, breaker_distribution)
tier = tier_from_percentile(pct)
pdata["breaker"] = {
"raw": raw_breaker,
"pct": pct,
"tier": tier,
"summary": f"Offensive disruptor ({tier} Tier, {pct:.1f} percentile)"
}
# Save PIR with first_season stamped for all canonical IDs
pir.save()
# --------------------------------------------------------- # ---------------------------------------------------------
# 6. Save DB # 6. Save DB
# --------------------------------------------------------- # ---------------------------------------------------------

View file

@ -210,6 +210,7 @@ def rank_match_players_consistency_career(match_players):
for i, p in enumerate(ranked, 1): for i, p in enumerate(ranked, 1):
out.append({ out.append({
"rank": i, "rank": i,
"id": p["id"],
"name": p["name"], "name": p["name"],
"team": p.get("team", "Unknown"), "team": p.get("team", "Unknown"),
"score_raw": p["raw"], "score_raw": p["raw"],

View file

@ -58,7 +58,7 @@ def top_players_by_tag(tag_name, limit=20):
# --------------------------------------------------------- # ---------------------------------------------------------
# Match-Based Consistency Ranking (Still Used in UI) # Match-Based Consistency Ranking (Modernized)
# --------------------------------------------------------- # ---------------------------------------------------------
def rank_match_players_consistency(players): def rank_match_players_consistency(players):
@ -66,13 +66,17 @@ def rank_match_players_consistency(players):
for p in players: for p in players:
tag = p.get("consistency", {}) tag = p.get("consistency", {})
score = tag.get("raw", 0.0) raw = tag.get("raw", 0.0)
pct = tag.get("pct", None)
tier = tag.get("tier", None)
ranked.append({ ranked.append({
"name": p.get("name", "Unknown"), "name": p.get("name", "Unknown"),
"team": p.get("team", ""), "team": p.get("team", ""),
"score_raw": score, "score_raw": raw,
"score_display": f"{score:.2f}", "score_display": f"{raw:.2f}",
"pct": pct,
"tier": tier,
}) })
ranked.sort(key=lambda x: x["score_raw"], reverse=True) ranked.sort(key=lambda x: x["score_raw"], reverse=True)
@ -84,7 +88,7 @@ def rank_match_players_consistency(players):
# --------------------------------------------------------- # ---------------------------------------------------------
# Match-Based Clutch Ranking (Still Used in UI) # Match-Based Clutch Ranking (Modernized)
# --------------------------------------------------------- # ---------------------------------------------------------
def rank_match_players_clutch(players): def rank_match_players_clutch(players):
@ -92,13 +96,17 @@ def rank_match_players_clutch(players):
for p in players: for p in players:
tag = p.get("clutch", {}) tag = p.get("clutch", {})
score = tag.get("raw", 0.0) raw = tag.get("raw", 0.0)
pct = tag.get("pct", None)
tier = tag.get("tier", None)
ranked.append({ ranked.append({
"name": p.get("name", "Unknown"), "name": p.get("name", "Unknown"),
"team": p.get("team", ""), "team": p.get("team", ""),
"score_raw": score, "score_raw": raw,
"score_display": f"{score:.2f}", "score_display": f"{raw:.2f}",
"pct": pct,
"tier": tier,
}) })
ranked.sort(key=lambda x: x["score_raw"], reverse=True) ranked.sort(key=lambda x: x["score_raw"], reverse=True)
@ -109,7 +117,6 @@ def rank_match_players_clutch(players):
return ranked return ranked
# --------------------------------------------------------- # ---------------------------------------------------------
# Generic Match Tag Ranking (Modern Tag System) # Generic Match Tag Ranking (Modern Tag System)
# --------------------------------------------------------- # ---------------------------------------------------------
@ -118,14 +125,18 @@ def rank_match_players_by_tag(players, tag):
rows = [] rows = []
for p in players: for p in players:
tag_data = p.get(tag) or {} # <‑‑‑ FIX HERE tag_data = p.get(tag) or {}
raw = tag_data.get("raw", 0) raw = tag_data.get("raw", 0.0)
pct = tag_data.get("pct", None)
tier = tag_data.get("tier", None)
rows.append({ rows.append({
"name": p.get("name", "Unknown"), "name": p.get("name", "Unknown"),
"team": p.get("team", ""), "team": p.get("team", ""),
"score_raw": raw, "score_raw": raw,
"score_display": f"{raw:.2f}", "score_display": f"{raw:.2f}",
"pct": pct,
"tier": tier,
}) })
rows.sort(key=lambda x: x["score_raw"], reverse=True) rows.sort(key=lambda x: x["score_raw"], reverse=True)
@ -136,11 +147,11 @@ def rank_match_players_by_tag(players, tag):
return rows return rows
# ---------------------------------------------------------
# Split into two columns for UI
# ---------------------------------------------------------
def split_two_columns(ranked, team_a, team_b): def split_two_columns(ranked, team_a, team_b):
"""
ranked: list of rows returned by rank_match_players_* functions.
Returns two lists: left (team_a) and right (team_b).
"""
left = [r for r in ranked if r.get("team") == team_a] left = [r for r in ranked if r.get("team") == team_a]
right = [r for r in ranked if r.get("team") == team_b] right = [r for r in ranked if r.get("team") == team_b]
return left, right return left, right