Milestone: Career DB rebuilt, canonical-ID sync stable, storyline tags correct. Pending: match ranking anchor/breaker.

This commit is contained in:
FireHorse 2026-04-21 18:55:26 +10:00
parent ef88da9c53
commit cbebd5820a
15 changed files with 8305 additions and 228 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,54 @@
import json
import os
PIR_PATH = os.path.join(os.path.dirname(__file__), "player_identity_registry.json")
class PlayerIdentityRegistry:
def __init__(self):
if os.path.exists(PIR_PATH):
with open(PIR_PATH, "r", encoding="utf-8") as f:
self.data = json.load(f)
else:
self.data = {"uuid_to_canonical": {}, "canonical": {}}
def save(self):
with open(PIR_PATH, "w", encoding="utf-8") as f:
json.dump(self.data, f, indent=2)
def resolve(self, uuid):
"""Return canonical ID for a UUID, or create one."""
uuid = str(uuid)
if uuid in self.data["uuid_to_canonical"]:
return self.data["uuid_to_canonical"][uuid]
# Create new canonical ID
canonical = f"player_{len(self.data['canonical'])+1}"
self.data["uuid_to_canonical"][uuid] = canonical
self.data["canonical"][canonical] = {"uuids": [uuid], "names": []}
self.save()
return canonical
def add_name(self, canonical, name):
entry = self.data["canonical"].setdefault(canonical, {"uuids": [], "names": []})
if name not in entry["names"]:
entry["names"].append(name)
self.save()
def merge(self, uuid_a, uuid_b):
"""Merge two UUIDs into one canonical identity."""
ca = self.resolve(uuid_a)
cb = self.resolve(uuid_b)
if ca == cb:
return ca
# Merge cb into ca
self.data["canonical"][ca]["uuids"].extend(self.data["canonical"][cb]["uuids"])
self.data["canonical"][ca]["names"].extend(self.data["canonical"][cb]["names"])
# Update uuid_to_canonical
for u in self.data["canonical"][cb]["uuids"]:
self.data["uuid_to_canonical"][u] = ca
del self.data["canonical"][cb]
self.save()
return ca

View file

@ -7,7 +7,6 @@ def build_player_summary(player):
- Sharpshooter
- Consistency
- Clutch
- Support Specialist
"""
def safe_pct(value):
@ -26,7 +25,6 @@ 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")
@ -51,8 +49,8 @@ def build_player_summary(player):
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', '')}")
# Breaker
lines.append(f"\nBreaker: {safe_tier(breaker.get('tier'))} ({safe_pct(breaker.get('pct')):.1f}%)")
lines.append(f" {breaker.get('summary', '')}")
return "\n".join(lines)

View file

@ -0,0 +1,24 @@
def compute_rookie_leaderboard(career_db, tag="slayer", limit=10):
rookies = []
for cid, pdata in career_db["players"].items():
maps = pdata["career"].get("maps", 0)
if maps >= 10:
continue # not a rookie
tag_data = pdata.get(tag, {})
rookies.append({
"canonical_id": cid,
"name": pdata["name"],
"team": pdata.get("team", ""),
"maps": maps,
"kd": pdata["career"]["KD"],
"kills": pdata["career"]["kills"],
"score": pdata["career"]["damage"], # or actual score if available
"tag_raw": tag_data.get("raw", 0.0),
"tag_pct": tag_data.get("pct", 0.0),
"tag_tier": tag_data.get("tier", "D"),
})
rookies.sort(key=lambda r: r["tag_pct"], reverse=True)
return rookies[:limit]

View file

@ -24,6 +24,8 @@ def summarize_team_tags(team_players):
"sharpshooter",
"consistency",
"clutch",
"anchor",
"breaker",
]
summary = {}
@ -68,6 +70,8 @@ def compare_team_strengths(teamA, teamB, team_a_name, team_b_name):
"sharpshooter": "Sharpshooter (Accuracy)",
"consistency": "Consistency (Stability)",
"clutch": "Clutch (High-Pressure Performance)",
"anchor": "Anchor (Defensive Stability)",
"breaker": "Breaker (Offensive Disruption)",
}
for tag, label in tags.items():
@ -76,6 +80,18 @@ def compare_team_strengths(teamA, teamB, team_a_name, team_b_name):
diff = a - b
# Custom storyline for Anchor
if tag == "anchor":
if abs(diff) < 5:
storyline.append("Both teams are evenly matched in defensive stability.")
elif diff > 0:
storyline.append(f"{team_a_name} have stronger defensive stability, surviving longer and preventing collapses.")
else:
storyline.append(f"{team_b_name} have stronger defensive stability, giving them an edge in long fights.")
continue
# Generic storyline for all other tags
if abs(diff) < 5:
storyline.append(f"Both teams are evenly matched in {label}.")
elif diff > 0:

View file

@ -1,10 +1,17 @@
from utils.safe_tag import safe_pct, safe_tier, safe_avg
# Caster‑friendly display names
DISPLAY_NAMES = {
"slayer": "Slayer",
"objective_payload": "Payload Specialist",
"sharpshooter": "Sharpshooter",
"consistency": "Consistency",
"clutch": "Clutch",
"anchor": "Anchor Specialist",
"breaker": "Breaker",
}
from utils.safe_tag import safe_pct, safe_tier, safe_avg
def summarize_team_tags(team_players):
"""
Aggregates tag tiers + percentiles for a team.
@ -14,7 +21,9 @@ def summarize_team_tags(team_players):
- tier counts
"""
tags = ["slayer", "objective_payload", "sharpshooter", "consistency", "clutch"]
tags = ["slayer", "objective_payload", "sharpshooter",
"consistency", "clutch", "anchor", "breaker"]
summary = {}
for tag in tags:
@ -24,10 +33,10 @@ def summarize_team_tags(team_players):
for p in team_players:
tag_data = p.get(tag, {})
pcts.append(safe_pct(tag_data.get("pct")))
tiers.append(safe_tier(tag_data.get("tier")))
tiers.append(safe_tier(tag_data.get("tier"))) # guaranteed valid tier
summary[tag] = {
"avg_pct": safe_avg(pcts),
"avg_pct": safe_avg(pcts), # guaranteed numeric
"top_tier": max(tiers, key=lambda t: "SABCD".index(t)),
"count_S": tiers.count("S"),
"count_A": tiers.count("A"),
@ -49,8 +58,9 @@ def classify_team_style(summary):
sharpshooter = summary["sharpshooter"]["avg_pct"]
consistency = summary["consistency"]["avg_pct"]
clutch = summary["clutch"]["avg_pct"]
anchor = summary["anchor"]["avg_pct"]
# Identify primary style
# Primary style selection
primary = max(
{
"Slayer-heavy": slayer,
@ -58,11 +68,12 @@ def classify_team_style(summary):
"Precision/Sharpshooter": sharpshooter,
"Stable/Consistent": consistency,
"High-pressure/Clutch": clutch,
"Anchor/Support": anchor,
}.items(),
key=lambda x: x[1]
)[0]
# Identify weaknesses
# Weakness detection
weaknesses = []
if slayer < 40:
weaknesses.append("low elimination pressure")
@ -74,6 +85,8 @@ def classify_team_style(summary):
weaknesses.append("inconsistent match-to-match output")
if clutch < 40:
weaknesses.append("poor high-pressure performance")
if anchor < 40:
weaknesses.append("weak defensive backbone / low anchor stability")
return primary, weaknesses
@ -87,15 +100,16 @@ def generate_team_identity_block(team_players, team_name="Team"):
summary = summarize_team_tags(team_players)
primary_style, weaknesses = classify_team_style(summary)
# Build strengths list
# Strengths list
strengths = []
for tag, data in summary.items():
if data["avg_pct"] >= 60:
display = DISPLAY_NAMES.get(tag, tag.replace("_", " ").title())
strengths.append(
f"Strong {tag.replace('_', ' ').title()} ({data['top_tier']} Tier)"
f"Strong {display} ({data['top_tier']} Tier)"
)
# Build tag breakdown
# Tag breakdown
tag_block = {}
for tag, data in summary.items():
tag_block[tag] = {
@ -103,7 +117,6 @@ def generate_team_identity_block(team_players, team_name="Team"):
"tier": data["top_tier"],
}
# Return a dict (NOT a string)
return {
"team_name": team_name,
"team_style": primary_style,
@ -112,6 +125,7 @@ def generate_team_identity_block(team_players, team_name="Team"):
"tags": tag_block,
}
def format_team_identity(identity_data):
"""
Converts the identity block dict into a readable text block.
@ -125,30 +139,30 @@ def format_team_identity(identity_data):
lines.append("")
# Strengths
lines.append("Strengths:")
if identity_data["strengths"]:
lines.append("Strengths:")
for s in identity_data["strengths"]:
lines.append(f" - {s}")
else:
lines.append("Strengths:")
lines.append(" - None identified")
lines.append("")
# Weaknesses
lines.append("Weaknesses:")
if identity_data["weaknesses"]:
lines.append("Weaknesses:")
for w in identity_data["weaknesses"]:
lines.append(f" - {w}")
else:
lines.append("Weaknesses:")
lines.append(" - None identified")
lines.append("")
lines.append("Tag Breakdown:")
for tag, data in identity_data["tags"].items():
tag_name = tag.replace("_", " ").title()
lines.append(f" - {tag_name}: {data['tier']} Tier (avg {data['avg_pct']:.1f} percentile)")
name = DISPLAY_NAMES.get(tag, tag.replace("_", " ").title())
lines.append(
f" - {name}: {data['tier']} Tier (avg {data['avg_pct']:.1f} percentile)"
)
return "\n".join(lines)

View file

@ -33,7 +33,8 @@ from match_engine import (
split_two_columns,
compute_slayer_prediction,
rank_match_players_consistency_career,
rank_match_players_support_specialist,
rank_match_players_by_tag,
_add_score_fields,
)
# ---------------------------------------------------------
@ -42,6 +43,9 @@ from match_engine import (
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
CONFIG_PATH = os.path.join(BASE_DIR, "config.json")
SCREENS_DIR = os.path.join(BASE_DIR, "screens")
# Load config (or default)
if os.path.exists(CONFIG_PATH):
@ -253,7 +257,46 @@ class DashLeagueGUI:
url = f"{API_BASE}/stats?season={self.current_season}"
data = fetch_json(url)
stats = data.get("data", [])
self.stats_by_id = {p["id"]: p for p in stats}
# ---------------------------------------------------------
# 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)
# ---------------------------------------------------------
# Rebuild stats_by_id keyed by canonical ID
# ---------------------------------------------------------
new_stats_by_id = {}
for p in stats:
raw_id = p["id"]
# Always resolve canonical FIRST
canonical = pir.resolve(raw_id)
p["canonical_id"] = canonical
# Attach career tags if available
pdata = career_db["players"].get(canonical)
if pdata:
p["slayer"] = pdata.get("slayer", {})
p["sharpshooter"] = pdata.get("sharpshooter", {})
p["objective_payload"] = pdata.get("objective_payload", {})
p["objective_domination"] = pdata.get("objective_domination", {})
p["consistency"] = pdata.get("consistency", {})
p["clutch"] = pdata.get("clutch", {})
p["anchor"] = pdata.get("anchor", {})
p["breaker"] = pdata.get("breaker", {})
# Store under canonical ID
new_stats_by_id[canonical] = p
self.stats_by_id = new_stats_by_id
self.set_status("Stats synced.")
messagebox.showinfo("Success", f"Synced {len(stats)} player stats.")
except Exception as e:
@ -276,7 +319,10 @@ class DashLeagueGUI:
def on_team_select(self, side):
team_name = self.team_a_var.get() if side == "A" else self.team_b_var.get()
team_players = [p for p in self.stats_by_id.values() if p.get("team") == team_name]
team_players = [
p for p in self.stats_by_id.values()
if p.get("team") == team_name
]
if side == "A":
self.team_a_players = team_players
@ -346,6 +392,7 @@ class DashLeagueGUI:
# Debug
if self.current_match_players:
print("DEBUG MATCH PLAYER:", self.current_match_players[0])
print("DEBUG PLAYER KEYS:", list(self.current_match_players[0].keys()))
else:
print("DEBUG MATCH PLAYER: EMPTY LIST")
@ -358,34 +405,51 @@ class DashLeagueGUI:
with open(career_path, "r", encoding="utf-8") as f:
career_db = json.load(f)
# ---------------------------------------------------------
# Resolve canonical IDs for all match players
# ---------------------------------------------------------
from analysis.player_identity_registry import PlayerIdentityRegistry
pir = PlayerIdentityRegistry()
for p in all_players:
raw_uuid = (
p.get("id")
or p.get("PlayerUUID")
or p.get("uuid")
)
if raw_uuid:
canonical = pir.resolve(raw_uuid)
p["canonical_id"] = canonical
# -----------------------------
# 4. Attach modern career tags
# -----------------------------
if career_db is not None:
for p in all_players:
pid = (
p.get("id")
or p.get("PlayerUUID")
or p.get("uuid")
)
canonical = p.get("canonical_id")
if not canonical:
continue
if pid and pid in career_db["players"]:
pdata = career_db["players"][pid]
pdata = career_db["players"].get(canonical)
if not pdata:
continue
# Attach modern tags directly
p["slayer"] = pdata.get("slayer", {})
p["sharpshooter"] = pdata.get("sharpshooter", {})
p["objective_payload"] = pdata.get("objective_payload", {})
# Core tags
p["slayer"] = pdata.get("slayer", {})
p["sharpshooter"] = pdata.get("sharpshooter", {})
p["objective_payload"] = pdata.get("objective_payload", {})
# DO NOT overwrite match-based DomObj
if "objective_domination" not in p:
p["objective_domination"] = pdata.get("objective_domination", {})
# Do NOT overwrite match-based DomObj
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", {})
# Career-based tags
p["consistency"] = pdata.get("consistency", {})
p["clutch"] = pdata.get("clutch", {})
p["anchor"] = pdata.get("anchor", {})
p["breaker"] = pdata.get("breaker", {})
# -----------------------------
@ -425,6 +489,35 @@ class DashLeagueGUI:
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", ""))
# -----------------------------
# 6. Export Rookie Leaderboard
# -----------------------------
from analysis.rookie_leaderboard import compute_rookie_leaderboard
rookies = compute_rookie_leaderboard(career_db, tag="slayer") # default tag
rookie_dir = os.path.join(SCREENS_DIR, "rookies")
os.makedirs(rookie_dir, exist_ok=True)
# Full table
table_lines = []
for i, r in enumerate(rookies):
table_lines.append(
f"{i+1}. {r['name']} — {r['tag_tier']} Tier ({r['tag_pct']:.1f}%) "
f"KD {r['kd']:.2f} — {r['kills']} Kills — {r['score']} Score"
)
write_text(os.path.join(rookie_dir, "table.txt"), "\n".join(table_lines))
# Individual rows
for i, r in enumerate(rookies):
row = (
f"{i+1}. {r['name']} — {r['tag_tier']} Tier ({r['tag_pct']:.1f}%)\n"
f"KD {r['kd']:.2f} — {r['kills']} Kills — {r['score']} Score"
)
write_text(os.path.join(rookie_dir, f"p{i}.txt"), row)
self.set_status("Player slots generated.")
messagebox.showinfo("Success", "Player slots generated into p0–p9.")
@ -442,6 +535,8 @@ class DashLeagueGUI:
output_path = os.path.join(BASE_DIR, "career_stats.json")
build_career_database(output_path)
#print("DEBUG CAREER DB KEYS:", career_db.keys())
#print("DEBUG PLAYER COUNT:", len(career_db.get("players", {})))
self.set_status("Career database built successfully.")
messagebox.showinfo("Success", "Career database has been built.")
@ -592,15 +687,6 @@ 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)
# ---------------------------------------------------------
@ -636,8 +722,8 @@ Consistency Summary:
return
# Extract players for each team
teamA_players = [self.stats_by_id[p["id"]] for p in self.current_match_players if p.get("team") == team_a]
teamB_players = [self.stats_by_id[p["id"]] for p in self.current_match_players if p.get("team") == team_b]
teamA_players = [self.stats_by_id[p["canonical_id"]] for p in self.current_match_players]
teamB_players = [self.stats_by_id[p["canonical_id"]] for p in self.current_match_players]
#print("TEAM A SAMPLE:", teamA_players[0])
#print("TEAM B SAMPLE:", teamB_players[0])
@ -718,7 +804,8 @@ Consistency Summary:
"Domination Objective Specialist",
"Consistency",
"Clutch",
"Support Specialist",
"Anchor",
"Breaker",
]
ttk.Label(win, text="Select Tag:").pack(anchor="w", padx=10, pady=5)
@ -743,23 +830,34 @@ Consistency Summary:
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 == "Clutch":
ranked = rank_match_players_clutch(self.current_match_players)
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)
elif tag == "Clutch":
ranked = rank_match_players_clutch(self.current_match_players)
else: # Sharpshooter
ranked = rank_match_players_sharpshooter(self.current_match_players)
elif tag == "Anchor":
ranked = _add_score_fields(
rank_match_players_by_tag(self.current_match_players, "anchor")
)
elif tag == "Breaker":
ranked = _add_score_fields(
rank_match_players_by_tag(self.current_match_players, "breaker")
)
else:
messagebox.showerror("Error", f"Unknown tag: {tag}")
return
if not ranked:
output.delete("1.0", tk.END)
@ -902,6 +1000,24 @@ Consistency Summary:
text += "- Collapse Avoidance = Player's lowest per-map performance score\n"
text += "- Conversion Rate = (Accuracy × 0.50) + (Headshot Rate × 0.50)\n"
elif tag == "Anchor":
text += "\nAnchor Formula:\n"
text += "Anchor = (Survivability × 0.45) + (DefensivePresence × 0.30) + (Consistency × 0.25)\n"
text += "Where:\n"
text += "- Survivability = (LeagueAvgDeathsPerMap / PlayerDeathsPerMap), clamped to [0.25, 1.50] then normalized to 0–1\n"
text += "- DefensivePresence = (DamagePerMap / LeagueAvgDamagePerMap), clamped to [0, 1.5] then normalized to 0–1\n"
text += "- Consistency = normalized 0–1 stability score from the Consistency tag\n"
text += "Survivability reflects how hard the player is to remove; Defensive Presence measures baseline contribution; Consistency rewards stable defensive performance.\n"
elif tag == "Breaker":
text += "\nBreaker Formula:\n"
text += "Breaker = (DamageNorm × 0.60) + (KillsNorm × 0.40)\n"
text += "Where:\n"
text += "- DamageNorm = Damage per map normalized against league average\n"
text += "- KillsNorm = Kills per map normalized against league average\n"
text += "Breaker measures disruptive offensive pressure through high damage output and aggressive fragging.\n"
output.delete("1.0", tk.END)
output.insert(tk.END, text)

View file

@ -9,15 +9,18 @@ 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 tags.anchor import compute_anchor_for_career_player
from tags.breaker import compute_breaker_raw
from data.league_metrics import compute_league_metrics, compute_league_clutch_metrics
from data.tag_framework import percentile_rank, tier_from_percentile
def build_career_database(output_path):
print("USING DB:", db_access.DB_PATH)
#print("USING DB:", db_access.DB_PATH)
# ---------------------------------------------------------
@ -29,6 +32,13 @@ def build_career_database(output_path):
LEFT JOIN players p ON p.PlayerUUID = s.PlayerUUID;
""")
# ---------------------------------------------------------
# Load Player Identity Registry (PIR)
# ---------------------------------------------------------
from analysis.player_identity_registry import PlayerIdentityRegistry
pir = PlayerIdentityRegistry()
if not player_rows:
print("No players found in local DB.")
return False
@ -39,13 +49,21 @@ def build_career_database(output_path):
# 2. Build per-player career stats from local DB
# ---------------------------------------------------------
for row in player_rows:
pid = row["PlayerUUID"]
raw_uuid = str(row["PlayerUUID"])
name = row["PlayerGameName"] or "Unknown"
matches = db_access.get_player_match_history(pid)
# Resolve canonical identity
canonical_id = pir.resolve(raw_uuid)
pir.add_name(canonical_id, name)
# Use RAW UUID for DB queries
matches = db_access.get_player_match_history(raw_uuid)
if not matches:
continue
# Use CANONICAL ID as the key in the career DB
pid = canonical_id
#print("DEBUG MATCH ROW FOR", pid, ":", matches[0])
#break
@ -176,18 +194,20 @@ def build_career_database(output_path):
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
# 4.6 Build Breaker distribution
# ---------------------------------------------------------
support_distribution = []
breaker_distribution = []
for pid, pdata in final_db["players"].items():
raw_support = compute_support_specialist(pdata, league_averages)
support_distribution.append(raw_support)
career = pdata["career"]
damage_per_map = career["damage"] / max(1, career["maps"])
kills_per_map = career["kills"] / max(1, career["maps"])
# Write back normalized career block
pdata["career"] = career
raw_breaker = compute_breaker_raw(damage_per_map, kills_per_map)
breaker_distribution.append(raw_breaker)
# ---------------------------------------------------------
@ -242,6 +262,15 @@ def build_career_database(output_path):
"summary": consistency_result.summary_short
}
# Anchor
consistency_norm = pdata["consistency"]["raw"] # normalized 0–1 score
pdata["anchor"] = compute_anchor_for_career_player(
pdata,
league_averages,
distributions["anchor"],
consistency_norm
)
# Clutch
pdata["clutch"] = compute_clutch(
pdata,
@ -249,12 +278,23 @@ def build_career_database(output_path):
clutch_distribution
)
# Support Specialist
pdata["support_specialist"] = build_support_specialist_tag(
pdata,
league_averages,
support_distribution
)
# Breaker
career = pdata["career"]
damage_per_map = career["damage"] / max(1, career["maps"])
kills_per_map = career["kills"] / max(1, career["maps"])
raw_breaker = compute_breaker_raw(damage_per_map, kills_per_map)
# Percentile + tier
pct = percentile_rank(raw_breaker, breaker_distribution)
tier = tier_from_percentile(pct)
pdata["breaker"] = {
"raw": raw_breaker,
"pct": pct,
"tier": tier,
"summary": f"Offensive disruptor ({tier} Tier, {pct:.1f} percentile)"
}
# ---------------------------------------------------------
# Consistency (Hybrid Career Tag - New System)

View file

@ -43,7 +43,8 @@ def compute_league_metrics(all_players):
"payload": [],
"consistency": [],
"domination": [],
"support_specialist": [],
"anchor": [],
"breaker": [],
}
# -----------------------------------------
@ -89,6 +90,9 @@ def compute_league_metrics(all_players):
for key, values in acc.items()
}
# Alias for clarity: deaths is already per-map
league_averages["deaths_per_map"] = league_averages.get("deaths", 0.0)
# Minimum smoothing to avoid divide-by-zero explosions
league_averages["accuracy"] = max(league_averages["accuracy"], 0.05)
league_averages["headshot_rate"] = max(league_averages["headshot_rate"], 0.03)
@ -112,12 +116,17 @@ def compute_league_metrics(all_players):
# Domination
dist["domination"].append(compute_dom_raw(p))
from tags.support_specialist import compute_support_specialist
from tags.anchor import compute_anchor_raw
dist["support_specialist"].append(
compute_support_specialist(p, league_averages)
# Consistency raw score for distribution building
# (You already compute consistency later, but for distributions we only need the raw normalized score)
consistency_norm = p.get("consistency", {}).get("raw", 0.5)
dist["anchor"].append(
compute_anchor_raw(p, league_averages, consistency_norm)
)
# -----------------------------------------
# Sort distributions
# -----------------------------------------

View file

@ -1,7 +1,5 @@
import bisect
from tags.support_specialist import build_support_specialist_tag

View file

@ -24,6 +24,20 @@ def _get_player_entry(db, pid):
return db["players"].get(pid)
def attach_career_tags_to_match_player(match_player, career_entry):
"""
Injects career tag blocks into a match_player object so that
storylines, predictions, and UI panels can access:
- consistency
- anchor
- clutch
- breaker
"""
for tag in ("consistency", "anchor", "clutch", "breaker"):
if tag in career_entry:
match_player[tag] = career_entry[tag]
# ---------------------------------------------------------
# Universal Match Ranking (Career Tags)
# ---------------------------------------------------------
@ -48,6 +62,8 @@ def rank_match_players_by_tag(match_players, tag_name):
if not entry:
continue
attach_career_tags_to_match_player(p, entry)
tag = entry.get(tag_name, {})
pct = tag.get("pct", 0)
raw = tag.get("raw", 0.0)
@ -234,36 +250,3 @@ def compute_slayer_prediction(team_a_players, team_b_players):
}
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

View file

@ -109,37 +109,32 @@ def rank_match_players_clutch(players):
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.
"""
# ---------------------------------------------------------
# Generic Match Tag Ranking (Modern Tag System)
# ---------------------------------------------------------
def rank_match_players_by_tag(players, tag_name):
ranked = []
for p in players:
tag = p.get("support_specialist", {})
raw = tag.get("raw")
tag = p.get(tag_name, {})
# Skip players with no data
if raw is None:
continue
# Modern tags store raw score under "raw"
score = 0.0
if isinstance(tag, dict):
score = tag.get("raw", 0.0)
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}",
"score_raw": score,
"score_display": f"{score:.2f}",
})
# Sort descending by raw score
ranked.sort(key=lambda x: x["raw"], reverse=True)
ranked.sort(key=lambda x: x["score_raw"], reverse=True)
# Assign ranks
for i, entry in enumerate(ranked, start=1):
entry["rank"] = i
for i, r in enumerate(ranked, start=1):
r["rank"] = i
return ranked

115
tags/anchor.py Normal file
View file

@ -0,0 +1,115 @@
# ---------------------------------------------------------
# Anchor Tag — Defensive Stability & Survivability
# Mode-agnostic, uses only universal stats
# ---------------------------------------------------------
from tags.tag_framework import build_tag_output
# ---------------------------------------------------------
# Anchor Summary (caster-friendly)
# ---------------------------------------------------------
def anchor_summary(tier, pct):
if tier == "S":
return f"Elite defensive anchor — top {100 - pct:.0f}% in the league for survivability and stability."
if tier == "A":
return "High-impact anchor with strong survivability and consistent defensive presence."
if tier == "B":
return "Above-average anchor who provides reliable defensive value."
if tier == "C":
return "Below-average anchor; inconsistent survivability or defensive presence."
return "Struggles to provide defensive stability."
# ---------------------------------------------------------
# Survivability Normalization
# ---------------------------------------------------------
def compute_survivability_norm(deaths_per_map, league_avg_deaths):
"""
Survivability = league_avg / player_deaths
Clamped to [0.25, 1.50], then normalized to 0–1.
"""
if deaths_per_map <= 0:
# Extremely rare, but treat as max survivability
return 1.0
raw = league_avg_deaths / deaths_per_map
# Clamp to avoid extreme values
raw = max(0.25, min(1.50, raw))
# Normalize to 0–1
return (raw - 0.25) / (1.50 - 0.25)
# ---------------------------------------------------------
# Defensive Presence Normalization
# ---------------------------------------------------------
def compute_defensive_presence_norm(damage_per_map, league_avg_damage):
"""
Defensive presence uses damage as a universal proxy.
Normalized to league average, clamped to [0, 1.5], then normalized to 0–1.
"""
if league_avg_damage <= 0:
return 0.0
raw = damage_per_map / league_avg_damage
# Clamp
raw = max(0.0, min(1.5, raw))
# Normalize to 0–1
return raw / 1.5
# ---------------------------------------------------------
# Anchor Raw Score
# ---------------------------------------------------------
def compute_anchor_raw(pdata, league_averages, consistency_norm):
"""
pdata: player stats dict (career-level)
league_averages: dict with league-wide averages
consistency_norm: 0–1 normalized consistency score
"""
c = pdata["career"]
deaths = c["deaths"]
damage = c["damage"]
maps = max(1, c["maps"])
deaths_per_map = deaths / maps
damage_per_map = damage / maps
league_avg_deaths = league_averages.get("deaths_per_map", 1)
league_avg_damage = league_averages.get("damage", 1)
survivability = compute_survivability_norm(deaths_per_map, league_avg_deaths)
defensive_presence = compute_defensive_presence_norm(damage_per_map, league_avg_damage)
# Weighted anchor score
raw = (
survivability * 0.45 +
defensive_presence * 0.30 +
consistency_norm * 0.25
)
return max(0.0, min(1.0, raw))
# ---------------------------------------------------------
# Public API
# ---------------------------------------------------------
def compute_anchor_for_career_player(pdata, league_averages, anchor_distribution, consistency_norm):
"""
pdata: player stats
league_averages: league-wide averages
anchor_distribution: distribution of raw anchor scores for percentile mapping
consistency_norm: 0–1 normalized consistency score (from consistency tag)
"""
raw = compute_anchor_raw(pdata, league_averages, consistency_norm)
return build_tag_output(raw, anchor_distribution, anchor_summary)

37
tags/breaker.py Normal file
View file

@ -0,0 +1,37 @@
# ============================================================
# BREAKER TAG (v1 - Mode Safe)
# ------------------------------------------------------------
# Breaker measures offensive disruption using only stats that
# exist for BOTH Payload and Domination:
#
# - Damage per map
# - Kills per map
#
# No captures, no counters, no streaks, no timing.
# ============================================================
def compute_breaker_raw(damage_per_map, kills_per_map):
"""
Computes the raw Breaker score (0–infinity).
This is later normalized by league distribution.
Breaker v1:
60% damage pressure
40% kill pressure
"""
return (0.60 * damage_per_map) + (0.40 * kills_per_map)
def build_breaker_summary(tier, pct):
"""
Returns caster-friendly summary text.
"""
if tier == "S":
return "One of the league’s top offensive disruptors."
if tier == "A":
return "A strong pressure player who cracks open defenses."
if tier == "B":
return "Provides steady offensive pressure."
if tier == "C":
return "Occasional pressure but inconsistent impact."
return "Low offensive disruption output."

View file

@ -1,77 +0,0 @@
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)