Modernize tag system, match engine, OBS exports, and output folder architecture
This commit is contained in:
parent
5c055ebee6
commit
c8f043155a
23 changed files with 326883 additions and 110282 deletions
30
.gitignore
vendored
30
.gitignore
vendored
|
|
@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
435672
career_stats.json
435672
career_stats.json
File diff suppressed because it is too large
Load diff
95
config.json
95
config.json
|
|
@ -1,49 +1,50 @@
|
||||||
{
|
{
|
||||||
"season": 10,
|
"season": 10,
|
||||||
"teams": [
|
"teams": [
|
||||||
"TANK",
|
"TANK",
|
||||||
"MEX",
|
"MEX",
|
||||||
"TBDi",
|
"TBDi",
|
||||||
"EMRD",
|
"EMRD",
|
||||||
"DMND",
|
"DMND",
|
||||||
"Doc!",
|
"Doc!",
|
||||||
"HAXX",
|
"HAXX",
|
||||||
"SWUA",
|
"SWUA",
|
||||||
"ZTi",
|
"ZTi",
|
||||||
"SLNT",
|
"SLNT",
|
||||||
"HOBO",
|
"HOBO",
|
||||||
"just",
|
"just",
|
||||||
"VRUS",
|
"VRUS",
|
||||||
"FAM!",
|
"FAM!",
|
||||||
"SKY",
|
"SKY",
|
||||||
"LMB0",
|
"LMB0",
|
||||||
"F1R3",
|
"F1R3",
|
||||||
"RVNG",
|
"RVNG",
|
||||||
"DRGN",
|
"DRGN",
|
||||||
"STHX",
|
"STHX",
|
||||||
"EMU"
|
"EMU"
|
||||||
],
|
],
|
||||||
"tiers": {
|
"tiers": {
|
||||||
"TANK": "Dasher",
|
"TANK": "Dasher",
|
||||||
"MEX": "Sprinter",
|
"MEX": "Sprinter",
|
||||||
"TBDi": "Walker",
|
"TBDi": "Walker",
|
||||||
"EMRD": "Dasher",
|
"EMRD": "Dasher",
|
||||||
"DMND": "Sprinter",
|
"DMND": "Sprinter",
|
||||||
"Doc!": "Walker",
|
"Doc!": "Walker",
|
||||||
"HAXX": "Dasher",
|
"HAXX": "Dasher",
|
||||||
"SWUA": "Sprinter",
|
"SWUA": "Sprinter",
|
||||||
"ZTi": "Walker",
|
"ZTi": "Walker",
|
||||||
"SLNT": "Dasher",
|
"SLNT": "Dasher",
|
||||||
"HOBO": "Sprinter",
|
"HOBO": "Sprinter",
|
||||||
"just": "Walker",
|
"just": "Walker",
|
||||||
"VRUS": "Dasher",
|
"VRUS": "Dasher",
|
||||||
"FAM!": "Sprinter",
|
"FAM!": "Sprinter",
|
||||||
"SKY": "Walker",
|
"SKY": "Walker",
|
||||||
"LMB0": "Dasher",
|
"LMB0": "Dasher",
|
||||||
"F1R3": "Sprinter",
|
"F1R3": "Sprinter",
|
||||||
"RVNG": "Walker",
|
"RVNG": "Walker",
|
||||||
"DRGN": "Dasher",
|
"DRGN": "Dasher",
|
||||||
"STHX": "Sprinter",
|
"STHX": "Sprinter",
|
||||||
"EMU": "Walker"
|
"EMU": "Walker"
|
||||||
}
|
},
|
||||||
|
"output_root": "C:\\Projects\\Casting\\stats"
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -213,11 +250,32 @@ class DashLeagueGUI:
|
||||||
self.team_b_listbox.delete(0, tk.END)
|
self.team_b_listbox.delete(0, tk.END)
|
||||||
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,34 +284,128 @@ 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
|
||||||
sorted(selected_b, key=lambda x: x.get("name", ""))
|
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]
|
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", {})
|
||||||
p["objective_domination_components"] = pdata.get("objective_domination_components", {})
|
p["objective_domination_components"] = pdata.get("objective_domination_components", {})
|
||||||
p["sharpshooter_components"] = pdata.get("sharpshooter_components", {})
|
p["sharpshooter_components"] = pdata.get("sharpshooter_components", {})
|
||||||
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,24 +419,21 @@ 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:
|
map_type = self.map_var.get()
|
||||||
pid = p.get("id")
|
tags_text = self.generate_player_tags(p, map_type)
|
||||||
if pid and pid in career_db["players"]:
|
write_text(os.path.join(slot_dir, "Tags.txt"), tags_text)
|
||||||
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)
|
|
||||||
|
|
||||||
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.")
|
||||||
|
|
||||||
self.export_all_obs_files()
|
self.export_all_obs_files()
|
||||||
|
|
||||||
def on_build_career_db(self):
|
def on_build_career_db(self):
|
||||||
|
|
@ -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,23 +623,24 @@ 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")
|
||||||
if sharp is not None:
|
if sharp is not None:
|
||||||
tags_out.append(f"Sharp {sharp:.2f}")
|
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 ---
|
# --- 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)
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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
|
|
||||||
if "loss" in entry and "losses" not in entry:
|
|
||||||
entry["losses"] = entry["loss"]
|
|
||||||
|
|
||||||
if "shots" not in entry:
|
for m in matches:
|
||||||
entry["shots"] = 0
|
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:
|
# Derived stats
|
||||||
entry["shots_hit"] = 0
|
maps = max(1, career_raw["maps"])
|
||||||
|
KD = career_raw["kills"] / career_raw["deaths"] if career_raw["deaths"] > 0 else career_raw["kills"]
|
||||||
if "headshots" not in entry:
|
accuracy = (career_raw["shots_hit"] / career_raw["shots"]) if career_raw["shots"] > 0 else 0.0
|
||||||
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
|
|
||||||
|
|
||||||
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():
|
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 ---
|
|
||||||
from tags.clutch import compute_clutch
|
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
127
match_engine.py
127
match_engine.py
|
|
@ -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}"),
|
||||||
})
|
})
|
||||||
|
|
@ -53,53 +56,82 @@ def rank_match_players_slayer(match_players):
|
||||||
for i, r in enumerate(ranked, 1):
|
for i, r in enumerate(ranked, 1):
|
||||||
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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
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()
|
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:
|
|
||||||
|
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
|
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:
|
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
|
||||||
|
|
||||||
|
|
@ -168,7 +202,8 @@ def compute_slayer_prediction(team_a_players, team_b_players):
|
||||||
"teamA_win": round(pA * 100),
|
"teamA_win": round(pA * 100),
|
||||||
"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}"),
|
||||||
})
|
})
|
||||||
|
|
@ -199,7 +234,8 @@ def rank_match_players_sharpshooter(match_players):
|
||||||
r["rank"] = i
|
r["rank"] = i
|
||||||
|
|
||||||
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:
|
||||||
|
|
@ -216,5 +252,4 @@ def rank_match_players_clutch(players):
|
||||||
for i, r in enumerate(ranked, start=1):
|
for i, r in enumerate(ranked, start=1):
|
||||||
r["rank"] = i
|
r["rank"] = i
|
||||||
|
|
||||||
return ranked
|
return ranked
|
||||||
|
|
||||||
Binary file not shown.
|
|
@ -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,14 +72,8 @@ 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":
|
per_tag[tag] = logistic_win_chance(a_avg, b_avg)
|
||||||
# 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)
|
|
||||||
|
|
||||||
# --- Weighted overall prediction ---
|
# --- Weighted overall prediction ---
|
||||||
weights = {
|
weights = {
|
||||||
|
|
|
||||||
76
rankings.py
76
rankings.py
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -105,4 +101,68 @@ def rank_match_players_clutch(players):
|
||||||
for i, r in enumerate(ranked, start=1):
|
for i, r in enumerate(ranked, start=1):
|
||||||
r["rank"] = i
|
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
|
return ranked
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue