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")
class PlayerIdentityRegistry:
def __init__(self):
if os.path.exists(PIR_PATH):
with open(PIR_PATH, "r", encoding="utf-8") as f:
self.data = json.load(f)
else:
self.data = {"uuid_to_canonical": {}, "canonical": {}}
self.data = {
"uuid_to_canonical": {},
"canonical": {}
}
def save(self):
with open(PIR_PATH, "w", encoding="utf-8") as f:
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)
# Known UUID
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
canonical = f"player_{len(self.data['canonical'])+1}"
self.data["uuid_to_canonical"][uuid] = canonical
self.data["canonical"][canonical] = {"uuids": [uuid], "names": []}
# New UUID → new canonical ID
cid = f"player_{len(self.data['canonical']) + 1}"
self.data["uuid_to_canonical"][uuid] = cid
self.data["canonical"][cid] = {
"uuids": [uuid],
"names": [],
"team_history": [],
"first_season": season,
"last_season": season,
}
self.save()
return canonical
return cid
def add_name(self, canonical, name):
entry = self.data["canonical"].setdefault(canonical, {"uuids": [], "names": []})
if name not in entry["names"]:
# ---------------------------------------------------------
# Track name changes
# ---------------------------------------------------------
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)
self.save()
def merge(self, uuid_a, uuid_b):
"""Merge two UUIDs into one canonical identity."""
ca = self.resolve(uuid_a)
cb = self.resolve(uuid_b)
if ca == cb:
return ca
# ---------------------------------------------------------
# Track team history
# ---------------------------------------------------------
def update_team(self, canonical_id, team):
entry = self.data["canonical"].get(canonical_id)
if not entry:
return
# Merge cb into ca
self.data["canonical"][ca]["uuids"].extend(self.data["canonical"][cb]["uuids"])
self.data["canonical"][ca]["names"].extend(self.data["canonical"][cb]["names"])
if team and (not entry["team_history"] or entry["team_history"][-1] != team):
entry["team_history"].append(team)
self.save()
# ---------------------------------------------------------
# Backwards compatibility: old method name
# ---------------------------------------------------------
def add_name(self, canonical_id, name):
"""Legacy alias for update_name()."""
self.update_name(canonical_id, name)
# Update uuid_to_canonical
for u in self.data["canonical"][cb]["uuids"]:
self.data["uuid_to_canonical"][u] = ca
def add_team(self, canonical_id, team):
"""Legacy alias for update_team()."""
self.update_team(canonical_id, team)
del self.data["canonical"][cb]
self.save()
return ca
# ---------------------------------------------------------
# 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):
rookies = []
import os
import json
for cid, pdata in career_db["players"].items():
maps = pdata["career"].get("maps", 0)
if maps >= 10:
continue # not a rookie
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CAREER_DB_PATH = os.path.join(PROJECT_ROOT, "career_stats.json")
tag_data = pdata.get(tag, {})
rookies.append({
"canonical_id": cid,
"name": pdata["name"],
"team": pdata.get("team", ""),
"maps": maps,
"kd": pdata["career"]["KD"],
"kills": pdata["career"]["kills"],
"score": pdata["career"]["damage"], # or actual score if available
"tag_raw": tag_data.get("raw", 0.0),
"tag_pct": tag_data.get("pct", 0.0),
"tag_tier": tag_data.get("tier", "D"),
# ---------------------------------------------------------
# Load DB
# ---------------------------------------------------------
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)
# ---------------------------------------------------------
# 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)
return rookies[:limit]
# Sort by raw score
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")))
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] = {
"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_A": tiers.count("A"),
"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.storylines import generate_storyline, matchup_storyline
from analysis.predictions_v2 import generate_prediction
from analysis.rookie_leaderboard import rank_rookies_by_tag
# OBS export
@ -50,6 +51,8 @@ from match_engine import (
rank_match_players_clutch,
compute_slayer_prediction,
rank_match_players_consistency_career,
load_career_db,
attach_career_tags_to_match_player,
)
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.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(
top_frame,
text="Select 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.set("Payload") # default
@ -300,11 +306,11 @@ class DashLeagueGUI:
url = f"{API_BASE}/stats?season={self.current_season}"
data = fetch_json(url)
stats = data.get("data", [])
print("DEBUG: Checking raw API teams...")
for p in stats:
print("TEAM FIELD:", repr(p.get("team")))
# ---------------------------------------------------------
# Load PIR + Career DB
# ---------------------------------------------------------
@ -315,60 +321,153 @@ class DashLeagueGUI:
with open(career_path, "r", encoding="utf-8") as 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 = {}
for p in stats:
raw_id = p["id"]
canonical = pir.resolve(raw_id)
raw_id = p.get("id")
if not raw_id:
continue
# Start with API stats
merged = dict(p)
# Resolve canonical + stamp seasons
canonical = pir.resolve(raw_id, season=self.current_season)
if not canonical:
continue
# 🔥 Normalize team field BEFORE anything else
team = merged.get("team")
if team is None:
merged["team"] = ""
# Ensure first_season is set if missing
entry = pir.data["canonical"].get(canonical)
#if entry is not None and entry.get("first_season") is None:
# 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:
merged["team"] = str(team).strip()
# Attach canonical ID
merged["canonical_id"] = canonical
pdata["kd"] = pdata["kills"]
# Keep seasons_played updated
#if merged.get("seasons_played"):
# pdata["seasons_played"] = merged["seasons_played"]
pdata["seasons_played"] = merged.get("seasons_played")
# Attach career tags
pdata = career_db["players"].get(canonical)
if pdata:
merged["slayer"] = pdata.get("slayer", {})
merged["sharpshooter"] = pdata.get("sharpshooter", {})
merged["objective_payload"] = pdata.get("objective_payload", {})
merged["objective_domination"] = pdata.get("objective_domination", {})
merged["consistency"] = pdata.get("consistency", {})
merged["clutch"] = pdata.get("clutch", {})
merged["anchor"] = pdata.get("anchor", {})
merged["breaker"] = pdata.get("breaker", {})
for tag in (
"slayer",
"sharpshooter",
"objective_payload",
"objective_domination",
"consistency",
"clutch",
"anchor",
"breaker",
):
merged[tag] = pdata.get(tag, {})
new_stats_by_id[canonical] = merged
# 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"]
# 🔥 Now safe to assign
# 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
# 🔥 Now safe to inspect teams
# ---------------------------------------------------------
# Debug: show teams detected
# ---------------------------------------------------------
teams_in_api = sorted({p["team"] for p in new_stats_by_id.values()})
print("TEAMS IN API:", teams_in_api)
self.set_status("Stats synced.")
messagebox.showinfo("Success", f"Synced {len(stats)} player stats.")
except Exception as e:
messagebox.showerror("Error", f"Failed to sync stats:\n{e}")
self.set_status("Sync failed.")
# Re-trigger team selection so listboxes repopulate
if self.team_a_var.get():
self.on_team_select("A")
if self.team_b_var.get():
self.on_team_select("B")
self.on_team_select("B")
def enforce_rolling_five(self, event, side):
@ -448,14 +547,42 @@ class DashLeagueGUI:
selected_a = selected_a[:5]
selected_b = selected_b[:5]
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
# ---------------------------------------------------------
for p in all_players:
p["CP_Captures"] = p.get("CP_captures", 0)
p["DOM_Captures"] = p.get("DOM_captures", 0)
p["DOM_Counters"] = p.get("DOM_counters", 0)
ms = p.get("match_stats", {})
# 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
@ -471,14 +598,14 @@ class DashLeagueGUI:
or p.get("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)
pid = (
p.get("id")
or p.get("PlayerUUID")
or p.get("uuid")
or p.get("canonical_id")
or p.get("canonical")
or p.get("name")
)
p["id"] = pid
@ -502,6 +629,13 @@ class DashLeagueGUI:
# ---------------------------------------------------------
# 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_map = {row["id"]: row["score_raw"] for row in consistency_rows}
@ -533,7 +667,7 @@ class DashLeagueGUI:
# ---------------------------------------------------------
if career_db is not None:
for p in all_players:
cid = p.get("canonical_id")
cid = p.get("canonical")
if not cid:
continue
@ -546,10 +680,10 @@ class DashLeagueGUI:
p["objective_payload"] = pdata.get("objective_payload", {})
p["clutch"] = pdata.get("clutch", {})
p["anchor"] = pdata.get("anchor", {})
p["breaker_career"] = pdata.get("breaker", {})
p["breaker"] = pdata.get("breaker", {})
# 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", {})
# ---------------------------------------------------------
@ -561,17 +695,20 @@ class DashLeagueGUI:
counters = dom.get("counters", 0)
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,
"pct": dom_score, # <- match panel can now read this
"tier": "-", # <- neutral tier for single-match view
"captures": caps,
"counters": counters,
"summary": "", # optional, keeps shape consistent
}
# ---------------------------------------------------------
# 11. Merge enriched tag data into raw match players
# ---------------------------------------------------------
# 11. Ensure stats_by_id contains all enriched tags
for p in all_players:
cid = p.get("canonical_id")
cid = p.get("canonical")
if not cid:
continue
@ -579,17 +716,10 @@ class DashLeagueGUI:
if not enriched:
continue
for key in (
"slayer",
"sharpshooter",
"objective_payload",
"objective_domination",
"clutch",
"anchor",
"breaker",
):
if key in enriched:
p[key] = enriched[key]
# Copy match fields into stats_by_id
enriched["team"] = p.get("team")
enriched["name"] = p.get("name")
enriched["id"] = p.get("id")
# ---------------------------------------------------------
# 12. Update current_match_players
@ -825,13 +955,24 @@ Consistency Summary:
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 = []
teamB_players = []
for p in self.current_match_players:
cid = p.get("canonical_id")
cid = p.get("canonical") # <-- correct key
if not cid:
continue
@ -843,20 +984,16 @@ Consistency Summary:
teamA_players.append(enriched)
elif enriched.get("team") == team_b:
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:
messagebox.showerror("Error", "No players selected for one or both teams.")
return
# -----------------------------------------
# 1. Generate storyline + identity block
# -----------------------------------------
# ---------------------------------------------------------
# 3. Generate storyline + identity blocks
# ---------------------------------------------------------
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_text_a = format_team_identity(identity_data_a)
@ -871,9 +1008,9 @@ Consistency Summary:
+ identity_text_b
)
# -----------------------------------------
# 2. Popup window
# -----------------------------------------
# ---------------------------------------------------------
# 4. Popup window
# ---------------------------------------------------------
win = tk.Toplevel(self.root)
win.title("Match Storyline")
win.geometry("600x700")
@ -882,9 +1019,9 @@ Consistency Summary:
output.pack(expand=True, fill="both", padx=10, pady=10)
output.insert(tk.END, full_story)
# -----------------------------------------
# 3. Export to OBS
# -----------------------------------------
# ---------------------------------------------------------
# 5. Export to OBS
# ---------------------------------------------------------
def export():
content = output.get("1.0", tk.END).strip()
if not content:
@ -925,7 +1062,6 @@ Consistency Summary:
"Clutch",
"Anchor",
"Breaker",
"Rookie",
]
ttk.Label(win, text="Select Tag:").pack(anchor="w", padx=10, pady=5)
@ -1165,7 +1301,39 @@ Consistency Summary:
messagebox.showinfo("Exported", f"Rankings exported to:\n{path}")
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):
"""

View file

@ -52,15 +52,18 @@ def build_career_database(output_path):
raw_uuid = str(row["PlayerUUID"])
name = row["PlayerGameName"] or "Unknown"
# Resolve canonical identity
canonical_id = pir.resolve(raw_uuid)
pir.add_name(canonical_id, name)
# Use RAW UUID for DB queries
# Get this player's full match history
matches = db_access.get_player_match_history(raw_uuid)
if not matches:
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
pid = canonical_id
@ -215,131 +218,50 @@ def build_career_database(output_path):
# ---------------------------------------------------------
for pid, pdata in final_db["players"].items():
# -----------------------------------------------------
# Slayer
# -----------------------------------------------------
pdata["slayer"] = compute_slayer_for_career_player(
pdata,
league_averages,
distributions["slayer"]
)
# -----------------------------------------------------
# Sharpshooter
# -----------------------------------------------------
pdata["sharpshooter"] = compute_sharpshooter_for_career_player(
pdata["career"],
league_averages,
distributions["sharpshooter"]
)
# -----------------------------------------------------
# Payload Objective Specialist
# -----------------------------------------------------
pdata["objective_payload"] = compute_payload_tag(
pdata,
league_averages,
distributions["payload"]
)
# -----------------------------------------------------
# Domination Objective Specialist
# -----------------------------------------------------
pdata["objective_domination"] = compute_dom_objective_for_career_player(
pdata,
league_averages,
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)
# ---------------------------------------------------------
matches = pdata["matches"]
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 = []
for m in matches:
# Slayer-like component
dmg = m.get("Damage", 0)
kills = m.get("Kills", 0)
deaths = m.get("Deaths", 0)
@ -351,7 +273,6 @@ def build_career_database(output_path):
(kd / 5) * 0.20
)
# Objective-like component
push = m.get("PAY_PushTime", 0)
caps = m.get("DOM_Captures", 0)
counters = m.get("DOM_Counters", 0)
@ -362,25 +283,18 @@ def build_career_database(output_path):
(counters / 20) * 0.30
)
# Accuracy-like component
shots = m.get("Shots", 0)
shots_hit = m.get("ShotsHit", 0)
accuracy = (shots_hit / shots) if shots > 0 else 0.0
accuracy_component = accuracy * 0.20
# Final per-match performance score
perf = slayer_component + objective_component + accuracy_component
per_match_scores.append(perf)
# ---------------------------------------------------------
# Compute floor, stability, average
# ---------------------------------------------------------
if per_match_scores:
floor_value = min(per_match_scores)
avg_value = sum(per_match_scores) / len(per_match_scores)
# Variance adjusted for match count
mean = avg_value
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)))
@ -390,12 +304,9 @@ def build_career_database(output_path):
avg_value = 0.0
stability_value = 0.0
# ---------------------------------------------------------
# Build hybrid consistency tag
# ---------------------------------------------------------
consistency_result = build_consistency_tag(
floor_current=floor_value,
floor_career=floor_value, # career = all matches
floor_career=floor_value,
floor_league=league_averages.get("consistency_floor", 0.0),
stability_current=stability_value,
@ -410,12 +321,9 @@ def build_career_database(output_path):
percentile=None
)
# ---------------------------------------------------------
# Store in DB (GUI + Rankings + Spotlight compatible)
# ---------------------------------------------------------
pdata["consistency"] = {
"raw": consistency_result.normalized_score,
"pct": consistency_result.percentile,
"pct": None,
"tier": consistency_result.tier,
"summary": consistency_result.summary_short,
"components": {
@ -425,14 +333,63 @@ def build_career_database(output_path):
},
"extras": consistency_result.extras,
}
# ---------------------------------------------------------
# Career Consistency Percentile + Tier (NEW)
# ---------------------------------------------------------
raw_cons = pdata["consistency"]["raw"]
cons_dist = distributions.get("consistency", [])
# Clutch (FINAL TAG)
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,
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)
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
# ---------------------------------------------------------

View file

@ -210,6 +210,7 @@ def rank_match_players_consistency_career(match_players):
for i, p in enumerate(ranked, 1):
out.append({
"rank": i,
"id": p["id"],
"name": p["name"],
"team": p.get("team", "Unknown"),
"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):
@ -66,13 +66,17 @@ def rank_match_players_consistency(players):
for p in players:
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({
"name": p.get("name", "Unknown"),
"team": p.get("team", ""),
"score_raw": score,
"score_display": f"{score:.2f}",
"score_raw": raw,
"score_display": f"{raw:.2f}",
"pct": pct,
"tier": tier,
})
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):
@ -92,13 +96,17 @@ def rank_match_players_clutch(players):
for p in players:
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({
"name": p.get("name", "Unknown"),
"team": p.get("team", ""),
"score_raw": score,
"score_display": f"{score:.2f}",
"score_raw": raw,
"score_display": f"{raw:.2f}",
"pct": pct,
"tier": tier,
})
ranked.sort(key=lambda x: x["score_raw"], reverse=True)
@ -107,25 +115,28 @@ def rank_match_players_clutch(players):
r["rank"] = i
return ranked
# ---------------------------------------------------------
# Generic Match Tag Ranking (Modern Tag System)
# ---------------------------------------------------------
# ---------------------------------------------------------
def rank_match_players_by_tag(players, tag):
rows = []
for p in players:
tag_data = p.get(tag) or {} # <‑‑‑ FIX HERE
raw = tag_data.get("raw", 0)
tag_data = p.get(tag) or {}
raw = tag_data.get("raw", 0.0)
pct = tag_data.get("pct", None)
tier = tag_data.get("tier", None)
rows.append({
"name": p.get("name", "Unknown"),
"team": p.get("team", ""),
"score_raw": raw,
"score_display": f"{raw:.2f}",
"pct": pct,
"tier": tier,
})
rows.sort(key=lambda x: x["score_raw"], reverse=True)
@ -134,13 +145,13 @@ def rank_match_players_by_tag(players, tag):
r["rank"] = i
return rows
# ---------------------------------------------------------
# Split into two columns for UI
# ---------------------------------------------------------
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]
right = [r for r in ranked if r.get("team") == team_b]
return left, right
return left, right