Modernize tag system, match engine, OBS exports, and output folder architecture

This commit is contained in:
FireHorse 2026-04-09 20:11:39 +10:00
parent 5c055ebee6
commit c8f043155a
23 changed files with 326883 additions and 110282 deletions

30
.gitignore vendored
View file

@ -1,24 +1,12 @@
# Python # Runtime / generated data
../stats/
stats/
players/
obs_exports/
*.txt
# Python cache
__pycache__/ __pycache__/
*.pyc *.pyc
*.pyo
*.pyd
.env
.venv
venv/
env/
# VSCode / IDE *.db
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
# Our caches
stats/data/cache/
cache/
# Ignore OBS output folder
../stats/

Binary file not shown.

File diff suppressed because it is too large Load diff

View file

@ -45,5 +45,6 @@
"DRGN": "Dasher", "DRGN": "Dasher",
"STHX": "Sprinter", "STHX": "Sprinter",
"EMU": "Walker" "EMU": "Walker"
} },
"output_root": "C:\\Projects\\Casting\\stats"
} }

View file

@ -2,32 +2,62 @@ import os
import json import json
import math import math
from collections import defaultdict from collections import defaultdict
import tkinter as tk import tkinter as tk
from tkinter import ttk, messagebox from tkinter import ttk, messagebox, filedialog
import requests import requests
# --- Internal modules ---
from data.career_db import build_career_database from data.career_db import build_career_database
from rankings import top_players_by_tag from rankings import top_players_by_tag
from team_identity import generate_team_identity, compare_team_identity from team_identity import generate_team_identity, compare_team_identity
from storylines import matchup_storyline from storylines import matchup_storyline
from tags.objective_domination import compute_dom_objective
from tags.objective_domination import (
compute_dom_objective_for_career_player,
compute_dom_objective_for_match_player,
compute_dom_objective_team_scores,
)
from predictions.prediction_engine import generate_predictions from predictions.prediction_engine import generate_predictions
from match_engine import ( from match_engine import (
rank_match_players_sharpshooter,
rank_match_players_slayer, rank_match_players_slayer,
rank_match_players_objpl, rank_match_players_objpl,
rank_match_players_objdom, rank_match_players_objdom,
rank_match_players_sharpshooter,
rank_match_players_clutch, rank_match_players_clutch,
split_two_columns, split_two_columns,
compute_slayer_prediction, compute_slayer_prediction,
) )
# ---------------------------------------------------------
# PATHS + CONFIG
# ---------------------------------------------------------
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) BASE_DIR = os.path.dirname(os.path.abspath(__file__))
CONFIG_PATH = os.path.join(BASE_DIR, "config.json") CONFIG_PATH = os.path.join(BASE_DIR, "config.json")
PLAYERS_DIR = os.path.join(BASE_DIR, "players")
OBS_EXPORT_DIR = os.path.join(BASE_DIR, "obs_exports") # Load config (or default)
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
config = json.load(f)
else:
config = {}
# Default output folder (outside repo)
DEFAULT_OUTPUT_ROOT = os.path.abspath(os.path.join(BASE_DIR, "..", "stats"))
# Allow override from config.json
output_root = config.get("output_root", DEFAULT_OUTPUT_ROOT)
# Build runtime directories
STATS_DIR = output_root
PLAYERS_DIR = os.path.join(STATS_DIR, "players")
OBS_EXPORT_DIR = os.path.join(STATS_DIR, "obs_exports")
os.makedirs(PLAYERS_DIR, exist_ok=True)
os.makedirs(OBS_EXPORT_DIR, exist_ok=True) os.makedirs(OBS_EXPORT_DIR, exist_ok=True)
API_BASE = "https://dashleague.games/api/v1" API_BASE = "https://dashleague.games/api/v1"
@ -120,6 +150,13 @@ class DashLeagueGUI:
self.rankings_button = ttk.Button(top_frame, text="Rankings", command=self.on_rankings) self.rankings_button = ttk.Button(top_frame, text="Rankings", command=self.on_rankings)
self.rankings_button.grid(row=0, column=5, padx=(0, 10)) self.rankings_button.grid(row=0, column=5, padx=(0, 10))
self.output_folder_button = ttk.Button(
top_frame,
text="Select Output Folder",
command=self.choose_output_folder
)
self.output_folder_button.grid(row=0, column=6, padx=(0, 10))
self.map_var = tk.StringVar() self.map_var = tk.StringVar()
self.map_var.set("Payload") # default self.map_var.set("Payload") # default
@ -214,10 +251,31 @@ class DashLeagueGUI:
for p in team_players: for p in team_players:
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):
return {
"id": p.get("PlayerUUID"),
"name": p.get("PlayerGameName"),
"team": p.get("TeamUUID"),
"KD": p.get("KD", ""),
"kills": p.get("Kills", ""),
"deaths": p.get("Deaths", ""),
"headshots": p.get("Headshots", ""),
"accuracy": p.get("Accuracy", ""),
"PAY_PushTime": p.get("PAY_PushTime", ""),
"DOM_captures": p.get("DOM_Captures", ""),
"DOM_counters": p.get("DOM_Counters", ""),
}
def on_generate_slots(self): def on_generate_slots(self):
try: try:
ensure_player_slots() ensure_player_slots()
# -----------------------------
# 1. Collect selected players
# -----------------------------
selected_a = [self.team_a_players[i] for i in self.team_a_listbox.curselection()] selected_a = [self.team_a_players[i] for i in self.team_a_listbox.curselection()]
selected_b = [self.team_b_players[i] for i in self.team_b_listbox.curselection()] selected_b = [self.team_b_players[i] for i in self.team_b_listbox.curselection()]
@ -226,26 +284,56 @@ class DashLeagueGUI:
if not selected_b: if not selected_b:
selected_b = self.team_b_players selected_b = self.team_b_players
all_players = sorted(selected_a, key=lambda x: x.get("name", "")) + \ # Sort and combine
all_players = (
sorted(selected_a, key=lambda x: x.get("name", "")) +
sorted(selected_b, key=lambda x: x.get("name", "")) sorted(selected_b, key=lambda x: x.get("name", ""))
)
# Limit to 10
all_players = all_players[:10] all_players = all_players[:10]
# -----------------------------
# 2. Normalize keys BEFORE attaching tags
# -----------------------------
for p in all_players:
# Normalize stat keys to match DB/tag expectations
p["CP_Captures"] = p.get("CP_captures", 0)
p["DOM_Captures"] = p.get("DOM_captures", 0)
p["DOM_Counters"] = p.get("DOM_counters", 0)
# Save match players
self.current_match_players = all_players self.current_match_players = all_players
# Debug
if self.current_match_players:
print("DEBUG MATCH PLAYER:", self.current_match_players[0])
else:
print("DEBUG MATCH PLAYER: EMPTY LIST")
# -----------------------------
# 3. Load career DB
# -----------------------------
career_path = os.path.join(BASE_DIR, "career_stats.json") career_path = os.path.join(BASE_DIR, "career_stats.json")
career_db = None career_db = None
if os.path.exists(career_path): if os.path.exists(career_path):
with open(career_path, "r", encoding="utf-8") as f: with open(career_path, "r", encoding="utf-8") as f:
career_db = json.load(f) career_db = json.load(f)
# Attach tag components from career DB to match players # -----------------------------
# 4. Attach tag components
# -----------------------------
if career_db is not None: if career_db is not None:
for p in all_players: for p in all_players:
pid = p.get("id") pid = (
p.get("id")
or p.get("PlayerUUID")
or p.get("uuid")
)
if pid and pid in career_db["players"]: if pid and pid in career_db["players"]:
pdata = career_db["players"][pid] pdata = career_db["players"][pid]
# Attach all tag components
p["clutch_components"] = pdata.get("clutch_components", {}) p["clutch_components"] = pdata.get("clutch_components", {})
p["consistency_components"] = pdata.get("consistency_components", {}) p["consistency_components"] = pdata.get("consistency_components", {})
p["objective_payload_components"] = pdata.get("objective_payload_components", {}) p["objective_payload_components"] = pdata.get("objective_payload_components", {})
@ -254,6 +342,70 @@ class DashLeagueGUI:
p["slayer_score_raw"] = pdata.get("slayer_score_raw", 0.0) p["slayer_score_raw"] = pdata.get("slayer_score_raw", 0.0)
# -----------------------------
# 4b. Compute match-based ObjDOM
# -----------------------------
from tags.objective_domination import compute_dom_objective_team_scores
team_entries = []
for p in all_players:
pid = (
p.get("id")
or p.get("PlayerUUID")
or p.get("uuid")
)
if not pid:
continue
entry = career_db["players"].get(pid, {})
team_entries.append((pid, entry, p))
# Compute match ObjDOM scores
dom_scores = compute_dom_objective_team_scores(team_entries)
# Attach to each player
for pid, entry, mp in team_entries:
score = dom_scores.get(pid, 0.0)
mp["objective_domination_components"] = {
"dom_score_raw": score,
"dom_score_display": f"{score:.2f}",
}
# -----------------------------
# 4c. Compute match-based ObjPL
# -----------------------------
from tags.objective_payload import compute_payload_objective_team_scores
pl_team_entries = []
for p in all_players:
pid = (
p.get("id")
or p.get("PlayerUUID")
or p.get("uuid")
)
if not pid:
continue
entry = career_db["players"].get(pid, {}) if career_db else {}
pl_team_entries.append((pid, entry, p))
if pl_team_entries:
pl_scores = compute_payload_objective_team_scores(pl_team_entries)
for pid, entry, mp in pl_team_entries:
score = pl_scores.get(pid, 0.0)
mp["objective_payload_components"] = {
"payload_score_raw": score,
"payload_score_display": f"{score:.2f}",
}
# -----------------------------
# 5. Write OBS slot files
# -----------------------------
for idx, p in enumerate(all_players): for idx, p in enumerate(all_players):
slot_dir = os.path.join(PLAYERS_DIR, f"p{idx}") slot_dir = os.path.join(PLAYERS_DIR, f"p{idx}")
os.makedirs(slot_dir, exist_ok=True) os.makedirs(slot_dir, exist_ok=True)
@ -267,20 +419,17 @@ class DashLeagueGUI:
write_text(os.path.join(slot_dir, "Headshots.txt"), p.get("headshots", "")) write_text(os.path.join(slot_dir, "Headshots.txt"), p.get("headshots", ""))
write_text(os.path.join(slot_dir, "Accuracy.txt"), p.get("accuracy", "")) write_text(os.path.join(slot_dir, "Accuracy.txt"), p.get("accuracy", ""))
write_text(os.path.join(slot_dir, "PushTime.txt"), p.get("PAY_PushTime", "")) write_text(os.path.join(slot_dir, "PushTime.txt"), p.get("PAY_PushTime", ""))
write_text(os.path.join(slot_dir, "Captures.txt"), p.get("DOM_captures", "")) 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", "")) write_text(os.path.join(slot_dir, "Counters.txt"), p.get("DOM_Counters", ""))
# Tag exports now come from real tag engines via rankings/career_db # Tag exports (use enriched match player dict)
if career_db is not None:
pid = p.get("id")
if pid and pid in career_db["players"]:
player_entry = career_db["players"][pid]
map_type = self.map_var.get() map_type = self.map_var.get()
tags_text = self.generate_player_tags(player_entry, map_type) tags_text = self.generate_player_tags(p, map_type)
write_text(os.path.join(slot_dir, "Tags.txt"), tags_text) write_text(os.path.join(slot_dir, "Tags.txt"), tags_text)
self.set_status("Player slots generated.") self.set_status("Player slots generated.")
messagebox.showinfo("Success", "Player slots generated into p0–p9.") messagebox.showinfo("Success", "Player slots generated into p0–p9.")
except Exception as e: except Exception as e:
messagebox.showerror("Error", f"Failed to generate slots:\n{e}") messagebox.showerror("Error", f"Failed to generate slots:\n{e}")
self.set_status("Generate failed.") self.set_status("Generate failed.")
@ -344,7 +493,7 @@ class DashLeagueGUI:
text = f"""PLAYER SPOTLIGHT — {name} text = f"""PLAYER SPOTLIGHT — {name}
Slayer Tier: {slayer_strength} Slayer Tier: {slayer_strength}
Payload Objective Specialist: {obj_pl:.2f} (if not None) 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}
@ -474,16 +623,17 @@ PushTime/Season: {derived.get('push_time_per_season', 0):.2f}
if slayer_strength: if slayer_strength:
tags_out.append(slayer_strength) tags_out.append(slayer_strength)
# Payload Objective Specialist # Payload Objective Specialist (match-based)
if map_type == "Payload": if map_type == "Payload":
obj_pl = player_entry.get("objective_payload_score") pl_info = player_entry.get("objective_payload_components", {})
if obj_pl is not None: pl_score = pl_info.get("payload_score_raw", 0.0)
tags_out.append(f"ObjPL {obj_pl:.2f}") tags_out.append(f"ObjPL {pl_score:.2f}")
# Domination Specialist # Domination Objective Specialist (match-based)
if map_type == "Domination": if map_type == "Domination":
obj_dom = compute_dom_objective(player_entry) dom_info = player_entry.get("objective_domination_components", {})
tags_out.append(f"ObjDOM {obj_dom:.2f}") dom_score = dom_info.get("dom_score_raw", 0.0)
tags_out.append(f"ObjDOM {dom_score:.2f}")
# Sharpshooter # Sharpshooter
sharp = player_entry.get("sharpshooter_score") sharp = player_entry.get("sharpshooter_score")
@ -523,7 +673,7 @@ PushTime/Season: {derived.get('push_time_per_season', 0):.2f}
# --- Export to OBS --- # --- Export to OBS ---
try: try:
with open("obs_exports/storyline.txt", "w", encoding="utf-8") as f: with open(os.path.join(OBS_EXPORT_DIR, "storyline.txt"), "w", encoding="utf-8") as f:
f.write(full_story) f.write(full_story)
except Exception as e: except Exception as e:
messagebox.showerror("File Error", f"Could not write storyline export:\n{e}") messagebox.showerror("File Error", f"Could not write storyline export:\n{e}")
@ -786,7 +936,7 @@ PushTime/Season: {derived.get('push_time_per_season', 0):.2f}
full_story = storyline_text + "\n\nTEAM IDENTITY SUMMARY\n" + identity_text full_story = storyline_text + "\n\nTEAM IDENTITY SUMMARY\n" + identity_text
write_text(os.path.join("obs_exports", "storyline.txt"), full_story) write_text(os.path.join(OBS_EXPORT_DIR, "storyline.txt"), full_story)
except Exception as e: except Exception as e:
print("Storyline export failed:", e) print("Storyline export failed:", e)
@ -819,6 +969,35 @@ PushTime/Season: {derived.get('push_time_per_season', 0):.2f}
print(f"OBS files updated for {map_type}") print(f"OBS files updated for {map_type}")
from tkinter import filedialog, messagebox
def refresh_output_dirs(self):
global STATS_DIR, PLAYERS_DIR, OBS_EXPORT_DIR
STATS_DIR = self.output_root
PLAYERS_DIR = os.path.join(STATS_DIR, "players")
OBS_EXPORT_DIR = os.path.join(STATS_DIR, "obs_exports")
os.makedirs(PLAYERS_DIR, exist_ok=True)
os.makedirs(OBS_EXPORT_DIR, exist_ok=True)
def choose_output_folder(self):
folder = filedialog.askdirectory(title="Select output folder for OBS + players")
if not folder:
return
self.output_root = os.path.abspath(folder)
config["output_root"] = self.output_root
self.refresh_output_dirs()
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(config, f, indent=4)
messagebox.showinfo(
"Output Folder Set",
f"Output folder set to:\n{self.output_root}\n\n"
"Point OBS to this folder for players and exports."
)
def main(): def main():
root = tk.Tk() root = tk.Tk()
app = DashLeagueGUI(root) app = DashLeagueGUI(root)

View file

@ -1,221 +1,120 @@
import os import os
import json import json
from collections import defaultdict from collections import defaultdict
import requests import data.db_access as db_access
CACHE_DIR = os.path.join(os.path.dirname(__file__), "cache")
SEASON_CACHE_DIR = os.path.join(CACHE_DIR, "seasons")
os.makedirs(SEASON_CACHE_DIR, exist_ok=True)
from tags.slayer import compute_slayer_for_career_player from tags.slayer import compute_slayer_for_career_player
from tags.objective_payload import compute_payload_objective_for_career_player from tags.objective_payload import compute_payload_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 from tags.consistency import compute_consistency
from tags.clutch import compute_clutch
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
API_BASE = "https://dashleague.games/api/v1"
def fetch_json(url):
resp = requests.get(url, timeout=10)
resp.raise_for_status()
return resp.json()
def detect_valid_seasons(max_season=50):
valid = []
for season in range(0, max_season + 1):
try:
url = f"{API_BASE}/stats?season={season}"
resp = requests.get(url, timeout=10)
if resp.status_code != 200:
continue
data = resp.json()
season_stats = data.get("data", [])
if isinstance(season_stats, list) and len(season_stats) > 0:
valid.append(season)
except Exception:
continue
return valid
def fetch_season_stats(season):
url = f"{API_BASE}/stats?season={season}"
data = fetch_json(url)
return data.get("data", [])
def build_career_database(output_path): def build_career_database(output_path):
seasons = detect_valid_seasons()
if not seasons: # ---------------------------------------------------------
print("No valid seasons found. Aborting career DB build.") # 1. Load all players from the local SQLite DB
# ---------------------------------------------------------
player_rows = db_access.query("SELECT PlayerUUID, PlayerGameName FROM players;")
if not player_rows:
print("No players found in local DB.")
return False return False
players = {} final_db = {"players": {}, "league_averages": {}}
league_accumulator = defaultdict(float)
league_counts = defaultdict(int)
for season in seasons: # ---------------------------------------------------------
season_stats = fetch_season_stats(season) # 2. Build per-player career stats from local DB
if not isinstance(season_stats, list): # ---------------------------------------------------------
for row in player_rows:
pid = row["PlayerUUID"]
name = row.get("PlayerGameName", "Unknown")
# Pull all matches from SQLite
matches = db_access.get_player_match_history(pid)
if not matches:
# Skip players with no match history
continue continue
for entry in season_stats: # Aggregate raw career totals
if not isinstance(entry, dict): career_raw = defaultdict(float)
continue
# Normalize inconsistent keys for m in matches:
if "loss" in entry and "losses" not in entry: career_raw["kills"] += m["Kills"]
entry["losses"] = entry["loss"] career_raw["deaths"] += m["Deaths"]
career_raw["damage"] += m["Damage"]
career_raw["shots"] += m["Shots"]
career_raw["shots_hit"] += m["ShotsHit"]
career_raw["headshots"] += m["Headshots"]
career_raw["PAY_PushTime"] += m["PAY_PushTime"]
career_raw["DOM_captures"] += m["DOM_Captures"]
career_raw["DOM_counters"] += m["DOM_Counters"]
career_raw["maps"] += 1 # each match = 1 map for now
if "shots" not in entry: # Derived stats
entry["shots"] = 0 maps = max(1, career_raw["maps"])
KD = career_raw["kills"] / career_raw["deaths"] if career_raw["deaths"] > 0 else career_raw["kills"]
if "shots_hit" not in entry: accuracy = (career_raw["shots_hit"] / career_raw["shots"]) if career_raw["shots"] > 0 else 0.0
entry["shots_hit"] = 0
if "headshots" not in entry:
entry["headshots"] = 0
if "damage" not in entry:
entry["damage"] = 0
if "maps" not in entry or entry["maps"] in (None, "Coming Soon!"):
entry["maps"] = 0
if "accuracy" not in entry or isinstance(entry["accuracy"], str):
entry["accuracy"] = 0.0
pid = entry.get("id")
if not pid:
continue
if pid not in players:
players[pid] = {
"name": entry.get("name", "Unknown"),
"team_history": set(),
"seasons_played": set(),
"per_season": {},
"career_raw": defaultdict(float)
}
p = players[pid]
team = entry.get("team") or "No Team"
p["team_history"].add(team)
p["seasons_played"].add(season)
p["per_season"][str(season)] = entry
for key in [
"kills",
"deaths",
"headshots",
"accuracy",
"PAY_PushTime",
"DOM_captures",
"DOM_counters",
"maps",
"wins",
"losses",
"damage",
"shots",
"shots_hit",
]:
if key in entry:
p["career_raw"][key] += entry.get(key, 0)
if "KD" in entry:
league_accumulator["KD"] += entry["KD"]
league_counts["KD"] += 1
if "accuracy" in entry:
league_accumulator["accuracy"] += entry["accuracy"]
league_counts["accuracy"] += 1
if "kills" in entry and "maps" in entry and entry["maps"] > 0:
league_accumulator["kills_per_map"] += entry["kills"] / entry["maps"]
league_counts["kills_per_map"] += 1
if "damage" in entry and "maps" in entry and entry["maps"] > 0:
league_accumulator["damage_per_map"] += entry["damage"] / entry["maps"]
league_counts["damage_per_map"] += 1
if "PAY_PushTime" in entry:
league_accumulator["push_time_per_season"] += entry["PAY_PushTime"]
league_counts["push_time_per_season"] += 1
league_averages = {}
for key in league_accumulator:
league_averages[key] = league_accumulator[key] / max(1, league_counts[key])
final_db = {"players": {}, "league_averages": league_averages}
for pid, pdata in players.items():
# 🔥 Skip players with no valid stats
if "career_raw" not in pdata or not pdata["career_raw"]:
continue
# print("DEBUG PLAYER:", pid, pdata["career_raw"])
raw = pdata["career_raw"]
# 🔥 Ensure maps always exists
if "maps" not in raw:
raw["maps"] = 0
maps = raw["maps"] if raw["maps"] > 0 else 1
seasons_played = len(pdata["seasons_played"])
kills_per_map = raw["kills"] / maps
deaths_per_map = raw["deaths"] / maps
push_time_per_season = raw["PAY_PushTime"] / max(1, seasons_played)
KD = raw["kills"] / raw["deaths"] if raw["deaths"] > 0 else raw["kills"]
accuracy = raw["accuracy"] / max(1, seasons_played)
derived = { derived = {
"kills_per_map": kills_per_map, "kills_per_map": career_raw["kills"] / maps,
"deaths_per_map": deaths_per_map, "deaths_per_map": career_raw["deaths"] / maps,
"push_time_per_season": push_time_per_season, "push_time_per_season": career_raw["PAY_PushTime"], # no seasons now
} }
final_db["players"][pid] = { final_db["players"][pid] = {
"name": pdata["name"], "name": name,
"team_history": list(pdata["team_history"]),
"seasons_played": list(pdata["seasons_played"]),
"per_season": pdata["per_season"],
"career": { "career": {
"kills": raw["kills"], "kills": career_raw["kills"],
"deaths": raw["deaths"], "deaths": career_raw["deaths"],
"KD": KD, "KD": KD,
"accuracy": accuracy, "accuracy": accuracy,
"push_time": raw["PAY_PushTime"], "push_time": career_raw["PAY_PushTime"],
"captures": raw["DOM_captures"], "captures": career_raw["DOM_captures"],
"counters": raw["DOM_counters"], "counters": career_raw["DOM_counters"],
"maps": raw["maps"], "maps": career_raw["maps"],
"wins": raw["wins"], "damage": career_raw["damage"],
"losses": raw["losses"], "shots": career_raw["shots"],
"damage": raw["damage"], "shots_hit": career_raw["shots_hit"],
"shots": raw.get("shots", 0), "headshots": career_raw["headshots"],
"shots_hit": raw.get("shots_hit", 0),
"headshots": raw.get("headshots", 0),
"damage_dealt": raw.get("damage", 0),
}, },
"derived": derived, "derived": derived,
"matches": matches,
} }
from data.player_history import get_player_match_history # ---------------------------------------------------------
# 3. Compute league averages (local-only)
# ---------------------------------------------------------
league_acc = defaultdict(float)
league_count = defaultdict(int)
final_db["players"][pid]["matches"] = get_player_match_history(pid)
# Compute all tag scores per player
for pid, pdata in final_db["players"].items(): for pid, pdata in final_db["players"].items():
c = pdata["career"]
# 🔥 Skip players missing a career block league_acc["KD"] += c["KD"]
if "career" not in pdata: league_count["KD"] += 1
print("SKIPPING PLAYER WITH NO CAREER:", pid)
continue league_acc["accuracy"] += c["accuracy"]
league_count["accuracy"] += 1
league_acc["kills_per_map"] += pdata["derived"]["kills_per_map"]
league_count["kills_per_map"] += 1
league_acc["damage"] += c["damage"]
league_count["damage"] += 1
league_averages = {
key: league_acc[key] / max(1, league_count[key])
for key in league_acc
}
final_db["league_averages"] = league_averages
# ---------------------------------------------------------
# 4. Compute all tags
# ---------------------------------------------------------
for pid, pdata in final_db["players"].items():
# Slayer # Slayer
slayer_info = compute_slayer_for_career_player(pdata, league_averages) slayer_info = compute_slayer_for_career_player(pdata, league_averages)
@ -224,34 +123,30 @@ def build_career_database(output_path):
pdata["slayer_strength"] = slayer_info["strength"] pdata["slayer_strength"] = slayer_info["strength"]
# Sharpshooter # Sharpshooter
sharp = compute_sharpshooter_for_career_player(pdata["career"]) pdata["sharpshooter"] = compute_sharpshooter_for_career_player(pdata["career"])
pdata["sharpshooter"] = sharp
# Payload Objective Specialist # Payload Objective Specialist
obj_components = compute_payload_objective_for_career_player(pdata, league_averages) pdata["objective_payload_components"] = compute_payload_objective_for_career_player(
pdata["objective_payload_components"] = obj_components pdata, league_averages
)
# --- Consistency Tag ---
from tags.consistency import compute_consistency
matches = pdata.get("matches", [])
# Consistency
matches = pdata["matches"]
consistency_raw, components = compute_consistency(matches) consistency_raw, components = compute_consistency(matches)
pdata["consistency_components"] = { pdata["consistency_components"] = {
**components, **components,
"consistency_raw": consistency_raw, "consistency_raw": consistency_raw,
"consistency_norm": components.get("consistency_norm", consistency_raw) "consistency_norm": components.get("consistency_norm", consistency_raw),
} }
# --- Clutch Tag --- # Clutch
from tags.clutch import compute_clutch
pdata["clutch_components"] = { pdata["clutch_components"] = {
"clutch_norm": compute_clutch(pdata, league_averages) "clutch_norm": compute_clutch(pdata, league_averages)
} }
# ---------------------------------------------------------
# 5. Save DB
# ---------------------------------------------------------
with open(output_path, "w", encoding="utf-8") as f: with open(output_path, "w", encoding="utf-8") as f:
json.dump(final_db, f, indent=2) json.dump(final_db, f, indent=2)

View file

@ -1,106 +0,0 @@
import os
import json
import requests
from data.season_cache import fetch_season_match_list, BASE as API_BASE
from data.match_cache import fetch_match_details
CACHE_DIR = os.path.join(os.path.dirname(__file__), "cache")
PLAYER_CACHE_DIR = os.path.join(CACHE_DIR, "players")
SEASON_CACHE_DIR = os.path.join(CACHE_DIR, "seasons")
os.makedirs(PLAYER_CACHE_DIR, exist_ok=True)
os.makedirs(SEASON_CACHE_DIR, exist_ok=True)
def _detect_cached_seasons():
out = []
for filename in os.listdir(SEASON_CACHE_DIR):
if filename.endswith("_matches.json"):
sid = filename.replace("_matches.json", "")
if sid.isdigit():
out.append(sid)
return out
def _fetch_all_seasons_from_api():
try:
resp = requests.get(f"{API_BASE}/seasons", timeout=10)
resp.raise_for_status()
return resp.json()
except Exception as e:
print("ERROR fetching seasons:", e)
return []
def get_player_match_history(player_id):
"""Build full match history for a player using cached data."""
cache_path = os.path.join(PLAYER_CACHE_DIR, f"{player_id}_matches.json")
# --- Use cached player history if available ---
if os.path.exists(cache_path):
try:
with open(cache_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
print("ERROR reading cached player history:", e)
history = []
# --- Load cached seasons ---
seasons = _detect_cached_seasons()
# --- If no cached seasons exist, fetch them ---
if not seasons:
print("No cached seasons found. Fetching from API...")
season_list = _fetch_all_seasons_from_api()
for s in season_list:
sid = s.get("id")
if sid:
print(f"Caching season {sid} match list...")
fetch_season_match_list(sid)
seasons = _detect_cached_seasons()
# --- Process each season ---
for season_id in seasons:
print(f"Processing season {season_id}...")
match_list = fetch_season_match_list(season_id)
for match_entry in match_list:
match_id = match_entry.get("id")
if not match_id:
continue
match_data = fetch_match_details(match_id)
if not match_data:
continue
mode = match_data.get("mode", "").lower()
for p in match_data.get("players", []):
if p.get("id") == player_id:
entry = {
"kills": p.get("kills", 0),
"deaths": p.get("deaths", 0),
"accuracy": p.get("accuracy", 0.0),
"damage": p.get("damage", 0),
"push_time": p.get("PAY_PushTime", 0),
"captures": p.get("DOM_captures", 0),
"counters": p.get("DOM_counters", 0),
"mode": mode
}
history.append(entry)
# --- Cache the built history ---
try:
with open(cache_path, "w", encoding="utf-8") as f:
json.dump(history, f, indent=2)
except Exception as e:
print("ERROR writing player history:", e)
return history

View file

@ -5,7 +5,10 @@ import json
import math import math
from tags.objective_payload import compute_payload_objective_team_scores from tags.objective_payload import compute_payload_objective_team_scores
from tags.objective_domination import compute_dom_objective from tags.objective_domination import (
compute_dom_objective_for_match_player,
compute_dom_objective_team_scores,
)
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) BASE_DIR = os.path.dirname(os.path.abspath(__file__))
CAREER_DB_PATH = os.path.join(BASE_DIR, "career_stats.json") CAREER_DB_PATH = os.path.join(BASE_DIR, "career_stats.json")
@ -44,7 +47,7 @@ def rank_match_players_slayer(match_players):
ranked.append({ ranked.append({
"id": pid, "id": pid,
"name": entry["name"], "name": entry["name"],
"team": entry["team_history"][-1] if entry["team_history"] else p.get("team", "Unknown"), "team": p.get("team", "Unknown"),
"score_raw": score, "score_raw": score,
"score_display": entry.get("slayer_score_display", f"{score:.2f}"), "score_display": entry.get("slayer_score_display", f"{score:.2f}"),
}) })
@ -54,52 +57,81 @@ def rank_match_players_slayer(match_players):
r["rank"] = i r["rank"] = i
return ranked return ranked
def rank_match_players_objdom(players):
def rank_match_players_objdom(match_players):
""" """
Rank players by Domination Objective Specialist score. Rank players by Domination Objective Specialist score (match-based).
players = list of player_entry dicts for the current match. Uses DOM_Captures and DOM_Counters from match player stats.
""" """
db = load_career_db()
if db is None:
return []
team_entries = []
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
team_entries.append((pid, entry, p))
if not team_entries:
return []
obj_scores = compute_dom_objective_team_scores(team_entries)
ranked = [] ranked = []
for p in players: for pid, entry, mp in team_entries:
score = compute_dom_objective(p) score = obj_scores.get(pid)
if score is None:
continue
ranked.append({ ranked.append({
"name": p["name"], "id": pid,
"team": p["team"], "name": entry["name"],
"team": mp.get("team", "Unknown"),
"score_raw": score, "score_raw": score,
"score_display": f"{score:.2f}", "score_display": f"{score:.2f}",
}) })
# Sort high → low
ranked.sort(key=lambda x: x["score_raw"], reverse=True) ranked.sort(key=lambda x: x["score_raw"], reverse=True)
# Assign global rank for i, r in enumerate(ranked, 1):
for i, entry in enumerate(ranked, start=1): r["rank"] = i
entry["rank"] = i
return ranked return ranked
def rank_match_players_objpl(match_players): def rank_match_players_objpl(match_players):
"""
match_players: list of dicts from stats API (current season),
each with at least: id, name, team
Returns: list of ranked players with ObjPL score
"""
db = load_career_db() db = load_career_db()
if db is None: if db is None:
return [] return []
# Build team entries for ObjPL engine
team_entries = [] team_entries = []
for p in match_players: for p in match_players:
pid = p.get("id") pid = (
p.get("id")
or p.get("PlayerUUID")
or p.get("uuid")
)
if not pid:
continue
entry = _get_player_entry(db, pid) entry = _get_player_entry(db, pid)
if not entry: if not entry:
continue continue
if "objective_payload_components" not in entry:
continue # ObjPL is match-based; we still attach career entry for name/team history
team_entries.append((pid, entry)) team_entries.append((pid, entry, p))
if not team_entries: if not team_entries:
return [] return []
@ -107,21 +139,24 @@ def rank_match_players_objpl(match_players):
obj_scores = compute_payload_objective_team_scores(team_entries) obj_scores = compute_payload_objective_team_scores(team_entries)
ranked = [] ranked = []
for pid, entry in team_entries: for pid, entry, mp in team_entries:
score = obj_scores.get(pid, 0.0) score = obj_scores.get(pid)
if score <= 0: if score is None:
continue continue
ranked.append({ ranked.append({
"id": pid, "id": pid,
"name": entry["name"], "name": entry["name"],
"team": entry["team_history"][-1] if entry["team_history"] else "Unknown", "team": mp.get("team", "Unknown"),
"score_raw": score, "score_raw": score,
"score_display": f"{score:.2f}", "score_display": f"{score:.2f}",
}) })
ranked.sort(key=lambda x: x["score_raw"], reverse=True) ranked.sort(key=lambda x: x["score_raw"], reverse=True)
for i, r in enumerate(ranked, 1): for i, r in enumerate(ranked, 1):
r["rank"] = i r["rank"] = i
return ranked return ranked
@ -137,7 +172,6 @@ def split_two_columns(ranked_players, team_a_name, team_b_name):
elif p["team"] == team_b_name: elif p["team"] == team_b_name:
right.append(p) right.append(p)
else: else:
# If team name mismatch, leave them out of columns
pass pass
return left, right return left, right
@ -169,6 +203,7 @@ def compute_slayer_prediction(team_a_players, team_b_players):
"teamB_win": round(pB * 100), "teamB_win": round(pB * 100),
} }
def rank_match_players_sharpshooter(match_players): def rank_match_players_sharpshooter(match_players):
db = load_career_db() db = load_career_db()
if db is None: if db is None:
@ -189,7 +224,7 @@ def rank_match_players_sharpshooter(match_players):
ranked.append({ ranked.append({
"id": pid, "id": pid,
"name": entry["name"], "name": entry["name"],
"team": entry["team_history"][-1] if entry["team_history"] else p.get("team", "Unknown"), "team": p.get("team", "Unknown"),
"score_raw": score, "score_raw": score,
"score_display": sharp.get("score_display", f"{score:.2f}"), "score_display": sharp.get("score_display", f"{score:.2f}"),
}) })
@ -200,6 +235,7 @@ def rank_match_players_sharpshooter(match_players):
return ranked return ranked
def rank_match_players_clutch(players): def rank_match_players_clutch(players):
ranked = [] ranked = []
for p in players: for p in players:
@ -217,4 +253,3 @@ def rank_match_players_clutch(players):
r["rank"] = i r["rank"] = i
return ranked return ranked

View file

@ -1,8 +1,8 @@
import math import math
from tags.objective_domination import compute_dom_objective from tags.objective_domination import compute_dom_objective_for_career_player
def logistic_win_chance(teamA_avg, teamB_avg, k=1.1): def logistic_win_chance(teamA_avg, teamB_avg, k=1.1):
"""Generic logistic win chance for non-Slayer tags.""" """Generic logistic win chance for all tags."""
if max(teamA_avg, teamB_avg) == 0: if max(teamA_avg, teamB_avg) == 0:
return 50, 50 return 50, 50
@ -14,19 +14,24 @@ def logistic_win_chance(teamA_avg, teamB_avg, k=1.1):
def compute_tag_averages(teamA_players, teamB_players): def compute_tag_averages(teamA_players, teamB_players):
"""Compute all tag averages for both teams.""" """Compute all tag averages for both teams using career identity scores."""
def avg_slayer(players): def avg_slayer(players):
return sum(p.get("slayer_score_raw", 0.0) for p in players) / max(1, len(players)) return sum(p.get("slayer_score_raw", 0.0) for p in players) / max(1, len(players))
def avg_objpl(players): def avg_objpl(players):
return sum( return sum(
p.get("objective_payload_components", {}).get("push_time_norm", 0.0) p.get("objective_payload_components", {}).get("payload_score_raw", 0.0)
for p in players for p in players
) / max(1, len(players)) ) / max(1, len(players))
def avg_objdom(players): def avg_objdom(players):
return sum(compute_dom_objective(p) for p in players) / max(1, len(players)) return sum(
compute_dom_objective_for_career_player(
p.get("career_entry", {})
).get("dom_score_raw", 0.0)
for p in players
) / max(1, len(players))
def avg_sharp(players): def avg_sharp(players):
return sum( return sum(
@ -57,7 +62,7 @@ def compute_tag_averages(teamA_players, teamB_players):
def generate_predictions(teamA_players, teamB_players): def generate_predictions(teamA_players, teamB_players):
"""Full Option D prediction engine.""" """Full Option D prediction engine (modernized)."""
team_a = teamA_players[0].get("team", "Team A") team_a = teamA_players[0].get("team", "Team A")
team_b = teamB_players[0].get("team", "Team B") team_b = teamB_players[0].get("team", "Team B")
@ -67,13 +72,7 @@ def generate_predictions(teamA_players, teamB_players):
# --- Per-tag win chances --- # --- Per-tag win chances ---
per_tag = {} per_tag = {}
for tag, (a_avg, b_avg) in avgs.items(): for tag, (a_avg, b_avg) in avgs.items():
if tag == "Slayer":
# Slayer uses its own prediction function
# Convert to simple logistic for consistency
per_tag[tag] = logistic_win_chance(a_avg, b_avg)
else:
per_tag[tag] = logistic_win_chance(a_avg, b_avg) per_tag[tag] = logistic_win_chance(a_avg, b_avg)
# --- Weighted overall prediction --- # --- Weighted overall prediction ---

View file

@ -41,24 +41,20 @@ def top_players_by_tag(tag_name):
return results return results
if tag_name == "Payload Objective Specialist": if tag_name == "Payload Objective Specialist":
# For rankings, we approximate team context by using league‑wide push components
# and treat all players as if they were on one "virtual team".
team_entries = [(pid, p) for pid, p in players.items()]
obj_scores = compute_payload_objective_team_scores(team_entries)
for pid, p in players.items(): for pid, p in players.items():
score = obj_scores.get(pid, 0.0) comp = p.get("objective_payload_components", {})
score = comp.get("payload_score_raw", 0.0)
if score <= 0: if score <= 0:
continue continue
results.append({ results.append({
"id": pid, "id": pid,
"name": p["name"], "name": p["name"],
"team": p["team_history"][-1] if p["team_history"] else "Unknown", "team": p["team_history"][-1] if p["team_history"] else "Unknown",
"score_raw": score, "score_raw": score,
"score_display": f"{score:.2f}", "score_display": comp.get("payload_score_display", f"{score:.2f}"),
}) })
results = [r for r in results if r["score_raw"] > 0]
results.sort(key=lambda x: x["score_raw"], reverse=True) results.sort(key=lambda x: x["score_raw"], reverse=True)
return results return results
@ -106,3 +102,67 @@ def rank_match_players_clutch(players):
r["rank"] = i r["rank"] = i
return ranked return ranked
def rank_match_players_objpl(players):
"""
Rank players by match-based Payload Objective Specialist score.
Uses match stats only (PAY_PushTime, damage, deaths).
"""
db = load_career_db()
if db is None:
return []
team_entries = []
for p in players:
pid = (
p.get("id")
or p.get("PlayerUUID")
or p.get("uuid")
)
if not pid:
continue
career_entry = db["players"].get(pid, {})
# Normalize match keys
push = p.get("PAY_PushTime", 0) or 0
dmg = p.get("damage", p.get("Damage", 0)) or 0
deaths = p.get("deaths", p.get("Deaths", 0)) or 0
# Only include players with actual payload participation
if push <= 0:
continue
# Attach normalized values back to match player
p["PAY_PushTime"] = push
p["damage"] = dmg
p["deaths"] = deaths
team_entries.append((pid, career_entry, p))
if not team_entries:
return []
# Compute match-based ObjPL
obj_scores = compute_payload_objective_team_scores(team_entries)
ranked = []
for pid, entry, mp in team_entries:
score = obj_scores.get(pid, 0.0)
ranked.append({
"id": pid,
"name": entry.get("name", mp.get("name", "Unknown")),
"team": mp.get("team", "Unknown"),
"score_raw": score,
"score_display": f"{score:.2f}",
})
ranked.sort(key=lambda x: x["score_raw"], reverse=True)
for i, r in enumerate(ranked, 1):
r["rank"] = i
return ranked

View file

@ -1,5 +1,9 @@
from rankings import load_career_db from rankings import load_career_db
from tags.objective_domination import compute_dom_objective from tags.objective_domination import (
compute_dom_objective_for_career_player,
compute_dom_objective_for_match_player,
compute_dom_objective_team_scores
)
def matchup_storyline(teamA_players, teamB_players): def matchup_storyline(teamA_players, teamB_players):
@ -17,7 +21,7 @@ def matchup_storyline(teamA_players, teamB_players):
def avg_obj_payload(players): def avg_obj_payload(players):
return sum( return sum(
p.get("objective_payload_components", {}).get("push_time_norm", 0.0) p.get("objective_payload_components", {}).get("payload_score_raw", 0.0)
for p in players for p in players
) / max(1, len(players)) ) / max(1, len(players))
@ -28,7 +32,12 @@ def matchup_storyline(teamA_players, teamB_players):
) / max(1, len(players)) ) / max(1, len(players))
def avg_obj_dom(players): def avg_obj_dom(players):
return sum(compute_dom_objective(p) for p in players) / max(1, len(players)) return sum(
compute_dom_objective_for_career_player(
p.get("career_entry", {})
).get("dom_score_raw", 0.0)
for p in players
) / max(1, len(players))
def avg_consistency(players): def avg_consistency(players):
return sum( return sum(

View file

@ -2,89 +2,61 @@ import math
def compute_consistency(matches): def compute_consistency(matches):
""" """
matches = list of dicts, each containing: Compute consistency based on per-match performance stability.
- kills Uses:
- deaths - KD per match
- accuracy - Damage per match
- damage - Accuracy per match
- push_time (payload)
- captures
- counters
- mode
""" """
# --- Not enough matches to compute variance --- if not matches:
if len(matches) < 2:
return 0.0, { return 0.0, {
"per_match_perf": [], "match_count": 0,
"floor": 0.0, "kd_std": 0.0,
"variance": 0.0, "dmg_std": 0.0,
"variance_adj": 0.0, "acc_std": 0.0,
"variance_score": 0.0, "consistency_norm": 0.0,
"avg_perf": 0.0,
"consistency_norm": 0.0
} }
per_match_perf_list = [] kds = []
dmgs = []
accs = []
for m in matches: for m in matches:
kills = m["Kills"]
deaths = m["Deaths"]
damage = m["Damage"]
shots = m["Shots"]
shots_hit = m["ShotsHit"]
# --- Slayer percentile (per match) --- kd = kills / max(1, deaths)
kd = m["kills"] / max(1, m["deaths"]) acc = shots_hit / shots if shots > 0 else 0.0
slayer_pct = percentile_of_kd(kd)
# --- Objective percentile (per match) --- kds.append(kd)
if m["mode"] == "payload": dmgs.append(damage)
obj_raw = m["push_time"] accs.append(acc)
elif m["mode"] == "domination":
obj_raw = m["captures"] + m["counters"]
else:
obj_raw = 0
objective_pct = percentile_of_objective(obj_raw) def std(values):
if len(values) <= 1:
return 0.0
mean = sum(values) / len(values)
var = sum((v - mean) ** 2 for v in values) / len(values)
return math.sqrt(var)
# --- Accuracy percentile (per match) --- kd_std = std(kds)
accuracy_pct = percentile_of_accuracy(m["accuracy"]) dmg_std = std(dmgs)
acc_std = std(accs)
# --- Composite per-match performance --- # Lower variance = more consistent
perf = ( # Normalize into a 0–1 score
slayer_pct * 0.40 + raw = 1.0 / (1.0 + kd_std + dmg_std + acc_std)
objective_pct * 0.40 +
accuracy_pct * 0.20
)
per_match_perf_list.append(perf)
# --- Floor ---
floor_score = percentile(min(per_match_perf_list))
# --- Variance (normalized by match count) ---
raw_variance = variance(per_match_perf_list)
adj_variance = raw_variance * (1 + (1 / len(matches)))
variance_score = 1 / (1 + adj_variance)
# --- Average performance ---
avg_perf = sum(per_match_perf_list) / len(per_match_perf_list)
# --- Final Consistency ---
consistency_raw = (
floor_score * 0.40 +
variance_score * 0.40 +
avg_perf * 0.20
)
# --- Normalized consistency (already 0–1) ---
consistency_norm = round(consistency_raw, 4)
# --- Component block returned to DB builder ---
components = { components = {
"per_match_perf": per_match_perf_list, "match_count": len(matches),
"floor": floor_score, "kd_std": kd_std,
"variance": raw_variance, "dmg_std": dmg_std,
"variance_adj": adj_variance, "acc_std": acc_std,
"variance_score": variance_score, "consistency_norm": raw,
"avg_perf": avg_perf,
"consistency_norm": consistency_norm
} }
return consistency_norm, components return raw, components

View file

@ -1,29 +1,140 @@
import math import math
def compute_dom_objective(player_entry): # ---------------------------------------------------------
# 1. CAREER DOMINATION OBJECTIVE SPECIALIST (ObjDOM)
# ---------------------------------------------------------
def compute_dom_objective_for_career_player(pdata, league_averages=None):
""" """
Domination Objective Specialist (ObjDOM) Compute a career Domination Objective score using ONLY Domination stats.
Uses only website stats: A "Domination map" is any match where DOM_Captures > 0 or DOM_Counters > 0.
- DOM_captures Payload and Control Point stats are ignored.
- DOM_counters
Log-scaled normalization:
CapRate = log(1 + captures) / log(1 + 20)
CounterRate = log(1 + counters) / log(1 + 20)
Formula (0–1 scale):
ObjDOM = 0.40 * CapRate + 0.60 * CounterRate
Counters are weighted more heavily because they prevent enemy scoring.
""" """
caps = player_entry.get("DOM_captures", 0) or 0 matches = pdata.get("matches", [])
counters = player_entry.get("DOM_counters", 0) or 0 if not matches:
return {
"dom_matches": 0,
"total_captures": 0,
"total_counters": 0,
"avg_captures": 0.0,
"avg_counters": 0.0,
"dom_score_raw": 0.0,
"dom_score_display": "0.00",
}
# Log-scaled normalization (smooths extremes, expands mid-range) dom_matches = 0
cap_rate = math.log1p(caps) / math.log1p(20) total_caps = 0
counter_rate = math.log1p(counters) / math.log1p(20) total_counters = 0
score = (0.40 * cap_rate) + (0.60 * counter_rate) for m in matches:
return round(score, 3) caps = m.get("DOM_Captures", 0) or 0
counters = m.get("DOM_Counters", 0) or 0
# Only count Domination maps
if caps > 0 or counters > 0:
dom_matches += 1
total_caps += caps
total_counters += counters
if dom_matches == 0:
return {
"dom_matches": 0,
"total_captures": 0,
"total_counters": 0,
"avg_captures": 0.0,
"avg_counters": 0.0,
"dom_score_raw": 0.0,
"dom_score_display": "0.00",
}
avg_caps = total_caps / dom_matches
avg_counters = total_counters / dom_matches
# Log-scaled normalization (smooth extremes, expand mid-range)
# Soft caps: ~20 caps / 20 counters across career
cap_rate = math.log1p(avg_caps) / math.log1p(20.0)
counter_rate = math.log1p(avg_counters) / math.log1p(20.0)
# Counters weighted more heavily (deny enemy scoring)
raw = (0.40 * cap_rate) + (0.60 * counter_rate)
return {
"dom_matches": dom_matches,
"total_captures": total_caps,
"total_counters": total_counters,
"avg_captures": avg_caps,
"avg_counters": avg_counters,
"dom_score_raw": raw,
"dom_score_display": f"{raw:.2f}",
}
# ---------------------------------------------------------
# 2. MATCH-BASED DOMINATION OBJECTIVE SPECIALIST
# ---------------------------------------------------------
def compute_dom_objective_for_match_player(p):
"""
Extract match-level Domination stats for a single player.
Used only for match-based ObjDOM.
"""
caps = p.get("DOM_Captures", 0) or 0
counters = p.get("DOM_Counters", 0) or 0
return {
"captures": caps,
"counters": counters,
}
# ---------------------------------------------------------
# 3. TEAM SCORE CALCULATION (MATCH-BASED ObjDOM)
# ---------------------------------------------------------
def compute_dom_objective_team_scores(team_entries):
"""
team_entries = list of (pid, entry, match_player)
entry = career DB entry (ignored for match ObjDOM)
match_player = match player dict with DOM stats
"""
# Total team captures / counters
team_caps = sum(mp.get("DOM_Captures", 0) or 0 for _, _, mp in team_entries)
team_counters = sum(mp.get("DOM_Counters", 0) or 0 for _, _, mp in team_entries)
# Avoid division by zero
if team_caps <= 0:
team_caps = 1
if team_counters <= 0:
team_counters = 1
scores = {}
for pid, entry, mp in team_entries:
caps = mp.get("DOM_Captures", 0) or 0
counters = mp.get("DOM_Counters", 0) or 0
# 1. Presence on objective (share of team captures)
cap_presence = caps / team_caps
# 2. Counter presence (share of team counters)
counter_presence = counters / team_counters
# 3. Log scaling to smooth extremes
cap_rate = math.log1p(caps) / math.log1p(10.0) # per-map soft cap ~10 caps
counter_rate = math.log1p(counters) / math.log1p(10.0)
# Blend presence + impact
# Captures: both presence + rate
# Counters: weighted more heavily (deny enemy scoring)
cap_component = 0.5 * cap_presence + 0.5 * cap_rate
counter_component = 0.5 * counter_presence + 0.5 * counter_rate
score = (0.40 * cap_component) + (0.60 * counter_component)
scores[pid] = round(score, 4)
return scores

View file

@ -1,79 +1,158 @@
import math import math
# ---------------------------------------------------------
# 1. CAREER PAYLOAD OBJECTIVE SPECIALIST (MODERNIZED)
# ---------------------------------------------------------
def compute_payload_objective_for_career_player(p, league_averages): def compute_payload_objective_for_career_player(pdata, league_averages=None):
career = p["career"] """
derived = p["derived"] Modernized career Payload Objective Specialist score.
Mirrors the structure of Domination's career tag.
push_time_total = career.get("push_time", 0) or 0 Components:
damage = career.get("damage", 0) or 0 - PresenceNorm: fraction of matches that were Payload
deaths = career.get("deaths", 0) or 0 - PushNorm: average push time normalized to 300s soft cap
push_time_per_season = derived.get("push_time_per_season", 0) or 0 - PSINorm: damage-per-death survivability normalized
# Push Survivability Index (PSI) Final score:
# PSI = Damage / (Deaths + 1) * 1 / sqrt(PushTime + 1) 0.40 * PushNorm
psi = 0.0 + 0.40 * PresenceNorm
if push_time_total > 0: + 0.20 * PSINorm
psi = (damage / (deaths + 1)) * (1.0 / math.sqrt(push_time_total + 1)) """
league_push_time_per_season = league_averages.get("push_time_per_season", 1.0) matches = pdata.get("matches", [])
push_time_norm = push_time_per_season / league_push_time_per_season if league_push_time_per_season > 0 else 0.0 if not matches:
return {
"payload_matches": 0,
"total_push_time": 0.0,
"avg_push_time": 0.0,
"presence_norm": 0.0,
"push_norm": 0.0,
"psi_norm": 0.0,
"payload_score_raw": 0.0,
"payload_score_display": "0.00",
}
total_matches = len(matches)
payload_matches = 0
total_push = 0.0
total_damage = 0.0
total_deaths = 0
for m in matches:
push = m.get("PAY_PushTime", 0) or 0
dmg = m.get("Damage", 0) or 0
deaths = m.get("Deaths", 0) or 0
if push > 0:
payload_matches += 1
total_push += push
total_damage += dmg
total_deaths += deaths
if payload_matches == 0:
return {
"payload_matches": 0,
"total_push_time": 0.0,
"avg_push_time": 0.0,
"presence_norm": 0.0,
"push_norm": 0.0,
"psi_norm": 0.0,
"payload_score_raw": 0.0,
"payload_score_display": "0.00",
}
# --- PresenceNorm ---
presence_norm = payload_matches / total_matches
# --- PushNorm ---
avg_push = total_push / payload_matches
push_norm = min(avg_push / 300.0, 1.0)
# --- PSINorm ---
psi_raw = total_damage / (total_deaths + 1)
psi_norm = psi_raw / (psi_raw + 300.0)
# --- Final Score ---
raw = (0.40 * push_norm) + (0.40 * presence_norm) + (0.20 * psi_norm)
return { return {
"push_time_total": push_time_total, "payload_matches": payload_matches,
"push_time_per_season": push_time_per_season, "total_push_time": total_push,
"push_time_norm": push_time_norm, "avg_push_time": avg_push,
"psi": psi, "presence_norm": presence_norm,
"push_norm": push_norm,
"psi_norm": psi_norm,
"payload_score_raw": raw,
"payload_score_display": f"{raw:.2f}",
} }
def compute_payload_objective_team_scores(team_player_entries): # ---------------------------------------------------------
""" # 2. MATCH-BASED PAYLOAD OBJECTIVE SPECIALIST (MODERNIZED)
team_player_entries: list of (player_id, player_entry_from_career_db) # ---------------------------------------------------------
Returns dict {pid: obj_pl_score} def compute_payload_objective_for_match_player(p):
""" """
# PresencePL: share of team push time Extract match-level Payload stats for a single player.
total_push_time = sum(p["objective_payload_components"]["push_time_total"] Normalized naming for consistency.
for _, p in team_player_entries) """
if total_push_time <= 0:
total_push_time = 1.0 push = p.get("PAY_PushTime", 0) or 0
dmg = p.get("damage", p.get("Damage", 0)) or 0
deaths = p.get("deaths", p.get("Deaths", 0)) or 0
return {
"push_time": push,
"damage": dmg,
"deaths": deaths,
}
# ---------------------------------------------------------
# 3. TEAM SCORE CALCULATION (MATCH-BASED, MODERNIZED)
# ---------------------------------------------------------
def compute_payload_objective_team_scores(team_entries):
"""
team_entries = list of (pid, career_entry, match_player)
Components:
- Presence: push_time / team_total_push
- PushNorm: push_time / 300s soft cap
- PSINorm: damage-per-death normalized
Final score:
0.40 * Presence
+ 0.40 * PushNorm
+ 0.20 * PSINorm
"""
# Total team push time
team_push = sum(mp.get("PAY_PushTime", 0) or 0 for _, _, mp in team_entries)
if team_push <= 0:
team_push = 1 # avoid div-by-zero
scores = {} scores = {}
for pid, pdata in team_player_entries:
comp = pdata["objective_payload_components"]
presence_pl = comp["push_time_total"] / total_push_time
push_time_norm = comp["push_time_norm"] for pid, entry, mp in team_entries:
psi = comp["psi"] push = mp.get("PAY_PushTime", 0) or 0
dmg = mp.get("damage", mp.get("Damage", 0)) or 0
deaths = mp.get("deaths", mp.get("Deaths", 0)) or 0
# Normalize PSI within team to avoid extreme outliers dominating # --- Presence ---
# We'll compute team PSI mean and std first presence = push / team_push
scores[pid] = {
"presence_pl": presence_pl,
"push_time_norm": push_time_norm,
"psi": psi,
}
# PSI normalization within team # --- PushNorm ---
psi_values = [v["psi"] for v in scores.values()] push_norm = min(push / 300.0, 1.0)
if psi_values:
mean_psi = sum(psi_values) / len(psi_values)
var_psi = sum((x - mean_psi) ** 2 for x in psi_values) / max(1, len(psi_values))
std_psi = math.sqrt(var_psi)
else:
mean_psi = 0.0
std_psi = 1.0
for pid, v in scores.items(): # --- PSINorm ---
if std_psi > 0: psi_raw = dmg / (deaths + 1)
psi_norm = (v["psi"] - mean_psi) / std_psi psi_norm = psi_raw / (psi_raw + 300.0)
else:
psi_norm = 0.0
# Final ObjPL score (website‑only version) # --- Final Score ---
# 0.40 Presence + 0.40 PushTimeNorm + 0.20 PSI_norm score = (0.40 * presence) + (0.40 * push_norm) + (0.20 * psi_norm)
obj_pl = (0.40 * v["presence_pl"]) + (0.40 * v["push_time_norm"]) + (0.20 * psi_norm)
v["obj_pl"] = obj_pl
return {pid: v["obj_pl"] for pid, v in scores.items()} scores[pid] = round(score, 4)
return scores

View file

@ -1,5 +1,9 @@
from rankings import load_career_db from rankings import load_career_db
from tags.objective_domination import compute_dom_objective from tags.objective_domination import (
compute_dom_objective_for_career_player,
compute_dom_objective_for_match_player,
compute_dom_objective_team_scores
)
def generate_team_identity(player_list): def generate_team_identity(player_list):
@ -17,7 +21,7 @@ def generate_team_identity(player_list):
def avg_payload(players): def avg_payload(players):
return sum( return sum(
p.get("objective_payload_components", {}).get("push_time_norm", 0.0) p.get("objective_payload_components", {}).get("payload_score_raw", 0.0)
for p in players for p in players
) / max(1, len(players)) ) / max(1, len(players))
@ -40,7 +44,12 @@ def generate_team_identity(player_list):
) / max(1, len(players)) ) / max(1, len(players))
def avg_objdom(players): def avg_objdom(players):
return sum(compute_dom_objective(p) for p in players) / max(1, len(players)) return sum(
compute_dom_objective_for_career_player(
p.get("career_entry", {})
).get("dom_score_raw", 0.0)
for p in players
) / max(1, len(players))
# --- Compute identity scores --- # --- Compute identity scores ---
identity = { identity = {