DL-Broadcast-Tool/dashleague_cast_tool.py

2548 lines
No EOL
107 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import os
import json
import math
import threading
import functools
from http.server import HTTPServer, SimpleHTTPRequestHandler
from collections import defaultdict
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import requests
# ---------------------------------------------------------
# Internal modules — clean, correct, no duplicates
# ---------------------------------------------------------
# Career DB builder
from data.career_db import build_career_database
# Career rankings (used for global leaderboards)
from rankings import (
top_players_by_tag,
split_two_columns,
rank_match_players_by_tag,
)
# Team identity + storyline + predictions
from analysis.team_identity_v2 import generate_team_identity_block
from analysis.storylines import generate_storyline, matchup_storyline, matchup_narrative
from analysis.predictions_v2 import generate_prediction
from analysis.rookie_leaderboard import rank_rookies_by_tag
# OBS export
from analysis.obs_export_v2 import export_all_obs
# Domination objective (match + career)
from tags.objective_domination import (
compute_dom_objective_for_career_player,
compute_dom_objective_for_match_player,
compute_dom_objective_team_scores,
)
# Prediction engine (legacy but still used)
from predictions.prediction_engine import generate_predictions
# Match-based tag engines
from match_engine import (
rank_match_players_slayer,
rank_match_players_objpl,
rank_match_players_objdom,
rank_match_players_sharpshooter,
rank_match_players_clutch,
compute_slayer_prediction,
rank_match_players_consistency_career,
load_career_db,
attach_career_tags_to_match_player,
)
from data.tag_framework import percentile_rank, tier_from_percentile
from data.roster_overrides import load_roster_overrides, save_roster_overrides, set_override, clear_override
# ---------------------------------------------------------
# PATHS + CONFIG
# ---------------------------------------------------------
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
CONFIG_PATH = os.path.join(BASE_DIR, "config.json")
SCREENS_DIR = os.path.join(BASE_DIR, "screens")
# Load config (or default)
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
config = json.load(f)
else:
config = {}
# Default output folder (outside repo)
DEFAULT_OUTPUT_ROOT = os.path.abspath(os.path.join(BASE_DIR, "..", "stats"))
# Allow override from config.json
output_root = config.get("output_root", DEFAULT_OUTPUT_ROOT)
# Build runtime directories
STATS_DIR = output_root
PLAYERS_DIR = os.path.join(STATS_DIR, "players")
OBS_EXPORT_DIR = os.path.join(STATS_DIR, "obs_exports")
os.makedirs(PLAYERS_DIR, exist_ok=True)
os.makedirs(OBS_EXPORT_DIR, exist_ok=True)
API_BASE = "https://dashleague.games/api/v1"
LOCAL_OBS_SERVER_PORT = 8787
def export_to_obs(filename, text):
path = os.path.join(OBS_EXPORT_DIR, filename)
with open(path, "w", encoding="utf-8") as f:
f.write(text)
return path
# ---------------------------------------------------------
# Breaker (match-based)
# ---------------------------------------------------------
def compute_breaker_for_match_player(p):
kills = p.get("kills", 0) or 0
damage = p.get("damage", 0) or 0
dom_counters = p.get("DOM_Counters", 0) or 0
dom_caps = p.get("DOM_Captures", 0) or 0
cp_caps = p.get("CP_Captures", 0) or 0
raw = (
dom_counters * 1.0 +
dom_caps * 0.6 +
cp_caps * 0.4 +
kills * 0.05 +
damage / 20000.0
)
return max(raw, 0.0)
# ---------------------------------------------------------
# Live-sync fallback formulas for every other tag. These are ONLY used
# when a player has no career data at all (e.g. missing from the local
# match_history.db cache that build_career_database depends on, which
# doesn't get refreshed with current-season activity). They REUSE the
# exact same real tag formulas career players get (tags/slayer.py,
# tags/sharpshooter.py, tags/anchor.py, tags/clutch.py,
# tags/breaker.py) fed with this season's live-synced cumulative stats,
# normalized against the SAME league averages — so the result is on a
# genuinely comparable scale to career-based scores, not a separate,
# incompatible calculation. Still always percentile-ranked only against
# OTHER no-data players in the same match, never mixed into a team
# average alongside real career-quality numbers, and always clearly
# labeled as an estimate wherever it's displayed.
# ---------------------------------------------------------
def estimate_maps_played(p, league_averages, career_db=None):
"""
Prefers a VERIFIED current-season maps count when available (fetched
per-player from /api/v1/player?PlayerID=X, which returns a real
per-season breakdown — see step 4.5 in on_generate_slots). That
value is already season-scoped, so it's used directly with no
further adjustment.
Otherwise, falls back to the bulk /stats endpoint's "maps" field,
which is CAREER-TOTAL (lifetime maps ever played) despite being
fetched from a season-filtered URL — confirmed against the
website's own per-season breakdown. In that case, subtract whatever
maps career_stats.json already has on file for this player (built
from match_history.db, which only covers PAST seasons) to estimate
just the current season's portion. For a player with no
career_stats.json entry at all, there's nothing to subtract — the
live total is used as-is, correct for a first-season player and an
overestimate for anyone with unbuilt history we have no way to
detect from here.
Falls back further to a deaths-based estimate only if no maps figure
is available at all.
"""
if p.get("_verified_season_stats"):
return max(1.0, float(p.get("maps") or 1))
real_maps = p.get("maps")
if real_maps:
prior_maps = 0
cid = p.get("canonical")
if career_db and cid:
pdata = career_db.get("players", {}).get(cid)
if pdata:
prior_maps = (pdata.get("career") or {}).get("maps", 0) or 0
current_season_maps = float(real_maps) - float(prior_maps)
return max(1.0, current_season_maps)
deaths = p.get("deaths", 0) or 0
avg_deaths_per_map = league_averages.get("deaths_per_map") or league_averages.get("deaths") or 1
if avg_deaths_per_map <= 0:
avg_deaths_per_map = 1
return max(1.0, deaths / avg_deaths_per_map)
def _pseudo_career_dict(p, maps):
return {
"kills": p.get("kills", 0) or 0,
"deaths": p.get("deaths", 0) or 0,
"damage": p.get("damage", 0) or 0,
"shots": p.get("shots", 0) or 0,
"shots_hit": p.get("shots_hit", 0) or 0,
"headshots": p.get("headshots", 0) or 0,
"maps": maps,
}
def compute_fallback_slayer(p, league_averages):
from tags.slayer import compute_slayer_raw
maps = estimate_maps_played(p, league_averages)
pseudo = {"career": _pseudo_career_dict(p, maps)}
return compute_slayer_raw(pseudo, league_averages)
def compute_fallback_sharpshooter(p, league_averages):
from tags.sharpshooter import compute_sharpshooter_raw
maps = estimate_maps_played(p, league_averages)
career = _pseudo_career_dict(p, maps)
return compute_sharpshooter_raw(career, league_averages)
def compute_fallback_anchor(p, league_averages):
from tags.anchor import compute_anchor_raw
maps = estimate_maps_played(p, league_averages)
pseudo = {"career": _pseudo_career_dict(p, maps)}
# Consistency genuinely can't be measured without per-match variance
# data, so use the neutral midpoint here too — same treatment as
# the standalone Consistency tag for no-data players.
return compute_anchor_raw(pseudo, league_averages, 0.5)
def compute_fallback_clutch(p, league_averages):
from tags.clutch import compute_clutch_raw
maps = estimate_maps_played(p, league_averages)
pseudo = {
"career": _pseudo_career_dict(p, maps),
"consistency_raw": 0.5,
}
return compute_clutch_raw(pseudo, league_averages)
def compute_fallback_payload(p, league_averages):
# compute_payload_raw() needs real per-match granularity (a list of
# individual matches) we don't have from live-sync cumulative
# totals, so it can't be reused directly. Approximate on a
# comparable scale instead: average push time per (estimated) map,
# capped at the same 300s soft cap the real formula uses.
maps = estimate_maps_played(p, league_averages)
push = p.get("PAY_PushTime", 0) or 0
avg_push = push / maps
return min(avg_push / 300.0, 1.0)
def compute_breaker_for_match_player(p, league_averages):
from tags.breaker import compute_breaker_raw
maps = estimate_maps_played(p, league_averages)
dmg_per_map = (p.get("damage", 0) or 0) / maps
kills_per_map = (p.get("kills", 0) or 0) / maps
return compute_breaker_raw(dmg_per_map, kills_per_map)
def compute_fallback_domination(p, league_averages):
"""
Reuses the EXACT same log-scaled, PER-MAP formula
compute_dom_objective_for_career_player uses, fed with this
season's captures/counters divided by (estimated) maps played.
"""
maps = estimate_maps_played(p, league_averages)
caps = p.get("DOM_Captures", 0) or p.get("DOM_captures", 0) or 0
counters = p.get("DOM_Counters", 0) or p.get("DOM_counters", 0) or 0
caps_per_map = caps / maps
counters_per_map = counters / maps
league_avg_dom = (league_averages or {}).get("dom_actions", 1) or 1
ceiling = max(1.0, league_avg_dom * 3)
cap_rate = math.log1p(caps_per_map) / math.log1p(1 + ceiling)
counter_rate = math.log1p(counters_per_map) / math.log1p(1 + ceiling)
raw = (0.40 * cap_rate) + (0.60 * counter_rate)
return max(0.05, min(raw, 1.0))
FALLBACK_FORMULAS = {
"breaker": (compute_breaker_for_match_player, "Breaker"),
"slayer": (compute_fallback_slayer, "Slayer"),
"sharpshooter": (compute_fallback_sharpshooter, "Sharpshooter"),
"objective_payload": (compute_fallback_payload, "Payload"),
"objective_domination": (compute_fallback_domination, "Domination"),
"anchor": (compute_fallback_anchor, "Anchor"),
"clutch": (compute_fallback_clutch, "Clutch"),
}
# ---------------------------------------------------------
# Team Identity Formatter (Standalone Helper)
# ---------------------------------------------------------
def format_team_identity(identity):
"""
Converts the identity block dict into readable caster-friendly text.
"""
lines = []
lines.append(f"Team Style: {identity.get('team_style', 'Unknown')}")
lines.append("")
lines.append("Strengths:")
for s in identity.get("strengths", []):
lines.append(f" - {s}")
lines.append("")
lines.append("Weaknesses:")
for w in identity.get("weaknesses", []):
lines.append(f" - {w}")
lines.append("")
lines.append("Tag Breakdown:")
for tag, data in identity.get("tags", {}).items():
lines.append(
f" - {tag.replace('_', ' ').title()}: "
f"{data['tier']} Tier (avg {data['avg_pct']:.1f} percentile)"
)
return "\n".join(lines)
def fetch_json(url):
resp = requests.get(url, timeout=10)
resp.raise_for_status()
return resp.json()
def fetch_player_season_stats(raw_uuid, season):
"""
Calls /api/v1/player?PlayerID=X&playoffs=false to get EXACT
per-season stats (maps, kills, deaths, damage, shots, shots_hit,
headshots, CP_captures, PAY_PushTime, DOM_captures, DOM_counters)
for one specific player. Far more accurate than the bulk /stats
endpoint's "maps" field, which is career-cumulative despite being
fetched from a season-filtered URL — confirmed against the
website's own per-season breakdown. Only called for players
missing from career_stats.json, so this is one extra request per
player who actually needs it, not per sync.
Returns None on any failure so callers can gracefully fall back to
the cruder estimate instead.
"""
try:
url = f"{API_BASE}/player?PlayerID={raw_uuid}&playoffs=false"
data = fetch_json(url)
return (data.get("seasons") or {}).get(str(season))
except Exception as e:
print(f"Player season stats fetch failed for {raw_uuid}: {e}")
return None
def ensure_player_slots():
os.makedirs(PLAYERS_DIR, exist_ok=True)
for i in range(10):
slot_dir = os.path.join(PLAYERS_DIR, f"p{i}")
os.makedirs(slot_dir, exist_ok=True)
def write_text(path, value):
with open(path, "w", encoding="utf-8") as f:
f.write(str(value))
# ---------------------------------------------------------
# Roster / mid-season team tracking
# ---------------------------------------------------------
def get_current_team(cid, pir, overrides):
"""
Returns a player's CURRENT team, honoring a manual roster override
first (for mid-season moves/merges that haven't shown up in a synced
match yet), falling back to the most recent entry in their
match-derived team_history otherwise.
"""
if cid in overrides:
return overrides[cid]
entry = pir.data.get("canonical", {}).get(cid, {})
team_history = entry.get("team_history") or []
return team_history[-1] if team_history else None
CAREER_AVERAGE_FIELDS = (
"kills", "deaths", "score", "KD", "accuracy", "push_time",
"DOM_Captures", "DOM_Counters", "maps", "damage", "shots",
"shots_hit", "headshots",
)
def compute_team_average_career(career_db, team_canonical_ids):
"""
Averages the 'career' block across the OTHER players on this specific
team selection who already have real career data — NOT a league-wide
average, and NOT tier-aware (Dasher/Sprinter/Walker), since a new
player's own tier is unknown until they've played. This is used
ONLY as a temporary display value inside the three team-comparison
graphics (Tale of the Tape, Team DNA, Win Probability) so those
graphics have something reasonable to plot for a new teammate. It is
never written back to career_stats.json and never shown on a
player's personal Spotlight card — a new player's individual stats
stay untouched (empty) until they actually have real data.
"""
players_db = career_db.get("players", {})
totals = {field: 0.0 for field in CAREER_AVERAGE_FIELDS}
count = 0
for cid in team_canonical_ids:
pdata = players_db.get(cid)
if not pdata:
continue
career = pdata.get("career")
if not career:
continue
count += 1
for field in CAREER_AVERAGE_FIELDS:
totals[field] += career.get(field, 0) or 0
if count == 0:
return {field: 0 for field in CAREER_AVERAGE_FIELDS}
return {field: totals[field] / count for field in CAREER_AVERAGE_FIELDS}
def resolve_tag_career_first(all_players, tag_name, career_db, fallback_fn, label):
"""
Generalizes the pattern originally built just for Breaker: use real
career_stats.json data when a player has it, and ONLY fall back to a
rough live-sync estimate for players who genuinely have none (e.g.
missing from the local match_history.db cache build_career_database
depends on — that cache only covers past seasons and never picks up
current-season activity). Fallback players are explicitly marked
has_data=False so they're excluded from team averages/predictions
elsewhere, rather than silently counted as if they'd earned a real
score of zero.
"""
league_averages = (career_db or {}).get("league_averages", {})
needs_fallback = []
for p in all_players:
cid = p.get("canonical")
pdata = career_db["players"].get(cid) if (career_db and cid) else None
career_tag = pdata.get(tag_name) if pdata else None
if career_tag and career_tag.get("raw") is not None:
p[tag_name] = dict(career_tag)
p[tag_name]["has_data"] = True
else:
needs_fallback.append(p)
if needs_fallback:
for p in needs_fallback:
p[tag_name] = {"raw": fallback_fn(p, league_averages)}
# Percentile-rank against the REAL league-wide distribution
# (saved by build_career_database) whenever it's available, so
# these players are measured against the SAME population career
# players are — not just each other. Ranking only against the
# handful of other no-data players in this one match was
# producing misleading results: a higher raw score could show a
# LOWER percentile than a lower raw score from a different
# player, simply because they were being compared against two
# completely different populations (tiny match-local group vs.
# the whole league).
league_distribution = (career_db or {}).get("distributions", {}).get(tag_name)
distribution = league_distribution if league_distribution else [p[tag_name]["raw"] for p in needs_fallback]
for p in needs_fallback:
raw = p[tag_name]["raw"]
pct = percentile_rank(raw, distribution)
tier = tier_from_percentile(pct)
p[tag_name]["pct"] = pct
p[tag_name]["tier"] = tier
if p.get("_verified_season_stats"):
# Real, verified current-season data (fetched per-player
# from the API, not a guess) — no reason to exclude it
# from team averages or flag it as an estimate anymore.
p[tag_name]["has_data"] = True
p[tag_name]["summary"] = (
f"{label}: {tier} Tier ({pct:.1f} percentile) — from verified "
f"current-season data (no prior-season career history on file yet)."
)
else:
p[tag_name]["has_data"] = False
p[tag_name]["summary"] = (
f"{label} ESTIMATE from this season's activity only — no "
f"career data on file yet. Not directly comparable to "
f"other players' career-based scores."
)
def rename_player(cid, new_name):
"""
Records a new current display name for a canonical player. Updates
BOTH the identity registry (which keeps the full name history, used
for team-roster display) AND career_stats.json's stored name (which
is what the Spotlight card, Tale of the Tape, Team DNA, and Win
Probability graphics all read) — otherwise a rename only shows up in
half the app, which is exactly the bug this fixes.
"""
from analysis.player_identity_registry import PlayerIdentityRegistry
pir = PlayerIdentityRegistry()
pir.update_name(cid, new_name)
career_path = os.path.join(BASE_DIR, "career_stats.json")
if os.path.exists(career_path):
with open(career_path, "r", encoding="utf-8") as f:
db = json.load(f)
if cid in db.get("players", {}):
db["players"][cid]["name"] = new_name
with open(career_path, "w", encoding="utf-8") as f:
json.dump(db, f, indent=2)
# ---------------------------------------------------------
# Ranking tags (shared between the on-screen Rankings popup and the
# auto-exported HTML pages, so both always agree)
# ---------------------------------------------------------
RANKING_TAGS = [
"Slayer",
"Payload Objective Specialist",
"Sharpshooter",
"Domination Objective Specialist",
"Consistency",
"Clutch",
"Anchor",
"Breaker",
]
RANKING_TAG_SLUGS = {
"Slayer": "slayer",
"Payload Objective Specialist": "payload",
"Sharpshooter": "sharpshooter",
"Domination Objective Specialist": "domination",
"Consistency": "consistency",
"Clutch": "clutch",
"Anchor": "anchor",
"Breaker": "breaker",
}
RANKING_FORMULAS = {
"Slayer": [
"Slayer Formula:",
"Slayer Score = (Damage per Map × 0.40) + (Kills per Map × 0.40) + (KD × 0.20)",
],
"Sharpshooter": [
"Sharpshooter Formula:",
"Sharpshooter = (Accuracy × 0.40) + (Headshot Rate × 0.40) + (Damage per Shot × 0.20)",
"Where:",
"- Accuracy = Shots Hit / Shots Fired",
"- Headshot Rate = Headshots / Shots Hit",
"- Damage per Shot = Damage / Shots Fired",
],
"Payload Objective Specialist": [
"Payload Objective Formula:",
"ObjPL = (Presence × 0.40) + (PushTime Normalized × 0.40) + (PSI × 0.20)",
"Where:",
"- Presence = Player PushTime / Team PushTime",
"- PSI = Damage / (Deaths + 1) × 1 / sqrt(PushTime + 1)",
],
"Domination Objective Specialist": [
"Domination Objective Formula:",
"ObjDOM = (CapRate × 0.40) + (CounterRate × 0.60)",
"Where:",
"- CapRate = log(1 + DOM_captures) / log(21)",
"- CounterRate = log(1 + DOM_counters) / log(21)",
"Counters are weighted more heavily because they prevent enemy scoring.",
],
"Consistency": [
"Consistency Formula (Hybrid Career Tag):",
"Consistency = (Floor × 0.40) + (Stability × 0.40) + (Average Performance × 0.20)",
"Where:",
"- Floor = lowest per-match performance score",
"- Stability = 1 / (1 + Adjusted Variance)",
"- Adjusted Variance = Variance × (1 + 1 / Match Count)",
"- Average Performance = mean per-match performance score",
],
"Clutch": [
"Clutch Formula:",
"Clutch = (Pressure Efficiency × 0.40) + (Collapse Avoidance × 0.40) + (Conversion Rate × 0.20)",
"Where:",
"- Pressure Efficiency = Damage / (Deaths + 1)",
"- Collapse Avoidance = Player's lowest per-map performance score",
"- Conversion Rate = (Accuracy × 0.50) + (Headshot Rate × 0.50)",
],
"Anchor": [
"Anchor Formula:",
"Anchor = (Survivability × 0.45) + (DefensivePresence × 0.30) + (Consistency × 0.25)",
"Where:",
"- Survivability = (LeagueAvgDeathsPerMap / PlayerDeathsPerMap), clamped to [0.25, 1.50] then normalized to 0–1",
"- DefensivePresence = (DamagePerMap / LeagueAvgDamagePerMap), clamped to [0, 1.5] then normalized to 0–1",
"- Consistency = normalized 0–1 stability score from the Consistency tag",
"Survivability reflects how hard the player is to remove; Defensive Presence measures baseline contribution; Consistency rewards stable defensive performance.",
],
"Breaker": [
"Breaker Formula:",
"Breaker = (DamageNorm × 0.60) + (KillsNorm × 0.40)",
"Where:",
"- DamageNorm = Damage per map normalized against league average",
"- KillsNorm = Kills per map normalized against league average",
"Breaker measures disruptive offensive pressure through high damage output and aggressive fragging.",
],
}
class DashLeagueGUI:
def __init__(self, root):
self.root = root
self.root.title("DashLeague Casting Tool")
self.config = {}
self.current_season = None
self.team_a_var = tk.StringVar()
self.team_b_var = tk.StringVar()
self.team_a_players = []
self.team_b_players = []
self.stats_by_id = {}
self.status_var = tk.StringVar(value="Ready")
self.load_config()
ensure_player_slots()
self._build_ui()
self.current_match_players = []
self.start_local_obs_server()
def start_local_obs_server(self):
"""
Serves the obs_exports folder over http://localhost:PORT instead
of file:// paths. OBS Browser Sources (and browsers generally)
block fetch() on file:// URLs by default as a security
restriction, which silently breaks the auto-refresh polling
script embedded in every exported HTML page — the fetch just
fails and the page never notices it's stale. Serving over
http:// avoids that entirely. Point OBS Browser Sources at
http://localhost:{port}/matchup.html etc. instead of a local
file path, and the auto-refresh will work reliably.
Safe to call again after the output folder changes — stops any
previously running server first so it always serves the
current export directory.
"""
export_dir = os.path.join(self.output_root, "obs_exports") if getattr(self, "output_root", None) else OBS_EXPORT_DIR
os.makedirs(export_dir, exist_ok=True)
existing = getattr(self, "_obs_httpd", None)
if existing:
try:
existing.shutdown()
existing.server_close()
except Exception:
pass
self._obs_httpd = None
handler = functools.partial(SimpleHTTPRequestHandler, directory=export_dir)
try:
httpd = HTTPServer(("localhost", LOCAL_OBS_SERVER_PORT), handler)
except OSError as e:
print(f"Local OBS server could not start on port {LOCAL_OBS_SERVER_PORT}: {e}")
self.set_status(f"Note: local OBS server unavailable (port {LOCAL_OBS_SERVER_PORT} busy) — auto-refresh may need manual toggling.")
return
self._obs_httpd = httpd
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
print(f"Local OBS server running at http://localhost:{LOCAL_OBS_SERVER_PORT}/ (serving {export_dir})")
def load_config(self):
if not os.path.exists(CONFIG_PATH):
messagebox.showerror("Error", f"config.json not found at {CONFIG_PATH}")
self.config = {}
return
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
self.config = json.load(f)
self.current_season = self.config.get("current_season", 11)
# Restore previously-selected output folder (if any) so the user
# doesn't have to re-click "Select Output Folder" every launch.
self.output_root = self.config.get("output_root")
def _build_ui(self):
main_frame = ttk.Frame(self.root, padding=10)
main_frame.grid(row=0, column=0, sticky="nsew")
self.team_a_var = tk.StringVar()
self.team_b_var = tk.StringVar()
self.root.columnconfigure(0, weight=1)
self.root.rowconfigure(0, weight=1)
main_frame.columnconfigure(0, weight=1)
main_frame.columnconfigure(1, weight=1)
top_frame = ttk.Frame(main_frame)
top_frame.grid(row=0, column=0, columnspan=2, sticky="ew", pady=(0, 10))
self.build_db_button = ttk.Button(top_frame, text="Build Career Database", command=self.on_build_career_db)
self.build_db_button.grid(row=0, column=0, padx=(0, 10))
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.team_id_button = ttk.Button(top_frame, text="Team Identity", command=self.on_team_identity)
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=3, padx=(0, 10))
self.rankings_button = ttk.Button(top_frame, text="Rankings", command=self.on_rankings)
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=5, padx=(0, 10))
self.manage_roster_button = ttk.Button(top_frame, text="Manage Roster/Names", command=self.open_roster_manager)
self.manage_roster_button.grid(row=0, column=6, padx=(0, 10))
self.output_folder_button = ttk.Button(
top_frame,
text="Select Output Folder",
command=self.choose_output_folder
)
self.output_folder_button.grid(row=0, column=7, padx=(0, 10))
self.map_var = tk.StringVar()
self.map_var.set("Payload") # default
teams = self.config.get("teams", [])
left_frame = ttk.LabelFrame(main_frame, text="Team A")
left_frame.grid(row=1, column=0, sticky="nsew", padx=(0, 5))
right_frame = ttk.LabelFrame(main_frame, text="Team B")
right_frame.grid(row=1, column=1, sticky="nsew", padx=(5, 0))
main_frame.rowconfigure(1, weight=1)
left_frame.rowconfigure(1, weight=1)
right_frame.rowconfigure(1, weight=1)
ttk.Label(left_frame, text="Team:").grid(row=0, column=0, sticky="w")
self.team_a_combo = ttk.Combobox(left_frame, textvariable=self.team_a_var, values=teams, state="readonly")
self.team_a_combo.grid(row=0, column=1, sticky="ew", pady=2)
self.team_a_combo.bind("<<ComboboxSelected>>", lambda e: self.on_team_select("A"))
ttk.Label(right_frame, text="Team:").grid(row=0, column=0, sticky="w")
self.team_b_combo = ttk.Combobox(right_frame, textvariable=self.team_b_var, values=teams, state="readonly")
self.team_b_combo.grid(row=0, column=1, sticky="ew", pady=2)
self.team_b_combo.bind("<<ComboboxSelected>>", lambda e: self.on_team_select("B"))
self.team_a_listbox = tk.Listbox(left_frame, selectmode=tk.MULTIPLE, exportselection=False)
self.team_a_listbox.grid(row=1, column=0, columnspan=2, sticky="nsew", pady=(5, 0))
self.team_b_listbox = tk.Listbox(right_frame, selectmode=tk.MULTIPLE, exportselection=False)
self.team_b_listbox.grid(row=1, column=0, columnspan=2, sticky="nsew", pady=(5, 0))
self.team_a_listbox.bind("<<ListboxSelect>>", lambda e: self.enforce_rolling_five(e, "A"))
self.team_b_listbox.bind("<<ListboxSelect>>", lambda e: self.enforce_rolling_five(e, "B"))
bottom_frame = ttk.Frame(main_frame)
bottom_frame.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(10, 0))
self.generate_button = ttk.Button(bottom_frame, text="Generate Player Slots", command=self.on_generate_slots)
self.generate_button.grid(row=0, column=0, sticky="ew")
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")
status_label = ttk.Label(status_frame, textvariable=self.status_var, anchor="w")
status_label.grid(row=0, column=0, sticky="ew")
status_frame.columnconfigure(0, weight=1)
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, for
use ONLY in the three team-comparison graphics (Tale of the Tape,
Team DNA, Win Probability).
Priority order per player:
1. Real career_stats.json data, if present.
2. Their OWN verified current-season stats (from
on_generate_slots step 4.5's per-player API fetch) if
available — a real, if season-only, number is far more
accurate than a teammate average and should be used
directly, not discarded.
3. A temporary, in-memory average of THIS TEAM's other players
(not the whole league) as an absolute last resort, only for
players with genuinely no data of any kind. Never saved to
career_stats.json, never used on their personal Spotlight
card.
IMPORTANT: that team-local average is built from every OTHER
teammate who has real data — including verified current-season
stats, which only ever exist in memory on self.current_match_
players, never on disk. Previously the average was computed
purely from career_stats.json on disk, so if most of a team's
real data came from verified fetches rather than the career
database, the average pool came up empty and silently returned
all zeros — which looked exactly like a player with no stats at
all, and skewed the whole comparison graphic.
"""
career_path = os.path.join(BASE_DIR, "career_stats.json")
with open(career_path, "r", encoding="utf-8") as f:
db = json.load(f)
team_players = [p for p in self.current_match_players if p.get("team") == team_name]
# First pass: classify every player and collect the REAL
# (career or verified) career dicts as we go.
resolved = []
real_careers = []
for p in team_players:
cid = p.get("canonical")
if not cid:
continue
pdata = db["players"].get(cid)
if pdata and pdata.get("career"):
resolved.append((p, "real", pdata))
real_careers.append(pdata["career"])
continue
kills = p.get("kills", 0) or 0
deaths = p.get("deaths", 0) or 0
if p.get("_verified_season_stats") and (kills or deaths or p.get("damage")):
own_career = {
"kills": kills,
"deaths": deaths,
"damage": p.get("damage", 0) or 0,
"maps": p.get("maps", 0) or 0,
"KD": (kills / deaths) if deaths > 0 else kills,
"score": p.get("score", 0) or 0,
}
resolved.append((p, "verified", own_career))
real_careers.append(own_career)
continue
resolved.append((p, "new", None))
# Team-local average built from every teammate with REAL data
# (career or verified), computed once, lazily, only if at least
# one player on this team actually needs it.
team_avg = None
needs_avg = any(mode == "new" for _, mode, _ in resolved)
if needs_avg and real_careers:
totals = {field: 0.0 for field in CAREER_AVERAGE_FIELDS}
for career in real_careers:
for field in CAREER_AVERAGE_FIELDS:
totals[field] += career.get(field, 0) or 0
team_avg = {field: totals[field] / len(real_careers) for field in CAREER_AVERAGE_FIELDS}
roster_data = []
for p, mode, extra in resolved:
cid = p.get("canonical")
name = p.get("name") or cid
if mode == "real":
p_data = dict(extra)
p_data["current_team"] = team_name
p_data["stats_estimated"] = False
roster_data.append(p_data)
continue
if mode == "verified":
pdata = db["players"].get(cid)
p_data = dict(pdata) if pdata else {"name": name}
p_data["name"] = name
p_data["current_team"] = team_name
p_data["career"] = extra
p_data["stats_estimated"] = False
# Generate Player Slots already computed real, season-
# based skill percentiles for this player (career-first/
# live-fallback system, ranked against the real league
# distribution) — they're sitting on p. Without copying
# them here, Tale of the Tape / Team DNA / Win
# Probability (which all read p.get(tag, {}).get("pct"),
# not the career dict) would silently show 0% for every
# tag for this player, dragging team averages down as if
# they contributed nothing.
for tag in ("slayer", "sharpshooter", "objective_payload", "objective_domination", "consistency", "clutch", "anchor", "breaker"):
tag_data = p.get(tag)
if tag_data:
p_data[tag] = tag_data
p_data["career_note"] = (
f"{name}'s numbers are from verified current-season data only — "
f"no prior-season career history on file yet."
)
roster_data.append(p_data)
continue
# mode == "new": genuinely nothing to go on for this player.
pdata = db["players"].get(cid)
p_data = dict(pdata) if pdata else {"name": name}
p_data["name"] = name
p_data["current_team"] = team_name
p_data["stats_estimated"] = True
if team_avg:
p_data["career"] = dict(team_avg)
p_data["career_note"] = (
f"NEW PLAYER — no career stats yet. Numbers shown for this "
f"comparison are a temporary {team_name} teammate average, "
f"not {name}'s actual performance."
)
else:
# No teammates with real data either — be explicit
# about it rather than silently showing zeros with no
# explanation, which is what was throwing the graphs off.
p_data["career"] = {field: 0 for field in CAREER_AVERAGE_FIELDS}
p_data["career_note"] = (
f"NEW PLAYER — no career stats yet, and no teammates with "
f"real data to average either. This comparison has no "
f"reliable numbers for {name}; treat it with caution."
)
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()
export_dir = self.require_output_folder()
if not export_dir:
return
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, export_dir=export_dir)
self.set_status("Generated 4K Tale of the Tape (PvP)")
from analysis.html_export import render_spotlight_html
wrapper_html = render_spotlight_html("tale_of_the_tape.png")
with open(os.path.join(export_dir, "tale_of_the_tape.html"), "w", encoding="utf-8") as f:
f.write(wrapper_html)
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()
export_dir = self.require_output_folder()
if not export_dir:
return
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, export_dir=export_dir)
self.set_status("Generated 4K Team DNA Graphic")
from analysis.html_export import render_spotlight_html
wrapper_html = render_spotlight_html("team_dna.png")
with open(os.path.join(export_dir, "team_dna.html"), "w", encoding="utf-8") as f:
f.write(wrapper_html)
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()
export_dir = self.require_output_folder()
if not export_dir:
return
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, export_dir=export_dir)
self.set_status("Generated 4K Win Probability Graphic")
from analysis.html_export import render_spotlight_html
wrapper_html = render_spotlight_html("win_probability.png")
with open(os.path.join(export_dir, "win_probability.html"), "w", encoding="utf-8") as f:
f.write(wrapper_html)
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:
self.set_status(f"Syncing stats for season {self.current_season}...")
url = f"{API_BASE}/stats?season={self.current_season}"
data = fetch_json(url)
stats = data.get("data", [])
# ---------------------------------------------------------
# Load PIR + Career DB
# ---------------------------------------------------------
from analysis.player_identity_registry import PlayerIdentityRegistry
pir = PlayerIdentityRegistry()
career_path = os.path.join(BASE_DIR, "career_stats.json")
with open(career_path, "r", encoding="utf-8") as f:
career_db = json.load(f)
players_db = career_db.setdefault("players", {})
# ---------------------------------------------------------
# BUILD stats_by_id (canonical → enriched player object)
# ---------------------------------------------------------
new_stats_by_id = {}
for p in stats:
raw_id = p.get("id")
if not raw_id:
continue
# Resolve canonical + stamp seasons
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
# Ensure first_season is set if missing
entry = pir.data["canonical"].get(canonical)
# Normalize DOM fields from API (for DomObj)
p["DOM_Captures"] = p.get("DOM_captures", p.get("DOM_Captures", 0))
p["DOM_Counters"] = p.get("DOM_counters", p.get("DOM_Counters", 0))
merged = {
"canonical": canonical,
"name": p.get("name"),
"team": (p.get("team") or "").strip(),
"id": raw_id,
"kills": p.get("kills", 0),
"deaths": p.get("deaths", 0),
"damage": p.get("damage", 0),
"shots": p.get("shots", 0),
"shots_hit": p.get("shots_hit", 0),
"headshots": p.get("headshots", 0),
"PAY_PushTime": p.get("PAY_PushTime", 0),
"CP_Captures": p.get("CP_captures", p.get("CP_Captures", 0)),
# The API actually gives us maps played directly —
# no need to estimate it from deaths.
"maps": p.get("maps", 0),
"playtime_minutes": p.get("Total PlayTime", 0),
"wins": p.get("wins", 0),
"loss": p.get("loss", 0),
"match_stats": p,
"seasons_played": p.get("seasons_played") or p.get("seasons") or "",
}
# Ensure player exists in career DB (so rookies are present)
if canonical not in players_db:
players_db[canonical] = {
"name": merged["name"],
"team_history": [merged["team"]] if merged["team"] else [],
"seasons_played": merged.get("seasons_played"),
"slayer": {},
"sharpshooter": {},
"objective_payload": {},
"objective_domination": {},
"consistency": {},
"clutch": {},
"anchor": {},
"breaker": {},
}
pdata = players_db.setdefault(canonical, {})
pdata["seasons_played"] = merged["seasons_played"]
# Always refresh the stored name, not just on first creation—
# otherwise a renamed player keeps showing their original
# in-game name forever on the Spotlight card and graphics,
# even though the identity registry correctly tracks the
# rename.
if merged.get("name"):
pdata["name"] = merged["name"]
# Store basic stats into career DB so rookies have real data
pdata["kills"] = merged.get("kills", 0)
pdata["deaths"] = merged.get("deaths", 0)
pdata["damage"] = merged.get("damage", 0)
pdata["shots"] = merged.get("shots", 0)
pdata["shots_hit"] = merged.get("shots_hit", 0)
pdata["headshots"] = merged.get("headshots", 0)
pdata["maps"] = merged.get("maps", 0)
pdata["PAY_PushTime"] = merged.get("PAY_PushTime", 0)
# Score is inside match_stats
ms = merged.get("match_stats", {})
pdata["score"] = ms.get("score", 0)
# KD
if pdata["deaths"] > 0:
pdata["kd"] = pdata["kills"] / pdata["deaths"]
else:
pdata["kd"] = pdata["kills"]
# Attach career tags
for tag in (
"slayer",
"sharpshooter",
"objective_payload",
"objective_domination",
"consistency",
"clutch",
"anchor",
"breaker",
):
merged[tag] = pdata.get(tag, {})
new_stats_by_id[canonical] = merged
# Write merged stats into career DB
players_db[canonical]["kills"] = merged["kills"]
players_db[canonical]["deaths"] = merged["deaths"]
players_db[canonical]["damage"] = merged["damage"]
players_db[canonical]["shots"] = merged["shots"]
players_db[canonical]["shots_hit"] = merged["shots_hit"]
players_db[canonical]["headshots"] = merged["headshots"]
players_db[canonical]["maps"] = merged["maps"]
players_db[canonical]["PAY_PushTime"] = merged["PAY_PushTime"]
players_db[canonical]["match_stats"] = merged["match_stats"]
players_db[canonical]["seasons_played"] = merged["seasons_played"]
# Compute KD
d = merged["deaths"]
players_db[canonical]["kd"] = merged["kills"] / d if d > 0 else merged["kills"]
# Score (inside match_stats)
players_db[canonical]["score"] = merged["match_stats"].get("score", 0)
# ---------------------------------------------------------
# Save updated career DB (rookies now included)
# ---------------------------------------------------------
with open(career_path, "w", encoding="utf-8") as f:
json.dump(career_db, f, indent=2)
# ---------------------------------------------------------
# Save PIR (first_season now persisted)
# ---------------------------------------------------------
pir.save()
# ---------------------------------------------------------
# Replace stats_by_id with enriched version
# ---------------------------------------------------------
self.stats_by_id = new_stats_by_id
self.set_status("Stats synced.")
messagebox.showinfo("Success", f"Synced {len(stats)} player stats.")
except Exception as e:
messagebox.showerror("Error", f"Failed to sync stats:\n{e}")
self.set_status("Sync failed.")
# Re-trigger team selection so listboxes repopulate
if self.team_a_var.get():
self.on_team_select("A")
if self.team_b_var.get():
self.on_team_select("B")
def enforce_rolling_five(self, event, side):
listbox = self.team_a_listbox if side == "A" else self.team_b_listbox
selection = listbox.curselection()
# If 5 or fewer selected, nothing to do
if len(selection) <= 5:
return
# More than 5 selected → rolling behavior:
# Deselect the *oldest* selected index
oldest = selection[0]
listbox.selection_clear(oldest)
def on_team_select(self, side):
team_name = self.team_a_var.get() if side == "A" else self.team_b_var.get()
from analysis.player_identity_registry import PlayerIdentityRegistry
pir = PlayerIdentityRegistry()
overrides = load_roster_overrides()
matching = []
seen_canonical = set()
# 1. Players with live stats this season whose CURRENT team (manual
# override takes priority, else match-derived team_history)
# matches. This correctly re-homes a player to their new team
# even if their most recent synced match was still under their
# old team (e.g. hasn't played a match since a merge yet).
for p in self.stats_by_id.values():
cid = p.get("canonical")
current_team = get_current_team(cid, pir, overrides) if cid else (p.get("team") or p.get("TeamUUID"))
if current_team == team_name:
p["team"] = team_name
matching.append(p)
if cid:
seen_canonical.add(cid)
# 2. Everyone else whose CURRENT team matches, even with no live
# stats yet this season (new to the team, roster merge, hasn't
# taken the field yet, etc). Without this, rostered players who
# simply haven't played a synced match silently disappear.
#
# RECENCY GUARD: team_history only ever gets a new entry when a
# player JOINS a new team — nothing marks them as having left.
# So a player who quit the league years ago while on this team
# would otherwise stay listed on its roster forever, inflating
# it past the real ~12-man cap. Players who haven't appeared in
# a synced match for more than one season are excluded here
# UNLESS a caster has explicitly placed them on this team via
# Manage Roster — an explicit override always wins.
RECENCY_WINDOW = 1
for cid, entry in pir.data.get("canonical", {}).items():
if cid in seen_canonical:
continue
current_team = get_current_team(cid, pir, overrides)
if current_team != team_name:
continue
has_override = cid in overrides
last_season = entry.get("last_season")
is_recent = (
last_season is None
or self.current_season is None
or last_season >= self.current_season - RECENCY_WINDOW
)
if not has_override and not is_recent:
continue
display_name = entry["names"][-1] if entry.get("names") else cid
matching.append({
"canonical": cid,
"name": display_name,
"team": team_name,
"id": cid,
"kills": 0,
"deaths": 0,
"damage": 0,
"shots": 0,
"shots_hit": 0,
"match_stats": {},
"seasons_played": "",
"slayer": {},
"sharpshooter": {},
"objective_payload": {},
"objective_domination": {},
"consistency": {},
"clutch": {},
"anchor": {},
"breaker": {},
})
seen_canonical.add(cid)
# Now assign to UI
if side == "A":
self.team_a_players = matching
self.team_a_listbox.delete(0, tk.END)
for p in matching:
self.team_a_listbox.insert(tk.END, p.get("name", "Unknown"))
else:
self.team_b_players = matching
self.team_b_listbox.delete(0, tk.END)
for p in matching:
self.team_b_listbox.insert(tk.END, p.get("name", "Unknown"))
def normalize_player(self, p):
return {
"id": p.get("id"),
"name": p.get("name"),
"team": p.get("team"),
"KD": p.get("KD", 0),
"kills": p.get("kills", 0),
"deaths": p.get("deaths", 0),
"headshots": p.get("headshots", 0),
"accuracy": p.get("accuracy", 0),
"PAY_PushTime": p.get("PAY_PushTime", 0),
"DOM_captures": p.get("DOM_captures", 0),
"DOM_counters": p.get("DOM_counters", 0),
}
def on_generate_slots(self):
try:
ensure_player_slots()
# ---------------------------------------------------------
# 1. Collect selected players
# ---------------------------------------------------------
selected_a = [self.team_a_players[i] for i in self.team_a_listbox.curselection()]
selected_b = [self.team_b_players[i] for i in self.team_b_listbox.curselection()]
if not selected_a:
selected_a = self.team_a_players
if not selected_b:
selected_b = self.team_b_players
# Limit to 5 per team
selected_a = selected_a[:5]
selected_b = selected_b[:5]
all_players = selected_a + selected_b
# ---------------------------------------------------------
# 2. Normalize stat keys BEFORE attaching tags
# ---------------------------------------------------------
for p in all_players:
ms = p.get("match_stats", {})
# DOM
p["DOM_Captures"] = (
p.get("DOM_Captures")
or p.get("DOM_captures")
or ms.get("DOM_Captures")
or ms.get("DOM_captures")
or 0
)
p["DOM_Counters"] = (
p.get("DOM_Counters")
or p.get("DOM_counters")
or ms.get("DOM_Counters")
or ms.get("DOM_counters")
or 0
)
# ---------------------------------------------------------
# 3. Resolve canonical IDs AND normalize player ID
# ---------------------------------------------------------
from analysis.player_identity_registry import PlayerIdentityRegistry
pir = PlayerIdentityRegistry()
for p in all_players:
# If this player already has a valid canonical ID (set by
# on_team_select — true for both live-synced players AND
# roster stub players who haven't played a match yet),
# do NOT re-resolve it. Stub players have their "id" set
# to their canonical string itself as a placeholder, not
# a real UUID; feeding that into pir.resolve() as if it
# were a raw UUID mints a brand new, bogus canonical ID
# with no career data, silently corrupting an already-
# correct resolution and making established players
# (e.g. anyone added via Manage Roster) look brand new.
if p.get("canonical"):
continue
raw_uuid = (
p.get("id")
or p.get("PlayerUUID")
or p.get("uuid")
)
if raw_uuid:
p["canonical"] = pir.resolve(raw_uuid, season=self.current_season)
# Normalize ID (AFTER canonical resolution)
pid = (
p.get("id")
or p.get("PlayerUUID")
or p.get("uuid")
or p.get("canonical")
or p.get("name")
)
p["id"] = pid
# ---------------------------------------------------------
# 4. Load career DB once
# ---------------------------------------------------------
career_db = None
career_path = os.path.join(BASE_DIR, "career_stats.json")
if os.path.exists(career_path):
with open(career_path, "r", encoding="utf-8") as f:
career_db = json.load(f)
# ---------------------------------------------------------
# 4.5 For any player missing from career_stats.json, fetch
# their EXACT current-season stats directly from
# /api/v1/player?PlayerID=X (confirmed to return a
# precise per-season breakdown: real maps, kills,
# deaths, damage, shots, shots_hit, headshots,
# CP_captures, PAY_PushTime, DOM_captures/counters).
# This replaces the deaths-based/subtraction guesses
# with real, verified numbers wherever the API call
# succeeds — only costs one extra request per player who
# actually needs it (typically a handful per match, not
# the whole roster). If the call fails for any reason
# (offline, unknown player, etc.), the existing
# estimate-based fallback in resolve_tag_career_first
# still applies as a safety net.
# ---------------------------------------------------------
for p in all_players:
cid = p.get("canonical")
pdata = career_db["players"].get(cid) if (career_db and cid) else None
has_career = pdata and pdata.get("slayer") and pdata["slayer"].get("raw") is not None
if has_career:
continue
entry = pir.data.get("canonical", {}).get(cid, {}) if cid else {}
uuids = entry.get("uuids") or []
raw_uuid = uuids[-1] if uuids else (p.get("uuid") or p.get("PlayerUUID"))
if not raw_uuid or str(raw_uuid).startswith("player_"):
continue # no real UUID available for this player
season_stats = fetch_player_season_stats(raw_uuid, self.current_season)
if not season_stats:
continue
p["kills"] = season_stats.get("kills", p.get("kills", 0))
p["deaths"] = season_stats.get("deaths", p.get("deaths", 0))
p["damage"] = season_stats.get("damage", p.get("damage", 0))
p["score"] = season_stats.get("score", p.get("score", 0))
p["shots"] = season_stats.get("shots", p.get("shots", 0))
p["shots_hit"] = season_stats.get("shots_hit", p.get("shots_hit", 0))
p["headshots"] = season_stats.get("headshots", p.get("headshots", 0))
p["maps"] = season_stats.get("maps", p.get("maps", 0))
p["PAY_PushTime"] = season_stats.get("PAY_PushTime", p.get("PAY_PushTime", 0))
p["CP_Captures"] = season_stats.get("CP_captures", p.get("CP_Captures", 0))
p["DOM_Captures"] = season_stats.get("DOM_captures", p.get("DOM_Captures", 0))
p["DOM_Counters"] = season_stats.get("DOM_counters", p.get("DOM_Counters", 0))
p["_verified_season_stats"] = True
# ---------------------------------------------------------
# 5. Resolve every tag career-first, with a live-sync
# fallback for anyone missing from career_stats.json
# (e.g. missing from match_history.db, which only covers
# past seasons and never picks up current-season
# activity). See resolve_tag_career_first for details.
# Fallback players are marked has_data=False so they're
# excluded from team averages/predictions rather than
# silently counted as a real score of zero. Domination
# Objective now goes through this same unified system
# (see compute_fallback_domination) instead of its own
# separate, unbounded formula.
# ---------------------------------------------------------
for tag_name, (fallback_fn, label) in FALLBACK_FORMULAS.items():
resolve_tag_career_first(all_players, tag_name, career_db, fallback_fn, label)
# ---------------------------------------------------------
# 6. Consistency — special-cased. It fundamentally needs
# per-match variance data that can't be reconstructed
# from season-cumulative totals alone, so there's no
# honest live-sync fallback formula for it the way there
# is for the other tags. Use real career consistency when
# available; otherwise mark it no-data (neutral raw,
# excluded from averages) instead of inventing a number.
# ---------------------------------------------------------
consistency_rows = rank_match_players_consistency_career(all_players)
consistency_map = {row["id"]: row for row in consistency_rows}
for p in all_players:
row = consistency_map.get(p["id"])
if row and row.get("has_data"):
p["consistency"] = {
"raw": row["score_raw"],
"pct": row.get("pct"),
"tier": row.get("tier"),
"has_data": True,
"summary": f"{row.get('tier','?')} Tier ({row.get('pct',0) or 0:.1f} percentile)" if row.get("pct") is not None else "",
}
else:
p["consistency"] = {
"raw": 0.5,
"pct": None,
"tier": None,
"has_data": False,
"summary": "No career match history available to measure consistency.",
}
# ---------------------------------------------------------
# 7. (Domination Objective is now handled in step 5 above,
# as part of the unified FALLBACK_FORMULAS system.)
# ---------------------------------------------------------
# 11. Ensure stats_by_id contains all enriched tags
for p in all_players:
cid = p.get("canonical")
if not cid:
continue
enriched = self.stats_by_id.get(cid)
if not enriched:
continue
# Copy match fields into stats_by_id
enriched["team"] = p.get("team")
enriched["name"] = p.get("name")
enriched["id"] = p.get("id")
# ---------------------------------------------------------
# 12. Update current_match_players
# ---------------------------------------------------------
self.current_match_players = all_players
# ---------------------------------------------------------
# 13. Write OBS slot files
# ---------------------------------------------------------
for idx, p in enumerate(all_players):
slot_dir = os.path.join(PLAYERS_DIR, f"p{idx}")
os.makedirs(slot_dir, exist_ok=True)
write_text(os.path.join(slot_dir, "IGN.txt"), p.get("name", ""))
write_text(os.path.join(slot_dir, "Team.txt"), p.get("team", ""))
write_text(os.path.join(slot_dir, "KD.txt"), p.get("KD", ""))
write_text(os.path.join(slot_dir, "Kills.txt"), p.get("kills", ""))
write_text(os.path.join(slot_dir, "Deaths.txt"), p.get("deaths", ""))
write_text(os.path.join(slot_dir, "Headshots.txt"), p.get("headshots", ""))
write_text(os.path.join(slot_dir, "Accuracy.txt"), p.get("accuracy", ""))
write_text(os.path.join(slot_dir, "PushTime.txt"), p.get("PAY_PushTime", ""))
write_text(os.path.join(slot_dir, "Captures.txt"), p.get("DOM_Captures", ""))
write_text(os.path.join(slot_dir, "Counters.txt"), p.get("DOM_Counters", ""))
# Consistency
write_text(os.path.join(slot_dir, "ConsistencyRaw.txt"), p["consistency"]["raw"])
write_text(os.path.join(slot_dir, "ConsistencyPct.txt"), p["consistency"]["pct"])
write_text(os.path.join(slot_dir, "ConsistencyTier.txt"), p["consistency"]["tier"])
self.set_status("Player slots generated.")
messagebox.showinfo("Success", "Player slots generated into p0–p9.")
except Exception as e:
messagebox.showerror("Error", f"Failed to generate slots:\n{e}")
self.set_status("Generate failed.")
self.export_all_obs_files()
def on_build_career_db(self):
try:
self.set_status("Building career database...")
# Use the same file the rest of the tool reads
output_path = os.path.join(BASE_DIR, "career_stats.json")
build_career_database(output_path, current_season=self.current_season)
self.set_status("Career database built successfully.")
messagebox.showinfo("Success", "Career database has been built.")
except Exception as e:
import traceback
traceback.print_exc()
messagebox.showerror("Error", f"Failed to build career database:\n{e}")
self.set_status("Career DB build failed.")
def on_player_spotlight(self):
if not self.current_match_players:
messagebox.showerror("Error", "Please generate player slots first.")
return
export_dir = self.require_output_folder()
if not export_dir:
return
# 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 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("Spotlight Operator")
win.geometry("500x700")
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), height=20)
output.pack(expand=True, fill="both", padx=20, pady=10)
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)
# Get full career data. Priority: real career data > this
# match player's own VERIFIED current-season stats (fetched
# per-player during Generate Player Slots, real numbers, not
# a guess) > genuinely nothing. Personal stats are NEVER
# averaged from teammates — that's only ever done for the
# 3 team-comparison graphics.
cid = p_match.get("canonical")
career_player_data = db["players"].get(cid)
has_real_career = bool(career_player_data and career_player_data.get("career"))
kills = p_match.get("kills", 0) or 0
deaths = p_match.get("deaths", 0) or 0
has_verified_season = bool(
p_match.get("_verified_season_stats")
and (kills or deaths or p_match.get("damage"))
)
if has_real_career:
player_data = dict(career_player_data)
is_new_player = False
is_season_only = False
elif has_verified_season:
player_data = dict(career_player_data) if career_player_data else {}
player_data["career"] = {
"kills": kills,
"deaths": deaths,
"damage": p_match.get("damage", 0) or 0,
"maps": p_match.get("maps", 0) or 0,
"KD": (kills / deaths) if deaths > 0 else kills,
"score": p_match.get("score", 0) or 0,
}
# Generate Player Slots already computed real, season-
# based skill percentiles for this player (the fallback
# system, ranked against the actual league distribution)
# — they're sitting right here on p_match. Without
# copying them over, the Skill Profile radar/bars read
# an empty dict and show a flat 0% for everything, even
# though real numbers exist.
for tag in ("slayer", "sharpshooter", "consistency", "clutch", "anchor", "breaker"):
tag_data = p_match.get(tag)
if tag_data:
player_data[tag] = tag_data
is_new_player = False
is_season_only = True
else:
player_data = {}
is_new_player = True
is_season_only = False
player_data["name"] = selected_name
player_data["stats_estimated"] = False
player_data["is_season_only"] = is_season_only
# Determine Side
team_b_names = [self.team_b_listbox.get(i) for i in range(self.team_b_listbox.size())]
side = "Red" if selected_name in team_b_names else "Blue"
# Generate 4K PNG
from utils.graphics_engine import generate_player_card
try:
generate_player_card(player_data, team_side=side, export_dir=export_dir)
self.set_status(f"Updated 4K Graphic: {selected_name}")
# Thin HTML wrapper around the PNG so it can sit in OBS
# as a Browser Source (auto-refreshing, like the other
# three graphics) instead of needing a screen-capture
# workaround.
from analysis.html_export import render_spotlight_html
spotlight_html = render_spotlight_html("spotlight_card.png")
with open(os.path.join(export_dir, "spotlight.html"), "w", encoding="utf-8") as f:
f.write(spotlight_html)
except Exception as e:
print(f"Graphics Error: {e}")
messagebox.showerror("Graphics Error", str(e))
# Update Text Preview
text = f"PLAYER: {selected_name}\n"
text += f"Team: {p_match.get('team', 'Unknown')}\n"
text += f"Side: {side}\n"
text += "-"*30 + "\n"
if is_new_player:
text += f"🆕 NEW PLAYER — Season {self.current_season}\n"
text += "No career stats recorded yet for this player.\n"
text += "(Not shown as an estimate or average — personal\n"
text += "stats stay untouched until they actually play.)\n"
else:
career = player_data.get("career", {})
if is_season_only:
text += f"🆕 NEW THIS SEASON — no prior-season history on file\n"
text += f"(numbers below are real Season {self.current_season} stats)\n\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" + (" (this season only):\n" if is_season_only else ":\n")
for tag in ["slayer", "sharpshooter", "consistency", "clutch", "anchor"]:
t_obj = player_data.get(tag, {})
pct = t_obj.get('pct')
pct_str = f"{pct:.0f}%" if pct is not None else "N/A"
text += f"{tag.title():<14}: {pct_str}\n"
output.delete("1.0", tk.END)
output.insert(tk.END, text)
# 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("<<ComboboxSelected>>", 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()
if not team:
messagebox.showerror("Error", "Select a team first.")
return
team_players = [p for p in self.current_match_players if p.get("team") == team]
if len(team_players) == 0:
messagebox.showerror("Error", "No players selected for this team.")
return
identity_data = generate_team_identity_block(team_players, team, self.map_var.get())
identity_text = format_team_identity(identity_data)
win = tk.Toplevel(self.root)
win.title(f"Team Identity — {team}")
win.geometry("500x600")
output = tk.Text(win, wrap="word")
output.pack(expand=True, fill="both", padx=10, pady=10)
output.insert(tk.END, identity_text)
output.config(state="disabled")
ttk.Label(
win,
text="This is included in the matchup.html auto-exported by Generate Player Slots.",
foreground="#666666",
).pack(pady=(0, 10))
def generate_player_tags(self, player_entry, map_type):
tags_out = []
# ---------------------------------------------------------
# Slayer (career)
# ---------------------------------------------------------
slayer = player_entry.get("slayer", {})
slayer_strength = slayer.get("strength") or player_entry.get("slayer_strength")
if isinstance(slayer_strength, str):
tags_out.append(slayer_strength)
# ---------------------------------------------------------
# Consistency (career - hybrid system)
# ---------------------------------------------------------
consistency = player_entry.get("consistency", {})
if isinstance(consistency, dict):
score = consistency.get("raw")
if isinstance(score, (int, float)):
tags_out.append(f"Cons {score:.2f}")
# ---------------------------------------------------------
# Payload Objective Specialist (match-based)
# ---------------------------------------------------------
if map_type == "Payload":
pl_info = player_entry.get("objective_payload_components", {})
pl_score = pl_info.get("payload_score_raw", 0.0)
tags_out.append(f"ObjPL {pl_score:.2f}")
# ---------------------------------------------------------
# Domination Objective Specialist (match-based)
# ---------------------------------------------------------
if map_type == "Domination":
dom_info = player_entry.get("objective_domination", {})
dom_score = dom_info.get("raw", 0.0)
tags_out.append(f"ObjDOM {dom_score:.2f}")
# ---------------------------------------------------------
# Sharpshooter (career)
# ---------------------------------------------------------
sharp = player_entry.get("sharpshooter", {}).get("score") or player_entry.get("sharpshooter_score")
if isinstance(sharp, (int, float)):
tags_out.append(f"Sharp {sharp:.2f}")
return ", ".join(tags_out)
def on_match_storyline(self):
team_a = self.team_a_var.get()
team_b = self.team_b_var.get()
if not team_a or not team_b:
messagebox.showerror("Error", "Select both Team A and Team B.")
return
if not self.current_match_players:
messagebox.showerror("Error", "Generate Player Slots first.")
return
# ---------------------------------------------------------
# Use the already-selected, already-enriched match players
# directly (same source get_team_roster_data / OBS export use)
# instead of re-looking them up in self.stats_by_id. That
# lookup silently dropped any player without live synced stats
# yet — e.g. a player just moved onto this team via Manage
# Roster who hasn't played a match under it yet — which could
# empty out a team's list even though players were clearly
# selected in the GUI.
# ---------------------------------------------------------
teamA_players = [p for p in self.current_match_players if p.get("team") == team_a]
teamB_players = [p for p in self.current_match_players if p.get("team") == team_b]
if not teamA_players or not teamB_players:
messagebox.showerror("Error", "No players selected for one or both teams.")
return
# ---------------------------------------------------------
# Generate storyline + identity blocks
# ---------------------------------------------------------
map_type = self.map_var.get()
storyline_text = matchup_storyline(teamA_players, teamB_players, map_type)
identity_data_a = generate_team_identity_block(teamA_players, team_a, map_type)
identity_text_a = format_team_identity(identity_data_a)
identity_data_b = generate_team_identity_block(teamB_players, team_b, map_type)
identity_text_b = format_team_identity(identity_data_b)
full_story = (
storyline_text
+ "\n\nTEAM IDENTITY SUMMARY – " + team_a + "\n"
+ identity_text_a
+ "\n\nTEAM IDENTITY SUMMARY – " + team_b + "\n"
+ identity_text_b
)
# ---------------------------------------------------------
# Popup window (preview only — export now happens
# automatically from Generate Player Slots as a pretty HTML
# file, so there's no separate export button here anymore)
# ---------------------------------------------------------
win = tk.Toplevel(self.root)
win.title("Match Storyline")
win.geometry("600x700")
output = tk.Text(win, wrap="word")
output.pack(expand=True, fill="both", padx=10, pady=10)
output.insert(tk.END, full_story)
output.config(state="disabled")
ttk.Label(
win,
text="This is auto-exported as matchup.html whenever you click Generate Player Slots.",
foreground="#666666",
).pack(pady=(0, 10))
def compute_ranking_data(self, tag):
"""
Shared computation used by both the on-screen Rankings popup and
the auto-exported HTML ranking pages, so they can never disagree.
Returns None if teams aren't selected, or a dict with
empty=True if there's no ranked data for this tag.
NOTE: this deliberately does NOT re-attach career tags from
match_engine.load_career_db() (final_db.json) — that's a stale,
separate legacy file from career_stats.json, and re-attaching
from it here was silently clobbering the correct consistency/
anchor/clutch/breaker values that on_generate_slots already
attached from career_stats.json. self.current_match_players is
already fully enriched by the time this runs.
"""
team_a = self.team_a_var.get()
team_b = self.team_b_var.get()
if not team_a or not team_b:
return None
if tag == "Slayer":
ranked = rank_match_players_slayer(self.current_match_players)
elif tag == "Sharpshooter":
ranked = rank_match_players_sharpshooter(self.current_match_players)
elif tag == "Payload Objective Specialist":
ranked = rank_match_players_objpl(self.current_match_players)
elif tag == "Domination Objective Specialist":
ranked = rank_match_players_objdom(self.current_match_players)
elif tag == "Consistency":
ranked = rank_match_players_by_tag(self.current_match_players, "consistency")
elif tag == "Clutch":
ranked = rank_match_players_clutch(self.current_match_players)
elif tag == "Anchor":
ranked = rank_match_players_by_tag(self.current_match_players, "anchor")
elif tag == "Breaker":
ranked = rank_match_players_by_tag(self.current_match_players, "breaker")
else:
return None
if not ranked:
return {"empty": True, "tag": tag, "team_a": team_a, "team_b": team_b}
left, right = split_two_columns(ranked, team_a, team_b)
# Only average players who actually HAVE real data for this tag.
# A player with no career data (e.g. missing from the local
# match-history cache) previously defaulted to a raw score of
# 0.0, silently counted as if they'd genuinely earned the worst
# possible score — dragging their team's average, and therefore
# the win prediction, down as if they were a real liability
# rather than simply unmeasured.
left_scored = [p for p in left if p.get("has_data", True)]
right_scored = [p for p in right if p.get("has_data", True)]
missing_left = len(left) - len(left_scored)
missing_right = len(right) - len(right_scored)
teamA_total = sum(p["score_raw"] for p in left_scored)
teamB_total = sum(p["score_raw"] for p in right_scored)
teamA_avg = teamA_total / max(1, len(left_scored))
teamB_avg = teamB_total / max(1, len(right_scored))
delta = teamB_avg - teamA_avg
if delta > 0:
fav_delta = team_b
elif delta < 0:
fav_delta = team_a
else:
fav_delta = "Neither team"
delta_mag = abs(delta)
stronger_avg = max(teamA_avg, teamB_avg)
rel_adv = (delta_mag / stronger_avg * 100) if stronger_avg > 0 else 0.0
if tag == "Slayer":
pred = compute_slayer_prediction(left_scored, right_scored)
teamA_win = pred["teamA_win"]
teamB_win = pred["teamB_win"]
else:
if max(teamA_avg, teamB_avg) == 0:
edge = 0
else:
edge = (teamB_avg - teamA_avg) / max(teamA_avg, teamB_avg)
k = 1.1
pB = 1 / (1 + math.exp(-k * edge))
pA = 1 - pB
teamA_win = round(pA * 100)
teamB_win = round(pB * 100)
swing = abs(teamA_win - teamB_win)
fav = team_a if teamA_win > teamB_win else team_b
return {
"empty": False,
"tag": tag,
"left": left,
"right": right,
"team_a": team_a,
"team_b": team_b,
"teamA_total": teamA_total,
"teamB_total": teamB_total,
"teamA_avg": teamA_avg,
"teamB_avg": teamB_avg,
"missing_left": missing_left,
"missing_right": missing_right,
"delta_mag": delta_mag,
"fav_delta": fav_delta,
"rel_adv": rel_adv,
"teamA_win": teamA_win,
"teamB_win": teamB_win,
"swing": swing,
"fav": fav,
}
def on_rankings(self):
if not self.current_match_players:
messagebox.showerror("Error", "Generate player slots first.")
return
win = tk.Toplevel(self.root)
win.title("Rankings")
win.geometry("800x600")
tags = RANKING_TAGS
ttk.Label(win, text="Select Tag:").pack(anchor="w", padx=10, pady=5)
combo = ttk.Combobox(win, values=tags, state="readonly")
combo.pack(fill="x", padx=10)
output = tk.Text(win, wrap="word")
output.pack(expand=True, fill="both", padx=10, pady=10)
def show():
tag = combo.get()
if not tag:
return
data = self.compute_ranking_data(tag)
if data is None:
messagebox.showerror("Error", "Select both teams first.")
return
if data.get("empty"):
output.delete("1.0", tk.END)
output.insert(tk.END, "No data available for this tag.")
return
team_a = data["team_a"]
team_b = data["team_b"]
left = data["left"]
right = data["right"]
text = f"TOP {tag.upper()} PLAYERS (THIS MATCH):\n\n"
# Two-column layout, but keep global rank
max_rows = max(len(left), len(right))
text += f"{team_a:<24}{team_b}\n"
text += "-" * 48 + "\n"
for i in range(max_rows):
left_str = ""
right_str = ""
if i < len(left):
lp = left[i]
pct = lp.get("pct")
if pct is not None:
pct_str = f"{pct:.0f}%"
left_str = f"{lp['rank']:>2}. {lp['name']:<16} {lp['score_display']:>6} ({pct_str})"
else:
left_str = f"{lp['rank']:>2}. {lp['name']:<16} {lp['score_display']:>6}"
if i < len(right):
rp = right[i]
pct = rp.get("pct")
if pct is not None:
pct_str = f"{pct:.0f}%"
right_str = f"{rp['rank']:>2}. {rp['name']:<16} {rp['score_display']:>6} ({pct_str})"
else:
right_str = f"{rp['rank']:>2}. {rp['name']:<16} {rp['score_display']:>6}"
text += f"{left_str:<24}{right_str}\n"
text += "-" * 48 + "\n"
text += f"{team_a} Total: {data['teamA_total']:.2f}".ljust(24)
text += f"{team_b} Total: {data['teamB_total']:.2f}\n"
text += f"{team_a} Avg: {data['teamA_avg']:.2f}".ljust(24)
text += f"{team_b} Avg: {data['teamB_avg']:.2f}\n"
text += f"Delta: {data['delta_mag']:.2f} in favour of {data['fav_delta']}\n"
text += f"Relative Advantage: {data['rel_adv']:.1f}%\n"
text += f"Win Chance: {team_a} {data['teamA_win']}% — {team_b} {data['teamB_win']}%\n"
text += f"Win Swing: {data['swing']}% in favour of {data['fav']}\n"
text += "\n" + "\n".join(RANKING_FORMULAS.get(tag, [])) + "\n"
output.delete("1.0", tk.END)
output.insert(tk.END, text)
ttk.Button(win, text="Show Rankings", command=show).pack(pady=5)
ttk.Label(
win,
text="All 8 tags are auto-exported as HTML whenever you click Generate Player Slots.",
foreground="#666666",
).pack(pady=(0, 5))
# dashleague_cast_tool.py -> on_rookie_leaderboard()
def on_rookie_leaderboard(self):
from analysis.rookie_leaderboard import rank_rookies_by_tag, ROOKIE_KILL_THRESHOLD
season = self.current_season
tag = "slayer"
rookies = rank_rookies_by_tag(season, tag)
# 1. Text display for the GUI window
lines = []
lines.append(f"ROOKIE LEADERBOARD — Season {season}")
lines.append(f"Rookie = under {ROOKIE_KILL_THRESHOLD:,} career kills, regardless of seasons played.")
lines.append("")
lines.append(f"{'Rank':<5} {'Name':<18} {'Team':<12} {'Score':<10} {'KD':<6} {'Maps':<6} {'Kills':<8}")
lines.append("-" * 75)
for r in rookies:
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)
win = tk.Toplevel(self.root)
win.title("Rookie Standings")
win.geometry("800x600")
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_html():
from analysis.html_export import render_rookie_leaderboard_html
html_content = render_rookie_leaderboard_html(rookies, season, ROOKIE_KILL_THRESHOLD)
html_path = os.path.join(OBS_EXPORT_DIR, "rookie_leaderboard.html")
with open(html_path, "w", encoding="utf-8") as f:
f.write(html_content)
self.set_status("Exported Rookie HTML")
messagebox.showinfo("Exported", f"HTML updated\nLocation: {html_path}")
ttk.Button(win, text="Export HTML for OBS Browser Source", command=export_html).pack(pady=10)
ttk.Label(
win,
text="Also auto-refreshed whenever you click Generate Player Slots.",
foreground="#666666",
).pack(pady=(0, 10))
def export_all_obs_files(self):
"""
Unified export function that writes all OBS text files
based on the current map type and current match players.
"""
map_type = self.map_var.get()
team_a = self.team_a_var.get()
team_b = self.team_b_var.get()
if not map_type or not team_a or not team_b:
return
# --- Extract selected players for each team ---
teamA_players = [p for p in self.current_match_players if p.get("team") == team_a]
teamB_players = [p for p in self.current_match_players if p.get("team") == team_b]
if len(teamA_players) == 0 or len(teamB_players) == 0:
print("OBS export skipped: no selected players for one or both teams.")
return
# Define names for identity block
teamA_name = team_a
teamB_name = team_b
# --- STORYLINE + TEAM IDENTITY (pretty HTML, replaces storyline.txt) ---
try:
narrative_text = matchup_narrative(teamA_players, teamB_players, map_type)
identity_data_a = generate_team_identity_block(teamA_players, teamA_name, map_type)
identity_data_b = generate_team_identity_block(teamB_players, teamB_name, map_type)
from analysis.html_export import render_matchup_html
tiers = self.config.get("tiers", {})
matchup_html = render_matchup_html(
narrative_text,
identity_data_a,
identity_data_b,
teamA_name,
teamB_name,
season=self.current_season,
tier_a=tiers.get(teamA_name),
tier_b=tiers.get(teamB_name),
)
with open(os.path.join(OBS_EXPORT_DIR, "matchup.html"), "w", encoding="utf-8") as f:
f.write(matchup_html)
except Exception as e:
print("Storyline export failed:", e)
# --- RANKING PAGES (7 tags relevant to the current map mode —
# Payload Objective is skipped in Domination matches and
# vice versa, since that data isn't relevant either way) ---
try:
from analysis.html_export import render_ranking_html
tiers = self.config.get("tiers", {})
for tag in RANKING_TAGS:
if tag == "Payload Objective Specialist" and map_type != "Payload":
continue
if tag == "Domination Objective Specialist" and map_type != "Domination":
continue
data = self.compute_ranking_data(tag)
if not data or data.get("empty"):
continue
ranking_html = render_ranking_html(
tag,
data,
RANKING_FORMULAS.get(tag, []),
season=self.current_season,
tier_a=tiers.get(teamA_name),
tier_b=tiers.get(teamB_name),
)
slug = RANKING_TAG_SLUGS.get(tag, tag.lower())
with open(os.path.join(OBS_EXPORT_DIR, f"rankings_{slug}.html"), "w", encoding="utf-8") as f:
f.write(ranking_html)
except Exception as e:
print("Rankings export failed:", e)
# --- ROOKIE LEADERBOARD (season-wide, refreshed here for
# convenience so it's always current before a broadcast) ---
try:
from analysis.rookie_leaderboard import rank_rookies_by_tag, ROOKIE_KILL_THRESHOLD
from analysis.html_export import render_rookie_leaderboard_html
rookies = rank_rookies_by_tag(self.current_season, "slayer")
rookie_html = render_rookie_leaderboard_html(rookies, self.current_season, ROOKIE_KILL_THRESHOLD)
with open(os.path.join(OBS_EXPORT_DIR, "rookie_leaderboard.html"), "w", encoding="utf-8") as f:
f.write(rookie_html)
except Exception as e:
print("Rookie leaderboard export failed:", e)
# --- TALE OF THE TAPE / TEAM DNA / WIN PROBABILITY (4K PNGs +
# HTML wrappers). These used to require manually clicking
# their own buttons every time; since every cast uses all
# three anyway, they now regenerate automatically alongside
# everything else whenever the team composition changes. The
# buttons still exist too, for a one-off manual refresh if
# ever needed without touching team selection. ---
try:
from utils.graphics_engine import (
generate_tale_of_the_tape,
generate_team_dna,
generate_win_probability,
)
from analysis.html_export import render_spotlight_html
roster_a = self.get_team_roster_data(teamA_name)
roster_b = self.get_team_roster_data(teamB_name)
generate_tale_of_the_tape(roster_a, roster_b, teamA_name, teamB_name, export_dir=OBS_EXPORT_DIR)
with open(os.path.join(OBS_EXPORT_DIR, "tale_of_the_tape.html"), "w", encoding="utf-8") as f:
f.write(render_spotlight_html("tale_of_the_tape.png"))
generate_team_dna(roster_a, roster_b, teamA_name, teamB_name, export_dir=OBS_EXPORT_DIR)
with open(os.path.join(OBS_EXPORT_DIR, "team_dna.html"), "w", encoding="utf-8") as f:
f.write(render_spotlight_html("team_dna.png"))
generate_win_probability(roster_a, roster_b, teamA_name, teamB_name, export_dir=OBS_EXPORT_DIR)
with open(os.path.join(OBS_EXPORT_DIR, "win_probability.html"), "w", encoding="utf-8") as f:
f.write(render_spotlight_html("win_probability.png"))
except Exception as e:
print("Comparison graphics export failed:", e)
# --- PREDICTIONS ---
try:
if hasattr(self, "generate_predictions"):
predictions = self.generate_predictions(teamA_players, teamB_players)
write_text(os.path.join(OBS_EXPORT_DIR, "predictions.txt"), predictions)
except Exception as e:
print("Prediction export failed:", e)
# --- PLAYER TAG EXPORTS ---
try:
for idx, p in enumerate(self.current_match_players):
# p IS the enriched match player object
tags_text = self.generate_player_tags(p, map_type)
slot_path = os.path.join(PLAYERS_DIR, f"p{idx}", "Tags.txt")
write_text(slot_path, tags_text)
except Exception as e:
print("Tag export failed:", e)
print(f"OBS files updated for {map_type}")
def refresh_output_dirs(self):
global STATS_DIR, PLAYERS_DIR, OBS_EXPORT_DIR
STATS_DIR = self.output_root
PLAYERS_DIR = os.path.join(STATS_DIR, "players")
OBS_EXPORT_DIR = os.path.join(STATS_DIR, "obs_exports")
os.makedirs(PLAYERS_DIR, exist_ok=True)
os.makedirs(OBS_EXPORT_DIR, exist_ok=True)
def choose_output_folder(self):
folder = filedialog.askdirectory(title="Select output folder for OBS + players")
if not folder:
return
self.output_root = os.path.abspath(folder)
config["output_root"] = self.output_root
self.refresh_output_dirs()
self.start_local_obs_server()
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(config, f, indent=4)
messagebox.showinfo(
"Output Folder Set",
f"Output folder set to:\n{self.output_root}\n\n"
"Point OBS to this folder for players and exports.\n\n"
f"For HTML graphics, use http://localhost:{LOCAL_OBS_SERVER_PORT}/<filename> "
"as the Browser Source URL instead of a local file path — "
"that lets them auto-refresh when regenerated, no manual "
"toggling needed."
)
def require_output_folder(self):
"""
Returns the obs_exports subfolder of the configured output root,
creating it if needed. If no output folder has been selected yet,
warns the user and returns None so callers can abort.
"""
if not getattr(self, "output_root", None):
messagebox.showwarning(
"Output Folder Not Set",
"Please click 'Select Output Folder' before generating "
"broadcast graphics, so exports know where to save."
)
return None
export_dir = os.path.join(self.output_root, "obs_exports")
os.makedirs(export_dir, exist_ok=True)
return export_dir
def open_roster_manager(self):
"""
Lets a caster manually move a player onto a new team mid-season,
before roster lock — e.g. a merge/disband where the player hasn't
played a match under the new team yet, so match-derived team
history hasn't caught up. Overrides are stored in
roster_overrides.json and always take priority over history.
"""
from analysis.player_identity_registry import PlayerIdentityRegistry
pir = PlayerIdentityRegistry()
win = tk.Toplevel(self.root)
win.title("Manage Roster & Player Names")
win.geometry("560x660")
ttk.Label(win, text="Search player:").pack(anchor="w", padx=10, pady=(10, 0))
search_var = tk.StringVar()
search_entry = ttk.Entry(win, textvariable=search_var)
search_entry.pack(fill="x", padx=10)
list_frame = ttk.Frame(win)
list_frame.pack(fill="both", expand=True, padx=10, pady=10)
scrollbar = ttk.Scrollbar(list_frame)
scrollbar.pack(side="right", fill="y")
roster_listbox = tk.Listbox(list_frame, yscrollcommand=scrollbar.set)
roster_listbox.pack(side="left", fill="both", expand=True)
scrollbar.config(command=roster_listbox.yview)
# cid list kept in sync (index-aligned) with roster_listbox rows
row_cids = []
def refresh_list():
pir.data = PlayerIdentityRegistry().data # reload from disk
overrides = load_roster_overrides()
query = search_var.get().strip().lower()
rows = []
for cid, entry in pir.data.get("canonical", {}).items():
names = entry.get("names") or []
display_name = names[-1] if names else cid
if query and query not in display_name.lower():
continue
current_team = get_current_team(cid, pir, overrides) or "(no team)"
overridden = " [MANUAL]" if cid in overrides else ""
rows.append((display_name, cid, current_team, overridden))
rows.sort(key=lambda r: r[0].lower())
roster_listbox.delete(0, tk.END)
row_cids.clear()
for display_name, cid, current_team, overridden in rows:
roster_listbox.insert(tk.END, f"{display_name} \u2014 {current_team}{overridden}")
row_cids.append(cid)
search_entry.bind("<KeyRelease>", lambda e: refresh_list())
assign_frame = ttk.Frame(win)
assign_frame.pack(fill="x", padx=10, pady=(0, 10))
ttk.Label(assign_frame, text="Move selected player to:").grid(row=0, column=0, sticky="w")
team_var = tk.StringVar()
team_combo = ttk.Combobox(assign_frame, textvariable=team_var, values=self.config.get("teams", []))
team_combo.grid(row=1, column=0, sticky="ew", pady=(0, 5))
assign_frame.columnconfigure(0, weight=1)
rename_frame = ttk.Frame(win)
rename_frame.pack(fill="x", padx=10, pady=(0, 10))
ttk.Label(rename_frame, text="Rename selected player to (their player ID stays the same):").grid(row=0, column=0, sticky="w")
rename_var = tk.StringVar()
rename_entry = ttk.Entry(rename_frame, textvariable=rename_var)
rename_entry.grid(row=1, column=0, sticky="ew", pady=(0, 5))
rename_frame.columnconfigure(0, weight=1)
def get_selected_cid():
sel = roster_listbox.curselection()
if not sel:
messagebox.showwarning("No Selection", "Select a player from the list first.")
return None
return row_cids[sel[0]]
def do_assign():
cid = get_selected_cid()
if not cid:
return
new_team = team_var.get().strip()
if not new_team:
messagebox.showwarning("No Team", "Choose or type a team to assign.")
return
set_override(cid, new_team)
refresh_list()
# Immediately reflect the move in the main window if either
# side currently has a team selected.
if self.team_a_var.get():
self.on_team_select("A")
if self.team_b_var.get():
self.on_team_select("B")
def do_clear():
cid = get_selected_cid()
if not cid:
return
clear_override(cid)
refresh_list()
if self.team_a_var.get():
self.on_team_select("A")
if self.team_b_var.get():
self.on_team_select("B")
def do_rename():
cid = get_selected_cid()
if not cid:
return
new_name = rename_var.get().strip()
if not new_name:
messagebox.showwarning("No Name", "Type the player's new name.")
return
rename_player(cid, new_name)
rename_var.set("")
refresh_list()
self.set_status(f"Renamed player to {new_name}")
if self.team_a_var.get():
self.on_team_select("A")
if self.team_b_var.get():
self.on_team_select("B")
btn_row = ttk.Frame(win)
btn_row.pack(fill="x", padx=10, pady=(0, 10))
ttk.Button(btn_row, text="Assign to Team", command=do_assign).pack(side="left", padx=(0, 5))
ttk.Button(btn_row, text="Clear Override (use match history)", command=do_clear).pack(side="left", padx=(0, 5))
ttk.Button(btn_row, text="Rename Player", command=do_rename).pack(side="left", padx=(0, 5))
ttk.Button(btn_row, text="Close", command=win.destroy).pack(side="right")
refresh_list()
def on_match_prediction(self):
team_a = self.team_a_var.get()
team_b = self.team_b_var.get()
if not team_a or not team_b:
messagebox.showerror("Error", "Select both Team A and Team B.")
return
teamA_players = [p for p in self.current_match_players if p.get("team") == team_a]
teamB_players = [p for p in self.current_match_players if p.get("team") == team_b]
if not teamA_players or not teamB_players:
messagebox.showerror("Error", "No players selected for one or both teams.")
return
prediction_text = generate_prediction(teamA_players, teamB_players, team_a, team_b)
win = tk.Toplevel(self.root)
win.title("Match Prediction")
win.geometry("600x600")
output = tk.Text(win, wrap="word")
output.pack(expand=True, fill="both", padx=10, pady=10)
output.insert(tk.END, prediction_text)
def export():
content = output.get("1.0", tk.END).strip()
if not content:
messagebox.showerror("Error", "No prediction text to export.")
return
path = export_to_obs("match_prediction.txt", content)
messagebox.showinfo("Exported", f"Prediction exported to:\n{path}")
ttk.Button(win, text="Export to OBS", command=export).pack(pady=5)
def on_export_obs_all(self):
team_a = self.team_a_var.get()
team_b = self.team_b_var.get()
if not team_a or not team_b:
messagebox.showerror("Error", "Select both Team A and Team B.")
return
teamA_players = [p for p in self.current_match_players if p.get("team") == team_a]
teamB_players = [p for p in self.current_match_players if p.get("team") == team_b]
if not teamA_players or not teamB_players:
messagebox.showerror("Error", "No players selected for one or both teams.")
return
output_folder = os.path.join(os.getcwd(), "obs_output")
export_all_obs(output_folder, teamA_players, teamB_players, team_a, team_b)
messagebox.showinfo("Exported", f"OBS files exported to:\n{output_folder}")
def main():
root = tk.Tk()
app = DashLeagueGUI(root)
root.mainloop()
if __name__ == "__main__":
main()