From ef88da9c53f145cee480dbfda8b01deb941e4810 Mon Sep 17 00:00:00 2001 From: FireHorse Date: Wed, 15 Apr 2026 16:15:07 +1000 Subject: [PATCH] Support Specialist v1: per-map league metrics + normalized stats --- analysis/obs_export_v2.py | 11 +++- analysis/player_summary_v2.py | 25 ++++++-- analysis/predictions_v2.py | 23 +++---- analysis/storylines.py | 20 ++---- analysis/team_identity_v2.py | 25 +++----- dashleague_cast_tool.py | 25 ++++++-- data/career_db.py | 112 +++++++++++++++++++++++++++++++++- data/league_metrics.py | 25 ++++++-- data/tag_framework.py | 5 ++ match_engine.py | 37 ++++++++++- rankings.py | 36 +++++++++++ tags/support_specialist.py | 77 +++++++++++++++++++++++ utils/safe_tag.py | 82 +++++++++++++++++++++++++ 13 files changed, 438 insertions(+), 65 deletions(-) create mode 100644 tags/support_specialist.py create mode 100644 utils/safe_tag.py diff --git a/analysis/obs_export_v2.py b/analysis/obs_export_v2.py index 27cb1c6..69491b9 100644 --- a/analysis/obs_export_v2.py +++ b/analysis/obs_export_v2.py @@ -5,6 +5,12 @@ from analysis.team_identity_v2 import format_team_identity, generate_team_identi from analysis.storylines import generate_storyline from analysis.predictions_v2 import generate_prediction +import re + +def safe_filename(name): + # Remove all illegal Windows filename characters + return re.sub(r'[\\/:*?"<>|]', '', name) + def export_obs_player_summaries(output_folder, team_players): """ @@ -15,7 +21,7 @@ def export_obs_player_summaries(output_folder, team_players): for p in team_players: name = p.get("name", "Unknown") - safe_name = name.replace(" ", "_") + safe_name = safe_filename(name) path = os.path.join(output_folder, f"{safe_name}_summary.txt") with open(path, "w", encoding="utf-8") as f: @@ -29,7 +35,8 @@ def export_obs_team_identity(output_folder, team_players, team_name): os.makedirs(output_folder, exist_ok=True) - path = os.path.join(output_folder, f"{team_name}_identity.txt") + safe_team = safe_filename(team_name) + path = os.path.join(output_folder, f"{safe_team}_identity.txt") with open(path, "w", encoding="utf-8") as f: identity_data = generate_team_identity_block(team_players, team_name) identity_text = format_team_identity(identity_data) diff --git a/analysis/player_summary_v2.py b/analysis/player_summary_v2.py index 0c92d9b..f9073f8 100644 --- a/analysis/player_summary_v2.py +++ b/analysis/player_summary_v2.py @@ -7,8 +7,18 @@ def build_player_summary(player): - Sharpshooter - Consistency - Clutch + - Support Specialist """ + def safe_pct(value): + try: + return float(value) if value is not None else 0.0 + except: + return 0.0 + + def safe_tier(value): + return value if value not in (None, "", "None") else "D" + name = player.get("name", "Unknown Player") slayer = player.get("slayer", {}) @@ -16,28 +26,33 @@ def build_player_summary(player): sharp = player.get("sharpshooter", {}) consistency = player.get("consistency", {}) clutch = player.get("clutch", {}) + support = player.get("support_specialist", {}) lines = [] lines.append(f"{name}\n") # Slayer - lines.append(f"Slayer: {slayer.get('tier', 'D')} ({slayer.get('pct', 0):.1f}%)") + lines.append(f"Slayer: {safe_tier(slayer.get('tier'))} ({safe_pct(slayer.get('pct')):.1f}%)") lines.append(f" {slayer.get('summary', '')}") # Payload - lines.append(f"\nPayload Objective: {payload.get('tier', 'D')} ({payload.get('pct', 0):.1f}%)") + lines.append(f"\nPayload Objective: {safe_tier(payload.get('tier'))} ({safe_pct(payload.get('pct')):.1f}%)") lines.append(f" {payload.get('summary', '')}") # Sharpshooter - lines.append(f"\nSharpshooter: {sharp.get('tier', 'D')} ({sharp.get('pct', 0):.1f}%)") + lines.append(f"\nSharpshooter: {safe_tier(sharp.get('tier'))} ({safe_pct(sharp.get('pct')):.1f}%)") lines.append(f" {sharp.get('summary', '')}") # Consistency - lines.append(f"\nConsistency: {consistency.get('tier', 'D')} ({consistency.get('pct', 0):.1f}%)") + lines.append(f"\nConsistency: {safe_tier(consistency.get('tier'))} ({safe_pct(consistency.get('pct')):.1f}%)") lines.append(f" {consistency.get('summary', '')}") # Clutch - lines.append(f"\nClutch: {clutch.get('tier', 'D')} ({clutch.get('pct', 0):.1f}%)") + lines.append(f"\nClutch: {safe_tier(clutch.get('tier'))} ({safe_pct(clutch.get('pct')):.1f}%)") lines.append(f" {clutch.get('summary', '')}") + # Support Specialist + lines.append(f"\nSupport Specialist: {safe_tier(support.get('tier'))} ({safe_pct(support.get('pct')):.1f}%)") + lines.append(f" {support.get('summary', '')}") + return "\n".join(lines) \ No newline at end of file diff --git a/analysis/predictions_v2.py b/analysis/predictions_v2.py index 5148880..dcafa14 100644 --- a/analysis/predictions_v2.py +++ b/analysis/predictions_v2.py @@ -1,23 +1,16 @@ + + from analysis.team_identity_v2 import summarize_team_tags, classify_team_style +from utils.safe_tag import safe_pct, safe_avg, safe_tier def compute_team_power_score(summary): - """ - Converts team tag percentiles into a single weighted power score. - Weights reflect real match impact: - Slayer: 30% - Objective: 25% - Consistency: 20% - Clutch: 15% - Sharpshooter: 10% - """ - return ( - summary["slayer"]["avg_pct"] * 0.30 + - summary["objective_payload"]["avg_pct"] * 0.25 + - summary["consistency"]["avg_pct"] * 0.20 + - summary["clutch"]["avg_pct"] * 0.15 + - summary["sharpshooter"]["avg_pct"] * 0.10 + safe_pct(summary["slayer"]["avg_pct"]) * 0.30 + + safe_pct(summary["objective_payload"]["avg_pct"]) * 0.25 + + safe_pct(summary["consistency"]["avg_pct"]) * 0.20 + + safe_pct(summary["clutch"]["avg_pct"]) * 0.15 + + safe_pct(summary["sharpshooter"]["avg_pct"]) * 0.10 ) diff --git a/analysis/storylines.py b/analysis/storylines.py index be61e5d..1cc6f5b 100644 --- a/analysis/storylines.py +++ b/analysis/storylines.py @@ -10,6 +10,8 @@ __all__ = ["matchup_storyline", "generate_storyline"] # Team Tag Aggregation # --------------------------------------------------------- +from utils.safe_tag import safe_pct, safe_tier, safe_avg + def summarize_team_tags(team_players): """ Aggregates tag tiers + percentiles for a team. @@ -32,23 +34,11 @@ def summarize_team_tags(team_players): for p in team_players: tag_data = p.get(tag, {}) - pcts.append(tag_data.get("pct", 0)) - tiers.append(tag_data.get("tier", "D")) - - if not pcts: - summary[tag] = { - "avg_pct": 0, - "top_tier": "D", - "count_S": 0, - "count_A": 0, - "count_B": 0, - "count_C": 0, - "count_D": 0, - } - continue + pcts.append(safe_pct(tag_data.get("pct"))) + tiers.append(safe_tier(tag_data.get("tier"))) summary[tag] = { - "avg_pct": sum(p for p in pcts if isinstance(p, (int, float))) / max(1, len([p for p in pcts if isinstance(p, (int, float))])), + "avg_pct": safe_avg(pcts), "top_tier": max(tiers, key=lambda t: "SABCD".index(t)), "count_S": tiers.count("S"), "count_A": tiers.count("A"), diff --git a/analysis/team_identity_v2.py b/analysis/team_identity_v2.py index 768e391..c5c9d97 100644 --- a/analysis/team_identity_v2.py +++ b/analysis/team_identity_v2.py @@ -1,3 +1,10 @@ + +from utils.safe_tag import safe_pct, safe_tier, safe_avg + + + +from utils.safe_tag import safe_pct, safe_tier, safe_avg + def summarize_team_tags(team_players): """ Aggregates tag tiers + percentiles for a team. @@ -16,23 +23,11 @@ def summarize_team_tags(team_players): for p in team_players: tag_data = p.get(tag, {}) - pcts.append(tag_data.get("pct", 0)) - tiers.append(tag_data.get("tier", "D")) - - if not pcts: - summary[tag] = { - "avg_pct": 0, - "top_tier": "D", - "count_S": 0, - "count_A": 0, - "count_B": 0, - "count_C": 0, - "count_D": 0, - } - continue + pcts.append(safe_pct(tag_data.get("pct"))) + tiers.append(safe_tier(tag_data.get("tier"))) summary[tag] = { - "avg_pct": sum(p for p in pcts if isinstance(p, (int, float))) / max(1, len([p for p in pcts if isinstance(p, (int, float))])), + "avg_pct": safe_avg(pcts), "top_tier": max(tiers, key=lambda t: "SABCD".index(t)), "count_S": tiers.count("S"), "count_A": tiers.count("A"), diff --git a/dashleague_cast_tool.py b/dashleague_cast_tool.py index 8490652..f6a09c1 100644 --- a/dashleague_cast_tool.py +++ b/dashleague_cast_tool.py @@ -33,6 +33,7 @@ from match_engine import ( split_two_columns, compute_slayer_prediction, rank_match_players_consistency_career, + rank_match_players_support_specialist, ) # --------------------------------------------------------- @@ -377,14 +378,14 @@ class DashLeagueGUI: p["objective_payload"] = pdata.get("objective_payload", {}) # DO NOT overwrite match-based DomObj - if "objective_domination_components" not in p: - p["objective_domination_components"] = pdata.get( - "objective_domination_components", - {} - ) + if "objective_domination" not in p: + p["objective_domination"] = pdata.get("objective_domination", {}) p["consistency"] = pdata.get("consistency", {}) p["clutch"] = pdata.get("clutch", {}) + + # Support Specialist + p["support_specialist"] = pdata.get("support_specialist", {}) # ----------------------------- @@ -591,6 +592,15 @@ Consistency Summary: if isinstance(score, (int, float)): tags_out.append(f"Cons {score:.2f}") + # --------------------------------------------------------- + # Support Specialist (career) + # --------------------------------------------------------- + support = player_entry.get("support_specialist", {}) + if isinstance(support, dict): + score = support.get("raw") + if isinstance(score, (int, float)): + tags_out.append(f"Supp {score:.2f}") + # --------------------------------------------------------- # Payload Objective Specialist (match-based) # --------------------------------------------------------- @@ -708,6 +718,7 @@ Consistency Summary: "Domination Objective Specialist", "Consistency", "Clutch", + "Support Specialist", ] ttk.Label(win, text="Select Tag:").pack(anchor="w", padx=10, pady=5) @@ -744,7 +755,9 @@ Consistency Summary: elif tag == "Consistency": ranked = rank_match_players_consistency_career(self.current_match_players) - + elif tag == "Support Specialist": + ranked = rank_match_players_support_specialist(self.current_match_players) + else: # Sharpshooter ranked = rank_match_players_sharpshooter(self.current_match_players) diff --git a/data/career_db.py b/data/career_db.py index 769352e..591494e 100644 --- a/data/career_db.py +++ b/data/career_db.py @@ -9,10 +9,12 @@ from tags.objective_domination import compute_dom_objective_for_career_player from tags.sharpshooter import compute_sharpshooter_for_career_player from tags.consistency import build_consistency_tag from tags.clutch import compute_clutch, compute_clutch_raw +from tags.support_specialist import build_support_specialist_tag from data.league_metrics import compute_league_metrics, compute_league_clutch_metrics + def build_career_database(output_path): print("USING DB:", db_access.DB_PATH) @@ -101,6 +103,66 @@ def build_career_database(output_path): for pdata in all_players: pdata["clutch_raw"] = compute_clutch_raw(pdata) + + # --------------------------------------------------------- + # NORMALIZE CAREER STAT KEYS BEFORE LEAGUE METRICS + # --------------------------------------------------------- + for pid, pdata in final_db["players"].items(): + career = pdata.get("career", {}) + + career["push_time"] = ( + career.get("push_time") + or career.get("PAY_PushTime") + or pdata.get("PAY_PushTime") + or 0 + ) + + career["captures"] = ( + career.get("captures") + or career.get("DOM_Captures") + or pdata.get("DOM_Captures") + or 0 + ) + + career["counters"] = ( + career.get("counters") + or career.get("DOM_Counters") + or pdata.get("DOM_Counters") + or 0 + ) + + career["damage"] = ( + career.get("damage") + or career.get("Damage") + or pdata.get("Damage") + or 0 + ) + + career["kills"] = ( + career.get("kills") + or career.get("Kills") + or pdata.get("Kills") + or 0 + ) + + career["deaths"] = ( + career.get("deaths") + or career.get("Deaths") + or pdata.get("Deaths") + or 0 + ) + + career["maps"] = ( + career.get("maps") + or pdata.get("maps") + or pdata.get("career", {}).get("maps") + or 1 + ) + + pdata["career"] = career + + + # --------------------------------------------------------- # 4. Compute league metrics for ALL TAGS @@ -112,7 +174,22 @@ def build_career_database(output_path): league_averages.update(clutch_averages) final_db["league_averages"] = league_averages + + + from tags.support_specialist import compute_support_specialist # at top of file if not already + # --------------------------------------------------------- + # 4.5 Build Support Specialist distribution + # --------------------------------------------------------- + support_distribution = [] + for pid, pdata in final_db["players"].items(): + raw_support = compute_support_specialist(pdata, league_averages) + support_distribution.append(raw_support) + + # Write back normalized career block + pdata["career"] = career + + # --------------------------------------------------------- # 5. Compute all tags using distributions # --------------------------------------------------------- @@ -140,12 +217,45 @@ def build_career_database(output_path): ) # Domination Objective Specialist - pdata["objective_domination_components"] = compute_dom_objective_for_career_player( + pdata["objective_domination"] = compute_dom_objective_for_career_player( pdata, league_averages, distributions["domination"] ) + # Consistency (legacy adapter) + consistency_result = build_consistency_tag( + floor_current=pdata.get("floor_current"), + floor_career=pdata.get("floor_career"), + floor_league=league_averages.get("consistency_floor", 0.0), + stability_current=pdata.get("stability_current"), + stability_career=pdata.get("stability_career"), + stability_league=league_averages.get("consistency_stability", 0.0), + average_current=pdata.get("average_current"), + average_career=pdata.get("average_career"), + average_league=league_averages.get("consistency_average", 0.0), + matches_played_current=pdata["career"].get("maps", 0), + percentile=None + ) + pdata["consistency"] = { + "raw": consistency_result.normalized_score, + "summary": consistency_result.summary_short + } + + # Clutch + pdata["clutch"] = compute_clutch( + pdata, + league_averages, + clutch_distribution + ) + + # Support Specialist + pdata["support_specialist"] = build_support_specialist_tag( + pdata, + league_averages, + support_distribution + ) + # --------------------------------------------------------- # Consistency (Hybrid Career Tag - New System) # --------------------------------------------------------- diff --git a/data/league_metrics.py b/data/league_metrics.py index 040de9e..bd4d4aa 100644 --- a/data/league_metrics.py +++ b/data/league_metrics.py @@ -30,6 +30,8 @@ def compute_league_metrics(all_players): "push_time": [], "headshot_rate": [], "pressure_eff": [], + "deaths": [], + "dom_actions": [], } # ----------------------------------------- @@ -41,10 +43,11 @@ def compute_league_metrics(all_players): "payload": [], "consistency": [], "domination": [], + "support_specialist": [], } # ----------------------------------------- - # Build league averages + # Build league averages (PER-MAP NORMALIZED) # ----------------------------------------- for p in all_players: c = p.get("career", {}) @@ -52,13 +55,17 @@ def compute_league_metrics(all_players): if maps <= 0: continue - kills = c.get("kills", 0) - deaths = c.get("deaths", 0) - damage = c.get("damage", 0) + # Per-map normalization + kills = c.get("kills", 0) / maps + deaths = c.get("deaths", 0) / maps + damage = c.get("damage", 0) / maps + push = c.get("push_time", 0) / maps + captures = c.get("captures", c.get("DOM_Captures", 0)) / maps + counters = c.get("counters", c.get("DOM_Counters", 0)) / maps + shots = c.get("shots", 0) shots_hit = c.get("shots_hit", 0) headshots = c.get("headshots", 0) - push = c.get("push_time", 0) KD = kills / deaths if deaths > 0 else kills accuracy = shots_hit / shots if shots > 0 else 0 @@ -71,6 +78,8 @@ def compute_league_metrics(all_players): acc["push_time"].append(push) acc["headshot_rate"].append(headshot_rate) acc["pressure_eff"].append(pressure_eff) + acc["deaths"].append(deaths) + acc["dom_actions"].append(captures + counters) # ----------------------------------------- # Compute league averages @@ -102,6 +111,12 @@ def compute_league_metrics(all_players): # Domination dist["domination"].append(compute_dom_raw(p)) + + from tags.support_specialist import compute_support_specialist + + dist["support_specialist"].append( + compute_support_specialist(p, league_averages) + ) # ----------------------------------------- # Sort distributions diff --git a/data/tag_framework.py b/data/tag_framework.py index 580696d..cc468e1 100644 --- a/data/tag_framework.py +++ b/data/tag_framework.py @@ -1,4 +1,9 @@ + + import bisect +from tags.support_specialist import build_support_specialist_tag + + # --------------------------------------------------------- # Percentile + Tier Helpers diff --git a/match_engine.py b/match_engine.py index c699d22..0e09f87 100644 --- a/match_engine.py +++ b/match_engine.py @@ -231,4 +231,39 @@ def compute_slayer_prediction(team_a_players, team_b_players): "teamB_avg": teamB_avg, "teamA_win": round(pA * 100), "teamB_win": round(pB * 100), - } \ No newline at end of file + } + + +def rank_match_players_support_specialist(players): + """ + Ranks players by Support Specialist (career tag) using percentile. + """ + + ranked = [] + + for p in players: + tag = p.get("support_specialist", {}) + + # Use percentile as the ranking basis + pct = tag.get("pct") + if pct is None: + continue + + ranked.append({ + "name": p.get("name", "Unknown"), + "team": p.get("team", ""), + "pct": pct, + "tier": tag.get("tier", "D"), + "summary": tag.get("summary", ""), + "score_raw": pct, # used for team totals + "score_display": f"{pct:.1f}", # what you see in the table + }) + + # Sort descending by percentile + ranked.sort(key=lambda x: x["pct"], reverse=True) + + # Assign ranks + for i, entry in enumerate(ranked, start=1): + entry["rank"] = i + + return ranked \ No newline at end of file diff --git a/rankings.py b/rankings.py index faca3a9..4fa078f 100644 --- a/rankings.py +++ b/rankings.py @@ -106,4 +106,40 @@ def rank_match_players_clutch(players): for i, r in enumerate(ranked, start=1): r["rank"] = i + return ranked + + +def rank_match_players_support_specialist(players): + """ + Ranks players by Support Specialist (career tag). + Uses the already-computed tag stored in each player's data. + """ + + ranked = [] + + for p in players: + tag = p.get("support_specialist", {}) + raw = tag.get("raw") + + # Skip players with no data + if raw is None: + continue + + ranked.append({ + "name": p.get("name", "Unknown"), + "team": p.get("team", ""), + "raw": raw, + "pct": tag.get("pct", 0.0), + "tier": tag.get("tier", "D"), + "summary": tag.get("summary", ""), + "score_display": f"{raw:.2f}", + }) + + # Sort descending by raw score + ranked.sort(key=lambda x: x["raw"], reverse=True) + + # Assign ranks + for i, entry in enumerate(ranked, start=1): + entry["rank"] = i + return ranked \ No newline at end of file diff --git a/tags/support_specialist.py b/tags/support_specialist.py new file mode 100644 index 0000000..dad3f57 --- /dev/null +++ b/tags/support_specialist.py @@ -0,0 +1,77 @@ +from utils.safe_tag import safe_raw + +# --------------------------------------------------------- +# RAW SCORE CALCULATION +# --------------------------------------------------------- + + +def compute_support_specialist(player, league_averages): + c = player.get("career", {}) + + dmg = safe_raw(c.get("damage")) + deaths = safe_raw(c.get("deaths")) + push_time = safe_raw(c.get("push_time")) + dom_caps = safe_raw(c.get("captures")) + dom_counters = safe_raw(c.get("counters")) + + # League anchors + league_push = max(1.0, league_averages.get("push_time", 1.0)) + league_dom_actions = max(1.0, league_averages.get("dom_actions", 1.0)) + league_damage = max(1.0, league_averages.get("damage", 1.0)) + league_deaths = max(1.0, league_averages.get("deaths", 1.0)) + league_pressure = max(1e-6, league_averages.get("pressure_eff", 1.0)) + + # Objective presence (relative to league, capped) + presence_payload = (push_time / league_push) + presence_dom = ((dom_caps + dom_counters) / league_dom_actions) + objective_presence = max(presence_payload, presence_dom) + objective_presence = max(0.0, min(objective_presence, 2.0)) + + # Survivability: pressure vs league pressure_eff + player_pressure = dmg / max(1.0, deaths) + survivability = player_pressure / league_pressure + survivability = max(0.0, min(survivability, 2.0)) + + # Damage component: relative to league damage + damage_component = dmg / league_damage + damage_component = max(0.0, min(damage_component, 2.0)) + + # Low-death bonus: 1 is good, 0 is bad + low_death_bonus = 1.0 - (deaths / league_deaths) + low_death_bonus = max(0.0, min(low_death_bonus, 1.0)) + + # Weighted sum, then scale back into 0–1 + raw = ( + 0.35 * objective_presence + + 0.35 * survivability + + 0.20 * damage_component + + 0.10 * low_death_bonus + ) / 2.0 # max of the capped components is 2 → divide by 2 to keep ≤ 1 + + return raw + +# --------------------------------------------------------- +# TAG BUILDER (RAW → PERCENTILE → TIER → SUMMARY) +# --------------------------------------------------------- + +def build_support_specialist_tag(player, league_averages, distribution): + """ + Converts raw Support Specialist score into full tag output. + """ + + from tags.tag_framework import build_tag_output + + raw = compute_support_specialist(player, league_averages) + + def summary_fn(tier, pct): + if pct >= 85: + return "Elite support presence — stabilizes fights and enables strong objective pushes." + if pct >= 70: + return "Reliable support player with strong survivability and objective presence." + if pct >= 50: + return "Provides steady support value through survivability and objective actions." + if pct >= 30: + return "Occasional support impact but inconsistent objective presence." + return "Limited support impact — low survivability and minimal objective presence." + + return build_tag_output(raw, distribution, summary_fn) \ No newline at end of file diff --git a/utils/safe_tag.py b/utils/safe_tag.py new file mode 100644 index 0000000..3ab7f02 --- /dev/null +++ b/utils/safe_tag.py @@ -0,0 +1,82 @@ +import math + +def safe_pct(value): + """ + Ensures pct is always a float between 0 and 100. + None, NaN, or invalid → 0.0 + """ + try: + if value is None: + return 0.0 + v = float(value) + if math.isnan(v): + return 0.0 + return max(0.0, min(100.0, v)) + except: + return 0.0 + + +def safe_tier(value): + """ + Ensures tier is always a valid letter. + None or empty → 'D' + """ + if value in (None, "", "None"): + return "D" + return str(value) + + +def safe_summary(value): + """ + Ensures summary is always a string. + """ + if value is None: + return "" + return str(value) + + +def safe_raw(value): + """ + Ensures raw is always a float. + """ + try: + if value is None: + return 0.0 + v = float(value) + if math.isnan(v): + return 0.0 + return v + except: + return 0.0 + + +def safe_tag(tag_dict): + """ + Normalizes an entire tag dict. + Guarantees all fields exist and are safe. + """ + if not isinstance(tag_dict, dict): + return { + "raw": 0.0, + "pct": 0.0, + "tier": "D", + "summary": "" + } + + return { + "raw": safe_raw(tag_dict.get("raw")), + "pct": safe_pct(tag_dict.get("pct")), + "tier": safe_tier(tag_dict.get("tier")), + "summary": safe_summary(tag_dict.get("summary")) + } + + +def safe_avg(values): + """ + Averages a list of pct values safely. + None, NaN, invalid → treated as 0.0 + """ + cleaned = [safe_pct(v) for v in values] + if not cleaned: + return 0.0 + return sum(cleaned) / len(cleaned) \ No newline at end of file