From 3dc737d819b9a82e3b04a044b90e04241bc681a5 Mon Sep 17 00:00:00 2001 From: FireHorse Date: Tue, 14 Apr 2026 17:32:50 +1000 Subject: [PATCH] Refactor: integrated hybrid Consistency tag system, updated DB builder, GUI, rankings, predictions, storyline, identity, and added test suite --- analysis/predictions_v2.py | 19 +++ analysis/storylines.py | 2 +- analysis/team_identity_v2.py | 2 +- dashleague_cast_tool.py | 114 +++++++++-------- data/career_db.py | 110 +++++++++++++++- data/league_metrics.py | 6 - data/tag_framework.py | 65 +++++++++- match_engine.py | 18 +++ tags/consistency.py | 189 +++++++++++++++++----------- tests/test_career_db_consistency.py | 31 +++++ tests/test_consistency_math.py | 45 +++++++ tests/test_generate_player_tags.py | 17 +++ utils/fallback.py | 28 +++++ utils/summaries.py | 34 +++++ utils/tiers.py | 16 +++ 15 files changed, 548 insertions(+), 148 deletions(-) create mode 100644 tests/test_career_db_consistency.py create mode 100644 tests/test_consistency_math.py create mode 100644 tests/test_generate_player_tags.py create mode 100644 utils/fallback.py create mode 100644 utils/summaries.py create mode 100644 utils/tiers.py diff --git a/analysis/predictions_v2.py b/analysis/predictions_v2.py index 745bac8..5148880 100644 --- a/analysis/predictions_v2.py +++ b/analysis/predictions_v2.py @@ -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" {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(f" {favorite}") diff --git a/analysis/storylines.py b/analysis/storylines.py index 12e0a46..be61e5d 100644 --- a/analysis/storylines.py +++ b/analysis/storylines.py @@ -48,7 +48,7 @@ def summarize_team_tags(team_players): continue 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)), "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 d555470..768e391 100644 --- a/analysis/team_identity_v2.py +++ b/analysis/team_identity_v2.py @@ -32,7 +32,7 @@ def summarize_team_tags(team_players): continue 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)), "count_S": tiers.count("S"), "count_A": tiers.count("A"), diff --git a/dashleague_cast_tool.py b/dashleague_cast_tool.py index 919a638..8490652 100644 --- a/dashleague_cast_tool.py +++ b/dashleague_cast_tool.py @@ -32,6 +32,7 @@ from match_engine import ( rank_match_players_clutch, split_two_columns, 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")) - def normalize_player(p): + def normalize_player(self, p): return { "id": p.get("PlayerUUID"), "name": p.get("PlayerGameName"), @@ -490,27 +491,40 @@ class DashLeagueGUI: obj_pl = player.get("objective_payload_score") 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} - Slayer Tier: {slayer_strength} - Payload Objective Specialist: {obj_pl if obj_pl is not None else "N/A"} +Slayer Tier: {slayer_strength} +Consistency: {cons_line} +Payload Objective Specialist: {obj_pl if obj_pl is not None else "N/A"} - Career Stats: - KD: {career['KD']:.2f} - Accuracy: {career['accuracy']:.2f} - Kills: {career['kills']} - Deaths: {career['deaths']} - Maps Played: {career['maps']} +Career Stats: +KD: {career['KD']:.2f} +Accuracy: {career['accuracy']:.2f} +Kills: {career['kills']} +Deaths: {career['deaths']} +Maps Played: {career['maps']} - Derived Metrics: - Kills/Map: {derived.get('kills_per_map', 0):.2f} - Deaths/Map: {derived.get('deaths_per_map', 0):.2f} - PushTime/Season: {derived.get('push_time_per_season', 0):.2f} - """ +Derived Metrics: +Kills/Map: {derived.get('kills_per_map', 0):.2f} +Deaths/Map: {derived.get('deaths_per_map', 0):.2f} +PushTime/Season: {derived.get('push_time_per_season', 0):.2f} + +Consistency Summary: +{cons_summary} +""" output.delete("1.0", tk.END) output.insert(tk.END, text) - def export(): content = output.get("1.0", tk.END).strip() if not content: @@ -560,34 +574,48 @@ class DashLeagueGUI: def generate_player_tags(self, player_entry, map_type): tags_out = [] + # --------------------------------------------------------- # Slayer (career) + # --------------------------------------------------------- slayer = player_entry.get("slayer", {}) slayer_strength = slayer.get("strength") or player_entry.get("slayer_strength") if isinstance(slayer_strength, str): tags_out.append(slayer_strength) + # --------------------------------------------------------- + # Consistency (career - hybrid system) + # --------------------------------------------------------- + consistency = player_entry.get("consistency", {}) + if isinstance(consistency, dict): + score = consistency.get("raw") + if isinstance(score, (int, float)): + tags_out.append(f"Cons {score:.2f}") + + # --------------------------------------------------------- # Payload Objective Specialist (match-based) + # --------------------------------------------------------- if map_type == "Payload": pl_info = player_entry.get("objective_payload_components", {}) pl_score = pl_info.get("payload_score_raw", 0.0) tags_out.append(f"ObjPL {pl_score:.2f}") + # --------------------------------------------------------- # Domination Objective Specialist (match-based) + # --------------------------------------------------------- if map_type == "Domination": dom_info = player_entry.get("objective_domination_components", {}) dom_score = dom_info.get("raw", 0.0) tags_out.append(f"ObjDOM {dom_score:.2f}") + # --------------------------------------------------------- # Sharpshooter (career) + # --------------------------------------------------------- sharp = player_entry.get("sharpshooter", {}).get("score") or player_entry.get("sharpshooter_score") if isinstance(sharp, (int, float)): tags_out.append(f"Sharp {sharp:.2f}") - print("DEBUG DOM:", player_entry.get("objective_domination_components")) - return ", ".join(tags_out) - def on_match_storyline(self): team_a = self.team_a_var.get() @@ -709,9 +737,13 @@ class DashLeagueGUI: 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) + ranked = rank_match_players_clutch(self.current_match_players) + + elif tag == "Consistency": + ranked = rank_match_players_consistency_career(self.current_match_players) + else: # Sharpshooter 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" 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 += "Where:\n" - text += "- Per-Match Performance = (Slayer × 0.40) + (Objective × 0.40) + (Accuracy × 0.20)\n" - text += "- Floor = the player's lowest per-match performance score\n" + text += "- Floor = lowest per-match performance score\n" text += "- Stability = 1 / (1 + Adjusted Variance)\n" text += "- Adjusted Variance = Variance × (1 + 1 / Match Count)\n" + text += "- Average Performance = mean per-match performance score\n" elif tag == "Clutch": text += "\nClutch Formula:\n" @@ -1025,41 +1057,7 @@ class DashLeagueGUI: output_folder = os.path.join(os.getcwd(), "obs_output") export_all_obs(output_folder, teamA_players, teamB_players, team_a, team_b) messagebox.showinfo("Exported", f"OBS files exported to:\n{output_folder}") - - - def 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(): root = tk.Tk() diff --git a/data/career_db.py b/data/career_db.py index bbdeaa7..769352e 100644 --- a/data/career_db.py +++ b/data/career_db.py @@ -7,7 +7,7 @@ from tags.slayer import compute_slayer_for_career_player from tags.objective_payload import compute_payload_tag from tags.objective_domination import compute_dom_objective_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 data.league_metrics import compute_league_metrics, compute_league_clutch_metrics @@ -146,14 +146,112 @@ def build_career_database(output_path): distributions["domination"] ) - # Consistency + # --------------------------------------------------------- + # Consistency (Hybrid Career Tag - New System) + # --------------------------------------------------------- + matches = pdata["matches"] - consistency_raw, components = compute_consistency(matches) - pdata["consistency"] = compute_consistency_tag( - components, - distributions["consistency"] + maps_played = pdata["career"].get("maps", 0) + + # --------------------------------------------------------- + # 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) pdata["clutch"] = compute_clutch( pdata, diff --git a/data/league_metrics.py b/data/league_metrics.py index 76a587c..040de9e 100644 --- a/data/league_metrics.py +++ b/data/league_metrics.py @@ -3,7 +3,6 @@ import math from tags.slayer import compute_slayer_raw from tags.sharpshooter import compute_sharpshooter_raw from tags.objective_payload import compute_payload_raw -from tags.consistency import compute_consistency_raw from tags.objective_domination import compute_dom_raw @@ -101,11 +100,6 @@ def compute_league_metrics(all_players): # Payload 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 dist["domination"].append(compute_dom_raw(p)) diff --git a/data/tag_framework.py b/data/tag_framework.py index f0062c0..580696d 100644 --- a/data/tag_framework.py +++ b/data/tag_framework.py @@ -55,4 +55,67 @@ def build_tag_output(raw_score, distribution, summary_fn): "pct": pct, "tier": tier, "summary": summary - } \ No newline at end of file + } + + +# ============================================================ +# 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 \ No newline at end of file diff --git a/match_engine.py b/match_engine.py index 333e646..c699d22 100644 --- a/match_engine.py +++ b/match_engine.py @@ -182,6 +182,24 @@ def rank_match_players_objdom(match_players): r["rank"] = i 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 diff --git a/tags/consistency.py b/tags/consistency.py index 7611c60..d071088 100644 --- a/tags/consistency.py +++ b/tags/consistency.py @@ -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 +# ============================================================ -# --------------------------------------------------------- -# Raw Consistency Score (Robust) -# --------------------------------------------------------- - -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)) +from dataclasses import dataclass +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 -# --------------------------------------------------------- -# Summary -# --------------------------------------------------------- +# ------------------------------------------------------------ +# Data structures +# ------------------------------------------------------------ -def consistency_summary(tier, pct): - if tier == "S": - return f"Ultra-stable performer — top {100 - pct:.0f}% in match-to-match consistency." - if tier == "A": - return "Very consistent across matches." - if tier == "B": - return "Above-average consistency." - if tier == "C": - return "Inconsistent performance." - return "Highly volatile match-to-match output." +@dataclass +class ConsistencyComponents: + floor: float + stability: float + average_performance: float -# --------------------------------------------------------- -# Public API -# --------------------------------------------------------- - -def compute_consistency(matches): - raw = compute_consistency_raw(matches) - return raw, {"consistency_norm": raw} +@dataclass +class ConsistencyResult: + raw_score: float + normalized_score: float + percentile: float | None + tier: str + 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) - return build_tag_output(raw, consistency_distribution, consistency_summary) \ No newline at end of file +# ------------------------------------------------------------ +# Core formula (your original formula) +# ------------------------------------------------------------ + +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 + ) \ No newline at end of file diff --git a/tests/test_career_db_consistency.py b/tests/test_career_db_consistency.py new file mode 100644 index 0000000..7d61849 --- /dev/null +++ b/tests/test_career_db_consistency.py @@ -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 \ No newline at end of file diff --git a/tests/test_consistency_math.py b/tests/test_consistency_math.py new file mode 100644 index 0000000..514af3a --- /dev/null +++ b/tests/test_consistency_math.py @@ -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") \ No newline at end of file diff --git a/tests/test_generate_player_tags.py b/tests/test_generate_player_tags.py new file mode 100644 index 0000000..4e4000c --- /dev/null +++ b/tests/test_generate_player_tags.py @@ -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 \ No newline at end of file diff --git a/utils/fallback.py b/utils/fallback.py new file mode 100644 index 0000000..b04578b --- /dev/null +++ b/utils/fallback.py @@ -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) \ No newline at end of file diff --git a/utils/summaries.py b/utils/summaries.py new file mode 100644 index 0000000..abb8285 --- /dev/null +++ b/utils/summaries.py @@ -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 \ No newline at end of file diff --git a/utils/tiers.py b/utils/tiers.py new file mode 100644 index 0000000..604cee6 --- /dev/null +++ b/utils/tiers.py @@ -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" \ No newline at end of file