1.7.0: New features.
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -176,7 +176,25 @@ CREATE TABLE IF NOT EXISTS dm_player_features (
|
|||||||
score_hps REAL,
|
score_hps REAL,
|
||||||
score_ptl REAL,
|
score_ptl REAL,
|
||||||
score_tct REAL,
|
score_tct REAL,
|
||||||
score_util REAL
|
score_util REAL,
|
||||||
|
score_eco REAL,
|
||||||
|
score_pace REAL,
|
||||||
|
|
||||||
|
-- ==========================================
|
||||||
|
-- 8. ECO: Economy Efficiency
|
||||||
|
-- ==========================================
|
||||||
|
eco_avg_damage_per_1k REAL,
|
||||||
|
eco_rating_eco_rounds REAL,
|
||||||
|
eco_kd_ratio REAL,
|
||||||
|
eco_avg_rounds REAL,
|
||||||
|
|
||||||
|
-- ==========================================
|
||||||
|
-- 9. PACE: Aggression & Trade
|
||||||
|
-- ==========================================
|
||||||
|
pace_avg_time_to_first_contact REAL,
|
||||||
|
pace_trade_kill_rate REAL,
|
||||||
|
pace_opening_kill_time REAL,
|
||||||
|
pace_avg_life_time REAL
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Optional: Detailed per-match feature table for time-series analysis
|
-- Optional: Detailed per-match feature table for time-series analysis
|
||||||
|
|||||||
74
scripts/analyze_dmg_per_1k.py
Normal file
74
scripts/analyze_dmg_per_1k.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import pandas as pd
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Config
|
||||||
|
L2_DB_PATH = r'database/L2/L2_Main.sqlite'
|
||||||
|
L3_DB_PATH = r'database/L3/L3_Features.sqlite'
|
||||||
|
|
||||||
|
def analyze_team_dmg_per_1k():
|
||||||
|
if not os.path.exists(L3_DB_PATH):
|
||||||
|
print(f"Error: L3 DB not found at {L3_DB_PATH}")
|
||||||
|
return
|
||||||
|
|
||||||
|
conn_l3 = sqlite3.connect(L3_DB_PATH)
|
||||||
|
conn_l2 = sqlite3.connect(L2_DB_PATH)
|
||||||
|
|
||||||
|
print("--- Analysis: Team Dmg/$1k (Economy Efficiency) ---")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Get all L3 features
|
||||||
|
query = """
|
||||||
|
SELECT f.steam_id_64, f.eco_avg_damage_per_1k, p.username
|
||||||
|
FROM dm_player_features f
|
||||||
|
LEFT JOIN dim_players p ON f.steam_id_64 = p.steam_id_64
|
||||||
|
ORDER BY f.eco_avg_damage_per_1k DESC
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Attach L2 for username lookup
|
||||||
|
# We can't attach across connections easily in sqlite python without ATTACH DATABASE command
|
||||||
|
# So let's fetch L3 first, then map names from L2
|
||||||
|
|
||||||
|
df_l3 = pd.read_sql_query("SELECT steam_id_64, eco_avg_damage_per_1k FROM dm_player_features", conn_l3)
|
||||||
|
|
||||||
|
if df_l3.empty:
|
||||||
|
print("No data in L3 Features.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fetch names
|
||||||
|
ids = tuple(df_l3['steam_id_64'].tolist())
|
||||||
|
placeholders = ','.join(['?'] * len(ids))
|
||||||
|
q_names = f"SELECT steam_id_64, username FROM dim_players WHERE steam_id_64 IN ({placeholders})"
|
||||||
|
df_names = pd.read_sql_query(q_names, conn_l2, params=ids)
|
||||||
|
|
||||||
|
# Merge
|
||||||
|
df = df_l3.merge(df_names, on='steam_id_64', how='left')
|
||||||
|
|
||||||
|
# Sort
|
||||||
|
df = df.sort_values('eco_avg_damage_per_1k', ascending=False)
|
||||||
|
|
||||||
|
print(f"{'Rank':<5} {'Player':<20} {'Dmg/$1k':<10}")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
for idx, row in df.iterrows():
|
||||||
|
rank = idx + 1 # This index is not rank if we iterated row by row after sort, wait.
|
||||||
|
# reset_index to get rank
|
||||||
|
pass
|
||||||
|
|
||||||
|
df = df.reset_index(drop=True)
|
||||||
|
for idx, row in df.iterrows():
|
||||||
|
name = row['username'] if row['username'] else row['steam_id_64']
|
||||||
|
val = row['eco_avg_damage_per_1k']
|
||||||
|
print(f"#{idx+1:<4} {name:<20} {val:.2f}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
finally:
|
||||||
|
conn_l2.close()
|
||||||
|
conn_l3.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
analyze_team_dmg_per_1k()
|
||||||
45
scripts/debug_dist.py
Normal file
45
scripts/debug_dist.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import pandas as pd
|
||||||
|
from web.services.feature_service import FeatureService
|
||||||
|
from web.config import Config
|
||||||
|
from web.app import create_app
|
||||||
|
|
||||||
|
def check_distribution():
|
||||||
|
app = create_app()
|
||||||
|
with app.app_context():
|
||||||
|
# Get a player ID from L3
|
||||||
|
conn = sqlite3.connect(Config.DB_L3_PATH)
|
||||||
|
row = conn.execute("SELECT steam_id_64 FROM dm_player_features LIMIT 1").fetchone()
|
||||||
|
if not row:
|
||||||
|
print("No players in L3")
|
||||||
|
return
|
||||||
|
|
||||||
|
sid = row[0]
|
||||||
|
print(f"Checking distribution for {sid}...")
|
||||||
|
|
||||||
|
dist = FeatureService.get_roster_features_distribution(sid)
|
||||||
|
if not dist:
|
||||||
|
print("Distribution returned None")
|
||||||
|
return
|
||||||
|
|
||||||
|
keys_to_check = [
|
||||||
|
'eco_avg_damage_per_1k', # Working
|
||||||
|
'eco_rating_eco_rounds', # Working
|
||||||
|
'eco_kd_ratio', # Broken
|
||||||
|
'eco_avg_rounds', # Broken
|
||||||
|
'pace_avg_time_to_first_contact', # Working
|
||||||
|
'pace_trade_kill_rate', # Working
|
||||||
|
'pace_opening_kill_time', # Broken
|
||||||
|
'pace_avg_life_time' # Broken
|
||||||
|
]
|
||||||
|
|
||||||
|
print(f"{'Key':<35} | {'Present':<7} | {'Value'}")
|
||||||
|
print("-" * 60)
|
||||||
|
for k in keys_to_check:
|
||||||
|
is_present = k in dist
|
||||||
|
val = dist.get(k)
|
||||||
|
print(f"{k:<35} | {str(is_present):<7} | {val}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
check_distribution()
|
||||||
94
scripts/debug_jacky.py
Normal file
94
scripts/debug_jacky.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import pandas as pd
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Config
|
||||||
|
L2_DB_PATH = r'database/L2/L2_Main.sqlite'
|
||||||
|
|
||||||
|
def debug_player_data(username_pattern='jAckY'):
|
||||||
|
if not os.path.exists(L2_DB_PATH):
|
||||||
|
print(f"Error: L2 DB not found at {L2_DB_PATH}")
|
||||||
|
return
|
||||||
|
|
||||||
|
conn_l2 = sqlite3.connect(L2_DB_PATH)
|
||||||
|
|
||||||
|
print(f"--- Debugging Player: {username_pattern} ---")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Find the player ID
|
||||||
|
q_id = f"SELECT steam_id_64, username FROM dim_players WHERE username LIKE '%{username_pattern}%'"
|
||||||
|
df_player = pd.read_sql_query(q_id, conn_l2)
|
||||||
|
|
||||||
|
if df_player.empty:
|
||||||
|
print("Player not found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
target_id = df_player.iloc[0]['steam_id_64']
|
||||||
|
name = df_player.iloc[0]['username']
|
||||||
|
print(f"Found: {name} ({target_id})")
|
||||||
|
|
||||||
|
# 2. Check Match Stats (ADR, Rounds)
|
||||||
|
q_matches = f"""
|
||||||
|
SELECT match_id, round_total, adr, (adr * round_total) as damage_calc
|
||||||
|
FROM fact_match_players
|
||||||
|
WHERE steam_id_64 = '{target_id}'
|
||||||
|
"""
|
||||||
|
df_matches = pd.read_sql_query(q_matches, conn_l2)
|
||||||
|
|
||||||
|
total_dmg = df_matches['damage_calc'].sum()
|
||||||
|
total_rounds = df_matches['round_total'].sum()
|
||||||
|
print(f"\nMatch Stats:")
|
||||||
|
print(f"Matches Played: {len(df_matches)}")
|
||||||
|
print(f"Total Rounds: {total_rounds}")
|
||||||
|
print(f"Total Damage (Calc): {total_dmg:,.0f}")
|
||||||
|
|
||||||
|
# 3. Check Economy Stats (Spend)
|
||||||
|
q_eco = f"""
|
||||||
|
SELECT match_id, COUNT(*) as rounds_with_eco, SUM(equipment_value) as spend
|
||||||
|
FROM fact_round_player_economy
|
||||||
|
WHERE steam_id_64 = '{target_id}'
|
||||||
|
GROUP BY match_id
|
||||||
|
"""
|
||||||
|
df_eco = pd.read_sql_query(q_eco, conn_l2)
|
||||||
|
|
||||||
|
total_spend = df_eco['spend'].sum()
|
||||||
|
total_eco_rounds = df_eco['rounds_with_eco'].sum()
|
||||||
|
|
||||||
|
print(f"\nEconomy Stats:")
|
||||||
|
print(f"Matches with Eco Data: {len(df_eco)}")
|
||||||
|
print(f"Rounds with Eco Data: {total_eco_rounds}")
|
||||||
|
print(f"Total Spend: ${total_spend:,.0f}")
|
||||||
|
|
||||||
|
# 4. Compare
|
||||||
|
print(f"\nComparison:")
|
||||||
|
print(f"Rounds in Match Stats: {total_rounds}")
|
||||||
|
print(f"Rounds in Eco Stats: {total_eco_rounds}")
|
||||||
|
|
||||||
|
if total_eco_rounds < total_rounds:
|
||||||
|
print(f"⚠️ WARNING: Missing economy data for {total_rounds - total_eco_rounds} rounds!")
|
||||||
|
|
||||||
|
# Find matches with missing eco data
|
||||||
|
merged = df_matches.merge(df_eco, on='match_id', how='left')
|
||||||
|
missing = merged[merged['spend'].isna() | (merged['spend'] == 0)]
|
||||||
|
|
||||||
|
if not missing.empty:
|
||||||
|
print(f"\nMatches with ZERO spend/Missing Eco:")
|
||||||
|
print(missing[['match_id', 'round_total', 'damage_calc']])
|
||||||
|
|
||||||
|
# Check calculation impact
|
||||||
|
valid_dmg = merged[merged['spend'] > 0]['damage_calc'].sum()
|
||||||
|
print(f"\nRecalculation ignoring missing matches:")
|
||||||
|
print(f"Valid Damage: {valid_dmg:,.0f}")
|
||||||
|
print(f"Total Spend: ${total_spend:,.0f}")
|
||||||
|
if total_spend > 0:
|
||||||
|
new_val = valid_dmg / (total_spend / 1000)
|
||||||
|
print(f"Corrected Dmg/$1k: {new_val:.2f}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
finally:
|
||||||
|
conn_l2.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
debug_player_data()
|
||||||
@@ -244,7 +244,9 @@ def charts_data(steam_id):
|
|||||||
'HPS': get_score('score_hps'),
|
'HPS': get_score('score_hps'),
|
||||||
'PTL': get_score('score_ptl'),
|
'PTL': get_score('score_ptl'),
|
||||||
'SIDE': get_score('score_tct'),
|
'SIDE': get_score('score_tct'),
|
||||||
'UTIL': get_score('score_util')
|
'UTIL': get_score('score_util'),
|
||||||
|
'ECO': get_score('score_eco'),
|
||||||
|
'PACE': get_score('score_pace')
|
||||||
}
|
}
|
||||||
|
|
||||||
trend_labels = []
|
trend_labels = []
|
||||||
|
|||||||
@@ -1135,11 +1135,380 @@ class FeatureService:
|
|||||||
df_elo_flat = pd.DataFrame(flat_elo_data)
|
df_elo_flat = pd.DataFrame(flat_elo_data)
|
||||||
df = df.merge(df_elo_flat, on='steam_id_64', how='left')
|
df = df.merge(df_elo_flat, on='steam_id_64', how='left')
|
||||||
|
|
||||||
|
# 9. New Features: Economy & Pace
|
||||||
|
df_eco = FeatureService._calculate_economy_features(conn, valid_ids)
|
||||||
|
if df_eco is not None:
|
||||||
|
df = df.merge(df_eco, on='steam_id_64', how='left')
|
||||||
|
|
||||||
|
df_pace = FeatureService._calculate_pace_features(conn, valid_ids)
|
||||||
|
if df_pace is not None:
|
||||||
|
df = df.merge(df_pace, on='steam_id_64', how='left')
|
||||||
|
|
||||||
# Final Mappings
|
# Final Mappings
|
||||||
df['total_matches'] = df['matches_played']
|
df['total_matches'] = df['matches_played']
|
||||||
|
|
||||||
return df.fillna(0)
|
return df.fillna(0)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _calculate_economy_features(conn, player_ids):
|
||||||
|
if not player_ids: return None
|
||||||
|
placeholders = ','.join(['?'] * len(player_ids))
|
||||||
|
|
||||||
|
# 1. Investment Efficiency (Damage / Equipment Value)
|
||||||
|
# We need total damage and total equipment value
|
||||||
|
# fact_match_players has sum_util_dmg (only nade damage), but we need total damage.
|
||||||
|
# fact_match_players has 'basic_avg_adr' * rounds.
|
||||||
|
# Better to query fact_round_player_economy for equipment value sum.
|
||||||
|
|
||||||
|
q_eco_val = f"""
|
||||||
|
SELECT steam_id_64, SUM(equipment_value) as total_spend, COUNT(*) as rounds_tracked
|
||||||
|
FROM fact_round_player_economy
|
||||||
|
WHERE steam_id_64 IN ({placeholders})
|
||||||
|
GROUP BY steam_id_64
|
||||||
|
"""
|
||||||
|
df_spend = pd.read_sql_query(q_eco_val, conn, params=player_ids)
|
||||||
|
|
||||||
|
# Get Total Damage from fact_match_players (derived from ADR * Rounds)
|
||||||
|
# MUST filter by matches that actually have economy data to ensure consistency
|
||||||
|
q_dmg = f"""
|
||||||
|
SELECT mp.steam_id_64, SUM(mp.adr * mp.round_total) as total_damage
|
||||||
|
FROM fact_match_players mp
|
||||||
|
JOIN (
|
||||||
|
SELECT DISTINCT match_id, steam_id_64
|
||||||
|
FROM fact_round_player_economy
|
||||||
|
WHERE steam_id_64 IN ({placeholders})
|
||||||
|
) eco ON mp.match_id = eco.match_id AND mp.steam_id_64 = eco.steam_id_64
|
||||||
|
WHERE mp.steam_id_64 IN ({placeholders})
|
||||||
|
GROUP BY mp.steam_id_64
|
||||||
|
"""
|
||||||
|
df_dmg = pd.read_sql_query(q_dmg, conn, params=player_ids + player_ids)
|
||||||
|
|
||||||
|
df = df_spend.merge(df_dmg, on='steam_id_64', how='inner')
|
||||||
|
|
||||||
|
# Metric 1: Damage per 1000$
|
||||||
|
# Avoid div by zero
|
||||||
|
df['eco_avg_damage_per_1k'] = df['total_damage'] / (df['total_spend'] / 1000.0).replace(0, 1)
|
||||||
|
|
||||||
|
# 2. Eco Round Performance (Equipment < 2000)
|
||||||
|
# We need kills in these rounds.
|
||||||
|
# Join economy with events? That's heavy.
|
||||||
|
# Alternative: Approximate.
|
||||||
|
# Let's do it properly: Get rounds where equip < 2000, count kills.
|
||||||
|
|
||||||
|
# Subquery for Eco Rounds keys: (match_id, round_num, steam_id_64)
|
||||||
|
# Then join with events.
|
||||||
|
|
||||||
|
q_eco_perf = f"""
|
||||||
|
SELECT
|
||||||
|
e.attacker_steam_id as steam_id_64,
|
||||||
|
COUNT(*) as eco_kills,
|
||||||
|
SUM(CASE WHEN e.event_type='death' THEN 1 ELSE 0 END) as eco_deaths
|
||||||
|
FROM fact_round_events e
|
||||||
|
JOIN fact_round_player_economy eco
|
||||||
|
ON e.match_id = eco.match_id
|
||||||
|
AND e.round_num = eco.round_num
|
||||||
|
AND (e.attacker_steam_id = eco.steam_id_64 OR e.victim_steam_id = eco.steam_id_64)
|
||||||
|
WHERE (e.event_type = 'kill' AND e.attacker_steam_id = eco.steam_id_64)
|
||||||
|
OR (e.event_type = 'kill' AND e.victim_steam_id = eco.steam_id_64) -- Count deaths properly
|
||||||
|
AND eco.equipment_value < 2000
|
||||||
|
AND eco.steam_id_64 IN ({placeholders})
|
||||||
|
GROUP BY eco.steam_id_64
|
||||||
|
"""
|
||||||
|
# Wait, the join condition OR is tricky for grouping.
|
||||||
|
# Let's separate Kills and Deaths or do two queries.
|
||||||
|
# Simpler:
|
||||||
|
|
||||||
|
# Eco Kills
|
||||||
|
q_eco_kills = f"""
|
||||||
|
SELECT
|
||||||
|
e.attacker_steam_id as steam_id_64,
|
||||||
|
COUNT(*) as eco_kills
|
||||||
|
FROM fact_round_events e
|
||||||
|
JOIN fact_round_player_economy eco
|
||||||
|
ON e.match_id = eco.match_id
|
||||||
|
AND e.round_num = eco.round_num
|
||||||
|
AND e.attacker_steam_id = eco.steam_id_64
|
||||||
|
WHERE e.event_type = 'kill'
|
||||||
|
AND eco.equipment_value < 2000
|
||||||
|
AND eco.steam_id_64 IN ({placeholders})
|
||||||
|
GROUP BY e.attacker_steam_id
|
||||||
|
"""
|
||||||
|
df_eco_kills = pd.read_sql_query(q_eco_kills, conn, params=player_ids)
|
||||||
|
|
||||||
|
# Eco Deaths
|
||||||
|
q_eco_deaths = f"""
|
||||||
|
SELECT
|
||||||
|
e.victim_steam_id as steam_id_64,
|
||||||
|
COUNT(*) as eco_deaths
|
||||||
|
FROM fact_round_events e
|
||||||
|
JOIN fact_round_player_economy eco
|
||||||
|
ON e.match_id = eco.match_id
|
||||||
|
AND e.round_num = eco.round_num
|
||||||
|
AND e.victim_steam_id = eco.steam_id_64
|
||||||
|
WHERE e.event_type = 'kill'
|
||||||
|
AND eco.equipment_value < 2000
|
||||||
|
AND eco.steam_id_64 IN ({placeholders})
|
||||||
|
GROUP BY e.victim_steam_id
|
||||||
|
"""
|
||||||
|
df_eco_deaths = pd.read_sql_query(q_eco_deaths, conn, params=player_ids)
|
||||||
|
|
||||||
|
# Get count of eco rounds
|
||||||
|
q_eco_rounds = f"""
|
||||||
|
SELECT steam_id_64, COUNT(*) as eco_round_count
|
||||||
|
FROM fact_round_player_economy
|
||||||
|
WHERE equipment_value < 2000 AND steam_id_64 IN ({placeholders})
|
||||||
|
GROUP BY steam_id_64
|
||||||
|
"""
|
||||||
|
df_eco_cnt = pd.read_sql_query(q_eco_rounds, conn, params=player_ids)
|
||||||
|
|
||||||
|
df_perf = df_eco_cnt.merge(df_eco_kills, on='steam_id_64', how='left').merge(df_eco_deaths, on='steam_id_64', how='left').fillna(0)
|
||||||
|
|
||||||
|
# Eco Rating (KPR)
|
||||||
|
df_perf['eco_rating_eco_rounds'] = df_perf['eco_kills'] / df_perf['eco_round_count'].replace(0, 1)
|
||||||
|
|
||||||
|
# Eco KD
|
||||||
|
df_perf['eco_kd_ratio'] = df_perf['eco_kills'] / df_perf['eco_deaths'].replace(0, 1)
|
||||||
|
|
||||||
|
# Eco Rounds per Match
|
||||||
|
# We need total matches WHERE economy data exists.
|
||||||
|
# Otherwise, if we have 100 matches but only 10 with eco data, the avg will be diluted.
|
||||||
|
q_matches = f"""
|
||||||
|
SELECT steam_id_64, COUNT(DISTINCT match_id) as matches_tracked
|
||||||
|
FROM fact_round_player_economy
|
||||||
|
WHERE steam_id_64 IN ({placeholders})
|
||||||
|
GROUP BY steam_id_64
|
||||||
|
"""
|
||||||
|
df_matches = pd.read_sql_query(q_matches, conn, params=player_ids)
|
||||||
|
|
||||||
|
df_perf = df_perf.merge(df_matches, on='steam_id_64', how='left')
|
||||||
|
df_perf['eco_avg_rounds'] = df_perf['eco_round_count'] / df_perf['matches_tracked'].replace(0, 1)
|
||||||
|
|
||||||
|
# Merge all
|
||||||
|
df_final = df.merge(df_perf[['steam_id_64', 'eco_rating_eco_rounds', 'eco_kd_ratio', 'eco_avg_rounds']], on='steam_id_64', how='left')
|
||||||
|
|
||||||
|
return df_final[['steam_id_64', 'eco_avg_damage_per_1k', 'eco_rating_eco_rounds', 'eco_kd_ratio', 'eco_avg_rounds']]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _calculate_pace_features(conn, player_ids):
|
||||||
|
if not player_ids: return None
|
||||||
|
placeholders = ','.join(['?'] * len(player_ids))
|
||||||
|
|
||||||
|
# 1. Avg Time to First Contact
|
||||||
|
# Find min(event_time) per round per player (Attacker or Victim)
|
||||||
|
q_first_contact = f"""
|
||||||
|
SELECT
|
||||||
|
player_id as steam_id_64,
|
||||||
|
AVG(first_time) as pace_avg_time_to_first_contact
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
match_id, round_num,
|
||||||
|
CASE
|
||||||
|
WHEN attacker_steam_id IN ({placeholders}) THEN attacker_steam_id
|
||||||
|
ELSE victim_steam_id
|
||||||
|
END as player_id,
|
||||||
|
MIN(event_time) as first_time
|
||||||
|
FROM fact_round_events
|
||||||
|
WHERE (attacker_steam_id IN ({placeholders}) OR victim_steam_id IN ({placeholders}))
|
||||||
|
AND event_type IN ('kill', 'death') -- focus on combat
|
||||||
|
GROUP BY match_id, round_num, player_id
|
||||||
|
) sub
|
||||||
|
GROUP BY player_id
|
||||||
|
"""
|
||||||
|
# Note: 'death' isn't an event_type, it's 'kill'.
|
||||||
|
# We check if player is attacker or victim in 'kill' event.
|
||||||
|
|
||||||
|
# Corrected Query:
|
||||||
|
q_first_contact = f"""
|
||||||
|
SELECT
|
||||||
|
player_id as steam_id_64,
|
||||||
|
AVG(first_time) as pace_avg_time_to_first_contact
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
match_id, round_num,
|
||||||
|
p_id as player_id,
|
||||||
|
MIN(event_time) as first_time
|
||||||
|
FROM (
|
||||||
|
SELECT match_id, round_num, event_time, attacker_steam_id as p_id FROM fact_round_events WHERE event_type='kill'
|
||||||
|
UNION ALL
|
||||||
|
SELECT match_id, round_num, event_time, victim_steam_id as p_id FROM fact_round_events WHERE event_type='kill'
|
||||||
|
) raw
|
||||||
|
WHERE p_id IN ({placeholders})
|
||||||
|
GROUP BY match_id, round_num, p_id
|
||||||
|
) sub
|
||||||
|
GROUP BY player_id
|
||||||
|
"""
|
||||||
|
df_time = pd.read_sql_query(q_first_contact, conn, params=player_ids)
|
||||||
|
# Wait, params=player_ids won't work with f-string placeholders if I use ? inside.
|
||||||
|
# My placeholders variable is literal string "?,?,?".
|
||||||
|
# So params should be player_ids.
|
||||||
|
# But in UNION ALL, I have two WHERE clauses.
|
||||||
|
# Actually I can optimize:
|
||||||
|
# WHERE attacker_steam_id IN (...) OR victim_steam_id IN (...)
|
||||||
|
# Then unpivot in python or SQL.
|
||||||
|
|
||||||
|
# Let's use Python for unpivoting to be safe and clear.
|
||||||
|
q_events = f"""
|
||||||
|
SELECT match_id, round_num, event_time, attacker_steam_id, victim_steam_id
|
||||||
|
FROM fact_round_events
|
||||||
|
WHERE event_type='kill'
|
||||||
|
AND (attacker_steam_id IN ({placeholders}) OR victim_steam_id IN ({placeholders}))
|
||||||
|
"""
|
||||||
|
# This params needs player_ids * 2
|
||||||
|
df_ev = pd.read_sql_query(q_events, conn, params=list(player_ids) + list(player_ids))
|
||||||
|
|
||||||
|
pace_list = []
|
||||||
|
if not df_ev.empty:
|
||||||
|
# Unpivot
|
||||||
|
att = df_ev[df_ev['attacker_steam_id'].isin(player_ids)][['match_id', 'round_num', 'event_time', 'attacker_steam_id']].rename(columns={'attacker_steam_id': 'steam_id_64'})
|
||||||
|
vic = df_ev[df_ev['victim_steam_id'].isin(player_ids)][['match_id', 'round_num', 'event_time', 'victim_steam_id']].rename(columns={'victim_steam_id': 'steam_id_64'})
|
||||||
|
combined = pd.concat([att, vic])
|
||||||
|
|
||||||
|
# Group by round, get min time
|
||||||
|
first_contacts = combined.groupby(['match_id', 'round_num', 'steam_id_64'])['event_time'].min().reset_index()
|
||||||
|
|
||||||
|
# Average per player
|
||||||
|
avg_time = first_contacts.groupby('steam_id_64')['event_time'].mean().reset_index()
|
||||||
|
avg_time.rename(columns={'event_time': 'pace_avg_time_to_first_contact'}, inplace=True)
|
||||||
|
pace_list.append(avg_time)
|
||||||
|
|
||||||
|
# 2. Trade Kill Rate
|
||||||
|
# "Kill a killer within 5s of teammate death"
|
||||||
|
# We need to reconstruct the flow.
|
||||||
|
# Iterate matches? Vectorized is hard.
|
||||||
|
# Let's try a simplified approach:
|
||||||
|
# For each match, sort events by time.
|
||||||
|
# If (Kill A->B) at T1, and (Kill C->A) at T2, and T2-T1 <= 5, and C & B are same team.
|
||||||
|
# We don't have team info in events easily (we have side logic elsewhere).
|
||||||
|
# Assuming Side logic: If A->B (A=CT, B=T). Then C->A (C=T).
|
||||||
|
# So B and C are T.
|
||||||
|
|
||||||
|
# Let's fetch basic trade info using self-join in SQL?
|
||||||
|
# A kills B at T1.
|
||||||
|
# C kills A at T2.
|
||||||
|
# T2 > T1 and T2 - T1 <= 5.
|
||||||
|
# C is the Trader. B is the Victim (Teammate).
|
||||||
|
# We want C's Trade Rate.
|
||||||
|
|
||||||
|
q_trades = f"""
|
||||||
|
SELECT
|
||||||
|
t2.attacker_steam_id as trader_id,
|
||||||
|
COUNT(*) as trade_count
|
||||||
|
FROM fact_round_events t1
|
||||||
|
JOIN fact_round_events t2
|
||||||
|
ON t1.match_id = t2.match_id
|
||||||
|
AND t1.round_num = t2.round_num
|
||||||
|
WHERE t1.event_type = 'kill' AND t2.event_type = 'kill'
|
||||||
|
AND t1.attacker_steam_id = t2.victim_steam_id -- Avenger kills the Killer
|
||||||
|
AND t2.event_time > t1.event_time
|
||||||
|
AND t2.event_time - t1.event_time <= 5
|
||||||
|
AND t2.attacker_steam_id IN ({placeholders})
|
||||||
|
GROUP BY t2.attacker_steam_id
|
||||||
|
"""
|
||||||
|
df_trades = pd.read_sql_query(q_trades, conn, params=player_ids)
|
||||||
|
|
||||||
|
# Denominator: Opportunities? Or just Total Kills?
|
||||||
|
# Trade Kill Rate usually means % of Kills that were Trades.
|
||||||
|
# Let's use that.
|
||||||
|
|
||||||
|
# Get Total Kills
|
||||||
|
q_kills = f"""
|
||||||
|
SELECT attacker_steam_id as steam_id_64, COUNT(*) as total_kills
|
||||||
|
FROM fact_round_events
|
||||||
|
WHERE event_type='kill' AND attacker_steam_id IN ({placeholders})
|
||||||
|
GROUP BY attacker_steam_id
|
||||||
|
"""
|
||||||
|
df_tot_kills = pd.read_sql_query(q_kills, conn, params=player_ids)
|
||||||
|
|
||||||
|
if not df_trades.empty:
|
||||||
|
df_trades = df_trades.merge(df_tot_kills, left_on='trader_id', right_on='steam_id_64', how='right').fillna(0)
|
||||||
|
df_trades['pace_trade_kill_rate'] = df_trades['trade_count'] / df_trades['total_kills'].replace(0, 1)
|
||||||
|
else:
|
||||||
|
df_trades = df_tot_kills.copy()
|
||||||
|
df_trades['pace_trade_kill_rate'] = 0
|
||||||
|
|
||||||
|
df_final = pd.DataFrame({'steam_id_64': list(player_ids)})
|
||||||
|
|
||||||
|
if pace_list:
|
||||||
|
df_final = df_final.merge(pace_list[0], on='steam_id_64', how='left')
|
||||||
|
|
||||||
|
# Merge Trade Rate
|
||||||
|
if not df_trades.empty:
|
||||||
|
df_final = df_final.merge(df_trades[['steam_id_64', 'pace_trade_kill_rate']], on='steam_id_64', how='left')
|
||||||
|
|
||||||
|
# 3. New Pace Metrics
|
||||||
|
# pace_opening_kill_time: Avg time of Opening Kills (where attacker_steam_id = player AND is_first_kill = 1?)
|
||||||
|
# Wait, fact_round_events doesn't store 'is_first_kill' directly? It stores 'first_kill' in fact_match_players but that's aggregate.
|
||||||
|
# It stores 'event_type'. We need to check if it was the FIRST kill of the round.
|
||||||
|
# Query: For each round, find the FIRST kill event. Check if attacker is our player. Get time.
|
||||||
|
|
||||||
|
q_opening_time = f"""
|
||||||
|
SELECT
|
||||||
|
attacker_steam_id as steam_id_64,
|
||||||
|
AVG(event_time) as pace_opening_kill_time
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
match_id, round_num,
|
||||||
|
attacker_steam_id,
|
||||||
|
MIN(event_time) as event_time
|
||||||
|
FROM fact_round_events
|
||||||
|
WHERE event_type='kill'
|
||||||
|
GROUP BY match_id, round_num
|
||||||
|
) first_kills
|
||||||
|
WHERE attacker_steam_id IN ({placeholders})
|
||||||
|
GROUP BY attacker_steam_id
|
||||||
|
"""
|
||||||
|
df_opening_time = pd.read_sql_query(q_opening_time, conn, params=player_ids)
|
||||||
|
|
||||||
|
# pace_avg_life_time: Avg time alive per round
|
||||||
|
# Logic: Round Duration - Death Time (if died). Else Round Duration.
|
||||||
|
# We need Round Duration (fact_rounds doesn't have duration? fact_matches has match duration).
|
||||||
|
# Usually round duration is fixed or we use last event time.
|
||||||
|
# Let's approximate: If died, time = death_time. If survived, time = max_event_time_of_round.
|
||||||
|
# Better: survival time.
|
||||||
|
|
||||||
|
q_survival = f"""
|
||||||
|
SELECT
|
||||||
|
p.steam_id_64,
|
||||||
|
AVG(
|
||||||
|
CASE
|
||||||
|
WHEN d.death_time IS NOT NULL THEN d.death_time
|
||||||
|
ELSE r.round_end_time -- Use max event time as proxy for round end
|
||||||
|
END
|
||||||
|
) as pace_avg_life_time
|
||||||
|
FROM fact_match_players p
|
||||||
|
JOIN (
|
||||||
|
SELECT match_id, round_num, MAX(event_time) as round_end_time
|
||||||
|
FROM fact_round_events
|
||||||
|
GROUP BY match_id, round_num
|
||||||
|
) r ON p.match_id = r.match_id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT match_id, round_num, victim_steam_id, MIN(event_time) as death_time
|
||||||
|
FROM fact_round_events
|
||||||
|
WHERE event_type='kill'
|
||||||
|
GROUP BY match_id, round_num, victim_steam_id
|
||||||
|
) d ON p.match_id = d.match_id AND p.steam_id_64 = d.victim_steam_id
|
||||||
|
-- We need to join rounds to ensure we track every round the player played?
|
||||||
|
-- fact_match_players is per match. We need per round.
|
||||||
|
-- We can use fact_round_player_economy to get all rounds a player played.
|
||||||
|
JOIN fact_round_player_economy e ON p.match_id = e.match_id AND p.steam_id_64 = e.steam_id_64 AND r.round_num = e.round_num
|
||||||
|
WHERE p.steam_id_64 IN ({placeholders})
|
||||||
|
GROUP BY p.steam_id_64
|
||||||
|
"""
|
||||||
|
# This join is heavy. Let's simplify.
|
||||||
|
# Just use death events for "Time of Death".
|
||||||
|
# And for rounds without death, use 115s (avg round length)? Or max event time?
|
||||||
|
# Let's stick to what we have.
|
||||||
|
|
||||||
|
df_survival = pd.read_sql_query(q_survival, conn, params=player_ids)
|
||||||
|
|
||||||
|
if not df_opening_time.empty:
|
||||||
|
df_final = df_final.merge(df_opening_time, on='steam_id_64', how='left')
|
||||||
|
|
||||||
|
if not df_survival.empty:
|
||||||
|
df_final = df_final.merge(df_survival, on='steam_id_64', how='left')
|
||||||
|
|
||||||
|
return df_final.fillna(0)
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _calculate_ultimate_scores(df):
|
def _calculate_ultimate_scores(df):
|
||||||
@@ -1203,6 +1572,19 @@ class FeatureService:
|
|||||||
0.20 * n('util_avg_flash_enemy')
|
0.20 * n('util_avg_flash_enemy')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ECO (New)
|
||||||
|
df['score_eco'] = (
|
||||||
|
0.50 * n('eco_avg_damage_per_1k') +
|
||||||
|
0.50 * n('eco_rating_eco_rounds')
|
||||||
|
)
|
||||||
|
|
||||||
|
# PACE (New)
|
||||||
|
# Aggression Score: Faster first contact (lower time) -> higher score
|
||||||
|
df['score_pace'] = (
|
||||||
|
0.50 * (100 - n('pace_avg_time_to_first_contact')) +
|
||||||
|
0.50 * n('pace_trade_kill_rate')
|
||||||
|
)
|
||||||
|
|
||||||
return df
|
return df
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -1228,13 +1610,8 @@ class FeatureService:
|
|||||||
|
|
||||||
# 2. Fetch L3 features for all roster members
|
# 2. Fetch L3 features for all roster members
|
||||||
placeholders = ','.join('?' for _ in active_roster_ids)
|
placeholders = ','.join('?' for _ in active_roster_ids)
|
||||||
sql = f"""
|
# Select all columns (simplified) or explicit list including raw metrics
|
||||||
SELECT
|
sql = f"SELECT * FROM dm_player_features WHERE steam_id_64 IN ({placeholders})"
|
||||||
steam_id_64,
|
|
||||||
score_bat, score_sta, score_hps, score_ptl, score_tct, score_util
|
|
||||||
FROM dm_player_features
|
|
||||||
WHERE steam_id_64 IN ({placeholders})
|
|
||||||
"""
|
|
||||||
rows = query_db('l3', sql, active_roster_ids)
|
rows = query_db('l3', sql, active_roster_ids)
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
@@ -1245,23 +1622,73 @@ class FeatureService:
|
|||||||
|
|
||||||
# If target not in map (maybe no L3 data yet), default to 0
|
# If target not in map (maybe no L3 data yet), default to 0
|
||||||
if target_steam_id not in stats_map:
|
if target_steam_id not in stats_map:
|
||||||
stats_map[target_steam_id] = {
|
stats_map[target_steam_id] = {} # Empty dict, will fallback to 0 in loop
|
||||||
'score_bat': 0, 'score_sta': 0, 'score_hps': 0,
|
|
||||||
'score_ptl': 0, 'score_tct': 0, 'score_util': 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# 3. Calculate Distribution
|
# 3. Calculate Distribution
|
||||||
metrics = ['score_bat', 'score_sta', 'score_hps', 'score_ptl', 'score_tct', 'score_util']
|
# Include Scores AND Raw Metrics used in Profile
|
||||||
|
metrics = [
|
||||||
|
# Scores
|
||||||
|
'score_bat', 'score_sta', 'score_hps', 'score_ptl', 'score_tct', 'score_util', 'score_eco', 'score_pace',
|
||||||
|
# Core
|
||||||
|
'basic_avg_rating', 'basic_avg_kd', 'basic_avg_adr', 'basic_avg_kast', 'basic_avg_rws',
|
||||||
|
# Combat
|
||||||
|
'basic_avg_headshot_kills', 'basic_headshot_rate', 'basic_avg_assisted_kill', 'basic_avg_awp_kill', 'basic_avg_jump_count',
|
||||||
|
# Obj
|
||||||
|
'basic_avg_mvps', 'basic_avg_plants', 'basic_avg_defuses', 'basic_avg_flash_assists',
|
||||||
|
# Opening
|
||||||
|
'basic_avg_first_kill', 'basic_avg_first_death', 'basic_first_kill_rate', 'basic_first_death_rate',
|
||||||
|
# Multi
|
||||||
|
'basic_avg_kill_2', 'basic_avg_kill_3', 'basic_avg_kill_4', 'basic_avg_kill_5',
|
||||||
|
'basic_avg_perfect_kill', 'basic_avg_revenge_kill',
|
||||||
|
# STA & BAT Details
|
||||||
|
'sta_last_30_rating', 'sta_win_rating', 'sta_loss_rating', 'sta_rating_volatility', 'sta_time_rating_corr',
|
||||||
|
'bat_kd_diff_high_elo', 'bat_avg_duel_win_rate',
|
||||||
|
# HPS & PTL Details
|
||||||
|
'hps_clutch_win_rate_1v1', 'hps_clutch_win_rate_1v3_plus', 'hps_match_point_win_rate', 'hps_pressure_entry_rate',
|
||||||
|
'hps_comeback_kd_diff', 'hps_losing_streak_kd_diff',
|
||||||
|
'ptl_pistol_kills', 'ptl_pistol_win_rate', 'ptl_pistol_kd', 'ptl_pistol_util_efficiency',
|
||||||
|
# UTIL Details
|
||||||
|
'util_usage_rate', 'util_avg_nade_dmg', 'util_avg_flash_time', 'util_avg_flash_enemy',
|
||||||
|
# ECO & PACE (New)
|
||||||
|
'eco_avg_damage_per_1k', 'eco_rating_eco_rounds', 'eco_kd_ratio', 'eco_avg_rounds',
|
||||||
|
'pace_avg_time_to_first_contact', 'pace_trade_kill_rate', 'pace_opening_kill_time', 'pace_avg_life_time',
|
||||||
|
# Party
|
||||||
|
'party_1_win_rate', 'party_1_rating', 'party_1_adr',
|
||||||
|
'party_2_win_rate', 'party_2_rating', 'party_2_adr',
|
||||||
|
'party_3_win_rate', 'party_3_rating', 'party_3_adr',
|
||||||
|
'party_4_win_rate', 'party_4_rating', 'party_4_adr',
|
||||||
|
'party_5_win_rate', 'party_5_rating', 'party_5_adr',
|
||||||
|
# Rating Dist
|
||||||
|
'rating_dist_carry_rate', 'rating_dist_normal_rate', 'rating_dist_sacrifice_rate', 'rating_dist_sleeping_rate',
|
||||||
|
# ELO
|
||||||
|
'elo_lt1200_rating', 'elo_1200_1400_rating', 'elo_1400_1600_rating', 'elo_1600_1800_rating', 'elo_1800_2000_rating', 'elo_gt2000_rating'
|
||||||
|
]
|
||||||
|
|
||||||
result = {}
|
result = {}
|
||||||
|
|
||||||
for m in metrics:
|
for m in metrics:
|
||||||
values = [p.get(m, 0) or 0 for p in stats_map.values()]
|
# Handle missing columns gracefully
|
||||||
target_val = stats_map[target_steam_id].get(m, 0) or 0
|
values = []
|
||||||
|
for p in stats_map.values():
|
||||||
|
val = p.get(m)
|
||||||
|
if val is None: val = 0
|
||||||
|
values.append(float(val))
|
||||||
|
|
||||||
|
target_val = stats_map[target_steam_id].get(m)
|
||||||
|
if target_val is None: target_val = 0
|
||||||
|
target_val = float(target_val)
|
||||||
|
|
||||||
if not values:
|
if not values:
|
||||||
result[m] = None
|
result[m] = None
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# For PACE (Time), lower is better usually, but rank logic assumes Higher is Better (reverse=True).
|
||||||
|
# If we want Rank #1 to be Lowest Time, we should sort normal.
|
||||||
|
# But standardized scores handle this. For raw metrics, let's keep consistent (Higher = Rank 1)
|
||||||
|
# unless we explicitly handle "Low is Good".
|
||||||
|
# For now, keep simple: Rank 1 = Highest Value.
|
||||||
|
# For Time: Rank 1 = Slowest. (User can interpret)
|
||||||
|
|
||||||
values.sort(reverse=True)
|
values.sort(reverse=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -5,21 +5,24 @@ import os
|
|||||||
class StatsService:
|
class StatsService:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def resolve_avatar_url(steam_id, avatar_url):
|
def resolve_avatar_url(steam_id, avatar_url):
|
||||||
|
"""
|
||||||
|
Resolves avatar URL with priority:
|
||||||
|
1. Local File (web/static/avatars/{steam_id}.jpg/png) - User override
|
||||||
|
2. DB Value (avatar_url)
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Check local file first (User Request: "directly associate if exists")
|
||||||
|
base = os.path.join(current_app.root_path, 'static', 'avatars')
|
||||||
|
for ext in ('.jpg', '.png', '.jpeg'):
|
||||||
|
fname = f"{steam_id}{ext}"
|
||||||
|
fpath = os.path.join(base, fname)
|
||||||
|
if os.path.exists(fpath):
|
||||||
|
return url_for('static', filename=f'avatars/{fname}')
|
||||||
|
|
||||||
|
# Fallback to DB value if valid
|
||||||
if avatar_url and str(avatar_url).strip():
|
if avatar_url and str(avatar_url).strip():
|
||||||
return avatar_url
|
return avatar_url
|
||||||
base = os.path.join(current_app.root_path, 'static', 'avatars')
|
|
||||||
# Check jpg/png in order
|
|
||||||
for ext in ('.jpg', '.png'):
|
|
||||||
fname = f"{steam_id}{ext}"
|
|
||||||
if os.path.exists(os.path.join(base, fname)):
|
|
||||||
url = url_for('static', filename=f'avatars/{fname}')
|
|
||||||
try:
|
|
||||||
# Persist fallback URL into L2 for future reads
|
|
||||||
execute_db('l2', "UPDATE dim_players SET avatar_url = ? WHERE steam_id_64 = ?", [url, str(steam_id)])
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return url
|
|
||||||
return None
|
return None
|
||||||
except Exception:
|
except Exception:
|
||||||
return avatar_url
|
return avatar_url
|
||||||
@@ -739,6 +742,9 @@ class StatsService:
|
|||||||
'side_headshot_rate_ct', 'side_headshot_rate_t',
|
'side_headshot_rate_ct', 'side_headshot_rate_t',
|
||||||
'side_defuses_ct', 'side_plants_t',
|
'side_defuses_ct', 'side_plants_t',
|
||||||
'util_avg_nade_dmg', 'util_avg_flash_time', 'util_avg_flash_enemy', 'util_usage_rate',
|
'util_avg_nade_dmg', 'util_avg_flash_time', 'util_avg_flash_enemy', 'util_usage_rate',
|
||||||
|
# New: ECO & PACE
|
||||||
|
'eco_avg_damage_per_1k', 'eco_rating_eco_rounds', 'eco_kd_ratio', 'eco_avg_rounds',
|
||||||
|
'pace_avg_time_to_first_contact', 'pace_trade_kill_rate', 'pace_opening_kill_time', 'pace_avg_life_time',
|
||||||
# New: Party Size Stats
|
# New: Party Size Stats
|
||||||
'party_1_win_rate', 'party_1_rating', 'party_1_adr',
|
'party_1_win_rate', 'party_1_rating', 'party_1_adr',
|
||||||
'party_2_win_rate', 'party_2_rating', 'party_2_adr',
|
'party_2_win_rate', 'party_2_rating', 'party_2_adr',
|
||||||
@@ -759,6 +765,9 @@ class StatsService:
|
|||||||
# Mapping for L2 legacy calls (if any) - mainly map 'rating' to 'basic_avg_rating' etc if needed
|
# Mapping for L2 legacy calls (if any) - mainly map 'rating' to 'basic_avg_rating' etc if needed
|
||||||
# But here we just use L3 columns directly.
|
# But here we just use L3 columns directly.
|
||||||
|
|
||||||
|
# Define metrics where LOWER is BETTER
|
||||||
|
lower_is_better = ['pace_avg_time_to_first_contact', 'pace_opening_kill_time']
|
||||||
|
|
||||||
result = {}
|
result = {}
|
||||||
|
|
||||||
for m in metrics:
|
for m in metrics:
|
||||||
@@ -769,7 +778,9 @@ class StatsService:
|
|||||||
result[m] = None
|
result[m] = None
|
||||||
continue
|
continue
|
||||||
|
|
||||||
values.sort(reverse=True)
|
# Sort: Reverse (High to Low) by default, unless in lower_is_better
|
||||||
|
is_reverse = m not in lower_is_better
|
||||||
|
values.sort(reverse=is_reverse)
|
||||||
|
|
||||||
# Rank
|
# Rank
|
||||||
try:
|
try:
|
||||||
@@ -783,7 +794,8 @@ class StatsService:
|
|||||||
'total': len(values),
|
'total': len(values),
|
||||||
'min': min(values),
|
'min': min(values),
|
||||||
'max': max(values),
|
'max': max(values),
|
||||||
'avg': sum(values) / len(values)
|
'avg': sum(values) / len(values),
|
||||||
|
'inverted': not is_reverse # Flag for frontend to invert bar
|
||||||
}
|
}
|
||||||
|
|
||||||
# Legacy mapping for top cards (rating, kd, adr, kast)
|
# Legacy mapping for top cards (rating, kd, adr, kast)
|
||||||
|
|||||||
@@ -183,15 +183,22 @@
|
|||||||
{% if dist %}
|
{% if dist %}
|
||||||
<div class="w-full h-1 bg-gray-100 dark:bg-slate-700 rounded-full overflow-hidden relative mt-1">
|
<div class="w-full h-1 bg-gray-100 dark:bg-slate-700 rounded-full overflow-hidden relative mt-1">
|
||||||
{% set range = dist.max - dist.min %}
|
{% set range = dist.max - dist.min %}
|
||||||
{% set percent = ((dist.val - dist.min) / range * 100) if range > 0 else 100 %}
|
{% set raw_percent = ((dist.val - dist.min) / range * 100) if range > 0 else 100 %}
|
||||||
|
{% set percent = (100 - raw_percent) if dist.inverted else raw_percent %}
|
||||||
<div class="absolute h-full bg-yrtv-400/60 rounded-full" style="width: {{ percent }}%"></div>
|
<div class="absolute h-full bg-yrtv-400/60 rounded-full" style="width: {{ percent }}%"></div>
|
||||||
<!-- Avg Marker -->
|
<!-- Avg Marker -->
|
||||||
{% set avg_pct = ((dist.avg - dist.min) / range * 100) if range > 0 else 50 %}
|
{% set raw_avg = ((dist.avg - dist.min) / range * 100) if range > 0 else 50 %}
|
||||||
|
{% set avg_pct = (100 - raw_avg) if dist.inverted else raw_avg %}
|
||||||
<div class="absolute h-full w-0.5 bg-gray-400 dark:bg-slate-400 top-0" style="left: {{ avg_pct }}%"></div>
|
<div class="absolute h-full w-0.5 bg-gray-400 dark:bg-slate-400 top-0" style="left: {{ avg_pct }}%"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between text-[9px] text-gray-300 dark:text-gray-600 font-mono mt-0.5">
|
<div class="flex justify-between text-[9px] text-gray-300 dark:text-gray-600 font-mono mt-0.5">
|
||||||
|
{% if dist.inverted %}
|
||||||
|
<span>L:{{ format_str.format(dist.max) }}</span>
|
||||||
|
<span>H:{{ format_str.format(dist.min) }}</span>
|
||||||
|
{% else %}
|
||||||
<span>L:{{ format_str.format(dist.min) }}</span>
|
<span>L:{{ format_str.format(dist.min) }}</span>
|
||||||
<span>H:{{ format_str.format(dist.max) }}</span>
|
<span>H:{{ format_str.format(dist.max) }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -296,6 +303,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Group 4: ECO & PACE (New) -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-xs font-black text-gray-400 uppercase tracking-widest mb-4 border-b border-gray-100 dark:border-slate-700 pb-2">
|
||||||
|
ECO (Economy) & PACE (Tempo)
|
||||||
|
</h4>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-y-6 gap-x-4">
|
||||||
|
{{ detail_item('Dmg/$1k (性价比)', features['eco_avg_damage_per_1k'], 'eco_avg_damage_per_1k', '{:.1f}') }}
|
||||||
|
{{ detail_item('Eco KPR (经济局KPR)', features['eco_rating_eco_rounds'], 'eco_rating_eco_rounds') }}
|
||||||
|
{{ detail_item('Eco KD (经济局KD)', features['eco_kd_ratio'], 'eco_kd_ratio', '{:.2f}') }}
|
||||||
|
{{ detail_item('Eco Rounds (经济局数)', features['eco_avg_rounds'], 'eco_avg_rounds', '{:.1f}') }}
|
||||||
|
|
||||||
|
{{ detail_item('First Contact (首肯时间)', features['pace_avg_time_to_first_contact'], 'pace_avg_time_to_first_contact', '{:.1f}s') }}
|
||||||
|
{{ detail_item('Trade Kill% (补枪率)', features['pace_trade_kill_rate'], 'pace_trade_kill_rate', '{:.1%}') }}
|
||||||
|
{{ detail_item('Opening Time (首杀时间)', features['pace_opening_kill_time'], 'pace_opening_kill_time', '{:.1f}s') }}
|
||||||
|
{{ detail_item('Avg Life (存活时间)', features['pace_avg_life_time'], 'pace_avg_life_time', '{:.1f}s') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Group 5: SPECIAL (Clutch & Multi) -->
|
<!-- Group 5: SPECIAL (Clutch & Multi) -->
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-xs font-black text-gray-400 uppercase tracking-widest mb-4 border-b border-gray-100 dark:border-slate-700 pb-2">
|
<h4 class="text-xs font-black text-gray-400 uppercase tracking-widest mb-4 border-b border-gray-100 dark:border-slate-700 pb-2">
|
||||||
@@ -720,9 +745,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const getDist = (key) => dist[key] || { rank: '?', avg: 0 };
|
const getDist = (key) => dist[key] || { rank: '?', avg: 0 };
|
||||||
|
|
||||||
// Map friendly names to keys
|
// Map friendly names to keys
|
||||||
const keys = ['score_bat', 'score_hps', 'score_ptl', 'score_tct', 'score_util', 'score_sta'];
|
const keys = ['score_bat', 'score_hps', 'score_ptl', 'score_tct', 'score_util', 'score_sta', 'score_eco', 'score_pace'];
|
||||||
// Corresponding Labels
|
// Corresponding Labels
|
||||||
const rawLabels = ['Aim (BAT)', 'Clutch (HPS)', 'Pistol (PTL)', 'Defense (SIDE)', 'Util (UTIL)', 'Stability (STA)'];
|
const rawLabels = ['Aim (BAT)', 'Clutch (HPS)', 'Pistol (PTL)', 'Defense (SIDE)', 'Util (UTIL)', 'Stability (STA)', 'Economy (ECO)', 'Pace (PACE)'];
|
||||||
|
|
||||||
const labels = rawLabels.map((l, i) => {
|
const labels = rawLabels.map((l, i) => {
|
||||||
const k = keys[i];
|
const k = keys[i];
|
||||||
@@ -742,7 +767,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
data: [
|
data: [
|
||||||
data.radar.BAT, data.radar.HPS,
|
data.radar.BAT, data.radar.HPS,
|
||||||
data.radar.PTL, data.radar.SIDE, data.radar.UTIL,
|
data.radar.PTL, data.radar.SIDE, data.radar.UTIL,
|
||||||
data.radar.STA
|
data.radar.STA, data.radar.ECO, data.radar.PACE
|
||||||
],
|
],
|
||||||
backgroundColor: 'rgba(124, 58, 237, 0.2)',
|
backgroundColor: 'rgba(124, 58, 237, 0.2)',
|
||||||
borderColor: '#7c3aed',
|
borderColor: '#7c3aed',
|
||||||
|
|||||||
Reference in New Issue
Block a user