# match_engine.py (Modernized & GUI-Compatible) import os import json import math BASE_DIR = os.path.dirname(os.path.abspath(__file__)) CAREER_DB_PATH = os.path.join(BASE_DIR, "final_db.json") CAREER_STATS_PATH = os.path.join(BASE_DIR, "career_stats.json") def load_career_stats_db(): if not os.path.exists(CAREER_STATS_PATH): return None with open(CAREER_STATS_PATH, "r", encoding="utf-8") as f: return json.load(f) # --------------------------------------------------------- # Load DB # --------------------------------------------------------- def load_career_db(): if not os.path.exists(CAREER_DB_PATH): return None with open(CAREER_DB_PATH, "r", encoding="utf-8") as f: return json.load(f) def _get_player_entry(db, pid): pid = str(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) # --------------------------------------------------------- def rank_match_players_by_tag(match_players, tag_name): """ Rank match players using career tag percentiles. """ db = load_career_db() if db is None: return [] ranked = [] for p in match_players: pid = p.get("id") or p.get("PlayerUUID") or p.get("uuid") if not pid: continue entry = _get_player_entry(db, pid) 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) ranked.append({ "id": pid, "name": entry.get("name", "Unknown"), "team": p.get("team", "Unknown"), "pct": pct, "raw": raw, "tier": tag.get("tier", "D"), "summary": tag.get("summary", ""), }) ranked.sort(key=lambda x: x["pct"], reverse=True) for i, r in enumerate(ranked, 1): r["rank"] = i return ranked # --------------------------------------------------------- # Match-Based Consistency (using match stats only) # --------------------------------------------------------- def rank_match_players_consistency(match_players): ranked = [] for p in match_players: tag = p.get("consistency", {}) raw = tag.get("raw", 0.0) ranked.append({ "name": p.get("name", "Unknown"), "team": p.get("team", ""), "score_raw": raw, "score_display": f"{raw:.2f}", }) ranked.sort(key=lambda x: x["score_raw"], reverse=True) for i, r in enumerate(ranked, 1): r["rank"] = i return ranked # --------------------------------------------------------- # Match-Based Clutch (using match stats only) # --------------------------------------------------------- def rank_match_players_clutch(match_players): """ Uses the same attached-tag pattern as Slayer/Sharpshooter/Payload. The previous version of this function only ever returned name/team/ score_raw/score_display — it never included "pct" at all, so no amount of fixing the underlying data could have made a percentile show up here; the field simply wasn't being read/returned. """ return _add_score_fields(_rank_by_attached_tag(match_players, "clutch")) # --------------------------------------------------------- # Utility: Split into two columns by team # --------------------------------------------------------- def split_two_columns(ranked_players, team_a_name, team_b_name): left = [] right = [] for p in ranked_players: if p["team"] == team_a_name: left.append(p) elif p["team"] == team_b_name: right.append(p) return left, right def _rank_by_attached_tag(match_players, tag_name): """ Ranks using whatever's already attached to each player object (p[tag_name]) — the same career_stats.json-sourced data on_generate_slots already attached — instead of rank_match_players_by_tag()'s independent final_db.json lookup by raw UUID. That lookup was a completely separate, stale data source from career_stats.json, keyed differently, and any player missing from final_db.json was silently dropped from the ranking entirely. "has_data" marks whether this player actually HAS a real tag entry, vs an empty/missing one defaulting to 0. Without this distinction, a player with no career data (e.g. not yet present in the local match-history cache used to build career_stats.json) looks identical to a player who genuinely scored the worst possible result — which silently drags their team's average down in every prediction, exactly as if they were a liability rather than simply unmeasured. """ ranked = [] for p in match_players: tag = p.get(tag_name) or {} ranked.append({ "id": p.get("id"), "name": p.get("name", "Unknown"), "team": p.get("team", "Unknown"), "pct": tag.get("pct", 0), "raw": tag.get("raw", 0.0), "tier": tag.get("tier", "D"), "summary": tag.get("summary", ""), "has_data": tag.get("has_data", bool(tag)), }) ranked.sort(key=lambda x: x["pct"], reverse=True) for i, r in enumerate(ranked, 1): r["rank"] = i return ranked # --------------------------------------------------------- # Legacy-Compatible Wrappers (GUI expects these) # --------------------------------------------------------- def _add_score_fields(ranked): """Adds BOTH score_raw and score_display required by GUI.""" for r in ranked: r["score_raw"] = r["raw"] r["score_display"] = f"{r['raw']:.2f}" return ranked def rank_match_players_slayer(match_players): return _add_score_fields(_rank_by_attached_tag(match_players, "slayer")) def rank_match_players_sharpshooter(match_players): return _add_score_fields(_rank_by_attached_tag(match_players, "sharpshooter")) def rank_match_players_objpl(match_players): return _add_score_fields(_rank_by_attached_tag(match_players, "objective_payload")) def rank_match_players_objdom(match_players): ranked = [] for p in match_players: # CHANGE: Look for "objective_domination" instead of "_components" dom_data = p.get("objective_domination", {}) raw = dom_data.get("raw", 0.0) ranked.append({ "id": p.get("id"), "name": p.get("name"), "team": p.get("team"), "score_raw": raw, "score_display": f"{raw:.2f}", "has_data": bool(dom_data), }) ranked.sort(key=lambda x: x["score_raw"], reverse=True) for i, r in enumerate(ranked, 1): r["rank"] = i return ranked def rank_match_players_consistency_career(match_players): """ Ranks by each player's career-level Consistency tag, read directly from career_stats.json BY CANONICAL ID — not the old final_db.json, a separate stale file keyed by raw UUID that many current players (and anyone resolved/merged since it was last built) simply aren't in, silently scoring them 0. """ career_db = load_career_stats_db() players_db = career_db.get("players", {}) if career_db else {} ranked = [] for p in match_players: cid = p.get("canonical") pdata = players_db.get(cid, {}) if cid else {} tag = pdata.get("consistency", {}) raw = tag.get("raw", 0.0) ranked.append({ "id": p.get("id"), "name": p.get("name", "Unknown"), "team": p.get("team", "Unknown"), "score_raw": raw, "score_display": f"{raw:.2f}", "pct": tag.get("pct"), "tier": tag.get("tier"), "has_data": bool(tag), }) ranked.sort(key=lambda x: x["score_raw"], reverse=True) for i, r in enumerate(ranked, 1): r["rank"] = i return ranked # --------------------------------------------------------- # Legacy Slayer Prediction Wrapper # --------------------------------------------------------- def compute_slayer_prediction(team_a_players, team_b_players): """ Legacy wrapper for GUI compatibility. IMPORTANT: team_a_players/team_b_players here are the RANKED ROW dicts produced by rank_match_players_slayer() (id/name/team/pct/ raw/tier/...), not the original match player objects — "raw" is a TOP-LEVEL key on these rows, not nested under a "slayer" key. This previously read p.get("slayer", {}).get("raw", 0.0), which doesn't exist on these rows and always silently returned 0.0 for every player on both teams — making the win prediction always compute to a flat, meaningless 50/50 regardless of the real data, while the Relative Advantage shown alongside it (computed correctly elsewhere from the same rows' real score_raw) told a completely different, accurate story. Reading "raw" directly fixes that mismatch. """ A = [p.get("raw", 0.0) for p in team_a_players] B = [p.get("raw", 0.0) for p in team_b_players] teamA_avg = sum(A) / max(1, len(A)) teamB_avg = sum(B) / max(1, len(B)) if max(teamA_avg, teamB_avg) == 0: slayer_edge = 0 else: slayer_edge = (teamB_avg - teamA_avg) / max(teamA_avg, teamB_avg) k = 1.1 pB = 1 / (1 + math.exp(-k * slayer_edge)) pA = 1 - pB return { "teamA_avg": teamA_avg, "teamB_avg": teamB_avg, "teamA_win": round(pA * 100), "teamB_win": round(pB * 100), }