315 lines
No EOL
9.9 KiB
Python
315 lines
No EOL
9.9 KiB
Python
# 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),
|
|
}
|
|
|
|
|