From 2ec2f821e43215fb8ebd87c4cc4cf10bcabdfef5 Mon Sep 17 00:00:00 2001 From: FireHorse Date: Wed, 6 May 2026 12:37:26 +1000 Subject: [PATCH] Finalize Win Probability screen with Blue/Red VS donuts and Experience gauge. UI Polish complete. --- analysis/player_identity_registry.json | 15 +- analysis/storylines.py | 3 +- dashleague_cast_tool.py | 197 ++++++++++---- data/career_db.py | 22 ++ data/db_access.py | 17 +- tags/clutch.py | 81 ++---- utils/graphics_engine.py | 359 +++++++++++++++++-------- 7 files changed, 470 insertions(+), 224 deletions(-) diff --git a/analysis/player_identity_registry.json b/analysis/player_identity_registry.json index 043807e..30120df 100644 --- a/analysis/player_identity_registry.json +++ b/analysis/player_identity_registry.json @@ -1772,7 +1772,8 @@ "0e7eb1b9-03c7-41a6-b211-3ada1d85935c" ], "names": [ - "Spaghetti" + "Spaghetti", + "Spaghetti CLNM" ], "team_history": [ "ZTi" @@ -1820,7 +1821,9 @@ "names": [ "Toga280" ], - "team_history": [], + "team_history": [ + "F1R3" + ], "first_season": 1, "last_season": 10 }, @@ -7797,7 +7800,9 @@ "names": [ "Java" ], - "team_history": [], + "team_history": [ + "RVNG" + ], "first_season": 27, "last_season": 10 }, @@ -9293,7 +9298,9 @@ "names": [ "XyLo" ], - "team_history": [], + "team_history": [ + "AGGG" + ], "first_season": 48, "last_season": 10 }, diff --git a/analysis/storylines.py b/analysis/storylines.py index e18d7f8..e01a32e 100644 --- a/analysis/storylines.py +++ b/analysis/storylines.py @@ -43,7 +43,8 @@ def summarize_team_tags(team_players): # 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 "-" + from data.tag_framework import tier_from_percentile + top_tier = tier_from_percentile(safe_avg(pcts)) # Map the average pct to a tier summary[tag] = { "avg_pct": safe_avg(pcts), diff --git a/dashleague_cast_tool.py b/dashleague_cast_tool.py index 3f1e908..3f5879e 100644 --- a/dashleague_cast_tool.py +++ b/dashleague_cast_tool.py @@ -226,20 +226,17 @@ class DashLeagueGUI: self.sync_button = ttk.Button(top_frame, text="Sync Stats", command=self.on_sync_stats) self.sync_button.grid(row=0, column=1, padx=(0, 10)) - self.spotlight_button = ttk.Button(top_frame, text="Player Spotlight", command=self.on_player_spotlight) - self.spotlight_button.grid(row=0, column=2, padx=(0, 10)) - self.team_id_button = ttk.Button(top_frame, text="Team Identity", command=self.on_team_identity) - self.team_id_button.grid(row=0, column=3, padx=(0, 10)) + self.team_id_button.grid(row=0, column=2, padx=(0, 10)) self.storyline_button = ttk.Button(top_frame, text="Match Storyline", command=self.on_match_storyline) - self.storyline_button.grid(row=0, column=4, padx=(0, 10)) + self.storyline_button.grid(row=0, column=3, padx=(0, 10)) self.rankings_button = ttk.Button(top_frame, text="Rankings", command=self.on_rankings) - self.rankings_button.grid(row=0, column=5, padx=(0, 10)) + self.rankings_button.grid(row=0, column=4, 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.rookie_button.grid(row=0, column=5, padx=(0, 10)) self.output_folder_button = ttk.Button( top_frame, @@ -289,6 +286,16 @@ class DashLeagueGUI: ttk.Label(bottom_frame, text="Map:").grid(row=0, column=1, padx=(10, 5)) ttk.OptionMenu(bottom_frame, self.map_var, "Payload", "Payload", "Domination").grid(row=0, column=2) + + # Create a specific section for the 4K Graphics + broadcast_frame = ttk.LabelFrame(main_frame, text="4K Broadcast Graphics", padding=10) + broadcast_frame.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(10, 0)) + + ttk.Button(broadcast_frame, text="Player Spotlight", command=self.on_player_spotlight).grid(row=0, column=0, padx=5) + ttk.Button(broadcast_frame, text="Tale of the Tape (PvP)", command=self.on_tale_of_the_tape).grid(row=0, column=1, padx=5) + ttk.Button(broadcast_frame, text="Team vs Team (DNA)", command=self.on_team_comparison).grid(row=0, column=2, padx=5) + ttk.Button(broadcast_frame, text="Win Probability", command=self.on_win_probability).grid(row=0, column=3, padx=5) + status_frame = ttk.Frame(self.root) status_frame.grid(row=1, column=0, sticky="ew") @@ -299,6 +306,86 @@ class DashLeagueGUI: def set_status(self, text): self.status_var.set(text) self.root.update_idletasks() + + + # Add these methods inside the DashLeagueGUI class + + def get_team_roster_data(self, team_name): + """Helper to get full career data for the current 5-man roster.""" + career_path = os.path.join(BASE_DIR, "career_stats.json") + with open(career_path, "r", encoding="utf-8") as f: + db = json.load(f) + + roster_data = [] + # Filter match players for this specific team + team_players = [p for p in self.current_match_players if p.get("team") == team_name] + + for p in team_players: + cid = p.get("canonical") + if cid in db["players"]: + # Merge match team info with career stats + p_data = db["players"][cid] + p_data["current_team"] = team_name + roster_data.append(p_data) + return roster_data + + def on_tale_of_the_tape(self): + if not self.current_match_players: + messagebox.showerror("Error", "Generate Player Slots first.") + return + + team_a = self.team_a_var.get() + team_b = self.team_b_var.get() + + roster_a = self.get_team_roster_data(team_a) + roster_b = self.get_team_roster_data(team_b) + + from utils.graphics_engine import generate_tale_of_the_tape + try: + generate_tale_of_the_tape(roster_a, roster_b, team_a, team_b) + self.set_status("Generated 4K Tale of the Tape (PvP)") + messagebox.showinfo("Success", "PvP Graphic generated in obs_exports.") + except Exception as e: + messagebox.showerror("Graphics Error", str(e)) + + def on_team_comparison(self): + if not self.current_match_players: + messagebox.showerror("Error", "Generate Player Slots first.") + return + + team_a = self.team_a_var.get() + team_b = self.team_b_var.get() + + roster_a = self.get_team_roster_data(team_a) + roster_b = self.get_team_roster_data(team_b) + + from utils.graphics_engine import generate_team_dna + try: + generate_team_dna(roster_a, roster_b, team_a, team_b) + self.set_status("Generated 4K Team DNA Graphic") + messagebox.showinfo("Success", "Team DNA Graphic generated in obs_exports.") + except Exception as e: + messagebox.showerror("Graphics Error", str(e)) + + + def on_win_probability(self): + if not self.current_match_players: + messagebox.showerror("Error", "Generate Player Slots first.") + return + + team_a = self.team_a_var.get() + team_b = self.team_b_var.get() + roster_a = self.get_team_roster_data(team_a) + roster_b = self.get_team_roster_data(team_b) + + from utils.graphics_engine import generate_win_probability + try: + generate_win_probability(roster_a, roster_b, team_a, team_b) + self.set_status("Generated 4K Win Probability Graphic") + messagebox.showinfo("Success", "Win Probability Graphic generated in obs_exports.") + except Exception as e: + messagebox.showerror("Graphics Error", str(e)) + def on_sync_stats(self): try: @@ -793,82 +880,92 @@ class DashLeagueGUI: def on_player_spotlight(self): - # 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.") + messagebox.showerror("Error", "Please 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() + # Prepare the list of players and names + players = self.current_match_players + names = [p["name"] for p in players] + self.spotlight_idx = 0 - # Load the career DB once so the 'show' function can pull match history for trends + # Load the career DB for Trend/Tag data career_path = os.path.join(BASE_DIR, "career_stats.json") with open(career_path, "r", encoding="utf-8") as f: db = json.load(f) win = tk.Toplevel(self.root) - win.title("Match Player Spotlight") - win.geometry("450x550") + win.title("Spotlight Operator") + win.geometry("500x700") - 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) + ttk.Label(win, text="Active Spotlight Player:").pack(pady=5) + + # Dropdown selection + combo_var = tk.StringVar() + combo = ttk.Combobox(win, textvariable=combo_var, values=names, state="readonly") + combo.pack(fill="x", padx=20) - output = tk.Text(win, wrap="word", font=("Courier", 10)) - output.pack(expand=True, fill="both", padx=10, pady=10) + output = tk.Text(win, wrap="word", font=("Courier", 10), height=20) + output.pack(expand=True, fill="both", padx=20, pady=10) - def show(): - selected_name = combo.get() - if not selected_name: - return + def update_spotlight(idx): + self.spotlight_idx = idx % len(players) + p_match = players[self.spotlight_idx] + selected_name = p_match["name"] + combo_var.set(selected_name) - # 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") + # Get full career data + cid = p_match.get("canonical") player_data = db["players"].get(cid) if not player_data: output.delete("1.0", tk.END) - output.insert(tk.END, "Error: Player data not found in career database.") + output.insert(tk.END, f"Data missing for {selected_name}") return - # Determine Team Side for Colors - # If the player is in the Team B listbox, they are 'Red' - side = "Blue" + # Determine Side 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" + side = "Red" if selected_name in team_b_names else "Blue" - # 3. Trigger the 4K Graphic + # Generate 4K PNG 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}") + generate_player_card(player_data, team_side=side) + self.set_status(f"Updated 4K Graphic: {selected_name}") except Exception as e: print(f"Graphics Error: {e}") - # 4. Generate the Text Preview for the GUI window + # Update Text Preview 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" + text = f"PLAYER: {selected_name}\n" + text += f"Team: {p_match.get('team', 'Unknown')}\n" + text += f"Side: {side}\n" + text += "-"*30 + "\n" + text += f"Kills: {int(career.get('kills',0)):,}\n" + text += f"Maps: {int(career.get('maps',0))}\n" + text += f"KD: {career.get('KD',0):.2f}\n\n" + text += "SKILL 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" + text += f"{tag.title():<14}: {t_obj.get('pct',0):.0f}%\n" output.delete("1.0", tk.END) output.insert(tk.END, text) - ttk.Button(win, text="Generate 4K Graphic", command=show).pack(pady=10) + # Button logic + btn_frame = ttk.Frame(win) + btn_frame.pack(pady=10) + + ttk.Button(btn_frame, text="<< Prev", + command=lambda: update_spotlight(self.spotlight_idx - 1)).grid(row=0, column=0, padx=5) + ttk.Button(btn_frame, text="Next >>", + command=lambda: update_spotlight(self.spotlight_idx + 1)).grid(row=0, column=1, padx=5) + + # Sync dropdown selection to buttons + combo.bind("<>", lambda e: update_spotlight(names.index(combo.get()))) + + # Initialize first player + update_spotlight(0) def on_team_identity(self): team = self.team_a_var.get() diff --git a/data/career_db.py b/data/career_db.py index 75ddba4..d3d1544 100644 --- a/data/career_db.py +++ b/data/career_db.py @@ -197,8 +197,30 @@ def build_career_database(output_path): # --- Advanced Tags --- pdata["anchor"] = compute_anchor_for_career_player(pdata, league_averages, distributions["anchor"], raw_cons) + + # FIX: Ensure we use the pre-calculated clutch_distribution pdata["clutch"] = compute_clutch(pdata, league_averages, clutch_distribution) + # --- Breaker Tag (Ensure distribution is full) --- + # Calculate raw for everyone to build a proper distribution + all_breaker_raws = [] + for inner_p in all_players: + ic = inner_p["career"] + all_breaker_raws.append(compute_breaker_raw(ic["damage"]/max(1,ic["maps"]), ic["kills"]/max(1,ic["maps"]))) + all_breaker_raws.sort() + + 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) + break_pct = percentile_rank(raw_breaker, all_breaker_raws) + + pdata["breaker"] = { + "raw": raw_breaker, + "pct": break_pct, + "tier": tier_from_percentile(break_pct), + "summary": f"{tier_from_percentile(break_pct)} Tier Offensive Disruptor" + } + # --- Breaker Tag --- dmg_map = career["damage"] / max(1, career["maps"]) kills_map = career["kills"] / max(1, career["maps"]) diff --git a/data/db_access.py b/data/db_access.py index aa9dc7c..3cc7152 100644 --- a/data/db_access.py +++ b/data/db_access.py @@ -88,4 +88,19 @@ def get_player_match_history(player_uuid): ORDER BY m.MatchID, r.RoundID; """ rows = query(sql, (player_uuid,)) - return aggregate_rounds_into_matches(rows) \ No newline at end of file + return aggregate_rounds_into_matches(rows) + + +def get_team_history_stats(team_uuid): + sql = """ + SELECT + COUNT(DISTINCT MatchID) as total_matches, + SUM(Kills) as total_kills, + SUM(Score) as total_score, + MIN(CycleID) as first_season + FROM aggregate_stats_table -- Adjust name based on your schema + WHERE TeamUUID = ? + """ + # Note: If your schema doesn't have a direct team stats table, + # we would aggregate the 'stats' table joined with 'matches'. + return query_one(sql, (team_uuid,)) \ No newline at end of file diff --git a/tags/clutch.py b/tags/clutch.py index ff3b96d..3b89f75 100644 --- a/tags/clutch.py +++ b/tags/clutch.py @@ -6,72 +6,35 @@ import math # --------------------------------------------------------- def compute_clutch_raw(player): - """ - Computes a meaningful 0–1 clutch score with real separation. - Components: - - pressure efficiency (log-scaled) - - high-pressure accuracy (expanded) - - consistency (smoothed) - - clutch moment density (rare-event amplifier) - """ - career = player.get("career", {}) - dmg = career.get("damage", 0) - deaths = career.get("deaths", 0) + # 1. Pressure Efficiency: Log-scaled but with a much higher ceiling (4000 instead of 3000) + # This pushes average players further down the scale. + pressure_eff = career.get("damage", 0) / (career.get("deaths", 0) + 1) + pressure_norm = math.log1p(pressure_eff) / math.log1p(4500) + + # 2. Consistency: Use their raw stability score + consistency_norm = player.get("consistency", {}).get("raw", 0.5) + + # 3. Conversion: Tighten elite accuracy requirements shots = career.get("shots", 0) - shots_hit = career.get("shots_hit", 0) - headshots = career.get("headshots", 0) - - # ----------------------------------------- - # 1. Pressure Efficiency (log-scaled) - # ----------------------------------------- - pressure_eff = dmg / (deaths + 1) - pressure_norm = math.log1p(pressure_eff) / math.log1p(3000) - - # ----------------------------------------- - # 2. Consistency (smoothed) - # ----------------------------------------- - consistency = player.get("consistency", {}) - consistency_norm = consistency.get("raw", 0.0) - consistency_norm = max(0.1, consistency_norm) # avoid collapse - - # ----------------------------------------- - # 3. Accuracy + Headshot Rate (expanded) - # ----------------------------------------- - accuracy = shots_hit / max(1, shots) - headshot_rate = headshots / max(1, shots_hit) - - acc_norm = min(1.0, accuracy / 0.25) # 25% = elite - hs_norm = min(1.0, headshot_rate / 0.20) # 20% = elite + hit = career.get("shots_hit", 0) + hs = career.get("headshots", 0) + accuracy = hit / max(1, shots) + headshot_rate = hs / max(1, hit) + # Elite is now 30% acc / 25% HS - harder to hit 1.0 + acc_norm = min(1.0, accuracy / 0.30) + hs_norm = min(1.0, headshot_rate / 0.25) conversion = (acc_norm * 0.4) + (hs_norm * 0.6) - # ----------------------------------------- - # 4. Clutch Moment Density (rare-event amplifier) - # ----------------------------------------- - matches = player.get("matches", []) - clutch_events = 0 - - for m in matches: - # Count high-pressure events (kills in final minute, etc.) - # We don't have real clutch stats, so approximate: - if m.get("Kills", 0) >= 10: - clutch_events += 1 - - density = clutch_events / max(1, len(matches)) - density_norm = min(1.0, density * 3.0) # amplify rare events - - # ----------------------------------------- - # Final weighted score (expanded) - # ----------------------------------------- + # 4. Weighting: Shift weight toward Pressure and Conversion raw = ( - pressure_norm * 0.35 + - consistency_norm * 0.25 + - conversion * 0.25 + - density_norm * 0.15 + pressure_norm * 0.40 + + conversion * 0.40 + + consistency_norm * 0.20 ) - - return max(0.05, min(1.0, raw)) + # Apply an exponential "curve" to create more separation at the top + return max(0.05, min(1.0, raw ** 1.5)) # --------------------------------------------------------- diff --git a/utils/graphics_engine.py b/utils/graphics_engine.py index 17722ad..434c8c1 100644 --- a/utils/graphics_engine.py +++ b/utils/graphics_engine.py @@ -2,132 +2,273 @@ import os import math from PIL import Image, ImageDraw, ImageFont -# --- PATHING CONFIGURATION --- +# --- PATHING --- 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") +EXPORT_DIR = os.path.join(BASE_DIR, "..", "stats", "obs_exports") -# Ensure directories exist -os.makedirs(ASSETS_DIR, exist_ok=True) -os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True) +# Hex to RGB conversion +COLOR_BLUE = (13, 134, 244) # #0d86f4 +COLOR_RED = (244, 13, 53) # #f40d35 + +SPOTLIGHT_PATH = os.path.join(EXPORT_DIR, "spotlight_card.png") +TAPE_PATH = os.path.join(EXPORT_DIR, "tale_of_the_tape.png") +DNA_PATH = os.path.join(EXPORT_DIR, "team_dna.png") +WIN_PATH = os.path.join(EXPORT_DIR, "win_probability.png") + +def get_font(size): + try: return ImageFont.truetype("arialbd.ttf", size) + except: + try: return ImageFont.truetype("arial.ttf", size) + except: return ImageFont.load_default() + +def load_bg(screen_name): + width, height = 3840, 2160 + paths = [os.path.join(ASSETS_DIR, f"{screen_name}_bg.png"), os.path.join(ASSETS_DIR, "background.png")] + for p in paths: + if os.path.exists(p): return Image.open(p).resize((width, height)) + return Image.new('RGB', (width, height), color=(10, 10, 15)) + +def draw_radar_chart(draw, center, radius, params, color): + points = [] + for i, (label, value) in enumerate(params): + angle = (i * 2 * math.pi / 5) - (math.pi / 2) + dist = (max(5, value) / 100) * radius + points.append((center[0] + dist * math.cos(angle), center[1] + dist * math.sin(angle))) + + for r_step in [0.25, 0.5, 0.75, 1.0]: + grid_points = [] + for i in range(5): + angle = (i * 2 * math.pi / 5) - (math.pi / 2) + grid_points.append((center[0] + (radius * r_step) * math.cos(angle), center[1] + (radius * r_step) * math.sin(angle))) + draw.polygon(grid_points, outline=(255, 255, 255, 30)) + if r_step == 1.0: + for i, (label, _) in enumerate(params): + angle = (i * 2 * math.pi / 5) - (math.pi / 2) + draw.text((center[0] + (radius + 80) * math.cos(angle), center[1] + (radius + 40) * math.sin(angle)), label, font=get_font(50), fill=(255, 255, 255), anchor="mm") + draw.polygon(points, fill=color + (80,), outline=color + (255,)) 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() + img = load_bg("spotlight") + draw = ImageDraw.Draw(img, 'RGBA') + accent_color = COLOR_BLUE if team_side == "Blue" else COLOR_RED - # 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)) + name = player_data.get("name", "Unknown").upper() + draw.text((200, 150), name, font=get_font(220), 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 - + last_5 = matches[-5:] if len(matches) >= 5 else matches + recent_kd = (sum(m.get("Kills",0) for m in last_5)/sum(m.get("Deaths",1) for m in last_5)) if last_5 else career_kd 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)) + draw.rounded_rectangle([200, 400, 950, 500], radius=20, fill=(50, 255, 50) if diff >= 0 else (255, 50, 50)) + draw.text((230, 415), f"{'HOT FORM' if diff >=0 else 'COOLING'} ({'+' 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 + 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}")] + 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 + draw.text((200, y), label, font=get_font(70), fill=(180, 180, 180)) + draw.text((200, y + 80), value, font=get_font(130), fill=(255, 255, 255)) + y += 280 - # 6. RIGHT COLUMN: SKILL BARS - draw.text((2000, 450), "SKILL PROFILE vs LEAGUE AVG", font=label_font, fill=accent_color) + radar_params = [("SLY", player_data.get('slayer', {}).get('pct', 0)), ("AIM", player_data.get('sharpshooter', {}).get('pct', 0)), ("STA", player_data.get('consistency', {}).get('pct', 0)), ("CLU", player_data.get('clutch', {}).get('pct', 0)), ("ANC", player_data.get('anchor', {}).get('pct', 0))] + draw_radar_chart(draw, (1850, 1150), 450, radar_params, accent_color) + + draw.text((2700, 550), "SKILL PROFILE", font=get_font(70), fill=accent_color) + by = 680 + for label_long, key in [("SLAYING", "slayer"), ("MARKSMAN", "sharpshooter"), ("STABILITY", "consistency"), ("PRESSURE", "clutch"), ("DEFENSIVE", "anchor")]: + pct = player_data.get(key, {}).get("pct", 0) + draw.text((2700, by), label_long, font=get_font(45), fill=(200, 200, 200)) + draw.rectangle([2700, by+60, 3600, by+90], fill=(40, 40, 45)) + draw.rectangle([2700, by+60, 2700 + (900 * (pct/100)), by+90], fill=accent_color) + draw.text((3610, by+45), f"{int(pct)}%", font=get_font(40), fill=(255,255,255), anchor="lm") + by += 180 + + best_tag = max(["slayer", "sharpshooter", "consistency", "clutch", "anchor"], key=lambda k: player_data.get(k, {}).get("pct", 0)) + intel = player_data.get(best_tag, {}).get("summary", "Reliable team player.") + draw.rectangle([150, 1850, 3690, 2080], fill=(20, 20, 25)) + draw.text((200, 1880), "SCOUTING REPORT", font=get_font(70), fill=accent_color) + draw.text((200, 1960), intel, font=get_font(60), fill=(230, 230, 230)) + img.save(SPOTLIGHT_PATH) + +def generate_tale_of_the_tape(rosterA, rosterB, nameA, nameB): + img = load_bg("tape") + draw = ImageDraw.Draw(img, 'RGBA') + rosterA.sort(key=lambda p: p['career'].get('KD', 0), reverse=True) + rosterB.sort(key=lambda p: p['career'].get('KD', 0), reverse=True) + + draw.text((960, 150), nameA, font=get_font(150), fill=COLOR_BLUE, anchor="mm") + draw.text((2880, 150), nameB, font=get_font(150), fill=COLOR_RED, anchor="mm") + draw.text((1920, 150), "PVP MATCHUPS", font=get_font(80), fill=(255, 255, 255), anchor="mm") + + y = 350 + # Added ANCHOR (Stability is Consistency) + tags = [("SLAYER", "slayer"), ("AIM", "sharpshooter"), ("STABLE", "consistency"), ("CLUTCH", "clutch"), ("ANCHOR", "anchor")] - tags_to_show = [ - ("SLAYING", "slayer"), - ("MARKSMAN", "sharpshooter"), - ("CONSISTENCY", "consistency"), - ("PRESSURE", "clutch"), - ("DEFENSIVE", "anchor") + for i in range(5): + pA = rosterA[i] if i < len(rosterA) else {"name": "---"} + pB = rosterB[i] if i < len(rosterB) else {"name": "---"} + draw.text((250, y+100), pA['name'].upper(), font=get_font(90), fill=(255,255,255)) + draw.text((3590, y+100), pB['name'].upper(), font=get_font(90), fill=(255,255,255), anchor="ra") + + sub_y = y + 10 + for label, key in tags: + pctA, pctB = pA.get(key, {}).get("pct", 0), pB.get(key, {}).get("pct", 0) + draw.text((1920, sub_y), label, font=get_font(25), fill=(150,150,150), anchor="mm") + draw.rectangle([1450, sub_y-10, 1850, sub_y+10], fill=(30,30,35)) + draw.rectangle([1850 - (400 * (pctA/100)), sub_y-10, 1850, sub_y+10], fill=COLOR_BLUE) + draw.rectangle([1990, sub_y-10, 2390, sub_y+10], fill=(30,30,35)) + draw.rectangle([1990, sub_y-10, 1990 + (400 * (pctB/100)), sub_y+10], fill=COLOR_RED) + sub_y += 48 + + draw.line([200, y+280, 3640, y+280], fill=(255,255,255, 15), width=2) + y += 310 + + avgA = sum(p.get('slayer', {}).get('pct', 0) for p in rosterA) / 5 + avgB = sum(p.get('slayer', {}).get('pct', 0) for p in rosterB) / 5 + draw.rectangle([0, 1950, 3840, 2160], fill=(15, 15, 20)) + draw.text((400, 2055), f"SQUAD SLAYER POWER: {avgA:.1f}%", font=get_font(60), fill=COLOR_BLUE, anchor="lm") + draw.text((3440, 2055), f"SQUAD SLAYER POWER: {avgB:.1f}%", font=get_font(60), fill=COLOR_RED, anchor="rm") + img.save(TAPE_PATH) + +def generate_team_dna(rosterA, rosterB, nameA, nameB): + img = load_bg("dna") + draw = ImageDraw.Draw(img, 'RGBA') + draw.text((200, 100), nameA, font=get_font(140), fill=COLOR_BLUE) + draw.text((3640, 100), nameB, font=get_font(140), fill=COLOR_RED, anchor="ra") + draw.text((1920, 150), "STRATEGY DNA", font=get_font(100), fill=(255,255,255), anchor="mm") + + tags = ["slayer", "sharpshooter", "consistency", "clutch", "objective_payload", "objective_domination", "anchor"] + y = 450 + for tag in tags: + avgA = sum(p.get(tag, {}).get("pct", 0) for p in rosterA) / max(1, len(rosterA)) + avgB = sum(p.get(tag, {}).get("pct", 0) for p in rosterB) / max(1, len(rosterB)) + draw.text((1920, y - 40), tag.replace("_", " ").upper(), font=get_font(55), fill=(160,160,160), anchor="mm") + draw.rectangle([600, y, 1850, y+70], fill=(30, 30, 35)) + draw.rectangle([1850 - (1250 * (avgA/100)), y, 1850, y+70], fill=COLOR_BLUE) + draw.rectangle([1990, y, 3240, y+70], fill=(30, 30, 35)) + draw.rectangle([1990, y, 1990 + (1250 * (avgB/100)), y+70], fill=COLOR_RED) + y += 200 + + draw.rectangle([0, 1850, 3840, 2160], fill=(15, 15, 20)) + vitals = [("TOTAL KILLS", lambda r: sum(p['career'].get('kills', 0) for p in r)), ("AVG KD", lambda r: sum(p['career'].get('KD', 0) for p in r) / len(r)), ("TOTAL MAPS", lambda r: sum(p['career'].get('maps', 0) for p in r))] + vx_pos = [450, 1100, 1750] # Increased spacing to fix overlap + for i, (label, func) in enumerate(vitals): + valA, valB = func(rosterA), func(rosterB) + draw.text((vx_pos[i], 1920), label, font=get_font(45), fill=(150, 150, 150), anchor="mm") + draw.text((vx_pos[i], 2020), f"{valA:,.1f}" if isinstance(valA, float) else f"{valA:,}", font=get_font(90), fill=COLOR_BLUE, anchor="mm") + draw.text((3840 - vx_pos[i], 1920), label, font=get_font(45), fill=(150, 150, 150), anchor="mm") + draw.text((3840 - vx_pos[i], 2020), f"{valB:,.1f}" if isinstance(valB, float) else f"{valB:,}", font=get_font(90), fill=COLOR_RED, anchor="mm") + img.save(DNA_PATH) + +def draw_vs_donut(draw, center, radius, valA, valB, colorA, colorB, label): + """Draws a split donut showing relative strength between two teams.""" + cx, cy = center + width = 35 + total = valA + valB + if total <= 0: # Prevent div by zero + valA, valB = 50, 50 + total = 100 + + # Percentages of the circle + pctA = valA / total + + # Background circle + draw.ellipse([cx-radius, cy-radius, cx+radius, cy+radius], outline=(40, 40, 45), width=width) + + # Arcs + start_angle = -90 + split_angle = start_angle + (360 * pctA) + + # Blue Arc (Team A) + draw.arc([cx-radius, cy-radius, cx+radius, cy+radius], start=start_angle, end=split_angle, fill=colorA, width=width) + # Red Arc (Team B) + draw.arc([cx-radius, cy-radius, cx+radius, cy+radius], start=split_angle, end=start_angle + 360, fill=colorB, width=width) + + # Center Text + draw.text((cx, cy), f"{int((valA/total)*100)}% | {int((valB/total)*100)}%", font=get_font(45), fill=(255, 255, 255), anchor="mm") + draw.text((cx, cy + radius + 60), label, font=get_font(40), fill=(180, 180, 180), anchor="mm") + +def generate_win_probability(rosterA, rosterB, nameA, nameB): + img = load_bg("win") + draw = ImageDraw.Draw(img, 'RGBA') + + # 1. DATA CALCULATION + from analysis.team_identity_v2 import summarize_team_tags + sumA, sumB = summarize_team_tags(rosterA), summarize_team_tags(rosterB) + + # Calculate Strengths (Tags >= 60th percentile) + def get_strengths(summary): + s_list = [] + for tag, data in summary.items(): + if data["avg_pct"] >= 60: + s_list.append(f"{tag.replace('_',' ').title()} Specialists") + return s_list if s_list else ["Versatile Roster"] + + strengthsA = get_strengths(sumA) + strengthsB = get_strengths(sumB) + + # 2. WIN PROBABILITY MATH + weights = {"slayer": 0.35, "consistency": 0.25, "clutch": 0.15, "objective_payload": 0.15, "sharpshooter": 0.10} + powerA = sum(sumA[tag]["avg_pct"] * weights[tag] for tag in weights) + powerB = sum(sumB[tag]["avg_pct"] * weights[tag] for tag in weights) + + probA = (powerA / (powerA + powerB)) * 100 if (powerA + powerB) > 0 else 50 + probB = 100 - probA + + # 3. HEADER + draw.text((1920, 150), "MATCH PREVIEW & PROBABILITY", font=get_font(120), fill=(255,255,255), anchor="mm") + + # Big Win Numbers + draw.text((960, 450), nameA.upper(), font=get_font(100), fill=COLOR_BLUE, anchor="mm") + draw.text((960, 650), f"{probA:.1f}%", font=get_font(300), fill=(255,255,255), anchor="mm") + + draw.text((2880, 450), nameB.upper(), font=get_font(100), fill=COLOR_RED, anchor="mm") + draw.text((2880, 650), f"{probB:.1f}%", font=get_font(300), fill=(255,255,255), anchor="mm") + + # 4. VS DONUT GAUGES (6 Total) + gauge_tags = [ + ("SLAYING", "slayer"), ("AIM", "sharpshooter"), + ("STABILITY", "consistency"), ("PRESSURE", "clutch"), ("TACTICS", "anchor") ] + gx = 450 + for label, key in gauge_tags: + draw_vs_donut(draw, (gx, 1150), 120, sumA[key]["avg_pct"], sumB[key]["avg_pct"], COLOR_BLUE, COLOR_RED, label) + gx += 600 - 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 + # 6th Gauge: EXPERIENCE (Maps Played) + mapsA = sum(p['career'].get('maps', 0) for p in rosterA) + mapsB = sum(p['career'].get('maps', 0) for p in rosterB) + draw_vs_donut(draw, (gx, 1150), 120, mapsA, mapsB, COLOR_BLUE, COLOR_RED, "EXPERIENCE") - # 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.") + # 5. MATCH KEY FACTOR + diffs = {tag: sumA[tag]["avg_pct"] - sumB[tag]["avg_pct"] for tag in weights} + best_tag = max(diffs, key=lambda k: abs(diffs[k])) + favored = nameA if diffs[best_tag] > 0 else nameB + + hooks = { + "slayer": f"The '{favored}' Firepower: Superior elimination pressure likely to dictate map pace.", + "consistency": f"The '{favored}' Floor: Higher stability makes them significantly harder to upset.", + "clutch": f"The '{favored}' Resolve: Statistical advantage in high-pressure closing moments.", + "objective_payload": f"The '{favored}' Efficiency: Superior objective focus provides a clear win-condition.", + "sharpshooter": f"The '{favored}' Precision: Lethal accuracy in duels could negate enemy numbers." + } + key_hook = hooks.get(best_tag, f"The {best_tag.title()} Edge: {favored} hold the strategic advantage.") - # 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)) + draw.rectangle([400, 1450, 3440, 1700], fill=(20, 20, 25)) + draw.text((1920, 1510), "PRODUCER'S KEY FACTOR", font=get_font(50), fill=COLOR_BLUE if favored == nameA else COLOR_RED, anchor="mm") + draw.text((1920, 1610), key_hook, font=get_font(70), fill=(255, 255, 255), anchor="mm") - # 8. SAVE - img.save(OUTPUT_PATH) - return OUTPUT_PATH \ No newline at end of file + # 6. TEAM INTEL (Strengths Only) + def draw_intel(draw, x, name, strengths, color): + draw.text((x, 1800), f"{name} STRENGTHS", font=get_font(55), fill=color, anchor="mm") + y_off = 1880 + for s in strengths[:3]: + draw.text((x, y_off), f"• {s}", font=get_font(45), fill=(220, 220, 220), anchor="mm") + y_off += 65 + + draw_intel(draw, 960, nameA, strengthsA, COLOR_BLUE) + draw_intel(draw, 2880, nameB, strengthsB, COLOR_RED) + + img.save(WIN_PATH) \ No newline at end of file