2026-04-08 08:13:02 +00:00
import os
import json
import math
2026-09-29 00:59:21 +00:00
import threading
import functools
from http . server import HTTPServer , SimpleHTTPRequestHandler
2026-04-08 08:13:02 +00:00
from collections import defaultdict
2026-04-09 10:11:39 +00:00
2026-04-08 08:13:02 +00:00
import tkinter as tk
2026-04-09 10:11:39 +00:00
from tkinter import ttk , messagebox , filedialog
2026-04-08 08:13:02 +00:00
import requests
2026-04-22 07:25:42 +00:00
# ---------------------------------------------------------
# Internal modules — clean, correct, no duplicates
# ---------------------------------------------------------
# Career DB builder
2026-04-08 08:13:02 +00:00
from data . career_db import build_career_database
2026-04-22 07:25:42 +00:00
# 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
2026-04-10 08:08:47 +00:00
from analysis . team_identity_v2 import generate_team_identity_block
2026-09-29 00:59:21 +00:00
from analysis . storylines import generate_storyline , matchup_storyline , matchup_narrative
2026-04-10 08:08:47 +00:00
from analysis . predictions_v2 import generate_prediction
2026-04-23 09:24:48 +00:00
from analysis . rookie_leaderboard import rank_rookies_by_tag
2026-04-22 07:25:42 +00:00
# OBS export
2026-04-10 08:08:47 +00:00
from analysis . obs_export_v2 import export_all_obs
2026-04-09 10:11:39 +00:00
2026-04-22 07:25:42 +00:00
# Domination objective (match + career)
2026-04-09 10:11:39 +00:00
from tags . objective_domination import (
compute_dom_objective_for_career_player ,
compute_dom_objective_for_match_player ,
compute_dom_objective_team_scores ,
)
2026-04-22 07:25:42 +00:00
# Prediction engine (legacy but still used)
2026-04-08 08:13:02 +00:00
from predictions . prediction_engine import generate_predictions
2026-04-22 07:25:42 +00:00
# Match-based tag engines
2026-04-08 08:13:02 +00:00
from match_engine import (
rank_match_players_slayer ,
rank_match_players_objpl ,
rank_match_players_objdom ,
2026-04-09 10:11:39 +00:00
rank_match_players_sharpshooter ,
2026-04-08 08:13:02 +00:00
rank_match_players_clutch ,
compute_slayer_prediction ,
2026-04-14 07:32:50 +00:00
rank_match_players_consistency_career ,
2026-04-23 09:24:48 +00:00
load_career_db ,
attach_career_tags_to_match_player ,
2026-04-08 08:13:02 +00:00
)
2026-04-22 07:25:42 +00:00
from data . tag_framework import percentile_rank , tier_from_percentile
2026-09-29 00:59:21 +00:00
from data . roster_overrides import load_roster_overrides , save_roster_overrides , set_override , clear_override
2026-04-22 07:25:42 +00:00
2026-04-09 10:11:39 +00:00
# ---------------------------------------------------------
# PATHS + CONFIG
# ---------------------------------------------------------
2026-04-08 08:13:02 +00:00
BASE_DIR = os . path . dirname ( os . path . abspath ( __file__ ) )
CONFIG_PATH = os . path . join ( BASE_DIR , " config.json " )
2026-04-21 08:55:26 +00:00
SCREENS_DIR = os . path . join ( BASE_DIR , " screens " )
2026-04-09 10:11:39 +00:00
# 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 )
2026-04-08 08:13:02 +00:00
os . makedirs ( OBS_EXPORT_DIR , exist_ok = True )
API_BASE = " https://dashleague.games/api/v1 "
2026-09-29 00:59:21 +00:00
LOCAL_OBS_SERVER_PORT = 8787
2026-04-08 08:13:02 +00:00
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
2026-04-10 08:08:47 +00:00
2026-04-22 07:25:42 +00:00
# ---------------------------------------------------------
# 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 )
2026-09-29 00:59:21 +00:00
# ---------------------------------------------------------
# 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 " ) ,
}
2026-04-22 07:25:42 +00:00
2026-04-10 08:08:47 +00:00
# ---------------------------------------------------------
# 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 } " )
2026-04-08 08:13:02 +00:00
2026-04-10 08:08:47 +00:00
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 )
2026-04-08 08:13:02 +00:00
def fetch_json ( url ) :
resp = requests . get ( url , timeout = 10 )
resp . raise_for_status ( )
return resp . json ( )
2026-09-29 00:59:21 +00:00
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
2026-04-08 08:13:02 +00:00
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 ) )
2026-09-29 00:59:21 +00:00
# ---------------------------------------------------------
# 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. " ,
] ,
}
2026-04-08 08:13:02 +00:00
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 ( )
2026-09-29 00:59:21 +00:00
2026-04-08 08:13:02 +00:00
self . current_match_players = [ ]
2026-09-29 00:59:21 +00:00
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 } ) " )
2026-04-08 08:13:02 +00:00
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 )
2026-09-29 00:59:21 +00:00
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 " )
2026-04-08 08:13:02 +00:00
def _build_ui ( self ) :
main_frame = ttk . Frame ( self . root , padding = 10 )
main_frame . grid ( row = 0 , column = 0 , sticky = " nsew " )
2026-04-22 07:25:42 +00:00
self . team_a_var = tk . StringVar ( )
self . team_b_var = tk . StringVar ( )
2026-04-08 08:13:02 +00:00
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 ) )
2026-04-13 07:56:38 +00:00
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 ) )
2026-04-08 08:13:02 +00:00
self . sync_button = ttk . Button ( top_frame , text = " Sync Stats " , command = self . on_sync_stats )
2026-04-13 07:56:38 +00:00
self . sync_button . grid ( row = 0 , column = 1 , padx = ( 0 , 10 ) )
2026-04-08 08:13:02 +00:00
self . team_id_button = ttk . Button ( top_frame , text = " Team Identity " , command = self . on_team_identity )
2026-05-06 02:37:26 +00:00
self . team_id_button . grid ( row = 0 , column = 2 , padx = ( 0 , 10 ) )
2026-04-08 08:13:02 +00:00
self . storyline_button = ttk . Button ( top_frame , text = " Match Storyline " , command = self . on_match_storyline )
2026-05-06 02:37:26 +00:00
self . storyline_button . grid ( row = 0 , column = 3 , padx = ( 0 , 10 ) )
2026-04-08 08:13:02 +00:00
self . rankings_button = ttk . Button ( top_frame , text = " Rankings " , command = self . on_rankings )
2026-05-06 02:37:26 +00:00
self . rankings_button . grid ( row = 0 , column = 4 , padx = ( 0 , 10 ) )
2026-04-08 08:13:02 +00:00
2026-04-23 09:24:48 +00:00
self . rookie_button = ttk . Button ( top_frame , text = " Rookie Leaderboard " , command = self . on_rookie_leaderboard )
2026-05-06 02:37:26 +00:00
self . rookie_button . grid ( row = 0 , column = 5 , padx = ( 0 , 10 ) )
2026-09-29 00:59:21 +00:00
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 ) )
2026-04-23 09:24:48 +00:00
2026-04-09 10:11:39 +00:00
self . output_folder_button = ttk . Button (
top_frame ,
text = " Select Output Folder " ,
command = self . choose_output_folder
)
2026-04-23 09:24:48 +00:00
self . output_folder_button . grid ( row = 0 , column = 7 , padx = ( 0 , 10 ) )
2026-04-09 10:11:39 +00:00
2026-04-08 08:13:02 +00:00
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 )
2026-05-06 02:37:26 +00:00
# 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 )
2026-04-08 08:13:02 +00:00
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 ( )
2026-05-06 02:37:26 +00:00
# Add these methods inside the DashLeagueGUI class
def get_team_roster_data ( self , team_name ) :
2026-09-29 00:59:21 +00:00
"""
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 .
"""
2026-05-06 02:37:26 +00:00
career_path = os . path . join ( BASE_DIR , " career_stats.json " )
with open ( career_path , " r " , encoding = " utf-8 " ) as f :
db = json . load ( f )
2026-09-29 00:59:21 +00:00
2026-05-06 02:37:26 +00:00
team_players = [ p for p in self . current_match_players if p . get ( " team " ) == team_name ]
2026-09-29 00:59:21 +00:00
# First pass: classify every player and collect the REAL
# (career or verified) career dicts as we go.
resolved = [ ]
real_careers = [ ]
2026-05-06 02:37:26 +00:00
for p in team_players :
cid = p . get ( " canonical " )
2026-09-29 00:59:21 +00:00
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 )
2026-05-06 02:37:26 +00:00
p_data [ " current_team " ] = team_name
2026-09-29 00:59:21 +00:00
p_data [ " stats_estimated " ] = False
2026-05-06 02:37:26 +00:00
roster_data . append ( p_data )
2026-09-29 00:59:21 +00:00
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 )
2026-05-06 02:37:26 +00:00
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 ( )
2026-09-29 00:59:21 +00:00
export_dir = self . require_output_folder ( )
if not export_dir :
return
2026-05-06 02:37:26 +00:00
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 :
2026-09-29 00:59:21 +00:00
generate_tale_of_the_tape ( roster_a , roster_b , team_a , team_b , export_dir = export_dir )
2026-05-06 02:37:26 +00:00
self . set_status ( " Generated 4K Tale of the Tape (PvP) " )
2026-09-29 00:59:21 +00:00
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 )
2026-05-06 02:37:26 +00:00
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 ( )
2026-09-29 00:59:21 +00:00
export_dir = self . require_output_folder ( )
if not export_dir :
return
2026-05-06 02:37:26 +00:00
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 :
2026-09-29 00:59:21 +00:00
generate_team_dna ( roster_a , roster_b , team_a , team_b , export_dir = export_dir )
2026-05-06 02:37:26 +00:00
self . set_status ( " Generated 4K Team DNA Graphic " )
2026-09-29 00:59:21 +00:00
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 )
2026-05-06 02:37:26 +00:00
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 ( )
2026-09-29 00:59:21 +00:00
export_dir = self . require_output_folder ( )
if not export_dir :
return
2026-05-06 02:37:26 +00:00
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 :
2026-09-29 00:59:21 +00:00
generate_win_probability ( roster_a , roster_b , team_a , team_b , export_dir = export_dir )
2026-05-06 02:37:26 +00:00
self . set_status ( " Generated 4K Win Probability Graphic " )
2026-09-29 00:59:21 +00:00
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 )
2026-05-06 02:37:26 +00:00
messagebox . showinfo ( " Success " , " Win Probability Graphic generated in obs_exports. " )
except Exception as e :
messagebox . showerror ( " Graphics Error " , str ( e ) )
2026-04-08 08:13:02 +00:00
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 " , [ ] )
2026-04-23 09:24:48 +00:00
2026-04-21 08:55:26 +00:00
# ---------------------------------------------------------
# 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 )
2026-04-23 09:24:48 +00:00
players_db = career_db . setdefault ( " players " , { } )
2026-04-21 08:55:26 +00:00
# ---------------------------------------------------------
2026-04-23 09:24:48 +00:00
# BUILD stats_by_id (canonical → enriched player object)
2026-04-21 08:55:26 +00:00
# ---------------------------------------------------------
new_stats_by_id = { }
for p in stats :
2026-04-23 09:24:48 +00:00
raw_id = p . get ( " id " )
if not raw_id :
continue
2026-04-21 08:55:26 +00:00
2026-04-23 09:24:48 +00:00
# Resolve canonical + stamp seasons
canonical = pir . resolve ( raw_id , season = self . current_season )
2026-09-29 00:59:21 +00:00
2026-04-24 06:30:50 +00:00
# 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 " ]
2026-04-23 09:24:48 +00:00
if not canonical :
continue
2026-04-22 07:25:42 +00:00
2026-04-23 09:24:48 +00:00
# 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 ) ,
2026-09-29 00:59:21 +00:00
" 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 ) ,
2026-04-23 09:24:48 +00:00
" match_stats " : p ,
" seasons_played " : p . get ( " seasons_played " ) or p . get ( " seasons " ) or " " ,
}
2026-04-21 08:55:26 +00:00
2026-04-23 09:24:48 +00:00
# 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 " : { } ,
}
2026-04-24 06:30:50 +00:00
pdata = players_db . setdefault ( canonical , { } )
pdata [ " seasons_played " ] = merged [ " seasons_played " ]
2026-09-29 00:59:21 +00:00
# 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 " ]
2026-04-23 09:24:48 +00:00
# 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 )
2026-09-29 00:59:21 +00:00
pdata [ " headshots " ] = merged . get ( " headshots " , 0 )
pdata [ " maps " ] = merged . get ( " maps " , 0 )
pdata [ " PAY_PushTime " ] = merged . get ( " PAY_PushTime " , 0 )
2026-04-23 09:24:48 +00:00
# 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 " ]
2026-04-21 08:55:26 +00:00
2026-04-22 07:25:42 +00:00
# Attach career tags
2026-04-23 09:24:48 +00:00
for tag in (
" slayer " ,
" sharpshooter " ,
" objective_payload " ,
" objective_domination " ,
" consistency " ,
" clutch " ,
" anchor " ,
" breaker " ,
) :
merged [ tag ] = pdata . get ( tag , { } )
2026-04-22 07:25:42 +00:00
new_stats_by_id [ canonical ] = merged
2026-04-23 09:24:48 +00:00
# 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 " ]
2026-09-29 00:59:21 +00:00
players_db [ canonical ] [ " headshots " ] = merged [ " headshots " ]
players_db [ canonical ] [ " maps " ] = merged [ " maps " ]
players_db [ canonical ] [ " PAY_PushTime " ] = merged [ " PAY_PushTime " ]
2026-04-23 09:24:48 +00:00
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 )
2026-04-22 07:25:42 +00:00
2026-04-23 09:24:48 +00:00
# ---------------------------------------------------------
# Save PIR (first_season now persisted)
# ---------------------------------------------------------
pir . save ( )
# ---------------------------------------------------------
# Replace stats_by_id with enriched version
# ---------------------------------------------------------
2026-04-21 08:55:26 +00:00
self . stats_by_id = new_stats_by_id
2026-04-22 07:25:42 +00:00
2026-04-08 08:13:02 +00:00
self . set_status ( " Stats synced. " )
messagebox . showinfo ( " Success " , f " Synced { len ( stats ) } player stats. " )
2026-04-23 09:24:48 +00:00
2026-04-08 08:13:02 +00:00
except Exception as e :
messagebox . showerror ( " Error " , f " Failed to sync stats: \n { e } " )
self . set_status ( " Sync failed. " )
2026-04-23 09:24:48 +00:00
2026-04-22 07:25:42 +00:00
# Re-trigger team selection so listboxes repopulate
if self . team_a_var . get ( ) :
self . on_team_select ( " A " )
if self . team_b_var . get ( ) :
2026-04-23 09:24:48 +00:00
self . on_team_select ( " B " )
2026-04-22 07:25:42 +00:00
2026-04-08 08:13:02 +00:00
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 ( )
2026-09-29 00:59:21 +00:00
from analysis . player_identity_registry import PlayerIdentityRegistry
pir = PlayerIdentityRegistry ( )
overrides = load_roster_overrides ( )
2026-04-22 07:25:42 +00:00
matching = [ ]
2026-09-29 00:59:21 +00:00
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).
2026-04-22 07:25:42 +00:00
for p in self . stats_by_id . values ( ) :
2026-09-29 00:59:21 +00:00
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
2026-04-22 07:25:42 +00:00
matching . append ( p )
2026-09-29 00:59:21 +00:00
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
2026-04-22 07:25:42 +00:00
2026-09-29 00:59:21 +00:00
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 )
2026-04-22 07:25:42 +00:00
# Now assign to UI
2026-04-08 08:13:02 +00:00
if side == " A " :
2026-04-22 07:25:42 +00:00
self . team_a_players = matching
2026-04-08 08:13:02 +00:00
self . team_a_listbox . delete ( 0 , tk . END )
2026-04-22 07:25:42 +00:00
for p in matching :
2026-04-08 08:13:02 +00:00
self . team_a_listbox . insert ( tk . END , p . get ( " name " , " Unknown " ) )
else :
2026-04-22 07:25:42 +00:00
self . team_b_players = matching
2026-04-08 08:13:02 +00:00
self . team_b_listbox . delete ( 0 , tk . END )
2026-04-22 07:25:42 +00:00
for p in matching :
2026-04-08 08:13:02 +00:00
self . team_b_listbox . insert ( tk . END , p . get ( " name " , " Unknown " ) )
2026-04-09 10:11:39 +00:00
2026-04-14 07:32:50 +00:00
def normalize_player ( self , p ) :
2026-04-09 10:11:39 +00:00
return {
2026-04-22 07:25:42 +00:00
" 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 ) ,
}
2026-04-09 10:11:39 +00:00
2026-04-08 08:13:02 +00:00
def on_generate_slots ( self ) :
try :
ensure_player_slots ( )
2026-04-22 07:25:42 +00:00
# ---------------------------------------------------------
2026-04-09 10:11:39 +00:00
# 1. Collect selected players
2026-04-22 07:25:42 +00:00
# ---------------------------------------------------------
2026-04-08 08:13:02 +00:00
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
2026-04-22 07:25:42 +00:00
# Limit to 5 per team
selected_a = selected_a [ : 5 ]
selected_b = selected_b [ : 5 ]
all_players = selected_a + selected_b
2026-04-08 08:13:02 +00:00
2026-04-22 07:25:42 +00:00
# ---------------------------------------------------------
# 2. Normalize stat keys BEFORE attaching tags
# ---------------------------------------------------------
2026-04-23 09:24:48 +00:00
2026-04-09 10:11:39 +00:00
for p in all_players :
2026-04-23 09:24:48 +00:00
ms = p . get ( " match_stats " , { } )
# DOM
p [ " DOM_Captures " ] = (
2026-04-24 06:30:50 +00:00
p . get ( " DOM_Captures " )
or p . get ( " DOM_captures " )
or ms . get ( " DOM_Captures " )
or ms . get ( " DOM_captures " )
2026-04-23 09:24:48 +00:00
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
)
2026-04-09 10:11:39 +00:00
2026-04-21 08:55:26 +00:00
# ---------------------------------------------------------
2026-04-22 07:25:42 +00:00
# 3. Resolve canonical IDs AND normalize player ID
2026-04-21 08:55:26 +00:00
# ---------------------------------------------------------
from analysis . player_identity_registry import PlayerIdentityRegistry
pir = PlayerIdentityRegistry ( )
for p in all_players :
2026-09-29 00:59:21 +00:00
# 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
2026-04-21 08:55:26 +00:00
raw_uuid = (
p . get ( " id " )
or p . get ( " PlayerUUID " )
or p . get ( " uuid " )
)
if raw_uuid :
2026-04-23 09:24:48 +00:00
p [ " canonical " ] = pir . resolve ( raw_uuid , season = self . current_season )
2026-04-21 08:55:26 +00:00
2026-04-22 07:25:42 +00:00
# Normalize ID (AFTER canonical resolution)
pid = (
p . get ( " id " )
or p . get ( " PlayerUUID " )
or p . get ( " uuid " )
2026-04-23 09:24:48 +00:00
or p . get ( " canonical " )
2026-04-22 07:25:42 +00:00
or p . get ( " name " )
)
p [ " id " ] = pid
2026-04-21 08:55:26 +00:00
2026-04-22 07:25:42 +00:00
# ---------------------------------------------------------
2026-09-29 00:59:21 +00:00
# 4. Load career DB once
2026-04-22 07:25:42 +00:00
# ---------------------------------------------------------
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 )
# ---------------------------------------------------------
2026-09-29 00:59:21 +00:00
# 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.
2026-04-22 07:25:42 +00:00
# ---------------------------------------------------------
2026-04-23 09:24:48 +00:00
for p in all_players :
2026-09-29 00:59:21 +00:00
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
2026-04-22 07:25:42 +00:00
2026-09-29 00:59:21 +00:00
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
2026-04-22 07:25:42 +00:00
# ---------------------------------------------------------
2026-09-29 00:59:21 +00:00
# 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.
2026-04-22 07:25:42 +00:00
# ---------------------------------------------------------
2026-09-29 00:59:21 +00:00
for tag_name , ( fallback_fn , label ) in FALLBACK_FORMULAS . items ( ) :
resolve_tag_career_first ( all_players , tag_name , career_db , fallback_fn , label )
2026-04-22 07:25:42 +00:00
# ---------------------------------------------------------
2026-09-29 00:59:21 +00:00
# 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.
2026-04-22 07:25:42 +00:00
# ---------------------------------------------------------
2026-09-29 00:59:21 +00:00
consistency_rows = rank_match_players_consistency_career ( all_players )
consistency_map = { row [ " id " ] : row for row in consistency_rows }
2026-04-22 07:25:42 +00:00
for p in all_players :
2026-09-29 00:59:21 +00:00
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. " ,
}
2026-04-21 08:55:26 +00:00
2026-04-22 07:25:42 +00:00
# ---------------------------------------------------------
2026-09-29 00:59:21 +00:00
# 7. (Domination Objective is now handled in step 5 above,
# as part of the unified FALLBACK_FORMULAS system.)
2026-04-22 07:25:42 +00:00
# ---------------------------------------------------------
2026-04-13 07:56:38 +00:00
2026-04-23 09:24:48 +00:00
# 11. Ensure stats_by_id contains all enriched tags
2026-04-22 07:25:42 +00:00
for p in all_players :
2026-04-23 09:24:48 +00:00
cid = p . get ( " canonical " )
2026-04-22 07:25:42 +00:00
if not cid :
continue
enriched = self . stats_by_id . get ( cid )
if not enriched :
continue
2026-04-23 09:24:48 +00:00
# Copy match fields into stats_by_id
enriched [ " team " ] = p . get ( " team " )
enriched [ " name " ] = p . get ( " name " )
enriched [ " id " ] = p . get ( " id " )
2026-04-13 07:56:38 +00:00
2026-04-22 07:25:42 +00:00
# ---------------------------------------------------------
# 12. Update current_match_players
# ---------------------------------------------------------
self . current_match_players = all_players
# ---------------------------------------------------------
# 13. Write OBS slot files
# ---------------------------------------------------------
2026-04-08 08:13:02 +00:00
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 " , " " ) )
2026-04-22 07:25:42 +00:00
2026-04-08 08:13:02 +00:00
write_text ( os . path . join ( slot_dir , " PushTime.txt " ) , p . get ( " PAY_PushTime " , " " ) )
2026-04-09 10:11:39 +00:00
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 " , " " ) )
2026-04-08 08:13:02 +00:00
2026-04-22 07:25:42 +00:00
# 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 " ] )
2026-04-21 08:55:26 +00:00
2026-04-08 08:13:02 +00:00
self . set_status ( " Player slots generated. " )
messagebox . showinfo ( " Success " , " Player slots generated into p0– p9. " )
2026-04-09 10:11:39 +00:00
2026-04-08 08:13:02 +00:00
except Exception as e :
messagebox . showerror ( " Error " , f " Failed to generate slots: \n { e } " )
self . set_status ( " Generate failed. " )
2026-04-09 10:11:39 +00:00
2026-04-08 08:13:02 +00:00
self . export_all_obs_files ( )
def on_build_career_db ( self ) :
try :
self . set_status ( " Building career database... " )
2026-04-10 08:08:47 +00:00
2026-04-13 07:56:38 +00:00
# Use the same file the rest of the tool reads
output_path = os . path . join ( BASE_DIR , " career_stats.json " )
2026-04-10 08:08:47 +00:00
2026-09-29 00:59:21 +00:00
build_career_database ( output_path , current_season = self . current_season )
2026-04-10 08:08:47 +00:00
2026-04-08 08:13:02 +00:00
self . set_status ( " Career database built successfully. " )
messagebox . showinfo ( " Success " , " Career database has been built. " )
2026-04-10 08:08:47 +00:00
2026-04-08 08:13:02 +00:00
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. " )
2026-04-13 07:56:38 +00:00
2026-04-08 08:13:02 +00:00
def on_player_spotlight ( self ) :
2026-04-24 06:30:50 +00:00
if not self . current_match_players :
2026-05-06 02:37:26 +00:00
messagebox . showerror ( " Error " , " Please generate player slots first. " )
2026-04-08 08:13:02 +00:00
return
2026-09-29 00:59:21 +00:00
export_dir = self . require_output_folder ( )
if not export_dir :
return
2026-05-06 02:37:26 +00:00
# Prepare the list of players and names
players = self . current_match_players
names = [ p [ " name " ] for p in players ]
self . spotlight_idx = 0
2026-04-24 06:30:50 +00:00
2026-05-06 02:37:26 +00:00
# Load the career DB for Trend/Tag data
2026-04-24 06:30:50 +00:00
career_path = os . path . join ( BASE_DIR , " career_stats.json " )
2026-04-08 08:13:02 +00:00
with open ( career_path , " r " , encoding = " utf-8 " ) as f :
db = json . load ( f )
win = tk . Toplevel ( self . root )
2026-05-06 02:37:26 +00:00
win . title ( " Spotlight Operator " )
win . geometry ( " 500x700 " )
2026-04-08 08:13:02 +00:00
2026-05-06 02:37:26 +00:00
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 )
2026-09-29 00:59:21 +00:00
# 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.
2026-05-06 02:37:26 +00:00
cid = p_match . get ( " canonical " )
2026-09-29 00:59:21 +00:00
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 " ) )
)
2026-04-24 06:30:50 +00:00
2026-09-29 00:59:21 +00:00
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
2026-04-08 08:13:02 +00:00
2026-05-06 02:37:26 +00:00
# Determine Side
2026-04-24 06:30:50 +00:00
team_b_names = [ self . team_b_listbox . get ( i ) for i in range ( self . team_b_listbox . size ( ) ) ]
2026-05-06 02:37:26 +00:00
side = " Red " if selected_name in team_b_names else " Blue "
2026-04-24 06:30:50 +00:00
2026-05-06 02:37:26 +00:00
# Generate 4K PNG
2026-04-24 06:30:50 +00:00
from utils . graphics_engine import generate_player_card
try :
2026-09-29 00:59:21 +00:00
generate_player_card ( player_data , team_side = side , export_dir = export_dir )
2026-05-06 02:37:26 +00:00
self . set_status ( f " Updated 4K Graphic: { selected_name } " )
2026-09-29 00:59:21 +00:00
# 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 )
2026-04-24 06:30:50 +00:00
except Exception as e :
print ( f " Graphics Error: { e } " )
2026-09-29 00:59:21 +00:00
messagebox . showerror ( " Graphics Error " , str ( e ) )
2026-04-24 06:30:50 +00:00
2026-05-06 02:37:26 +00:00
# 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 "
2026-09-29 00:59:21 +00:00
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 "
2026-04-08 08:13:02 +00:00
output . delete ( " 1.0 " , tk . END )
output . insert ( tk . END , text )
2026-05-06 02:37:26 +00:00
# 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 )
2026-04-08 08:13:02 +00:00
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
2026-09-29 00:59:21 +00:00
identity_data = generate_team_identity_block ( team_players , team , self . map_var . get ( ) )
2026-04-10 08:08:47 +00:00
identity_text = format_team_identity ( identity_data )
2026-04-08 08:13:02 +00:00
win = tk . Toplevel ( self . root )
win . title ( f " Team Identity — { team } " )
2026-04-10 08:08:47 +00:00
win . geometry ( " 500x600 " )
2026-04-08 08:13:02 +00:00
output = tk . Text ( win , wrap = " word " )
output . pack ( expand = True , fill = " both " , padx = 10 , pady = 10 )
2026-04-10 08:08:47 +00:00
output . insert ( tk . END , identity_text )
2026-09-29 00:59:21 +00:00
output . config ( state = " disabled " )
2026-04-08 08:13:02 +00:00
2026-09-29 00:59:21 +00:00
ttk . Label (
win ,
text = " This is included in the matchup.html auto-exported by Generate Player Slots. " ,
foreground = " #666666 " ,
) . pack ( pady = ( 0 , 10 ) )
2026-04-22 07:25:42 +00:00
2026-04-08 08:13:02 +00:00
def generate_player_tags ( self , player_entry , map_type ) :
tags_out = [ ]
2026-04-14 07:32:50 +00:00
# ---------------------------------------------------------
2026-04-13 07:56:38 +00:00
# Slayer (career)
2026-04-14 07:32:50 +00:00
# ---------------------------------------------------------
2026-04-13 07:56:38 +00:00
slayer = player_entry . get ( " slayer " , { } )
slayer_strength = slayer . get ( " strength " ) or player_entry . get ( " slayer_strength " )
if isinstance ( slayer_strength , str ) :
2026-04-08 08:13:02 +00:00
tags_out . append ( slayer_strength )
2026-04-14 07:32:50 +00:00
# ---------------------------------------------------------
# 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 } " )
# ---------------------------------------------------------
2026-04-09 10:11:39 +00:00
# Payload Objective Specialist (match-based)
2026-04-14 07:32:50 +00:00
# ---------------------------------------------------------
2026-04-08 08:13:02 +00:00
if map_type == " Payload " :
2026-04-09 10:11:39 +00:00
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 } " )
2026-04-08 08:13:02 +00:00
2026-04-14 07:32:50 +00:00
# ---------------------------------------------------------
2026-04-09 10:11:39 +00:00
# Domination Objective Specialist (match-based)
2026-04-14 07:32:50 +00:00
# ---------------------------------------------------------
2026-04-08 08:13:02 +00:00
if map_type == " Domination " :
2026-04-24 06:30:50 +00:00
dom_info = player_entry . get ( " objective_domination " , { } )
2026-04-13 07:56:38 +00:00
dom_score = dom_info . get ( " raw " , 0.0 )
2026-04-09 10:11:39 +00:00
tags_out . append ( f " ObjDOM { dom_score : .2f } " )
2026-04-08 08:13:02 +00:00
2026-04-14 07:32:50 +00:00
# ---------------------------------------------------------
2026-04-13 07:56:38 +00:00
# Sharpshooter (career)
2026-04-14 07:32:50 +00:00
# ---------------------------------------------------------
2026-04-13 07:56:38 +00:00
sharp = player_entry . get ( " sharpshooter " , { } ) . get ( " score " ) or player_entry . get ( " sharpshooter_score " )
if isinstance ( sharp , ( int , float ) ) :
2026-04-08 08:13:02 +00:00
tags_out . append ( f " Sharp { sharp : .2f } " )
2026-04-09 10:11:39 +00:00
return " , " . join ( tags_out )
2026-04-08 08:13:02 +00:00
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 :
2026-04-10 08:08:47 +00:00
messagebox . showerror ( " Error " , " Select both Team A and Team B. " )
2026-04-08 08:13:02 +00:00
return
2026-09-29 00:59:21 +00:00
if not self . current_match_players :
messagebox . showerror ( " Error " , " Generate Player Slots first. " )
return
2026-04-23 09:24:48 +00:00
# ---------------------------------------------------------
2026-09-29 00:59:21 +00:00
# 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.
2026-04-22 07:25:42 +00:00
# ---------------------------------------------------------
2026-09-29 00:59:21 +00:00
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 ]
2026-04-08 08:13:02 +00:00
2026-04-10 08:08:47 +00:00
if not teamA_players or not teamB_players :
messagebox . showerror ( " Error " , " No players selected for one or both teams. " )
2026-04-08 08:13:02 +00:00
return
2026-04-23 09:24:48 +00:00
# ---------------------------------------------------------
2026-09-29 00:59:21 +00:00
# Generate storyline + identity blocks
2026-04-23 09:24:48 +00:00
# ---------------------------------------------------------
2026-09-29 00:59:21 +00:00
map_type = self . map_var . get ( )
storyline_text = matchup_storyline ( teamA_players , teamB_players , map_type )
2026-04-10 08:08:47 +00:00
2026-09-29 00:59:21 +00:00
identity_data_a = generate_team_identity_block ( teamA_players , team_a , map_type )
2026-04-10 08:08:47 +00:00
identity_text_a = format_team_identity ( identity_data_a )
2026-09-29 00:59:21 +00:00
identity_data_b = generate_team_identity_block ( teamB_players , team_b , map_type )
2026-04-10 08:08:47 +00:00
identity_text_b = format_team_identity ( identity_data_b )
2026-04-08 08:13:02 +00:00
full_story = (
storyline_text
2026-04-10 08:08:47 +00:00
+ " \n \n TEAM IDENTITY SUMMARY – " + team_a + " \n "
+ identity_text_a
+ " \n \n TEAM IDENTITY SUMMARY – " + team_b + " \n "
+ identity_text_b
2026-04-08 08:13:02 +00:00
)
2026-04-23 09:24:48 +00:00
# ---------------------------------------------------------
2026-09-29 00:59:21 +00:00
# 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)
2026-04-23 09:24:48 +00:00
# ---------------------------------------------------------
2026-04-08 08:13:02 +00:00
win = tk . Toplevel ( self . root )
win . title ( " Match Storyline " )
2026-04-10 08:08:47 +00:00
win . geometry ( " 600x700 " )
2026-04-08 08:13:02 +00:00
output = tk . Text ( win , wrap = " word " )
output . pack ( expand = True , fill = " both " , padx = 10 , pady = 10 )
output . insert ( tk . END , full_story )
2026-09-29 00:59:21 +00:00
output . config ( state = " disabled " )
2026-04-08 08:13:02 +00:00
2026-09-29 00:59:21 +00:00
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 )
2026-04-10 08:08:47 +00:00
2026-09-29 00:59:21 +00:00
swing = abs ( teamA_win - teamB_win )
fav = team_a if teamA_win > teamB_win else team_b
2026-04-10 08:08:47 +00:00
2026-09-29 00:59:21 +00:00
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 ,
}
2026-04-08 08:13:02 +00:00
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 " )
2026-04-13 07:56:38 +00:00
win . geometry ( " 800x600 " )
2026-04-08 08:13:02 +00:00
2026-09-29 00:59:21 +00:00
tags = RANKING_TAGS
2026-04-08 08:13:02 +00:00
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
2026-09-29 00:59:21 +00:00
data = self . compute_ranking_data ( tag )
if data is None :
2026-04-08 08:13:02 +00:00
messagebox . showerror ( " Error " , " Select both teams first. " )
return
2026-09-29 00:59:21 +00:00
if data . get ( " empty " ) :
2026-04-08 08:13:02 +00:00
output . delete ( " 1.0 " , tk . END )
output . insert ( tk . END , " No data available for this tag. " )
return
2026-09-29 00:59:21 +00:00
team_a = data [ " team_a " ]
team_b = data [ " team_b " ]
left = data [ " left " ]
right = data [ " right " ]
2026-04-08 08:13:02 +00:00
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 ]
2026-04-22 07:25:42 +00:00
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 } "
2026-04-08 08:13:02 +00:00
if i < len ( right ) :
rp = right [ i ]
2026-04-22 07:25:42 +00:00
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 } "
2026-04-08 08:13:02 +00:00
text + = f " { left_str : <24 } { right_str } \n "
text + = " - " * 48 + " \n "
2026-09-29 00:59:21 +00:00
text + = f " { team_a } Total: { data [ ' teamA_total ' ] : .2f } " . ljust ( 24 )
text + = f " { team_b } Total: { data [ ' teamB_total ' ] : .2f } \n "
2026-04-08 08:13:02 +00:00
2026-09-29 00:59:21 +00:00
text + = f " { team_a } Avg: { data [ ' teamA_avg ' ] : .2f } " . ljust ( 24 )
text + = f " { team_b } Avg: { data [ ' teamB_avg ' ] : .2f } \n "
2026-04-08 08:13:02 +00:00
2026-09-29 00:59:21 +00:00
text + = f " Delta: { data [ ' delta_mag ' ] : .2f } in favour of { data [ ' fav_delta ' ] } \n "
text + = f " Relative Advantage: { data [ ' rel_adv ' ] : .1f } % \n "
2026-04-08 08:13:02 +00:00
2026-09-29 00:59:21 +00:00
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 "
2026-04-08 08:13:02 +00:00
2026-09-29 00:59:21 +00:00
text + = " \n " + " \n " . join ( RANKING_FORMULAS . get ( tag , [ ] ) ) + " \n "
2026-04-08 08:13:02 +00:00
output . delete ( " 1.0 " , tk . END )
output . insert ( tk . END , text )
ttk . Button ( win , text = " Show Rankings " , command = show ) . pack ( pady = 5 )
2026-09-29 00:59:21 +00:00
ttk . Label (
win ,
text = " All 8 tags are auto-exported as HTML whenever you click Generate Player Slots. " ,
foreground = " #666666 " ,
) . pack ( pady = ( 0 , 5 ) )
2026-04-23 09:24:48 +00:00
2026-04-24 06:30:50 +00:00
# dashleague_cast_tool.py -> on_rookie_leaderboard()
2026-04-23 09:24:48 +00:00
def on_rookie_leaderboard ( self ) :
2026-09-29 00:59:21 +00:00
from analysis . rookie_leaderboard import rank_rookies_by_tag , ROOKIE_KILL_THRESHOLD
2026-04-23 09:24:48 +00:00
season = self . current_season
2026-04-24 06:30:50 +00:00
tag = " slayer "
2026-05-06 05:01:40 +00:00
2026-04-23 09:24:48 +00:00
rookies = rank_rookies_by_tag ( season , tag )
2026-05-06 05:01:40 +00:00
# 1. Text display for the GUI window
2026-04-23 09:24:48 +00:00
lines = [ ]
2026-04-24 06:30:50 +00:00
lines . append ( f " ROOKIE LEADERBOARD — Season { season } " )
2026-09-29 00:59:21 +00:00
lines . append ( f " Rookie = under { ROOKIE_KILL_THRESHOLD : , } career kills, regardless of seasons played. " )
2026-04-23 09:24:48 +00:00
lines . append ( " " )
2026-04-24 06:30:50 +00:00
lines . append ( f " { ' Rank ' : <5 } { ' Name ' : <18 } { ' Team ' : <12 } { ' Score ' : <10 } { ' KD ' : <6 } { ' Maps ' : <6 } { ' Kills ' : <8 } " )
lines . append ( " - " * 75 )
2026-04-23 09:24:48 +00:00
for r in rookies :
2026-04-24 06:30:50 +00:00
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 } " )
2026-04-23 09:24:48 +00:00
text = " \n " . join ( lines )
win = tk . Toplevel ( self . root )
2026-04-24 06:30:50 +00:00
win . title ( " Rookie Standings " )
2026-05-06 05:01:40 +00:00
win . geometry ( " 800x600 " )
2026-04-23 09:24:48 +00:00
2026-04-24 06:30:50 +00:00
output = tk . Text ( win , wrap = " none " , font = ( " Courier " , 10 ) )
2026-04-23 09:24:48 +00:00
output . pack ( expand = True , fill = " both " , padx = 10 , pady = 10 )
output . insert ( tk . END , text )
2026-04-24 06:30:50 +00:00
output . config ( state = " disabled " )
2026-04-23 09:24:48 +00:00
2026-05-06 05:01:40 +00:00
def export_html ( ) :
2026-09-29 00:59:21 +00:00
from analysis . html_export import render_rookie_leaderboard_html
html_content = render_rookie_leaderboard_html ( rookies , season , ROOKIE_KILL_THRESHOLD )
2026-05-06 05:01:40 +00:00
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 )
2026-09-29 00:59:21 +00:00
self . set_status ( " Exported Rookie HTML " )
2026-05-06 05:01:40 +00:00
messagebox . showinfo ( " Exported " , f " HTML updated \n Location: { html_path } " )
ttk . Button ( win , text = " Export HTML for OBS Browser Source " , command = export_html ) . pack ( pady = 10 )
2026-09-29 00:59:21 +00:00
ttk . Label (
win ,
text = " Also auto-refreshed whenever you click Generate Player Slots. " ,
foreground = " #666666 " ,
) . pack ( pady = ( 0 , 10 ) )
2026-05-06 05:01:40 +00:00
2026-04-08 08:13:02 +00:00
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
2026-04-10 08:08:47 +00:00
# Define names for identity block
teamA_name = team_a
teamB_name = team_b
2026-09-29 00:59:21 +00:00
# --- STORYLINE + TEAM IDENTITY (pretty HTML, replaces storyline.txt) ---
2026-04-08 08:13:02 +00:00
try :
2026-09-29 00:59:21 +00:00
narrative_text = matchup_narrative ( teamA_players , teamB_players , map_type )
2026-04-08 08:13:02 +00:00
2026-09-29 00:59:21 +00:00
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 )
2026-04-10 08:08:47 +00:00
2026-09-29 00:59:21 +00:00
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 )
2026-04-10 08:08:47 +00:00
2026-09-29 00:59:21 +00:00
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 ,
2026-04-10 08:08:47 +00:00
)
2026-09-29 00:59:21 +00:00
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 )
2026-04-08 08:13:02 +00:00
2026-09-29 00:59:21 +00:00
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 " ) )
2026-04-10 08:08:47 +00:00
2026-09-29 00:59:21 +00:00
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 " ) )
2026-04-08 08:13:02 +00:00
except Exception as e :
2026-09-29 00:59:21 +00:00
print ( " Comparison graphics export failed: " , e )
2026-04-08 08:13:02 +00:00
2026-04-10 08:08:47 +00:00
# --- PREDICTIONS ---
2026-04-08 08:13:02 +00:00
try :
if hasattr ( self , " generate_predictions " ) :
predictions = self . generate_predictions ( teamA_players , teamB_players )
2026-04-10 08:08:47 +00:00
write_text ( os . path . join ( OBS_EXPORT_DIR , " predictions.txt " ) , predictions )
2026-04-08 08:13:02 +00:00
except Exception as e :
print ( " Prediction export failed: " , e )
2026-04-13 07:56:38 +00:00
# --- PLAYER TAG EXPORTS ---
2026-04-08 08:13:02 +00:00
try :
2026-04-13 07:56:38 +00:00
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 )
2026-04-08 08:13:02 +00:00
2026-04-13 07:56:38 +00:00
slot_path = os . path . join ( PLAYERS_DIR , f " p { idx } " , " Tags.txt " )
write_text ( slot_path , tags_text )
2026-04-10 08:08:47 +00:00
2026-04-08 08:13:02 +00:00
except Exception as e :
print ( " Tag export failed: " , e )
2026-04-10 08:08:47 +00:00
print ( f " OBS files updated for { map_type } " )
2026-04-09 10:11:39 +00:00
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 ( )
2026-09-29 00:59:21 +00:00
self . start_local_obs_server ( )
2026-04-09 10:11:39 +00:00
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 "
2026-09-29 00:59:21 +00:00
" 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. "
2026-04-09 10:11:39 +00:00
)
2026-09-29 00:59:21 +00:00
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 ( )
2026-04-10 08:08:47 +00:00
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 } " )
2026-04-14 07:32:50 +00:00
2026-04-09 10:11:39 +00:00
2026-04-08 08:13:02 +00:00
def main ( ) :
root = tk . Tk ( )
app = DashLeagueGUI ( root )
root . mainloop ( )
if __name__ == " __main__ " :
main ( )