Enhance Rookie logic, fix Domination tags, and implement 4K Player Spotlight graphics
This commit is contained in:
parent
acfc7d48a0
commit
4c384621aa
8 changed files with 964 additions and 651 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
|
||||
# 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
|
||||
|
||||
# True rookie = only played this season
|
||||
if len(season_list) == 1 and season_list[0] == season:
|
||||
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():
|
||||
|
||||
# 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]
|
||||
career = p.get("career", {})
|
||||
|
||||
# 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)
|
||||
|
||||
# Team handling: use history, then registry, then Free Agent
|
||||
history = p.get("team_history", [])
|
||||
team_name = "Free Agent"
|
||||
|
||||
# Search backward through history for a non-UUID name
|
||||
for t in reversed(history):
|
||||
if t and not (len(str(t)) > 20 and "-" in str(t)):
|
||||
team_name = str(t)
|
||||
break
|
||||
|
||||
# If still Free Agent, check if the player object has a 'team' key
|
||||
# (often populated during Sync)
|
||||
if team_name == "Free Agent" and p.get("team"):
|
||||
team_name = p.get("team")
|
||||
|
||||
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)
|
||||
|
||||
# Assign ranks
|
||||
# Sort by Score instead of Rating
|
||||
rows.sort(key=lambda x: x["score"], reverse=True)
|
||||
|
||||
for i, r in enumerate(rows, start=1):
|
||||
r["rank"] = i
|
||||
|
||||
return rows[:limit]
|
||||
|
|
@ -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,21 +578,12 @@ 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")
|
||||
or p.get("DOM_captures")
|
||||
or ms.get("DOM_Captures")
|
||||
or ms.get("DOM_captures")
|
||||
p.get("DOM_Captures")
|
||||
or p.get("DOM_captures")
|
||||
or ms.get("DOM_Captures")
|
||||
or ms.get("DOM_captures")
|
||||
or 0
|
||||
)
|
||||
|
||||
|
|
@ -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"
|
||||
|
||||
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}
|
||||
"""
|
||||
# 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"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}")
|
||||
|
||||
|
|
@ -1075,6 +1080,15 @@ Consistency Summary:
|
|||
tag = combo.get()
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -57,34 +57,46 @@ 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"]
|
||||
career_raw["maps"] += 1
|
||||
# 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"])
|
||||
KD = career_raw["kills"] / career_raw["deaths"] if career_raw["deaths"] > 0 else career_raw["kills"]
|
||||
accuracy = (career_raw["shots_hit"] / career_raw["shots"]) if career_raw["shots"] > 0 else 0.0
|
||||
|
|
@ -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
|
||||
# ---------------------------------------------------------
|
||||
|
||||
|
||||
breaker_distribution = []
|
||||
# ---------------------------------------------------------
|
||||
# 5. Finalize Tag Calculation (Percentiles & Tiers)
|
||||
# ---------------------------------------------------------
|
||||
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)"
|
||||
}
|
||||
|
||||
# --- 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)
|
||||
|
||||
# --- 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)
|
||||
|
||||
# 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)
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 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,
|
||||
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)"
|
||||
"pct": break_pct,
|
||||
"tier": break_tier,
|
||||
"summary": f"{break_tier} Tier Offensive Disruptor"
|
||||
}
|
||||
|
||||
# Save PIR with first_season stamped for all canonical IDs
|
||||
pir.save()
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 6. Save DB
|
||||
# 6. Save and Finish
|
||||
# ---------------------------------------------------------
|
||||
pir.save()
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(final_db, f, indent=2)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# If no career totals, try matches
|
||||
if caps == 0 and counters == 0:
|
||||
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)
|
||||
|
||||
avg_caps = caps / maps
|
||||
avg_counters = counters / maps
|
||||
|
||||
dom_matches = 0
|
||||
total_caps = 0
|
||||
total_counters = 0
|
||||
# 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)
|
||||
|
||||
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 caps == 0 and counters == 0:
|
||||
continue
|
||||
|
||||
dom_matches += 1
|
||||
total_caps += caps
|
||||
total_counters += counters
|
||||
|
||||
if dom_matches == 0:
|
||||
return 0.05 # minimum floor
|
||||
|
||||
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
133
utils/graphics_engine.py
Normal 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
|
||||
Loading…
Reference in a new issue