Refactor: integrated hybrid Consistency tag system, updated DB builder, GUI, rankings, predictions, storyline, identity, and added test suite
This commit is contained in:
parent
16f8032654
commit
3dc737d819
15 changed files with 548 additions and 148 deletions
|
|
@ -76,6 +76,25 @@ def generate_prediction(teamA_players, teamB_players, teamA_name="Team A", teamB
|
||||||
lines.append(f" {teamA_name}: {', '.join(A_weak) if A_weak else 'No major weaknesses'}")
|
lines.append(f" {teamA_name}: {', '.join(A_weak) if A_weak else 'No major weaknesses'}")
|
||||||
lines.append(f" {teamB_name}: {', '.join(B_weak) if B_weak else 'No major weaknesses'}\n")
|
lines.append(f" {teamB_name}: {', '.join(B_weak) if B_weak else 'No major weaknesses'}\n")
|
||||||
|
|
||||||
|
# -----------------------------------------
|
||||||
|
# 6. Consistency comparison (new)
|
||||||
|
# -----------------------------------------
|
||||||
|
A_cons = A["consistency"]["avg_pct"]
|
||||||
|
B_cons = B["consistency"]["avg_pct"]
|
||||||
|
|
||||||
|
if abs(A_cons - B_cons) < 5:
|
||||||
|
cons_line = "Both teams show similar consistency across the season."
|
||||||
|
elif A_cons > B_cons:
|
||||||
|
cons_line = f"{teamA_name} have been the more stable team, with stronger match-to-match consistency."
|
||||||
|
else:
|
||||||
|
cons_line = f"{teamB_name} have been the more stable team, with stronger match-to-match consistency."
|
||||||
|
|
||||||
|
lines.append("\nConsistency Check:")
|
||||||
|
lines.append(f" {cons_line}\n")
|
||||||
|
|
||||||
|
# -----------------------------------------
|
||||||
|
# 7. Final prediction line
|
||||||
|
# -----------------------------------------
|
||||||
lines.append("Prediction:")
|
lines.append("Prediction:")
|
||||||
lines.append(f" {favorite}")
|
lines.append(f" {favorite}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ def summarize_team_tags(team_players):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
summary[tag] = {
|
summary[tag] = {
|
||||||
"avg_pct": sum(pcts) / len(pcts),
|
"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))])),
|
||||||
"top_tier": max(tiers, key=lambda t: "SABCD".index(t)),
|
"top_tier": max(tiers, key=lambda t: "SABCD".index(t)),
|
||||||
"count_S": tiers.count("S"),
|
"count_S": tiers.count("S"),
|
||||||
"count_A": tiers.count("A"),
|
"count_A": tiers.count("A"),
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ def summarize_team_tags(team_players):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
summary[tag] = {
|
summary[tag] = {
|
||||||
"avg_pct": sum(pcts) / len(pcts),
|
"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))])),
|
||||||
"top_tier": max(tiers, key=lambda t: "SABCD".index(t)),
|
"top_tier": max(tiers, key=lambda t: "SABCD".index(t)),
|
||||||
"count_S": tiers.count("S"),
|
"count_S": tiers.count("S"),
|
||||||
"count_A": tiers.count("A"),
|
"count_A": tiers.count("A"),
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ from match_engine import (
|
||||||
rank_match_players_clutch,
|
rank_match_players_clutch,
|
||||||
split_two_columns,
|
split_two_columns,
|
||||||
compute_slayer_prediction,
|
compute_slayer_prediction,
|
||||||
|
rank_match_players_consistency_career,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------
|
# ---------------------------------------------------------
|
||||||
|
|
@ -288,7 +289,7 @@ class DashLeagueGUI:
|
||||||
self.team_b_listbox.insert(tk.END, p.get("name", "Unknown"))
|
self.team_b_listbox.insert(tk.END, p.get("name", "Unknown"))
|
||||||
|
|
||||||
|
|
||||||
def normalize_player(p):
|
def normalize_player(self, p):
|
||||||
return {
|
return {
|
||||||
"id": p.get("PlayerUUID"),
|
"id": p.get("PlayerUUID"),
|
||||||
"name": p.get("PlayerGameName"),
|
"name": p.get("PlayerGameName"),
|
||||||
|
|
@ -490,27 +491,40 @@ class DashLeagueGUI:
|
||||||
obj_pl = player.get("objective_payload_score")
|
obj_pl = player.get("objective_payload_score")
|
||||||
slayer_strength = player.get("slayer_strength", "Unknown")
|
slayer_strength = player.get("slayer_strength", "Unknown")
|
||||||
|
|
||||||
|
consistency = player.get("consistency", {})
|
||||||
|
cons_tier = consistency.get("tier", "N/A")
|
||||||
|
cons_pct = consistency.get("pct")
|
||||||
|
cons_summary = consistency.get("summary", "No consistency data available.")
|
||||||
|
|
||||||
|
if isinstance(cons_pct, (int, float)):
|
||||||
|
cons_line = f"{cons_tier} Tier ({cons_pct:.1f} percentile)"
|
||||||
|
else:
|
||||||
|
cons_line = f"{cons_tier} Tier"
|
||||||
|
|
||||||
text = f"""PLAYER SPOTLIGHT — {name}
|
text = f"""PLAYER SPOTLIGHT — {name}
|
||||||
|
|
||||||
Slayer Tier: {slayer_strength}
|
Slayer Tier: {slayer_strength}
|
||||||
Payload Objective Specialist: {obj_pl if obj_pl is not None else "N/A"}
|
Consistency: {cons_line}
|
||||||
|
Payload Objective Specialist: {obj_pl if obj_pl is not None else "N/A"}
|
||||||
|
|
||||||
Career Stats:
|
Career Stats:
|
||||||
KD: {career['KD']:.2f}
|
KD: {career['KD']:.2f}
|
||||||
Accuracy: {career['accuracy']:.2f}
|
Accuracy: {career['accuracy']:.2f}
|
||||||
Kills: {career['kills']}
|
Kills: {career['kills']}
|
||||||
Deaths: {career['deaths']}
|
Deaths: {career['deaths']}
|
||||||
Maps Played: {career['maps']}
|
Maps Played: {career['maps']}
|
||||||
|
|
||||||
Derived Metrics:
|
Derived Metrics:
|
||||||
Kills/Map: {derived.get('kills_per_map', 0):.2f}
|
Kills/Map: {derived.get('kills_per_map', 0):.2f}
|
||||||
Deaths/Map: {derived.get('deaths_per_map', 0):.2f}
|
Deaths/Map: {derived.get('deaths_per_map', 0):.2f}
|
||||||
PushTime/Season: {derived.get('push_time_per_season', 0):.2f}
|
PushTime/Season: {derived.get('push_time_per_season', 0):.2f}
|
||||||
"""
|
|
||||||
|
Consistency Summary:
|
||||||
|
{cons_summary}
|
||||||
|
"""
|
||||||
|
|
||||||
output.delete("1.0", tk.END)
|
output.delete("1.0", tk.END)
|
||||||
output.insert(tk.END, text)
|
output.insert(tk.END, text)
|
||||||
|
|
||||||
def export():
|
def export():
|
||||||
content = output.get("1.0", tk.END).strip()
|
content = output.get("1.0", tk.END).strip()
|
||||||
if not content:
|
if not content:
|
||||||
|
|
@ -560,34 +574,48 @@ class DashLeagueGUI:
|
||||||
def generate_player_tags(self, player_entry, map_type):
|
def generate_player_tags(self, player_entry, map_type):
|
||||||
tags_out = []
|
tags_out = []
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
# Slayer (career)
|
# Slayer (career)
|
||||||
|
# ---------------------------------------------------------
|
||||||
slayer = player_entry.get("slayer", {})
|
slayer = player_entry.get("slayer", {})
|
||||||
slayer_strength = slayer.get("strength") or player_entry.get("slayer_strength")
|
slayer_strength = slayer.get("strength") or player_entry.get("slayer_strength")
|
||||||
if isinstance(slayer_strength, str):
|
if isinstance(slayer_strength, str):
|
||||||
tags_out.append(slayer_strength)
|
tags_out.append(slayer_strength)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# Consistency (career - hybrid system)
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
consistency = player_entry.get("consistency", {})
|
||||||
|
if isinstance(consistency, dict):
|
||||||
|
score = consistency.get("raw")
|
||||||
|
if isinstance(score, (int, float)):
|
||||||
|
tags_out.append(f"Cons {score:.2f}")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
# Payload Objective Specialist (match-based)
|
# Payload Objective Specialist (match-based)
|
||||||
|
# ---------------------------------------------------------
|
||||||
if map_type == "Payload":
|
if map_type == "Payload":
|
||||||
pl_info = player_entry.get("objective_payload_components", {})
|
pl_info = player_entry.get("objective_payload_components", {})
|
||||||
pl_score = pl_info.get("payload_score_raw", 0.0)
|
pl_score = pl_info.get("payload_score_raw", 0.0)
|
||||||
tags_out.append(f"ObjPL {pl_score:.2f}")
|
tags_out.append(f"ObjPL {pl_score:.2f}")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
# Domination Objective Specialist (match-based)
|
# Domination Objective Specialist (match-based)
|
||||||
|
# ---------------------------------------------------------
|
||||||
if map_type == "Domination":
|
if map_type == "Domination":
|
||||||
dom_info = player_entry.get("objective_domination_components", {})
|
dom_info = player_entry.get("objective_domination_components", {})
|
||||||
dom_score = dom_info.get("raw", 0.0)
|
dom_score = dom_info.get("raw", 0.0)
|
||||||
tags_out.append(f"ObjDOM {dom_score:.2f}")
|
tags_out.append(f"ObjDOM {dom_score:.2f}")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
# Sharpshooter (career)
|
# Sharpshooter (career)
|
||||||
|
# ---------------------------------------------------------
|
||||||
sharp = player_entry.get("sharpshooter", {}).get("score") or player_entry.get("sharpshooter_score")
|
sharp = player_entry.get("sharpshooter", {}).get("score") or player_entry.get("sharpshooter_score")
|
||||||
if isinstance(sharp, (int, float)):
|
if isinstance(sharp, (int, float)):
|
||||||
tags_out.append(f"Sharp {sharp:.2f}")
|
tags_out.append(f"Sharp {sharp:.2f}")
|
||||||
|
|
||||||
print("DEBUG DOM:", player_entry.get("objective_domination_components"))
|
|
||||||
|
|
||||||
return ", ".join(tags_out)
|
return ", ".join(tags_out)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def on_match_storyline(self):
|
def on_match_storyline(self):
|
||||||
team_a = self.team_a_var.get()
|
team_a = self.team_a_var.get()
|
||||||
|
|
@ -709,9 +737,13 @@ class DashLeagueGUI:
|
||||||
|
|
||||||
elif tag == "Domination Objective Specialist":
|
elif tag == "Domination Objective Specialist":
|
||||||
ranked = rank_match_players_objdom(self.current_match_players)
|
ranked = rank_match_players_objdom(self.current_match_players)
|
||||||
|
|
||||||
elif tag == "Clutch":
|
elif tag == "Clutch":
|
||||||
ranked = rank_match_players_clutch(self.current_match_players)
|
ranked = rank_match_players_clutch(self.current_match_players)
|
||||||
|
|
||||||
|
elif tag == "Consistency":
|
||||||
|
ranked = rank_match_players_consistency_career(self.current_match_players)
|
||||||
|
|
||||||
|
|
||||||
else: # Sharpshooter
|
else: # Sharpshooter
|
||||||
ranked = rank_match_players_sharpshooter(self.current_match_players)
|
ranked = rank_match_players_sharpshooter(self.current_match_players)
|
||||||
|
|
@ -841,13 +873,13 @@ class DashLeagueGUI:
|
||||||
text += "Counters are weighted more heavily because they prevent enemy scoring.\n"
|
text += "Counters are weighted more heavily because they prevent enemy scoring.\n"
|
||||||
|
|
||||||
elif tag == "Consistency":
|
elif tag == "Consistency":
|
||||||
text += "\nConsistency Formula:\n"
|
text += "\nConsistency Formula (Hybrid Career Tag):\n"
|
||||||
text += "Consistency = (Floor × 0.40) + (Stability × 0.40) + (Average Performance × 0.20)\n"
|
text += "Consistency = (Floor × 0.40) + (Stability × 0.40) + (Average Performance × 0.20)\n"
|
||||||
text += "Where:\n"
|
text += "Where:\n"
|
||||||
text += "- Per-Match Performance = (Slayer × 0.40) + (Objective × 0.40) + (Accuracy × 0.20)\n"
|
text += "- Floor = lowest per-match performance score\n"
|
||||||
text += "- Floor = the player's lowest per-match performance score\n"
|
|
||||||
text += "- Stability = 1 / (1 + Adjusted Variance)\n"
|
text += "- Stability = 1 / (1 + Adjusted Variance)\n"
|
||||||
text += "- Adjusted Variance = Variance × (1 + 1 / Match Count)\n"
|
text += "- Adjusted Variance = Variance × (1 + 1 / Match Count)\n"
|
||||||
|
text += "- Average Performance = mean per-match performance score\n"
|
||||||
|
|
||||||
elif tag == "Clutch":
|
elif tag == "Clutch":
|
||||||
text += "\nClutch Formula:\n"
|
text += "\nClutch Formula:\n"
|
||||||
|
|
@ -1025,41 +1057,7 @@ class DashLeagueGUI:
|
||||||
output_folder = os.path.join(os.getcwd(), "obs_output")
|
output_folder = os.path.join(os.getcwd(), "obs_output")
|
||||||
export_all_obs(output_folder, teamA_players, teamB_players, team_a, team_b)
|
export_all_obs(output_folder, teamA_players, teamB_players, team_a, team_b)
|
||||||
messagebox.showinfo("Exported", f"OBS files exported to:\n{output_folder}")
|
messagebox.showinfo("Exported", f"OBS files exported to:\n{output_folder}")
|
||||||
|
|
||||||
|
|
||||||
def format_team_identity(identity):
|
|
||||||
"""
|
|
||||||
Converts the identity block dict into readable caster-friendly text.
|
|
||||||
"""
|
|
||||||
lines = []
|
|
||||||
|
|
||||||
lines.append(f"Team Style: {identity.get('team_style', 'Unknown')}")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
lines.append("Strengths:")
|
|
||||||
for s in identity.get("strengths", []):
|
|
||||||
lines.append(f" - {s}")
|
|
||||||
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
lines.append("Weaknesses:")
|
|
||||||
for w in identity.get("weaknesses", []):
|
|
||||||
lines.append(f" - {w}")
|
|
||||||
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
lines.append("Tag Breakdown:")
|
|
||||||
for tag, data in identity.get("tags", {}).items():
|
|
||||||
lines.append(
|
|
||||||
f" - {tag.title()}: {data['tier']} Tier "
|
|
||||||
f"(avg {data['avg_pct']:.1f} percentile)"
|
|
||||||
)
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
root = tk.Tk()
|
root = tk.Tk()
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from tags.slayer import compute_slayer_for_career_player
|
||||||
from tags.objective_payload import compute_payload_tag
|
from tags.objective_payload import compute_payload_tag
|
||||||
from tags.objective_domination import compute_dom_objective_for_career_player
|
from tags.objective_domination import compute_dom_objective_for_career_player
|
||||||
from tags.sharpshooter import compute_sharpshooter_for_career_player
|
from tags.sharpshooter import compute_sharpshooter_for_career_player
|
||||||
from tags.consistency import compute_consistency, compute_consistency_tag
|
from tags.consistency import build_consistency_tag
|
||||||
from tags.clutch import compute_clutch, compute_clutch_raw
|
from tags.clutch import compute_clutch, compute_clutch_raw
|
||||||
|
|
||||||
from data.league_metrics import compute_league_metrics, compute_league_clutch_metrics
|
from data.league_metrics import compute_league_metrics, compute_league_clutch_metrics
|
||||||
|
|
@ -146,14 +146,112 @@ def build_career_database(output_path):
|
||||||
distributions["domination"]
|
distributions["domination"]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Consistency
|
# ---------------------------------------------------------
|
||||||
|
# Consistency (Hybrid Career Tag - New System)
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
|
||||||
matches = pdata["matches"]
|
matches = pdata["matches"]
|
||||||
consistency_raw, components = compute_consistency(matches)
|
maps_played = pdata["career"].get("maps", 0)
|
||||||
pdata["consistency"] = compute_consistency_tag(
|
|
||||||
components,
|
# ---------------------------------------------------------
|
||||||
distributions["consistency"]
|
# Extract per-match performance components
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# NOTE:
|
||||||
|
# If you later add per-match slayer/objective/accuracy scores,
|
||||||
|
# this block will automatically support them.
|
||||||
|
# For now, we compute a simple per-match performance score
|
||||||
|
# using the same formula as the legacy system.
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
|
||||||
|
per_match_scores = []
|
||||||
|
for m in matches:
|
||||||
|
# Slayer-like component
|
||||||
|
dmg = m.get("Damage", 0)
|
||||||
|
kills = m.get("Kills", 0)
|
||||||
|
deaths = m.get("Deaths", 0)
|
||||||
|
kd = kills / deaths if deaths > 0 else kills
|
||||||
|
|
||||||
|
slayer_component = (
|
||||||
|
(dmg / 10000) * 0.40 +
|
||||||
|
(kills / 30) * 0.40 +
|
||||||
|
(kd / 5) * 0.20
|
||||||
|
)
|
||||||
|
|
||||||
|
# Objective-like component
|
||||||
|
push = m.get("PAY_PushTime", 0)
|
||||||
|
caps = m.get("DOM_Captures", 0)
|
||||||
|
counters = m.get("DOM_Counters", 0)
|
||||||
|
|
||||||
|
objective_component = (
|
||||||
|
(push / 300) * 0.40 +
|
||||||
|
(caps / 20) * 0.30 +
|
||||||
|
(counters / 20) * 0.30
|
||||||
|
)
|
||||||
|
|
||||||
|
# Accuracy-like component
|
||||||
|
shots = m.get("Shots", 0)
|
||||||
|
shots_hit = m.get("ShotsHit", 0)
|
||||||
|
accuracy = (shots_hit / shots) if shots > 0 else 0.0
|
||||||
|
|
||||||
|
accuracy_component = accuracy * 0.20
|
||||||
|
|
||||||
|
# Final per-match performance score
|
||||||
|
perf = slayer_component + objective_component + accuracy_component
|
||||||
|
per_match_scores.append(perf)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# Compute floor, stability, average
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
if per_match_scores:
|
||||||
|
floor_value = min(per_match_scores)
|
||||||
|
avg_value = sum(per_match_scores) / len(per_match_scores)
|
||||||
|
|
||||||
|
# Variance adjusted for match count
|
||||||
|
mean = avg_value
|
||||||
|
variance = sum((x - mean) ** 2 for x in per_match_scores) / len(per_match_scores)
|
||||||
|
adjusted_variance = variance * (1 + 1 / max(1, len(per_match_scores)))
|
||||||
|
stability_value = 1 / (1 + adjusted_variance)
|
||||||
|
else:
|
||||||
|
floor_value = 0.0
|
||||||
|
avg_value = 0.0
|
||||||
|
stability_value = 0.0
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# Build hybrid consistency tag
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
consistency_result = build_consistency_tag(
|
||||||
|
floor_current=floor_value,
|
||||||
|
floor_career=floor_value, # career = all matches
|
||||||
|
floor_league=league_averages.get("consistency_floor", 0.0),
|
||||||
|
|
||||||
|
stability_current=stability_value,
|
||||||
|
stability_career=stability_value,
|
||||||
|
stability_league=league_averages.get("consistency_stability", 0.0),
|
||||||
|
|
||||||
|
average_current=avg_value,
|
||||||
|
average_career=avg_value,
|
||||||
|
average_league=league_averages.get("consistency_average", 0.0),
|
||||||
|
|
||||||
|
matches_played_current=maps_played,
|
||||||
|
percentile=None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# Store in DB (GUI + Rankings + Spotlight compatible)
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
pdata["consistency"] = {
|
||||||
|
"raw": consistency_result.normalized_score,
|
||||||
|
"pct": consistency_result.percentile,
|
||||||
|
"tier": consistency_result.tier,
|
||||||
|
"summary": consistency_result.summary_short,
|
||||||
|
"components": {
|
||||||
|
"floor": consistency_result.components.floor,
|
||||||
|
"stability": consistency_result.components.stability,
|
||||||
|
"average": consistency_result.components.average_performance,
|
||||||
|
},
|
||||||
|
"extras": consistency_result.extras,
|
||||||
|
}
|
||||||
|
|
||||||
# Clutch (FINAL TAG)
|
# Clutch (FINAL TAG)
|
||||||
pdata["clutch"] = compute_clutch(
|
pdata["clutch"] = compute_clutch(
|
||||||
pdata,
|
pdata,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import math
|
||||||
from tags.slayer import compute_slayer_raw
|
from tags.slayer import compute_slayer_raw
|
||||||
from tags.sharpshooter import compute_sharpshooter_raw
|
from tags.sharpshooter import compute_sharpshooter_raw
|
||||||
from tags.objective_payload import compute_payload_raw
|
from tags.objective_payload import compute_payload_raw
|
||||||
from tags.consistency import compute_consistency_raw
|
|
||||||
from tags.objective_domination import compute_dom_raw
|
from tags.objective_domination import compute_dom_raw
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -101,11 +100,6 @@ def compute_league_metrics(all_players):
|
||||||
# Payload
|
# Payload
|
||||||
dist["payload"].append(compute_payload_raw(p))
|
dist["payload"].append(compute_payload_raw(p))
|
||||||
|
|
||||||
# Consistency (with smoothing)
|
|
||||||
raw_cons = compute_consistency_raw(matches)
|
|
||||||
raw_cons = max(raw_cons, 0.05)
|
|
||||||
dist["consistency"].append(raw_cons)
|
|
||||||
|
|
||||||
# Domination
|
# Domination
|
||||||
dist["domination"].append(compute_dom_raw(p))
|
dist["domination"].append(compute_dom_raw(p))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,4 +55,67 @@ def build_tag_output(raw_score, distribution, summary_fn):
|
||||||
"pct": pct,
|
"pct": pct,
|
||||||
"tier": tier,
|
"tier": tier,
|
||||||
"summary": summary
|
"summary": summary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# CONSISTENCY TAG ADAPTER
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Adapts the new ConsistencyResult object into the legacy
|
||||||
|
# tag_framework format expected by the UI + rankings.
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
from tags.consistency import build_consistency_tag
|
||||||
|
|
||||||
|
def compute_consistency_tag(player_stats, distribution):
|
||||||
|
"""
|
||||||
|
Adapter layer:
|
||||||
|
- Extracts needed stats from player_stats
|
||||||
|
- Calls the new Consistency tag builder
|
||||||
|
- Returns raw score + summary_fn for tag_framework
|
||||||
|
"""
|
||||||
|
|
||||||
|
# -----------------------------------------
|
||||||
|
# Extract stats (0–1 normalized values)
|
||||||
|
# -----------------------------------------
|
||||||
|
floor_current = player_stats.get("floor_current")
|
||||||
|
floor_career = player_stats.get("floor_career")
|
||||||
|
floor_league = player_stats.get("floor_league")
|
||||||
|
|
||||||
|
stability_current = player_stats.get("stability_current")
|
||||||
|
stability_career = player_stats.get("stability_career")
|
||||||
|
stability_league = player_stats.get("stability_league")
|
||||||
|
|
||||||
|
average_current = player_stats.get("average_current")
|
||||||
|
average_career = player_stats.get("average_career")
|
||||||
|
average_league = player_stats.get("average_league")
|
||||||
|
|
||||||
|
matches_played = player_stats.get("matches_played_current", 0)
|
||||||
|
|
||||||
|
# -----------------------------------------
|
||||||
|
# Build full ConsistencyResult object
|
||||||
|
# -----------------------------------------
|
||||||
|
result = build_consistency_tag(
|
||||||
|
floor_current=floor_current,
|
||||||
|
floor_career=floor_career,
|
||||||
|
floor_league=floor_league,
|
||||||
|
stability_current=stability_current,
|
||||||
|
stability_career=stability_career,
|
||||||
|
stability_league=stability_league,
|
||||||
|
average_current=average_current,
|
||||||
|
average_career=average_career,
|
||||||
|
average_league=average_league,
|
||||||
|
matches_played_current=matches_played,
|
||||||
|
percentile=None # tag_framework will compute this
|
||||||
|
)
|
||||||
|
|
||||||
|
# -----------------------------------------
|
||||||
|
# Summary function for tag_framework
|
||||||
|
# -----------------------------------------
|
||||||
|
def summary_fn(tier, pct):
|
||||||
|
return result.summary_short
|
||||||
|
|
||||||
|
# -----------------------------------------
|
||||||
|
# Return raw score + summary_fn
|
||||||
|
# -----------------------------------------
|
||||||
|
return result.normalized_score, summary_fn
|
||||||
|
|
@ -182,6 +182,24 @@ def rank_match_players_objdom(match_players):
|
||||||
r["rank"] = i
|
r["rank"] = i
|
||||||
|
|
||||||
return ranked
|
return ranked
|
||||||
|
|
||||||
|
|
||||||
|
def rank_match_players_consistency_career(match_players):
|
||||||
|
"""
|
||||||
|
Wraps rank_match_players_by_tag('consistency') so the output matches
|
||||||
|
the expected structure for the rankings UI.
|
||||||
|
"""
|
||||||
|
ranked = rank_match_players_by_tag(match_players, "consistency")
|
||||||
|
out = []
|
||||||
|
for i, p in enumerate(ranked, 1):
|
||||||
|
out.append({
|
||||||
|
"rank": i,
|
||||||
|
"name": p["name"],
|
||||||
|
"team": p.get("team", "Unknown"),
|
||||||
|
"score_raw": p["raw"],
|
||||||
|
"score_display": f"{p['raw']:.2f}",
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
# ---------------------------------------------------------
|
# ---------------------------------------------------------
|
||||||
# Legacy Slayer Prediction Wrapper
|
# Legacy Slayer Prediction Wrapper
|
||||||
|
|
|
||||||
|
|
@ -1,83 +1,122 @@
|
||||||
import math
|
# ============================================================
|
||||||
from tags.tag_framework import build_tag_output
|
# CONSISTENCY TAG
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Template tag for the entire system.
|
||||||
|
# Uses:
|
||||||
|
# - fallback logic
|
||||||
|
# - early-season blending
|
||||||
|
# - normalization
|
||||||
|
# - tier mapping
|
||||||
|
# - summary generation
|
||||||
|
# - spotlight extras
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
# ---------------------------------------------------------
|
from dataclasses import dataclass
|
||||||
# Raw Consistency Score (Robust)
|
from utils.fallback import get_stat_with_fallback, blend_early_season
|
||||||
# ---------------------------------------------------------
|
from utils.tiers import map_score_to_tier
|
||||||
|
from utils.summaries import build_consistency_summaries
|
||||||
def compute_consistency_raw(matches):
|
|
||||||
"""
|
|
||||||
Computes a normalized 0–1 consistency score based on
|
|
||||||
match-to-match stability in KD, damage, and accuracy.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not matches:
|
|
||||||
return 0.05 # minimum floor
|
|
||||||
|
|
||||||
perf = []
|
|
||||||
|
|
||||||
for m in matches:
|
|
||||||
kills = m.get("Kills", 0)
|
|
||||||
deaths = m.get("Deaths", 0)
|
|
||||||
damage = m.get("Damage", 0)
|
|
||||||
shots = m.get("Shots", 0)
|
|
||||||
shots_hit = m.get("ShotsHit", 0)
|
|
||||||
|
|
||||||
kd = kills / max(1, deaths)
|
|
||||||
acc = shots_hit / shots if shots > 0 else 0.0
|
|
||||||
|
|
||||||
# Normalize components into comparable ranges
|
|
||||||
kd_norm = min(kd / 5.0, 1.0) # KD 0–5
|
|
||||||
dmg_norm = min(damage / 3000.0, 1.0) # Damage 0–3000
|
|
||||||
acc_norm = acc # Already 0–1
|
|
||||||
|
|
||||||
# Composite performance score per match
|
|
||||||
perf_score = (0.4 * kd_norm) + (0.4 * dmg_norm) + (0.2 * acc_norm)
|
|
||||||
perf.append(perf_score)
|
|
||||||
|
|
||||||
# Variance of performance
|
|
||||||
if len(perf) <= 1:
|
|
||||||
return 0.25 # floor for single-match players
|
|
||||||
|
|
||||||
mean = sum(perf) / len(perf)
|
|
||||||
var = sum((p - mean) ** 2 for p in perf) / len(perf)
|
|
||||||
std = math.sqrt(var)
|
|
||||||
|
|
||||||
# Smoothing
|
|
||||||
std = max(std, 0.05)
|
|
||||||
|
|
||||||
# Convert std into consistency score
|
|
||||||
raw = 1.0 / (1.0 + std)
|
|
||||||
|
|
||||||
# Soft clamp
|
|
||||||
return max(0.05, min(raw, 1.0))
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
# Summary
|
# Data structures
|
||||||
# ---------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
def consistency_summary(tier, pct):
|
@dataclass
|
||||||
if tier == "S":
|
class ConsistencyComponents:
|
||||||
return f"Ultra-stable performer — top {100 - pct:.0f}% in match-to-match consistency."
|
floor: float
|
||||||
if tier == "A":
|
stability: float
|
||||||
return "Very consistent across matches."
|
average_performance: float
|
||||||
if tier == "B":
|
|
||||||
return "Above-average consistency."
|
|
||||||
if tier == "C":
|
|
||||||
return "Inconsistent performance."
|
|
||||||
return "Highly volatile match-to-match output."
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------
|
@dataclass
|
||||||
# Public API
|
class ConsistencyResult:
|
||||||
# ---------------------------------------------------------
|
raw_score: float
|
||||||
|
normalized_score: float
|
||||||
def compute_consistency(matches):
|
percentile: float | None
|
||||||
raw = compute_consistency_raw(matches)
|
tier: str
|
||||||
return raw, {"consistency_norm": raw}
|
components: ConsistencyComponents
|
||||||
|
summary_short: str
|
||||||
|
summary_long: str
|
||||||
|
storyline_hook: str
|
||||||
|
identity_signal: str
|
||||||
|
extras: dict
|
||||||
|
|
||||||
|
|
||||||
def compute_consistency_tag(components, consistency_distribution):
|
# ------------------------------------------------------------
|
||||||
raw = components.get("consistency_norm", 0.0)
|
# Core formula (your original formula)
|
||||||
return build_tag_output(raw, consistency_distribution, consistency_summary)
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
|
def compute_consistency_score(components: ConsistencyComponents) -> float:
|
||||||
|
return (
|
||||||
|
components.floor * 0.40 +
|
||||||
|
components.stability * 0.40 +
|
||||||
|
components.average_performance * 0.20
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Main builder
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
|
def build_consistency_tag(
|
||||||
|
*,
|
||||||
|
floor_current,
|
||||||
|
floor_career,
|
||||||
|
floor_league,
|
||||||
|
stability_current,
|
||||||
|
stability_career,
|
||||||
|
stability_league,
|
||||||
|
average_current,
|
||||||
|
average_career,
|
||||||
|
average_league,
|
||||||
|
matches_played_current,
|
||||||
|
percentile=None
|
||||||
|
) -> ConsistencyResult:
|
||||||
|
|
||||||
|
# --- FALLBACK ---
|
||||||
|
floor_raw = get_stat_with_fallback(floor_current, floor_career, floor_league)
|
||||||
|
stability_raw = get_stat_with_fallback(stability_current, stability_career, stability_league)
|
||||||
|
average_raw = get_stat_with_fallback(average_current, average_career, average_league)
|
||||||
|
|
||||||
|
# --- EARLY SEASON BLENDING ---
|
||||||
|
floor_value = blend_early_season(floor_raw, floor_career or floor_raw, matches_played_current)
|
||||||
|
stability_value = blend_early_season(stability_raw, stability_career or stability_raw, matches_played_current)
|
||||||
|
average_value = blend_early_season(average_raw, average_career or average_raw, matches_played_current)
|
||||||
|
|
||||||
|
# --- COMPONENTS ---
|
||||||
|
components = ConsistencyComponents(
|
||||||
|
floor=floor_value,
|
||||||
|
stability=stability_value,
|
||||||
|
average_performance=average_value
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- SCORE ---
|
||||||
|
raw_score = compute_consistency_score(components)
|
||||||
|
normalized_score = max(0.0, min(1.0, raw_score))
|
||||||
|
|
||||||
|
# --- TIER + SUMMARIES ---
|
||||||
|
tier = map_score_to_tier(normalized_score)
|
||||||
|
summary_short, summary_long, storyline, identity = build_consistency_summaries(
|
||||||
|
normalized_score, components, tier
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- EXTRAS (Spotlight) ---
|
||||||
|
extras = {
|
||||||
|
"floor_value": floor_value,
|
||||||
|
"stability_value": stability_value,
|
||||||
|
"average_value": average_value,
|
||||||
|
"matches_played_current": matches_played_current,
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConsistencyResult(
|
||||||
|
raw_score=raw_score,
|
||||||
|
normalized_score=normalized_score,
|
||||||
|
percentile=percentile,
|
||||||
|
tier=tier,
|
||||||
|
components=components,
|
||||||
|
summary_short=summary_short,
|
||||||
|
summary_long=summary_long,
|
||||||
|
storyline_hook=storyline,
|
||||||
|
identity_signal=identity,
|
||||||
|
extras=extras
|
||||||
|
)
|
||||||
31
tests/test_career_db_consistency.py
Normal file
31
tests/test_career_db_consistency.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import json
|
||||||
|
from data.career_db import build_career_database
|
||||||
|
|
||||||
|
def test_career_db_consistency_output(tmp_path):
|
||||||
|
out = tmp_path / "career_stats.json"
|
||||||
|
ok = build_career_database(str(out))
|
||||||
|
assert ok is True
|
||||||
|
|
||||||
|
data = json.loads(out.read_text())
|
||||||
|
players = data["players"]
|
||||||
|
|
||||||
|
# Ensure at least one player has consistency data
|
||||||
|
assert len(players) > 0
|
||||||
|
|
||||||
|
sample = next(iter(players.values()))
|
||||||
|
cons = sample.get("consistency", None)
|
||||||
|
assert cons is not None
|
||||||
|
|
||||||
|
# Check required fields
|
||||||
|
assert "raw" in cons
|
||||||
|
assert "pct" in cons
|
||||||
|
assert "tier" in cons
|
||||||
|
assert "summary" in cons
|
||||||
|
assert "components" in cons
|
||||||
|
assert "extras" in cons
|
||||||
|
|
||||||
|
# Check component structure
|
||||||
|
comps = cons["components"]
|
||||||
|
assert "floor" in comps
|
||||||
|
assert "stability" in comps
|
||||||
|
assert "average" in comps
|
||||||
45
tests/test_consistency_math.py
Normal file
45
tests/test_consistency_math.py
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import math
|
||||||
|
from tags.consistency import compute_consistency_score, build_consistency_tag
|
||||||
|
|
||||||
|
def test_consistency_score_basic():
|
||||||
|
# Simple synthetic match performance scores
|
||||||
|
perfs = [0.60, 0.65, 0.70, 0.75]
|
||||||
|
|
||||||
|
floor = min(perfs)
|
||||||
|
avg = sum(perfs) / len(perfs)
|
||||||
|
variance = sum((x - avg) ** 2 for x in perfs) / len(perfs)
|
||||||
|
adjusted_variance = variance * (1 + 1 / len(perfs))
|
||||||
|
stability = 1 / (1 + adjusted_variance)
|
||||||
|
|
||||||
|
score = compute_consistency_score(
|
||||||
|
floor=floor,
|
||||||
|
stability=stability,
|
||||||
|
average=avg
|
||||||
|
)
|
||||||
|
|
||||||
|
assert 0.0 <= score <= 1.0
|
||||||
|
assert score > 0.60 # should be above floor
|
||||||
|
assert score < 0.80 # should be below average
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_consistency_tag_structure():
|
||||||
|
tag = build_consistency_tag(
|
||||||
|
floor_current=0.60,
|
||||||
|
floor_career=0.60,
|
||||||
|
floor_league=0.50,
|
||||||
|
stability_current=0.80,
|
||||||
|
stability_career=0.80,
|
||||||
|
stability_league=0.70,
|
||||||
|
average_current=0.70,
|
||||||
|
average_career=0.70,
|
||||||
|
average_league=0.60,
|
||||||
|
matches_played_current=10,
|
||||||
|
percentile=None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert hasattr(tag, "normalized_score")
|
||||||
|
assert hasattr(tag, "percentile")
|
||||||
|
assert hasattr(tag, "tier")
|
||||||
|
assert hasattr(tag, "summary_short")
|
||||||
|
assert hasattr(tag, "components")
|
||||||
|
assert hasattr(tag, "extras")
|
||||||
17
tests/test_generate_player_tags.py
Normal file
17
tests/test_generate_player_tags.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
from dashleague_cast_tool import DashLeagueGUI
|
||||||
|
|
||||||
|
def test_generate_player_tags_consistency():
|
||||||
|
gui = DashLeagueGUI.__new__(DashLeagueGUI) # bypass Tk init
|
||||||
|
|
||||||
|
player = {
|
||||||
|
"slayer": {"strength": "S+"},
|
||||||
|
"consistency": {"raw": 0.82},
|
||||||
|
"objective_payload_components": {"payload_score_raw": 0.55},
|
||||||
|
"objective_domination_components": {"raw": 0.44},
|
||||||
|
"sharpshooter": {"score": 0.66},
|
||||||
|
}
|
||||||
|
|
||||||
|
tags = gui.generate_player_tags(player, map_type="Payload")
|
||||||
|
assert "Cons 0.82" in tags
|
||||||
|
assert "ObjPL 0.55" in tags
|
||||||
|
assert "Sharp 0.66" in tags
|
||||||
28
utils/fallback.py
Normal file
28
utils/fallback.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# ============================================================
|
||||||
|
# FALLBACK + EARLY SEASON WEIGHTING
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Provides universal fallback logic for all tags:
|
||||||
|
# current → career → league average → neutral baseline
|
||||||
|
# Also provides early-season blending to stabilize noisy stats.
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def get_stat_with_fallback(current, career, league_avg, neutral_baseline=0.5):
|
||||||
|
"""Universal fallback logic used by all tags."""
|
||||||
|
if current is not None:
|
||||||
|
return current
|
||||||
|
if career is not None:
|
||||||
|
return career
|
||||||
|
if league_avg is not None:
|
||||||
|
return league_avg
|
||||||
|
return neutral_baseline
|
||||||
|
|
||||||
|
|
||||||
|
def blend_early_season(current_value, career_value, matches_played_current, ramp_matches=5):
|
||||||
|
"""Blend current season and career stats early in the season."""
|
||||||
|
if matches_played_current <= 0:
|
||||||
|
return career_value
|
||||||
|
|
||||||
|
weight_current = min(matches_played_current / ramp_matches, 1.0)
|
||||||
|
weight_career = 1.0 - weight_current
|
||||||
|
|
||||||
|
return (current_value * weight_current) + (career_value * weight_career)
|
||||||
34
utils/summaries.py
Normal file
34
utils/summaries.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# ============================================================
|
||||||
|
# SUMMARY GENERATION
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Builds caster-friendly summaries for tags.
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def build_consistency_summaries(score, components, tier):
|
||||||
|
summary_short = (
|
||||||
|
f"Consistency: {score:.2f} ({tier} tier). "
|
||||||
|
f"Floor {components.floor:.2f}, stability {components.stability:.2f}."
|
||||||
|
)
|
||||||
|
|
||||||
|
summary_long = (
|
||||||
|
f"This player shows a {tier}-tier level of consistency ({score:.2f}). "
|
||||||
|
f"Their floor ({components.floor:.2f}) shows how rarely they drop off, "
|
||||||
|
f"while stability ({components.stability:.2f}) reflects match-to-match variance. "
|
||||||
|
f"Their average performance ({components.average_performance:.2f}) "
|
||||||
|
f"sets expectations for today's matchup."
|
||||||
|
)
|
||||||
|
|
||||||
|
if tier in ("S", "A"):
|
||||||
|
storyline = "A rock for their team — expect dependable output every map."
|
||||||
|
identity = "Anchor"
|
||||||
|
elif tier == "B":
|
||||||
|
storyline = "Generally reliable with occasional swings."
|
||||||
|
identity = "Steady"
|
||||||
|
elif tier == "C":
|
||||||
|
storyline = "Inconsistent — performance varies significantly."
|
||||||
|
identity = "Volatile"
|
||||||
|
else:
|
||||||
|
storyline = "Highly unpredictable — a true wildcard."
|
||||||
|
identity = "Wildcard"
|
||||||
|
|
||||||
|
return summary_short, summary_long, storyline, identity
|
||||||
16
utils/tiers.py
Normal file
16
utils/tiers.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# ============================================================
|
||||||
|
# TIER MAPPING
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Converts normalized tag scores (0–1) into caster-friendly tiers.
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def map_score_to_tier(score: float) -> str:
|
||||||
|
if score >= 0.90:
|
||||||
|
return "S"
|
||||||
|
if score >= 0.75:
|
||||||
|
return "A"
|
||||||
|
if score >= 0.60:
|
||||||
|
return "B"
|
||||||
|
if score >= 0.45:
|
||||||
|
return "C"
|
||||||
|
return "D"
|
||||||
Loading…
Reference in a new issue