Enhance Rookie logic, fix Domination tags, and implement 4K Player Spotlight graphics

This commit is contained in:
FireHorse 2026-04-24 16:30:50 +10:00
parent acfc7d48a0
commit 4c384621aa
8 changed files with 964 additions and 651 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,98 +1,92 @@
import os
import json
import re
# Paths
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CAREER_DB_PATH = os.path.join(PROJECT_ROOT, "career_stats.json")
# ---------------------------------------------------------
# Load DB
# ---------------------------------------------------------
def load_career_db():
if not os.path.exists(CAREER_DB_PATH):
return None
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 = {}
current_season_int = int(season)
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()]
career = p.get("career", {})
maps_played = career.get("maps", 0)
kills = career.get("kills", 0)
# True rookie = only played this season
if len(season_list) == 1 and season_list[0] == season:
# Determine if they are active this season
matches = p.get("matches", [])
played_this_season = any(int(m.get("CycleID", 0)) >= current_season_int for m in matches)
# Registry check
reg_first = p.get("first_season")
# --- THE TRIPLE LOCK ---
# 1. Must be active now
# 2. Maps must be low (< 50)
# 3. Kills must be low (< 800) - This catches veterans with missing history
is_rookie = False
if played_this_season and maps_played < 50 and kills < 1000:
# Final check: Registry shouldn't show them in a previous season
if reg_first is None or int(reg_first) >= current_season_int:
is_rookie = True
if is_rookie:
rookies[pid] = p
return rookies
# analysis/rookie_leaderboard.py
# ---------------------------------------------------------
# Rank rookies by a selected tag
# ---------------------------------------------------------
def rank_rookies_by_tag(season, tag_name, limit=10):
"""
Rank rookies using REAL career stats.
"""
def rank_rookies_by_tag(season, tag_name, limit=15):
db = load_career_db()
if db is None:
return []
if db is None: return []
rookies = get_rookies(db, season)
rows = []
for pid, p in rookies.items():
career = p.get("career", {})
# Pull stats from career DB (correct source)
kills = p.get("kills", 0)
deaths = p.get("deaths", 0)
score = p.get("score", 0)
# Pull core stats
kills_val = career.get("kills", 0)
kd_val = career.get("KD", 0)
score_val = career.get("score", 0)
maps_val = career.get("maps", 0)
# KD
if deaths > 0:
kd = kills / deaths
else:
kd = kills
# Team handling: use history, then registry, then Free Agent
history = p.get("team_history", [])
team_name = "Free Agent"
# Raw score = KD (for now)
raw = kd
# 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
# Percentile placeholder (we can compute real percentiles later)
pct = raw
# Team
team = "Unknown"
if p.get("team_history"):
team = p["team_history"][-1]
# 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")
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": "",
"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 raw score
rows.sort(key=lambda x: x["raw"], reverse=True)
# Sort by Score instead of Rating
rows.sort(key=lambda x: x["score"], reverse=True)
# Assign ranks
for i, r in enumerate(rows, start=1):
r["rank"] = i
return rows[:limit]

View file

@ -335,6 +335,21 @@ class DashLeagueGUI:
# Resolve canonical + stamp seasons
canonical = pir.resolve(raw_id, season=self.current_season)
canonical = pir.resolve(raw_id, season=self.current_season)
# Update Registry and History with clean names
clean_name = p.get("name")
clean_team = (p.get("team") or "").strip()
pir.update_name(canonical, clean_name)
if clean_team:
pir.update_team(canonical, clean_team)
# Ensure career_stats has this history
if canonical in players_db:
players_db[canonical]["team_history"] = pir.data["canonical"][canonical]["team_history"]
if not canonical:
continue
@ -377,7 +392,12 @@ class DashLeagueGUI:
"breaker": {},
}
pdata = players_db.get(canonical, {})
pdata = players_db.setdefault(canonical, {})
pdata["seasons_played"] = merged["seasons_played"]
print("DEBUG: seasons fields:")
#for p in stats:
# print(p.get("name"), "seasons:", p.get("seasons"), "seasons_played:", p.get("seasons_played"))
# Store basic stats into career DB so rookies have real data
@ -401,7 +421,7 @@ class DashLeagueGUI:
#if merged.get("seasons_played"):
# pdata["seasons_played"] = merged["seasons_played"]
pdata["seasons_played"] = merged.get("seasons_played")
#pdata["seasons_played"] = merged.get("seasons_played")
# Attach career tags
for tag in (
@ -558,15 +578,6 @@ class DashLeagueGUI:
for p in all_players:
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")
@ -782,88 +793,82 @@ class DashLeagueGUI:
def on_player_spotlight(self):
career_path = os.path.join(BASE_DIR, "career_stats.json")
if not os.path.exists(career_path):
messagebox.showerror("Error", "Build career database first.")
# 1. Check if players are actually selected for a match
if not self.current_match_players:
messagebox.showerror("Error", "Please select teams and click 'Generate Player Slots' first.")
return
# 2. Get names ONLY from the 10 players in the current match
names = [p["name"] for p in self.current_match_players]
names.sort()
# Load the career DB once so the 'show' function can pull match history for trends
career_path = os.path.join(BASE_DIR, "career_stats.json")
with open(career_path, "r", encoding="utf-8") as f:
db = json.load(f)
names = [p["name"] for p in db["players"].values()]
names.sort()
win = tk.Toplevel(self.root)
win.title("Player Spotlight")
win.geometry("400x500")
win.title("Match Player Spotlight")
win.geometry("450x550")
ttk.Label(win, text="Select Player:").pack(anchor="w", padx=10, pady=5)
ttk.Label(win, text="Select a Player from this Match:").pack(anchor="w", padx=10, pady=5)
combo = ttk.Combobox(win, values=names, state="readonly")
combo.pack(fill="x", padx=10)
output = tk.Text(win, wrap="word")
output = tk.Text(win, wrap="word", font=("Courier", 10))
output.pack(expand=True, fill="both", padx=10, pady=10)
def show():
name = combo.get()
if not name:
selected_name = combo.get()
if not selected_name:
return
player = next((p for p in db["players"].values() if p["name"] == name), None)
if not player:
# Find the match player object (this has the current team/side info)
match_p = next((p for p in self.current_match_players if p["name"] == selected_name), None)
# Find the full career object (this has the history/matches for the graphics engine)
cid = match_p.get("canonical")
player_data = db["players"].get(cid)
if not player_data:
output.delete("1.0", tk.END)
output.insert(tk.END, "Player not found in career DB.")
output.insert(tk.END, "Error: Player data not found in career database.")
return
career = player["career"]
derived = player.get("derived", {})
obj_pl = player.get("objective_payload_score")
slayer_strength = player.get("slayer_strength", "Unknown")
# Determine Team Side for Colors
# If the player is in the Team B listbox, they are 'Red'
side = "Blue"
team_b_names = [self.team_b_listbox.get(i) for i in range(self.team_b_listbox.size())]
if selected_name in team_b_names:
side = "Red"
consistency = player.get("consistency", {})
cons_tier = consistency.get("tier", "N/A")
cons_pct = consistency.get("pct")
cons_summary = consistency.get("summary", "No consistency data available.")
# 3. Trigger the 4K Graphic
from utils.graphics_engine import generate_player_card
try:
img_path = generate_player_card(player_data, team_side=side)
self.set_status(f"Generated 4K Spotlight: {selected_name}")
except Exception as e:
print(f"Graphics Error: {e}")
if isinstance(cons_pct, (int, float)):
cons_line = f"{cons_tier} Tier ({cons_pct:.1f} percentile)"
else:
cons_line = f"{cons_tier} Tier"
# 4. Generate the Text Preview for the GUI window
career = player_data["career"]
text = f"PLAYER SPOTLIGHT: {selected_name}\n"
text += f"Team: {match_p.get('team', 'Unknown')}\n"
text += f"-----------------------------------\n\n"
text += f"CAREER STATS:\n"
text += f"Kills: {int(career['kills']):,}\n"
text += f"Maps: {int(career['maps'])}\n"
text += f"KD: {career['KD']:.2f}\n\n"
text = f"""PLAYER SPOTLIGHT — {name}
Slayer Tier: {slayer_strength}
Consistency: {cons_line}
Payload Objective Specialist: {obj_pl if obj_pl is not None else "N/A"}
Career Stats:
KD: {career['KD']:.2f}
Accuracy: {career['accuracy']:.2f}
Kills: {career['kills']}
Deaths: {career['deaths']}
Maps Played: {career['maps']}
Derived Metrics:
Kills/Map: {derived.get('kills_per_map', 0):.2f}
Deaths/Map: {derived.get('deaths_per_map', 0):.2f}
PushTime/Season: {derived.get('push_time_per_season', 0):.2f}
Consistency Summary:
{cons_summary}
"""
text += f"TAG PERCENTILES:\n"
for tag in ["slayer", "sharpshooter", "consistency", "clutch", "anchor"]:
t_obj = player_data.get(tag, {})
text += f"{tag.title():<14}: {t_obj.get('tier', 'D')} ({t_obj.get('pct', 0):.0f}%)\n"
output.delete("1.0", tk.END)
output.insert(tk.END, text)
def export():
content = output.get("1.0", tk.END).strip()
if not content:
messagebox.showerror("Error", "No spotlight text to export.")
return
path = export_to_obs("spotlight.txt", content)
messagebox.showinfo("Exported", f"Spotlight exported to:\n{path}")
ttk.Button(win, text="Show Spotlight", command=show).pack(pady=5)
ttk.Button(win, text="Export to OBS", command=export).pack(pady=5)
ttk.Button(win, text="Generate 4K Graphic", command=show).pack(pady=10)
def on_team_identity(self):
team = self.team_a_var.get()
@ -932,7 +937,7 @@ Consistency Summary:
# Domination Objective Specialist (match-based)
# ---------------------------------------------------------
if map_type == "Domination":
dom_info = player_entry.get("objective_domination_components", {})
dom_info = player_entry.get("objective_domination", {})
dom_score = dom_info.get("raw", 0.0)
tags_out.append(f"ObjDOM {dom_score:.2f}")
@ -1076,6 +1081,15 @@ Consistency Summary:
if not tag:
return
from match_engine import load_career_db, attach_career_tags_to_match_player
career_db = load_career_db()
if career_db:
for p in self.current_match_players:
cid = p.get("canonical")
if cid and cid in career_db["players"]:
attach_career_tags_to_match_player(p, career_db["players"][cid])
team_a = self.team_a_var.get()
team_b = self.team_b_var.get()
@ -1284,9 +1298,6 @@ Consistency Summary:
text += "- KillsNorm = Kills per map normalized against league average\n"
text += "Breaker measures disruptive offensive pressure through high damage output and aggressive fragging.\n"
output.delete("1.0", tk.END)
output.insert(tk.END, text)
@ -1303,36 +1314,42 @@ Consistency Summary:
ttk.Button(win, text="Show Rankings", command=show).pack(pady=5)
# dashleague_cast_tool.py -> on_rookie_leaderboard()
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
tag = "slayer"
rookies = rank_rookies_by_tag(season, tag)
# Build text output
lines = []
lines.append(f"ROOKIE LEADERBOARD — {tag.upper()} — Season {season}")
lines.append(f"ROOKIE LEADERBOARD — Season {season}")
lines.append("Rookies: < 50 maps and < 1000 kills.")
lines.append("")
lines.append(f"{'Rank':<5} {'Name':<18} {'Team':<8} {'Tier':<6} {'Pct':<6} {'Raw':<6}")
lines.append("-" * 60)
# Updated Header
lines.append(f"{'Rank':<5} {'Name':<18} {'Team':<12} {'Score':<10} {'KD':<6} {'Maps':<6} {'Kills':<8}")
lines.append("-" * 75)
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}")
# Format thousands for score (e.g. 96,107)
score_str = f"{int(r['score']):,}"
kd_str = f"{r['kd']:.2f}"
maps_str = f"{int(r['maps'])}"
kills_str = f"{int(r['kills'])}"
lines.append(f"{r['rank']:<5} {r['name']:<18} {r['team']:<12} {score_str:<10} {kd_str:<6} {maps_str:<6} {kills_str:<8}")
text = "\n".join(lines)
# Popup window
win = tk.Toplevel(self.root)
win.title("Rookie Leaderboard")
win.geometry("600x500")
win.title("Rookie Standings")
win.geometry("800x550")
output = tk.Text(win, wrap="word")
output = tk.Text(win, wrap="none", font=("Courier", 10))
output.pack(expand=True, fill="both", padx=10, pady=10)
output.insert(tk.END, text)
output.config(state="disabled")
def export_all_obs_files(self):

View file

@ -57,32 +57,44 @@ def build_career_database(output_path):
if not matches:
continue
# Infer "season" from earliest CycleID (or whatever you treat as season)
# Infer "season" from earliest CycleID
first_cycle = min(m.get("CycleID", 0) for m in matches) or 0
# Resolve canonical identity AND stamp first_season
# Resolve canonical identity
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
#print("DEBUG MATCH ROW FOR", pid, ":", matches[0])
#break
career_raw = defaultdict(float)
teams_seen = []
# LOOKUP CLEAN NAMES FROM REGISTRY
reg_entry = pir.data["canonical"].get(pid, {})
reg_teams = reg_entry.get("team_history", [])
for m in matches:
career_raw["kills"] += m["Kills"]
career_raw["deaths"] += m["Deaths"]
career_raw["damage"] += m["Damage"]
career_raw["shots"] += m["Shots"]
career_raw["shots_hit"] += m["ShotsHit"]
career_raw["headshots"] += m["Headshots"]
career_raw["PAY_PushTime"] += m["PAY_PushTime"]
career_raw["DOM_captures"] += m["DOM_Captures"]
career_raw["DOM_counters"] += m["DOM_Counters"]
# 1. Handle Team Names
t_raw = str(m.get("TeamUUID") or m.get("team") or "")
# Use registry name if available, otherwise raw
clean_team = reg_teams[-1] if reg_teams else t_raw
if clean_team and (not teams_seen or teams_seen[-1] != clean_team):
# Filter out raw UUIDs (e.g. "e960a65e...")
if not (len(clean_team) > 20 and "-" in clean_team):
teams_seen.append(clean_team)
# 2. Aggregate Stats (Including Score)
career_raw["kills"] += m.get("Kills", 0)
career_raw["deaths"] += m.get("Deaths", 0)
career_raw["damage"] += m.get("Damage", 0)
career_raw["score"] += m.get("Score", 0)
career_raw["shots"] += m.get("Shots", 0)
career_raw["shots_hit"] += m.get("ShotsHit", 0)
career_raw["headshots"] += m.get("Headshots", 0)
career_raw["PAY_PushTime"] += m.get("PAY_PushTime", 0)
career_raw["DOM_Captures"] += m.get("DOM_Captures", 0)
career_raw["DOM_Counters"] += m.get("DOM_Counters", 0)
career_raw["maps"] += 1
maps = max(1, career_raw["maps"])
@ -97,302 +109,117 @@ def build_career_database(output_path):
final_db["players"][pid] = {
"name": name,
"first_season": pir.get_first_season(canonical_id),
"team_history": teams_seen,
"career": {
"kills": career_raw["kills"],
"deaths": career_raw["deaths"],
"score": career_raw["score"],
"KD": KD,
"accuracy": accuracy,
"push_time": career_raw["PAY_PushTime"],
"DOM_Captures": int(career_raw["DOM_captures"]),
"DOM_Counters": int(career_raw["DOM_counters"]),
"maps": career_raw["maps"],
"DOM_Captures": int(career_raw["DOM_Captures"]),
"DOM_Counters": int(career_raw["DOM_Counters"]),
"maps": int(career_raw["maps"]),
"damage": career_raw["damage"],
"shots": career_raw["shots"],
"shots_hit": career_raw["shots_hit"],
"headshots": career_raw["headshots"],
},
"DOM_Captures": int(career_raw["DOM_captures"]), # 👈 add this
"DOM_Counters": int(career_raw["DOM_counters"]), # 👈 and this
"DOM_Captures": int(career_raw["DOM_Captures"]),
"DOM_Counters": int(career_raw["DOM_Counters"]),
"derived": derived,
"matches": matches,
}
# ---------------------------------------------------------
# 3. PRECOMPUTE RAW CLUTCH VALUES (required for distribution)
# 3. PRE-COMPUTE RAW VALUES FOR DISTRIBUTIONS
# ---------------------------------------------------------
all_players = list(final_db["players"].values())
for pdata in all_players:
# A. Clutch Raw
pdata["clutch_raw"] = compute_clutch_raw(pdata)
# ---------------------------------------------------------
# NORMALIZE CAREER STAT KEYS BEFORE LEAGUE METRICS
# ---------------------------------------------------------
for pid, pdata in final_db["players"].items():
career = pdata.get("career", {})
career["push_time"] = (
career.get("push_time")
or career.get("PAY_PushTime")
or pdata.get("PAY_PushTime")
or 0
)
career["captures"] = (
career.get("captures")
or career.get("DOM_Captures")
or pdata.get("DOM_Captures")
or 0
)
career["counters"] = (
career.get("counters")
or career.get("DOM_Counters")
or pdata.get("DOM_Counters")
or 0
)
career["damage"] = (
career.get("damage")
or career.get("Damage")
or pdata.get("Damage")
or 0
)
career["kills"] = (
career.get("kills")
or career.get("Kills")
or pdata.get("Kills")
or 0
)
career["deaths"] = (
career.get("deaths")
or career.get("Deaths")
or pdata.get("Deaths")
or 0
)
career["maps"] = (
career.get("maps")
or pdata.get("maps")
or pdata.get("career", {}).get("maps")
or 1
)
pdata["career"] = career
# B. Consistency Raw (Calibration for Top Players)
matches = pdata.get("matches", [])
per_match_scores = []
for m in matches:
# Calculate a "Performance Score" for every single match played
s_kills = m.get("Kills", 0)
s_deaths = max(1, m.get("Deaths", 0))
s_dmg = m.get("Damage", 0)
# Simple match power formula
match_perf = (s_kills / 25) * 0.4 + (s_dmg / 8000) * 0.4 + ((s_kills / s_deaths) / 4) * 0.2
per_match_scores.append(match_perf)
if len(per_match_scores) > 1:
mean = sum(per_match_scores) / len(per_match_scores)
# Variance calculation
var = sum((x - mean) ** 2 for x in per_match_scores) / len(per_match_scores)
# Use Standard Deviation (sqrt of variance) to avoid punishing high-scorers
pdata["consistency_raw"] = 1 / (1 + (var ** 0.5))
else:
# Default for players with only 1 match (can't measure consistency yet)
pdata["consistency_raw"] = 0.5
# ---------------------------------------------------------
# 4. Compute league metrics for ALL TAGS
# 4. Compute League Metrics (Distributions now populated)
# ---------------------------------------------------------
league_averages, distributions = compute_league_metrics(all_players)
clutch_averages, clutch_distribution = compute_league_clutch_metrics(all_players)
# Merge clutch averages into league averages
league_averages.update(clutch_averages)
final_db["league_averages"] = league_averages
# ---------------------------------------------------------
# 4.6 Build Breaker distribution
# 5. Finalize Tag Calculation (Percentiles & Tiers)
# ---------------------------------------------------------
breaker_distribution = []
for pid, pdata in final_db["players"].items():
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)
breaker_distribution.append(raw_breaker)
# ---------------------------------------------------------
# 5. Compute all tags using distributions
# ---------------------------------------------------------
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 (Hybrid Career Tag - New System)
# ---------------------------------------------------------
matches = pdata["matches"]
maps_played = pdata["career"].get("maps", 0)
per_match_scores = []
for m in matches:
dmg = m.get("Damage", 0)
kills = m.get("Kills", 0)
deaths = m.get("Deaths", 0)
kd = kills / deaths if deaths > 0 else kills
slayer_component = (
(dmg / 10000) * 0.40 +
(kills / 30) * 0.40 +
(kd / 5) * 0.20
)
push = m.get("PAY_PushTime", 0)
caps = m.get("DOM_Captures", 0)
counters = m.get("DOM_Counters", 0)
objective_component = (
(push / 300) * 0.40 +
(caps / 20) * 0.30 +
(counters / 20) * 0.30
)
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
perf = slayer_component + objective_component + accuracy_component
per_match_scores.append(perf)
if per_match_scores:
floor_value = min(per_match_scores)
avg_value = sum(per_match_scores) / len(per_match_scores)
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)))
stability_value = 1 / (1 + adjusted_variance)
else:
floor_value = 0.0
avg_value = 0.0
stability_value = 0.0
consistency_result = build_consistency_tag(
floor_current=floor_value,
floor_career=floor_value,
floor_league=league_averages.get("consistency_floor", 0.0),
stability_current=stability_value,
stability_career=stability_value,
stability_league=league_averages.get("consistency_stability", 0.0),
average_current=avg_value,
average_career=avg_value,
average_league=league_averages.get("consistency_average", 0.0),
matches_played_current=maps_played,
percentile=None
)
# --- Standard Tags ---
pdata["slayer"] = compute_slayer_for_career_player(pdata, league_averages, distributions["slayer"])
pdata["sharpshooter"] = compute_sharpshooter_for_career_player(career, league_averages, distributions["sharpshooter"])
pdata["objective_payload"] = compute_payload_tag(pdata, league_averages, distributions["payload"])
pdata["objective_domination"] = compute_dom_objective_for_career_player(pdata, league_averages, distributions["domination"])
# --- Consistency Tag (Fixed logic) ---
raw_cons = pdata.get("consistency_raw", 0.5)
cons_pct = percentile_rank(raw_cons, distributions.get("consistency", []))
cons_tier = tier_from_percentile(cons_pct)
pdata["consistency"] = {
"raw": consistency_result.normalized_score,
"pct": None,
"tier": consistency_result.tier,
"summary": consistency_result.summary_short,
"components": {
"floor": consistency_result.components.floor,
"stability": consistency_result.components.stability,
"average": consistency_result.components.average_performance,
},
"extras": consistency_result.extras,
"raw": raw_cons,
"pct": cons_pct,
"tier": cons_tier,
"summary": f"{cons_tier} Tier Stability ({cons_pct:.1f} percentile)"
}
# ---------------------------------------------------------
# Career Consistency Percentile + Tier (NEW)
# ---------------------------------------------------------
raw_cons = pdata["consistency"]["raw"]
cons_dist = distributions.get("consistency", [])
# --- Advanced Tags ---
pdata["anchor"] = compute_anchor_for_career_player(pdata, league_averages, distributions["anchor"], raw_cons)
pdata["clutch"] = compute_clutch(pdata, league_averages, clutch_distribution)
pct = percentile_rank(raw_cons, cons_dist)
tier = tier_from_percentile(pct)
# --- Breaker Tag ---
dmg_map = career["damage"] / max(1, career["maps"])
kills_map = career["kills"] / max(1, career["maps"])
raw_breaker = compute_breaker_raw(dmg_map, kills_map)
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)
# We handle breaker distribution locally for simplicity
breaker_dist = sorted([ (p["career"]["damage"]/max(1,p["career"]["maps"])*0.6 + p["career"]["kills"]/max(1,p["career"]["maps"])*0.4) for p in all_players])
break_pct = percentile_rank(raw_breaker, breaker_dist)
break_tier = tier_from_percentile(break_pct)
pdata["breaker"] = {
"raw": raw_breaker,
"pct": pct,
"tier": tier,
"summary": f"Offensive disruptor ({tier} Tier, {pct:.1f} percentile)"
"pct": break_pct,
"tier": break_tier,
"summary": f"{break_tier} Tier Offensive Disruptor"
}
# Save PIR with first_season stamped for all canonical IDs
# ---------------------------------------------------------
# 6. Save and Finish
# ---------------------------------------------------------
pir.save()
# ---------------------------------------------------------
# 6. Save DB
# ---------------------------------------------------------
with open(output_path, "w", encoding="utf-8") as f:
json.dump(final_db, f, indent=2)

View file

@ -116,6 +116,9 @@ def compute_league_metrics(all_players):
# Domination
dist["domination"].append(compute_dom_raw(p))
# Collect the raw consistency score we will calculate in the next step
dist["consistency"].append(p.get("consistency_raw", 0.0))
from tags.anchor import compute_anchor_raw
# Consistency raw score for distribution building

View file

@ -176,13 +176,13 @@ def rank_match_players_sharpshooter(match_players):
def rank_match_players_objpl(match_players):
return _add_score_fields(rank_match_players_by_tag(match_players, "objective_payload"))
def rank_match_players_objdom(match_players):
ranked = []
for p in match_players:
comp = p.get("objective_domination_components", {})
raw = comp.get("raw", 0.0)
# CHANGE: Look for "objective_domination" instead of "_components"
dom_data = p.get("objective_domination", {})
raw = dom_data.get("raw", 0.0)
ranked.append({
"id": p.get("id"),

View file

@ -22,49 +22,27 @@ def dom_summary(tier, pct):
# ---------------------------------------------------------
def compute_dom_raw(pdata):
"""
Computes a normalized 0–1 Domination Objective Specialist score.
Uses ONLY Domination stats:
- captures
- counters
"""
# Try career totals first
caps = pdata.get("career", {}).get("DOM_Captures") or pdata.get("DOM_Captures") or 0
counters = pdata.get("career", {}).get("DOM_Counters") or pdata.get("DOM_Counters") or 0
maps = max(1, pdata.get("career", {}).get("maps") or 1)
matches = pdata.get("matches", [])
if not matches:
return 0.05 # minimum floor
dom_matches = 0
total_caps = 0
total_counters = 0
for m in matches:
caps = m.get("DOM_Captures", 0) or 0
counters = m.get("DOM_Counters", 0) or 0
# Skip matches with no DOM stats
# If no career totals, try matches
if caps == 0 and counters == 0:
continue
matches = pdata.get("matches", [])
for m in matches:
caps += m.get("DOM_Captures", 0) or m.get("DOM_captures", 0)
counters += m.get("DOM_Counters", 0) or m.get("DOM_counters", 0)
dom_matches += 1
total_caps += caps
total_counters += counters
avg_caps = caps / maps
avg_counters = counters / maps
if dom_matches == 0:
return 0.05 # minimum floor
# Use a lower log base to make small numbers more visible to the score
cap_rate = math.log1p(avg_caps) / math.log1p(10.0)
counter_rate = math.log1p(avg_counters) / math.log1p(10.0)
avg_caps = total_caps / dom_matches
avg_counters = total_counters / dom_matches
# Log-scaled normalization (smooth extremes, expand mid-range)
cap_rate = math.log1p(avg_caps) / math.log1p(15.0)
counter_rate = math.log1p(avg_counters) / math.log1p(15.0)
# Counters weighted more heavily (deny enemy scoring)
raw = (0.40 * cap_rate) + (0.60 * counter_rate)
# Soft clamp
raw = max(0.05, min(raw, 1.0))
return raw
return max(0.05, min(raw, 1.0))
# ---------------------------------------------------------

133
utils/graphics_engine.py Normal file
View file

@ -0,0 +1,133 @@
import os
import math
from PIL import Image, ImageDraw, ImageFont
# --- PATHING CONFIGURATION ---
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ASSETS_DIR = os.path.join(BASE_DIR, "assets")
# This points to your stats folder for OBS to read
OUTPUT_PATH = os.path.join(BASE_DIR, "..", "stats", "obs_exports", "spotlight_card.png")
# Ensure directories exist
os.makedirs(ASSETS_DIR, exist_ok=True)
os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True)
def generate_player_card(player_data, team_side="Blue"):
"""
Generates a 4K (3840x2160) Player Spotlight card.
team_side: "Blue" or "Red" (determines accent colors)
"""
# 1. Setup Canvas (4K)
width, height = 3840, 2160
# Professional Blue/Red Hex codes
accent_color = (0, 162, 255) if team_side == "Blue" else (255, 38, 0)
# 2. Background Logic
bg_path = os.path.join(ASSETS_DIR, "background.png")
if os.path.exists(bg_path):
img = Image.open(bg_path).resize((width, height))
else:
# Create a deep dark slate gradient-style background
img = Image.new('RGB', (width, height), color=(10, 10, 15))
draw = ImageDraw.Draw(img, 'RGBA') # RGBA for semi-transparent lines
# Font Helper
def get_font(size):
# On Windows, arial is standard. On Linux/Mac, you might need a path to a .ttf
try:
return ImageFont.truetype("arial.ttf", size)
except:
return ImageFont.load_default()
# Define Font Styles
title_font = get_font(220)
label_font = get_font(70)
stat_font = get_font(130)
tag_font = get_font(90)
hook_font = get_font(60)
# 3. DRAW PLAYER HEADER
name = player_data.get("name", "Unknown Player").upper()
draw.text((200, 150), name, font=title_font, fill=(255, 255, 255))
# 4. TREND ANALYSIS (Last 5 Matches vs Career)
matches = player_data.get("matches", [])
career_kd = player_data.get("career", {}).get("KD", 1.0)
recent_kd = career_kd
if len(matches) >= 3:
last_5 = matches[-5:]
recent_kills = sum(m.get("Kills", 0) or 0 for m in last_5)
recent_deaths = sum(m.get("Deaths", 0) or 0 for m in last_5)
recent_kd = recent_kills / recent_deaths if recent_deaths > 0 else recent_kills
diff = recent_kd - career_kd
trend_color = (50, 255, 50) if diff >= 0 else (255, 50, 50)
trend_text = "HOT FORM" if diff >= 0 else "COOLING"
# Draw Trend Box
draw.rounded_rectangle([200, 400, 850, 500], radius=20, fill=trend_color)
draw.text((230, 415), f"{trend_text} ({'+' if diff >=0 else ''}{diff:.2f} KD)", font=get_font(45), fill=(0,0,0))
# 5. LEFT COLUMN: CORE CAREER STATS
career = player_data.get("career", {})
stats = [
("TOTAL KILLS", f"{int(career.get('kills', 0)):,}"),
("TOTAL SCORE", f"{int(career.get('score', 0)):,}"),
("MAPS PLAYED", f"{int(career.get('maps', 0))}"),
("CAREER KD", f"{career.get('KD', 0):.2f}")
]
curr_y = 650
for label, value in stats:
draw.text((200, curr_y), label, font=label_font, fill=(180, 180, 180))
draw.text((200, curr_y + 80), value, font=stat_font, fill=(255, 255, 255))
curr_y += 280
# 6. RIGHT COLUMN: SKILL BARS
draw.text((2000, 450), "SKILL PROFILE vs LEAGUE AVG", font=label_font, fill=accent_color)
tags_to_show = [
("SLAYING", "slayer"),
("MARKSMAN", "sharpshooter"),
("CONSISTENCY", "consistency"),
("PRESSURE", "clutch"),
("DEFENSIVE", "anchor")
]
bar_y = 550
for display_name, key in tags_to_show:
tag_obj = player_data.get(key, {})
pct = tag_obj.get("pct", 0)
tier = tag_obj.get("tier", "D")
# Label
draw.text((2000, bar_y), f"{display_name} ({tier})", font=tag_font, fill=(255, 255, 255))
# Bar BG
draw.rectangle([2000, bar_y + 110, 3600, bar_y + 140], fill=(40, 40, 45))
# Actual Data Bar
fill_width = 2000 + (1600 * (pct / 100))
draw.rectangle([2000, bar_y + 110, fill_width, bar_y + 140], fill=accent_color)
# --- LEAGUE AVG LINE (50%) ---
avg_x = 2000 + (1600 * 0.5)
# Drawing a vertical white line at 50%
draw.line([avg_x, bar_y + 100, avg_x, bar_y + 150], fill=(255, 255, 255, 180), width=6)
bar_y += 230
# 7. FOOTER: SCOUTING REPORT
# Get the summary from their highest-performing tag
best_tag_key = max(tags_to_show, key=lambda x: player_data.get(x[1], {}).get("pct", 0))[1]
intel = player_data.get(best_tag_key, {}).get("summary", "Reliable league veteran.")
# Semi-transparent dark box for footer
draw.rectangle([150, 1800, 3690, 2050], fill=(20, 20, 25))
draw.text((200, 1830), "SCOUTING REPORT", font=label_font, fill=accent_color)
draw.text((200, 1920), intel, font=hook_font, fill=(230, 230, 230))
# 8. SAVE
img.save(OUTPUT_PATH)
return OUTPUT_PATH