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__/
*.pyc
*.pyo
*.pyd
.env
.venv
venv/
env/
# VSCode / IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
# Our caches
stats/data/cache/
cache/
# Ignore OBS output folder
../stats/
*.db

Binary file not shown.

File diff suppressed because it is too large Load diff

View file

@ -1,49 +1,50 @@
{
"season": 10,
"teams": [
"TANK",
"MEX",
"TBDi",
"EMRD",
"DMND",
"Doc!",
"HAXX",
"SWUA",
"ZTi",
"SLNT",
"HOBO",
"just",
"VRUS",
"FAM!",
"SKY",
"LMB0",
"F1R3",
"RVNG",
"DRGN",
"STHX",
"EMU"
],
"tiers": {
"TANK": "Dasher",
"MEX": "Sprinter",
"TBDi": "Walker",
"EMRD": "Dasher",
"DMND": "Sprinter",
"Doc!": "Walker",
"HAXX": "Dasher",
"SWUA": "Sprinter",
"ZTi": "Walker",
"SLNT": "Dasher",
"HOBO": "Sprinter",
"just": "Walker",
"VRUS": "Dasher",
"FAM!": "Sprinter",
"SKY": "Walker",
"LMB0": "Dasher",
"F1R3": "Sprinter",
"RVNG": "Walker",
"DRGN": "Dasher",
"STHX": "Sprinter",
"EMU": "Walker"
}
"season": 10,
"teams": [
"TANK",
"MEX",
"TBDi",
"EMRD",
"DMND",
"Doc!",
"HAXX",
"SWUA",
"ZTi",
"SLNT",
"HOBO",
"just",
"VRUS",
"FAM!",
"SKY",
"LMB0",
"F1R3",
"RVNG",
"DRGN",
"STHX",
"EMU"
],
"tiers": {
"TANK": "Dasher",
"MEX": "Sprinter",
"TBDi": "Walker",
"EMRD": "Dasher",
"DMND": "Sprinter",
"Doc!": "Walker",
"HAXX": "Dasher",
"SWUA": "Sprinter",
"ZTi": "Walker",
"SLNT": "Dasher",
"HOBO": "Sprinter",
"just": "Walker",
"VRUS": "Dasher",
"FAM!": "Sprinter",
"SKY": "Walker",
"LMB0": "Dasher",
"F1R3": "Sprinter",
"RVNG": "Walker",
"DRGN": "Dasher",
"STHX": "Sprinter",
"EMU": "Walker"
},
"output_root": "C:\\Projects\\Casting\\stats"
}

View file

@ -2,32 +2,62 @@ import os
import json
import math
from collections import defaultdict
import tkinter as tk
from tkinter import ttk, messagebox
from tkinter import ttk, messagebox, filedialog
import requests
# --- Internal modules ---
from data.career_db import build_career_database
from rankings import top_players_by_tag
from team_identity import generate_team_identity, compare_team_identity
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 match_engine import (
rank_match_players_sharpshooter,
rank_match_players_slayer,
rank_match_players_objpl,
rank_match_players_objdom,
rank_match_players_sharpshooter,
rank_match_players_clutch,
split_two_columns,
compute_slayer_prediction,
)
# ---------------------------------------------------------
# PATHS + CONFIG
# ---------------------------------------------------------
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
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)
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.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.set("Payload") # default
@ -213,11 +250,32 @@ class DashLeagueGUI:
self.team_b_listbox.delete(0, tk.END)
for p in team_players:
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):
try:
ensure_player_slots()
# -----------------------------
# 1. Collect selected players
# -----------------------------
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()]
@ -226,34 +284,128 @@ class DashLeagueGUI:
if not selected_b:
selected_b = self.team_b_players
all_players = sorted(selected_a, key=lambda x: x.get("name", "")) + \
sorted(selected_b, 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", ""))
)
# Limit to 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
# 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_db = None
if os.path.exists(career_path):
with open(career_path, "r", encoding="utf-8") as 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:
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"]:
pdata = career_db["players"][pid]
# Attach all tag components
p["clutch_components"] = pdata.get("clutch_components", {})
p["consistency_components"] = pdata.get("consistency_components", {})
p["objective_payload_components"] = pdata.get("objective_payload_components", {})
p["objective_domination_components"] = pdata.get("objective_domination_components", {})
p["sharpshooter_components"] = pdata.get("sharpshooter_components", {})
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):
slot_dir = os.path.join(PLAYERS_DIR, f"p{idx}")
os.makedirs(slot_dir, exist_ok=True)
@ -267,24 +419,21 @@ class DashLeagueGUI:
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, "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, "Counters.txt"), p.get("DOM_counters", ""))
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", ""))
# Tag exports now come from real tag engines via rankings/career_db
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()
tags_text = self.generate_player_tags(player_entry, map_type)
write_text(os.path.join(slot_dir, "Tags.txt"), tags_text)
# Tag exports (use enriched match player dict)
map_type = self.map_var.get()
tags_text = self.generate_player_tags(p, map_type)
write_text(os.path.join(slot_dir, "Tags.txt"), tags_text)
self.set_status("Player slots generated.")
messagebox.showinfo("Success", "Player slots generated into p0–p9.")
except Exception as e:
messagebox.showerror("Error", f"Failed to generate slots:\n{e}")
self.set_status("Generate failed.")
self.export_all_obs_files()
def on_build_career_db(self):
@ -344,7 +493,7 @@ class DashLeagueGUI:
text = f"""PLAYER SPOTLIGHT — {name}
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:
KD: {career['KD']:.2f}
@ -474,23 +623,24 @@ PushTime/Season: {derived.get('push_time_per_season', 0):.2f}
if slayer_strength:
tags_out.append(slayer_strength)
# Payload Objective Specialist
# Payload Objective Specialist (match-based)
if map_type == "Payload":
obj_pl = player_entry.get("objective_payload_score")
if obj_pl is not None:
tags_out.append(f"ObjPL {obj_pl:.2f}")
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 Specialist
# Domination Objective Specialist (match-based)
if map_type == "Domination":
obj_dom = compute_dom_objective(player_entry)
tags_out.append(f"ObjDOM {obj_dom:.2f}")
dom_info = player_entry.get("objective_domination_components", {})
dom_score = dom_info.get("dom_score_raw", 0.0)
tags_out.append(f"ObjDOM {dom_score:.2f}")
# Sharpshooter
sharp = player_entry.get("sharpshooter_score")
if sharp is not None:
tags_out.append(f"Sharp {sharp:.2f}")
return ", ".join(tags_out)
return ", ".join(tags_out)
@ -523,7 +673,7 @@ PushTime/Season: {derived.get('push_time_per_season', 0):.2f}
# --- Export to OBS ---
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)
except Exception as 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
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:
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}")
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():
root = tk.Tk()
app = DashLeagueGUI(root)

View file

@ -1,221 +1,120 @@
import os
import json
from collections import defaultdict
import requests
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)
import data.db_access as db_access
from tags.slayer import compute_slayer_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.consistency import compute_consistency
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", [])
from tags.clutch import compute_clutch
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
players = {}
league_accumulator = defaultdict(float)
league_counts = defaultdict(int)
final_db = {"players": {}, "league_averages": {}}
for season in seasons:
season_stats = fetch_season_stats(season)
if not isinstance(season_stats, list):
# ---------------------------------------------------------
# 2. Build per-player career stats from local DB
# ---------------------------------------------------------
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
for entry in season_stats:
if not isinstance(entry, dict):
continue
# Normalize inconsistent keys
if "loss" in entry and "losses" not in entry:
entry["losses"] = entry["loss"]
# Aggregate raw career totals
career_raw = defaultdict(float)
if "shots" not in entry:
entry["shots"] = 0
for m in matches:
career_raw["kills"] += m["Kills"]
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_hit" not in entry:
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 stats
maps = max(1, career_raw["maps"])
KD = career_raw["kills"] / career_raw["deaths"] if career_raw["deaths"] > 0 else career_raw["kills"]
accuracy = (career_raw["shots_hit"] / career_raw["shots"]) if career_raw["shots"] > 0 else 0.0
derived = {
"kills_per_map": kills_per_map,
"deaths_per_map": deaths_per_map,
"push_time_per_season": push_time_per_season,
"kills_per_map": career_raw["kills"] / maps,
"deaths_per_map": career_raw["deaths"] / maps,
"push_time_per_season": career_raw["PAY_PushTime"], # no seasons now
}
final_db["players"][pid] = {
"name": pdata["name"],
"team_history": list(pdata["team_history"]),
"seasons_played": list(pdata["seasons_played"]),
"per_season": pdata["per_season"],
"name": name,
"career": {
"kills": raw["kills"],
"deaths": raw["deaths"],
"kills": career_raw["kills"],
"deaths": career_raw["deaths"],
"KD": KD,
"accuracy": accuracy,
"push_time": raw["PAY_PushTime"],
"captures": raw["DOM_captures"],
"counters": raw["DOM_counters"],
"maps": raw["maps"],
"wins": raw["wins"],
"losses": raw["losses"],
"damage": raw["damage"],
"shots": raw.get("shots", 0),
"shots_hit": raw.get("shots_hit", 0),
"headshots": raw.get("headshots", 0),
"damage_dealt": raw.get("damage", 0),
"push_time": career_raw["PAY_PushTime"],
"captures": career_raw["DOM_captures"],
"counters": career_raw["DOM_counters"],
"maps": career_raw["maps"],
"damage": career_raw["damage"],
"shots": career_raw["shots"],
"shots_hit": career_raw["shots_hit"],
"headshots": career_raw["headshots"],
},
"derived": derived,
"matches": matches,
}
from data.player_history import get_player_match_history
final_db["players"][pid]["matches"] = get_player_match_history(pid)
# ---------------------------------------------------------
# 3. Compute league averages (local-only)
# ---------------------------------------------------------
league_acc = defaultdict(float)
league_count = defaultdict(int)
# Compute all tag scores per player
for pid, pdata in final_db["players"].items():
c = pdata["career"]
# 🔥 Skip players missing a career block
if "career" not in pdata:
print("SKIPPING PLAYER WITH NO CAREER:", pid)
continue
league_acc["KD"] += c["KD"]
league_count["KD"] += 1
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_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"]
# Sharpshooter
sharp = compute_sharpshooter_for_career_player(pdata["career"])
pdata["sharpshooter"] = sharp
pdata["sharpshooter"] = compute_sharpshooter_for_career_player(pdata["career"])
# Payload Objective Specialist
obj_components = compute_payload_objective_for_career_player(pdata, league_averages)
pdata["objective_payload_components"] = obj_components
# --- Consistency Tag ---
from tags.consistency import compute_consistency
matches = pdata.get("matches", [])
pdata["objective_payload_components"] = compute_payload_objective_for_career_player(
pdata, league_averages
)
# Consistency
matches = pdata["matches"]
consistency_raw, components = compute_consistency(matches)
pdata["consistency_components"] = {
**components,
"consistency_raw": consistency_raw,
"consistency_norm": components.get("consistency_norm", consistency_raw)
"consistency_norm": components.get("consistency_norm", consistency_raw),
}
# --- Clutch Tag ---
from tags.clutch import compute_clutch
# Clutch
pdata["clutch_components"] = {
"clutch_norm": compute_clutch(pdata, league_averages)
}
# ---------------------------------------------------------
# 5. Save DB
# ---------------------------------------------------------
with open(output_path, "w", encoding="utf-8") as f:
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
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__))
CAREER_DB_PATH = os.path.join(BASE_DIR, "career_stats.json")
@ -44,7 +47,7 @@ def rank_match_players_slayer(match_players):
ranked.append({
"id": pid,
"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_display": entry.get("slayer_score_display", f"{score:.2f}"),
})
@ -53,53 +56,82 @@ def rank_match_players_slayer(match_players):
for i, r in enumerate(ranked, 1):
r["rank"] = i
return ranked
def rank_match_players_objdom(players):
def rank_match_players_objdom(match_players):
"""
Rank players by Domination Objective Specialist score.
players = list of player_entry dicts for the current match.
Rank players by Domination Objective Specialist score (match-based).
Uses DOM_Captures and DOM_Counters from match player stats.
"""
ranked = []
for p in players:
score = compute_dom_objective(p)
ranked.append({
"name": p["name"],
"team": p["team"],
"score_raw": score,
"score_display": f"{score:.2f}",
})
# Sort high → low
ranked.sort(key=lambda x: x["score_raw"], reverse=True)
# Assign global rank
for i, entry in enumerate(ranked, start=1):
entry["rank"] = i
return ranked
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()
if db is None:
return []
# Build team entries for ObjPL engine
team_entries = []
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)
if not entry:
continue
if "objective_payload_components" not in entry:
team_entries.append((pid, entry, p))
if not team_entries:
return []
obj_scores = compute_dom_objective_team_scores(team_entries)
ranked = []
for pid, entry, mp in team_entries:
score = obj_scores.get(pid)
if score is None:
continue
team_entries.append((pid, entry))
ranked.append({
"id": pid,
"name": entry["name"],
"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
def rank_match_players_objpl(match_players):
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
# ObjPL is match-based; we still attach career entry for name/team history
team_entries.append((pid, entry, p))
if not team_entries:
return []
@ -107,21 +139,24 @@ def rank_match_players_objpl(match_players):
obj_scores = compute_payload_objective_team_scores(team_entries)
ranked = []
for pid, entry in team_entries:
score = obj_scores.get(pid, 0.0)
if score <= 0:
for pid, entry, mp in team_entries:
score = obj_scores.get(pid)
if score is None:
continue
ranked.append({
"id": pid,
"name": entry["name"],
"team": entry["team_history"][-1] if entry["team_history"] else "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
@ -137,7 +172,6 @@ def split_two_columns(ranked_players, team_a_name, team_b_name):
elif p["team"] == team_b_name:
right.append(p)
else:
# If team name mismatch, leave them out of columns
pass
return left, right
@ -168,7 +202,8 @@ def compute_slayer_prediction(team_a_players, team_b_players):
"teamA_win": round(pA * 100),
"teamB_win": round(pB * 100),
}
def rank_match_players_sharpshooter(match_players):
db = load_career_db()
if db is None:
@ -189,7 +224,7 @@ def rank_match_players_sharpshooter(match_players):
ranked.append({
"id": pid,
"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_display": sharp.get("score_display", f"{score:.2f}"),
})
@ -199,7 +234,8 @@ def rank_match_players_sharpshooter(match_players):
r["rank"] = i
return ranked
def rank_match_players_clutch(players):
ranked = []
for p in players:
@ -216,5 +252,4 @@ def rank_match_players_clutch(players):
for i, r in enumerate(ranked, start=1):
r["rank"] = i
return ranked
return ranked

View file

@ -1,8 +1,8 @@
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):
"""Generic logistic win chance for non-Slayer tags."""
"""Generic logistic win chance for all tags."""
if max(teamA_avg, teamB_avg) == 0:
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):
"""Compute all tag averages for both teams."""
"""Compute all tag averages for both teams using career identity scores."""
def avg_slayer(players):
return sum(p.get("slayer_score_raw", 0.0) for p in players) / max(1, len(players))
def avg_objpl(players):
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
) / max(1, len(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):
return sum(
@ -57,7 +62,7 @@ def compute_tag_averages(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_b = teamB_players[0].get("team", "Team B")
@ -67,14 +72,8 @@ def generate_predictions(teamA_players, teamB_players):
# --- Per-tag win chances ---
per_tag = {}
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 ---
weights = {

View file

@ -41,24 +41,20 @@ def top_players_by_tag(tag_name):
return results
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():
score = obj_scores.get(pid, 0.0)
comp = p.get("objective_payload_components", {})
score = comp.get("payload_score_raw", 0.0)
if score <= 0:
continue
results.append({
"id": pid,
"name": p["name"],
"team": p["team_history"][-1] if p["team_history"] else "Unknown",
"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)
return results
@ -105,4 +101,68 @@ def rank_match_players_clutch(players):
for i, r in enumerate(ranked, start=1):
r["rank"] = i
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 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):
@ -17,7 +21,7 @@ def matchup_storyline(teamA_players, teamB_players):
def avg_obj_payload(players):
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
) / max(1, len(players))
@ -28,7 +32,12 @@ def matchup_storyline(teamA_players, teamB_players):
) / max(1, len(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):
return sum(

View file

@ -2,89 +2,61 @@ import math
def compute_consistency(matches):
"""
matches = list of dicts, each containing:
- kills
- deaths
- accuracy
- damage
- push_time (payload)
- captures
- counters
- mode
Compute consistency based on per-match performance stability.
Uses:
- KD per match
- Damage per match
- Accuracy per match
"""
# --- Not enough matches to compute variance ---
if len(matches) < 2:
if not matches:
return 0.0, {
"per_match_perf": [],
"floor": 0.0,
"variance": 0.0,
"variance_adj": 0.0,
"variance_score": 0.0,
"avg_perf": 0.0,
"consistency_norm": 0.0
"match_count": 0,
"kd_std": 0.0,
"dmg_std": 0.0,
"acc_std": 0.0,
"consistency_norm": 0.0,
}
per_match_perf_list = []
kds = []
dmgs = []
accs = []
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 = m["kills"] / max(1, m["deaths"])
slayer_pct = percentile_of_kd(kd)
kd = kills / max(1, deaths)
acc = shots_hit / shots if shots > 0 else 0.0
# --- Objective percentile (per match) ---
if m["mode"] == "payload":
obj_raw = m["push_time"]
elif m["mode"] == "domination":
obj_raw = m["captures"] + m["counters"]
else:
obj_raw = 0
kds.append(kd)
dmgs.append(damage)
accs.append(acc)
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) ---
accuracy_pct = percentile_of_accuracy(m["accuracy"])
kd_std = std(kds)
dmg_std = std(dmgs)
acc_std = std(accs)
# --- Composite per-match performance ---
perf = (
slayer_pct * 0.40 +
objective_pct * 0.40 +
accuracy_pct * 0.20
)
# Lower variance = more consistent
# Normalize into a 0–1 score
raw = 1.0 / (1.0 + kd_std + dmg_std + acc_std)
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 = {
"per_match_perf": per_match_perf_list,
"floor": floor_score,
"variance": raw_variance,
"variance_adj": adj_variance,
"variance_score": variance_score,
"avg_perf": avg_perf,
"consistency_norm": consistency_norm
"match_count": len(matches),
"kd_std": kd_std,
"dmg_std": dmg_std,
"acc_std": acc_std,
"consistency_norm": raw,
}
return consistency_norm, components
return raw, components

View file

@ -1,29 +1,140 @@
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:
- DOM_captures
- 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.
A "Domination map" is any match where DOM_Captures > 0 or DOM_Counters > 0.
Payload and Control Point stats are ignored.
"""
caps = player_entry.get("DOM_captures", 0) or 0
counters = player_entry.get("DOM_counters", 0) or 0
matches = pdata.get("matches", [])
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)
cap_rate = math.log1p(caps) / math.log1p(20)
counter_rate = math.log1p(counters) / math.log1p(20)
dom_matches = 0
total_caps = 0
total_counters = 0
score = (0.40 * cap_rate) + (0.60 * counter_rate)
return round(score, 3)
for m in matches:
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
# ---------------------------------------------------------
# 1. CAREER PAYLOAD OBJECTIVE SPECIALIST (MODERNIZED)
# ---------------------------------------------------------
def compute_payload_objective_for_career_player(p, league_averages):
career = p["career"]
derived = p["derived"]
def compute_payload_objective_for_career_player(pdata, league_averages=None):
"""
Modernized career Payload Objective Specialist score.
Mirrors the structure of Domination's career tag.
push_time_total = career.get("push_time", 0) or 0
damage = career.get("damage", 0) or 0
deaths = career.get("deaths", 0) or 0
push_time_per_season = derived.get("push_time_per_season", 0) or 0
Components:
- PresenceNorm: fraction of matches that were Payload
- PushNorm: average push time normalized to 300s soft cap
- PSINorm: damage-per-death survivability normalized
# Push Survivability Index (PSI)
# PSI = Damage / (Deaths + 1) * 1 / sqrt(PushTime + 1)
psi = 0.0
if push_time_total > 0:
psi = (damage / (deaths + 1)) * (1.0 / math.sqrt(push_time_total + 1))
Final score:
0.40 * PushNorm
+ 0.40 * PresenceNorm
+ 0.20 * PSINorm
"""
league_push_time_per_season = league_averages.get("push_time_per_season", 1.0)
push_time_norm = push_time_per_season / league_push_time_per_season if league_push_time_per_season > 0 else 0.0
matches = pdata.get("matches", [])
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 {
"push_time_total": push_time_total,
"push_time_per_season": push_time_per_season,
"push_time_norm": push_time_norm,
"psi": psi,
"payload_matches": payload_matches,
"total_push_time": total_push,
"avg_push_time": avg_push,
"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):
"""
team_player_entries: list of (player_id, player_entry_from_career_db)
# ---------------------------------------------------------
# 2. MATCH-BASED PAYLOAD OBJECTIVE SPECIALIST (MODERNIZED)
# ---------------------------------------------------------
Returns dict {pid: obj_pl_score}
def compute_payload_objective_for_match_player(p):
"""
# PresencePL: share of team push time
total_push_time = sum(p["objective_payload_components"]["push_time_total"]
for _, p in team_player_entries)
if total_push_time <= 0:
total_push_time = 1.0
Extract match-level Payload stats for a single player.
Normalized naming for consistency.
"""
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 = {}
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"]
psi = comp["psi"]
for pid, entry, mp in team_entries:
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
# We'll compute team PSI mean and std first
scores[pid] = {
"presence_pl": presence_pl,
"push_time_norm": push_time_norm,
"psi": psi,
}
# --- Presence ---
presence = push / team_push
# PSI normalization within team
psi_values = [v["psi"] for v in scores.values()]
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
# --- PushNorm ---
push_norm = min(push / 300.0, 1.0)
for pid, v in scores.items():
if std_psi > 0:
psi_norm = (v["psi"] - mean_psi) / std_psi
else:
psi_norm = 0.0
# --- PSINorm ---
psi_raw = dmg / (deaths + 1)
psi_norm = psi_raw / (psi_raw + 300.0)
# Final ObjPL score (website‑only version)
# 0.40 Presence + 0.40 PushTimeNorm + 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
# --- Final Score ---
score = (0.40 * presence) + (0.40 * push_norm) + (0.20 * psi_norm)
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 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):
@ -17,7 +21,7 @@ def generate_team_identity(player_list):
def avg_payload(players):
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
) / max(1, len(players))
@ -40,7 +44,12 @@ def generate_team_identity(player_list):
) / max(1, len(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 ---
identity = {