2.1 : New

This commit is contained in:
2026-01-28 01:20:26 +08:00
parent b3941cad3b
commit 4afb728bfa
12 changed files with 1084 additions and 16 deletions

3
.gitignore vendored
View File

@@ -67,3 +67,6 @@ venv.bak/
output/ output/
output_arena/ output_arena/
arena/ arena/
scripts/
experiment
yrtv.zip

View File

@@ -157,6 +157,7 @@ class PlayerEconomy:
main_weapon: str = "" main_weapon: str = ""
has_helmet: bool = False has_helmet: bool = False
has_defuser: bool = False has_defuser: bool = False
has_zeus: bool = False
round_performance_score: float = 0.0 round_performance_score: float = 0.0
@dataclass @dataclass
@@ -865,6 +866,9 @@ class MatchParser:
if evt.get('trade_score_change'): if evt.get('trade_score_change'):
re.trade_killer_steam_id = list(evt['trade_score_change'].keys())[0] re.trade_killer_steam_id = list(evt['trade_score_change'].keys())[0]
if evt.get('assist_killer_score_change'):
re.assister_steam_id = list(evt['assist_killer_score_change'].keys())[0]
if evt.get('flash_assist_killer_score_change'): if evt.get('flash_assist_killer_score_change'):
re.flash_assist_steam_id = list(evt['flash_assist_killer_score_change'].keys())[0] re.flash_assist_steam_id = list(evt['flash_assist_killer_score_change'].keys())[0]
@@ -944,6 +948,7 @@ class MatchParser:
has_helmet = False has_helmet = False
has_defuser = False has_defuser = False
has_zeus = False
if isinstance(items, list): if isinstance(items, list):
for it in items: for it in items:
if isinstance(it, dict): if isinstance(it, dict):
@@ -952,6 +957,8 @@ class MatchParser:
has_helmet = True has_helmet = True
elif name == 'item_defuser': elif name == 'item_defuser':
has_defuser = True has_defuser = True
elif name and ('taser' in name or 'zeus' in name):
has_zeus = True
rd.economies.append(PlayerEconomy( rd.economies.append(PlayerEconomy(
steam_id_64=str(sid), steam_id_64=str(sid),
@@ -961,6 +968,7 @@ class MatchParser:
main_weapon=main_weapon, main_weapon=main_weapon,
has_helmet=has_helmet, has_helmet=has_helmet,
has_defuser=has_defuser, has_defuser=has_defuser,
has_zeus=has_zeus,
round_performance_score=float(score) round_performance_score=float(score)
)) ))
@@ -1026,6 +1034,28 @@ class MatchParser:
victim_pos=(vpos.get('x', 0), vpos.get('y', 0), vpos.get('z', 0)) victim_pos=(vpos.get('x', 0), vpos.get('y', 0), vpos.get('z', 0))
) )
rd.events.append(re) rd.events.append(re)
c4_events = r.get('c4_event', [])
for e in c4_events:
if not isinstance(e, dict):
continue
event_name = str(e.get('event_name') or '').lower()
if not event_name:
continue
if 'plant' in event_name:
etype = 'bomb_plant'
elif 'defus' in event_name:
etype = 'bomb_defuse'
else:
continue
sid = e.get('steamid_64')
re = RoundEvent(
event_id=f"{self.match_id}_{rd.round_num}_{etype}_{e.get('pasttime', 0)}_{sid}",
event_type=etype,
event_time=int(e.get('pasttime', 0) or 0),
attacker_steam_id=str(sid) if sid is not None else None,
)
rd.events.append(re)
self.match_data.rounds.append(rd) self.match_data.rounds.append(rd)
@@ -1325,14 +1355,14 @@ def save_match(cursor, m: MatchData):
cursor.execute(""" cursor.execute("""
INSERT OR REPLACE INTO fact_round_events INSERT OR REPLACE INTO fact_round_events
(event_id, match_id, round_num, event_type, event_time, attacker_steam_id, victim_steam_id, (event_id, match_id, round_num, event_type, event_time, attacker_steam_id, victim_steam_id, assister_steam_id,
weapon, is_headshot, is_wallbang, is_blind, is_through_smoke, is_noscope, weapon, is_headshot, is_wallbang, is_blind, is_through_smoke, is_noscope,
trade_killer_steam_id, flash_assist_steam_id, score_change_attacker, score_change_victim, trade_killer_steam_id, flash_assist_steam_id, score_change_attacker, score_change_victim,
attacker_pos_x, attacker_pos_y, attacker_pos_z, victim_pos_x, victim_pos_y, victim_pos_z) attacker_pos_x, attacker_pos_y, attacker_pos_z, victim_pos_x, victim_pos_y, victim_pos_z)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", ( """, (
e.event_id, m.match_id, r.round_num, e.event_type, e.event_time, e.attacker_steam_id, e.victim_steam_id, e.event_id, m.match_id, r.round_num, e.event_type, e.event_time, e.attacker_steam_id, e.victim_steam_id,
e.weapon, e.is_headshot, e.is_wallbang, e.is_blind, e.is_through_smoke, e.is_noscope, e.assister_steam_id, e.weapon, e.is_headshot, e.is_wallbang, e.is_blind, e.is_through_smoke, e.is_noscope,
e.trade_killer_steam_id, e.flash_assist_steam_id, e.score_change_attacker, e.score_change_victim, e.trade_killer_steam_id, e.flash_assist_steam_id, e.score_change_attacker, e.score_change_victim,
ax, ay, az, vx, vy, vz ax, ay, az, vx, vy, vz
)) ))
@@ -1340,10 +1370,10 @@ def save_match(cursor, m: MatchData):
for pe in r.economies: for pe in r.economies:
cursor.execute(""" cursor.execute("""
INSERT OR REPLACE INTO fact_round_player_economy INSERT OR REPLACE INTO fact_round_player_economy
(match_id, round_num, steam_id_64, side, start_money, equipment_value, main_weapon, has_helmet, has_defuser, round_performance_score) (match_id, round_num, steam_id_64, side, start_money, equipment_value, main_weapon, has_helmet, has_defuser, has_zeus, round_performance_score)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", ( """, (
m.match_id, r.round_num, pe.steam_id_64, pe.side, pe.start_money, pe.equipment_value, pe.main_weapon, pe.has_helmet, pe.has_defuser, pe.round_performance_score m.match_id, r.round_num, pe.steam_id_64, pe.side, pe.start_money, pe.equipment_value, pe.main_weapon, pe.has_helmet, pe.has_defuser, pe.has_zeus, pe.round_performance_score
)) ))
# 6. Calculate & Save Clutch Attempts # 6. Calculate & Save Clutch Attempts

View File

@@ -18,6 +18,17 @@ logger = logging.getLogger(__name__)
L3_DB_PATH = Config.DB_L3_PATH L3_DB_PATH = Config.DB_L3_PATH
SCHEMA_PATH = os.path.join(Config.BASE_DIR, 'database', 'L3', 'schema.sql') SCHEMA_PATH = os.path.join(Config.BASE_DIR, 'database', 'L3', 'schema.sql')
def _get_existing_columns(conn, table_name):
cur = conn.execute(f"PRAGMA table_info({table_name})")
return {row[1] for row in cur.fetchall()}
def _ensure_columns(conn, table_name, columns):
existing = _get_existing_columns(conn, table_name)
for col, col_type in columns.items():
if col in existing:
continue
conn.execute(f"ALTER TABLE {table_name} ADD COLUMN {col} {col_type}")
def init_db(): def init_db():
l3_dir = os.path.dirname(L3_DB_PATH) l3_dir = os.path.dirname(L3_DB_PATH)
if not os.path.exists(l3_dir): if not os.path.exists(l3_dir):
@@ -26,6 +37,40 @@ def init_db():
conn = sqlite3.connect(L3_DB_PATH) conn = sqlite3.connect(L3_DB_PATH)
with open(SCHEMA_PATH, 'r', encoding='utf-8') as f: with open(SCHEMA_PATH, 'r', encoding='utf-8') as f:
conn.executescript(f.read()) conn.executescript(f.read())
_ensure_columns(
conn,
"dm_player_features",
{
"rd_phase_kill_early_share": "REAL",
"rd_phase_kill_mid_share": "REAL",
"rd_phase_kill_late_share": "REAL",
"rd_phase_death_early_share": "REAL",
"rd_phase_death_mid_share": "REAL",
"rd_phase_death_late_share": "REAL",
"rd_firstdeath_team_first_death_rounds": "INTEGER",
"rd_firstdeath_team_first_death_win_rate": "REAL",
"rd_invalid_death_rounds": "INTEGER",
"rd_invalid_death_rate": "REAL",
"rd_pressure_kpr_ratio": "REAL",
"rd_pressure_perf_ratio": "REAL",
"rd_pressure_rounds_down3": "INTEGER",
"rd_pressure_rounds_normal": "INTEGER",
"rd_matchpoint_kpr_ratio": "REAL",
"rd_matchpoint_perf_ratio": "REAL",
"rd_matchpoint_rounds": "INTEGER",
"rd_comeback_kill_share": "REAL",
"rd_comeback_rounds": "INTEGER",
"rd_trade_response_10s_rate": "REAL",
"rd_weapon_top_json": "TEXT",
"rd_roundtype_split_json": "TEXT",
"map_stability_coef": "REAL",
"basic_avg_knife_kill": "REAL",
"basic_avg_zeus_kill": "REAL",
"basic_zeus_pick_rate": "REAL",
},
)
conn.commit() conn.commit()
conn.close() conn.close()
logger.info("L3 DB Initialized/Updated with Schema.") logger.info("L3 DB Initialized/Updated with Schema.")

Binary file not shown.

View File

@@ -573,6 +573,7 @@ CREATE TABLE IF NOT EXISTS fact_round_player_economy (
main_weapon TEXT, main_weapon TEXT,
has_helmet BOOLEAN, has_helmet BOOLEAN,
has_defuser BOOLEAN, has_defuser BOOLEAN,
has_zeus BOOLEAN,
-- Round Performance Summary (Leetify) -- Round Performance Summary (Leetify)
round_performance_score REAL, round_performance_score REAL,

Binary file not shown.

View File

@@ -32,6 +32,9 @@ CREATE TABLE IF NOT EXISTS dm_player_features (
basic_avg_revenge_kill REAL, basic_avg_revenge_kill REAL,
basic_avg_awp_kill REAL, basic_avg_awp_kill REAL,
basic_avg_jump_count REAL, basic_avg_jump_count REAL,
basic_avg_knife_kill REAL,
basic_avg_zeus_kill REAL,
basic_zeus_pick_rate REAL,
basic_avg_mvps REAL, basic_avg_mvps REAL,
basic_avg_plants REAL, basic_avg_plants REAL,
basic_avg_defuses REAL, basic_avg_defuses REAL,
@@ -194,7 +197,30 @@ CREATE TABLE IF NOT EXISTS dm_player_features (
pace_avg_time_to_first_contact REAL, pace_avg_time_to_first_contact REAL,
pace_trade_kill_rate REAL, pace_trade_kill_rate REAL,
pace_opening_kill_time REAL, pace_opening_kill_time REAL,
pace_avg_life_time REAL pace_avg_life_time REAL,
rd_phase_kill_early_share REAL,
rd_phase_kill_mid_share REAL,
rd_phase_kill_late_share REAL,
rd_phase_death_early_share REAL,
rd_phase_death_mid_share REAL,
rd_phase_death_late_share REAL,
rd_firstdeath_team_first_death_rounds INTEGER,
rd_firstdeath_team_first_death_win_rate REAL,
rd_invalid_death_rounds INTEGER,
rd_invalid_death_rate REAL,
rd_pressure_kpr_ratio REAL,
rd_pressure_perf_ratio REAL,
rd_pressure_rounds_down3 INTEGER,
rd_pressure_rounds_normal INTEGER,
rd_matchpoint_kpr_ratio REAL,
rd_matchpoint_perf_ratio REAL,
rd_matchpoint_rounds INTEGER,
rd_comeback_kill_share REAL,
rd_comeback_rounds INTEGER,
rd_trade_response_10s_rate REAL,
rd_weapon_top_json TEXT,
rd_roundtype_split_json TEXT,
map_stability_coef REAL
); );
-- Optional: Detailed per-match feature table for time-series analysis -- Optional: Detailed per-match feature table for time-series analysis

View File

@@ -4,3 +4,4 @@ numpy
playwright playwright
gunicorn gunicorn
gevent gevent
matplotlib

View File

@@ -2,6 +2,7 @@ from web.database import query_db, get_db, execute_db
import sqlite3 import sqlite3
import pandas as pd import pandas as pd
import numpy as np import numpy as np
from web.services.weapon_service import get_weapon_info
class FeatureService: class FeatureService:
@staticmethod @staticmethod
@@ -357,6 +358,46 @@ class FeatureService:
valid_ids = tuple(df['steam_id_64'].tolist()) valid_ids = tuple(df['steam_id_64'].tolist())
placeholders = ','.join(['?'] * len(valid_ids)) placeholders = ','.join(['?'] * len(valid_ids))
try:
query_weapon_kills = f"""
SELECT attacker_steam_id as steam_id_64,
SUM(CASE WHEN lower(weapon) LIKE '%knife%' OR lower(weapon) LIKE '%bayonet%' THEN 1 ELSE 0 END) as knife_kills,
SUM(CASE WHEN lower(weapon) LIKE '%taser%' OR lower(weapon) LIKE '%zeus%' THEN 1 ELSE 0 END) as zeus_kills
FROM fact_round_events
WHERE event_type = 'kill'
AND attacker_steam_id IN ({placeholders})
GROUP BY attacker_steam_id
"""
df_weapon_kills = pd.read_sql_query(query_weapon_kills, conn, params=valid_ids)
if not df_weapon_kills.empty:
df = df.merge(df_weapon_kills, on='steam_id_64', how='left')
else:
df['knife_kills'] = 0
df['zeus_kills'] = 0
except Exception:
df['knife_kills'] = 0
df['zeus_kills'] = 0
df['basic_avg_knife_kill'] = df['knife_kills'].fillna(0) / df['matches_played'].replace(0, 1)
df['basic_avg_zeus_kill'] = df['zeus_kills'].fillna(0) / df['matches_played'].replace(0, 1)
try:
query_zeus_pick = f"""
SELECT steam_id_64,
AVG(CASE WHEN has_zeus = 1 THEN 1.0 ELSE 0.0 END) as basic_zeus_pick_rate
FROM fact_round_player_economy
WHERE steam_id_64 IN ({placeholders})
GROUP BY steam_id_64
"""
df_zeus_pick = pd.read_sql_query(query_zeus_pick, conn, params=valid_ids)
if not df_zeus_pick.empty:
df = df.merge(df_zeus_pick, on='steam_id_64', how='left')
except Exception:
df['basic_zeus_pick_rate'] = 0.0
df['basic_zeus_pick_rate'] = df.get('basic_zeus_pick_rate', 0.0)
df['basic_zeus_pick_rate'] = pd.to_numeric(df['basic_zeus_pick_rate'], errors='coerce').fillna(0.0)
# 2. STA (Detailed) # 2. STA (Detailed)
query_sta = f""" query_sta = f"""
@@ -481,12 +522,18 @@ class FeatureService:
break break
df_fh_sides = pd.DataFrame(fh_rows) df_fh_sides = pd.DataFrame(fh_rows)
if not df_fh_sides.empty: if df_fh_sides.empty:
df_fh_sides = pd.DataFrame(columns=['match_id', 'steam_id_64', 'fh_side', 'halftime_round'])
else:
df_fh_sides = df_fh_sides.merge(df_meta[['match_id', 'halftime_round']], on='match_id', how='left') df_fh_sides = df_fh_sides.merge(df_meta[['match_id', 'halftime_round']], on='match_id', how='left')
if 'halftime_round' not in df_fh_sides.columns:
df_fh_sides['halftime_round'] = 15
df_fh_sides['halftime_round'] = df_fh_sides['halftime_round'].fillna(15).astype(int)
# B. Get Kill Events # B. Get Kill Events
query_events = f""" query_events = f"""
SELECT match_id, round_num, attacker_steam_id, victim_steam_id, event_type, is_headshot, event_time SELECT match_id, round_num, attacker_steam_id, victim_steam_id, event_type, is_headshot, event_time,
weapon, trade_killer_steam_id, flash_assist_steam_id
FROM fact_round_events FROM fact_round_events
WHERE event_type='kill' WHERE event_type='kill'
AND (attacker_steam_id IN ({placeholders}) OR victim_steam_id IN ({placeholders})) AND (attacker_steam_id IN ({placeholders}) OR victim_steam_id IN ({placeholders}))
@@ -495,7 +542,7 @@ class FeatureService:
# C. Get Round Scores # C. Get Round Scores
query_rounds = f""" query_rounds = f"""
SELECT match_id, round_num, ct_score, t_score, winner_side SELECT match_id, round_num, ct_score, t_score, winner_side, duration
FROM fact_rounds FROM fact_rounds
WHERE match_id IN (SELECT match_id FROM fact_match_players WHERE steam_id_64 IN ({placeholders})) WHERE match_id IN (SELECT match_id FROM fact_match_players WHERE steam_id_64 IN ({placeholders}))
""" """
@@ -982,7 +1029,7 @@ class FeatureService:
# Fetch Base Data for Calculation # Fetch Base Data for Calculation
q_new_feats = f""" q_new_feats = f"""
SELECT mp.steam_id_64, mp.match_id, mp.match_team_id, mp.team_id, SELECT mp.steam_id_64, mp.match_id, mp.match_team_id, mp.team_id,
mp.rating, mp.adr, mp.is_win mp.rating, mp.adr, mp.is_win, mp.map as map_name
FROM fact_match_players mp FROM fact_match_players mp
WHERE mp.steam_id_64 IN ({placeholders}) WHERE mp.steam_id_64 IN ({placeholders})
""" """
@@ -1139,10 +1186,448 @@ class FeatureService:
if df_pace is not None: if df_pace is not None:
df = df.merge(df_pace, on='steam_id_64', how='left') df = df.merge(df_pace, on='steam_id_64', how='left')
if not df_base.empty:
player_mean = df_base.groupby('steam_id_64', as_index=False)['rating'].mean().rename(columns={'rating': 'player_mean_rating'})
map_mean = df_base.groupby(['steam_id_64', 'map_name'], as_index=False)['rating'].mean().rename(columns={'rating': 'map_mean_rating'})
map_dev = map_mean.merge(player_mean, on='steam_id_64', how='left')
map_dev['abs_dev'] = (map_dev['map_mean_rating'] - map_dev['player_mean_rating']).abs()
map_coef = map_dev.groupby('steam_id_64', as_index=False)['abs_dev'].mean().rename(columns={'abs_dev': 'map_stability_coef'})
df = df.merge(map_coef, on='steam_id_64', how='left')
import json
df['rd_phase_kill_early_share'] = 0.0
df['rd_phase_kill_mid_share'] = 0.0
df['rd_phase_kill_late_share'] = 0.0
df['rd_phase_death_early_share'] = 0.0
df['rd_phase_death_mid_share'] = 0.0
df['rd_phase_death_late_share'] = 0.0
df['rd_firstdeath_team_first_death_rounds'] = 0
df['rd_firstdeath_team_first_death_win_rate'] = 0.0
df['rd_invalid_death_rounds'] = 0
df['rd_invalid_death_rate'] = 0.0
df['rd_pressure_kpr_ratio'] = 0.0
df['rd_pressure_perf_ratio'] = 0.0
df['rd_pressure_rounds_down3'] = 0
df['rd_pressure_rounds_normal'] = 0
df['rd_matchpoint_kpr_ratio'] = 0.0
df['rd_matchpoint_perf_ratio'] = 0.0
df['rd_matchpoint_rounds'] = 0
df['rd_comeback_kill_share'] = 0.0
df['rd_comeback_rounds'] = 0
df['rd_trade_response_10s_rate'] = 0.0
df['rd_weapon_top_json'] = "[]"
df['rd_roundtype_split_json'] = "{}"
if not df_events.empty:
df_events['event_time'] = pd.to_numeric(df_events['event_time'], errors='coerce').fillna(0).astype(int)
df_events['phase_bucket'] = pd.cut(
df_events['event_time'],
bins=[-1, 30, 60, float('inf')],
labels=['early', 'mid', 'late']
)
k_cnt = df_events.groupby(['attacker_steam_id', 'phase_bucket']).size().unstack(fill_value=0)
k_tot = k_cnt.sum(axis=1).replace(0, 1)
k_share = k_cnt.div(k_tot, axis=0)
k_share.index.name = 'steam_id_64'
k_share = k_share.reset_index().rename(columns={
'early': 'rd_phase_kill_early_share',
'mid': 'rd_phase_kill_mid_share',
'late': 'rd_phase_kill_late_share'
})
df = df.merge(
k_share[['steam_id_64', 'rd_phase_kill_early_share', 'rd_phase_kill_mid_share', 'rd_phase_kill_late_share']],
on='steam_id_64',
how='left',
suffixes=('', '_calc')
)
for c in ['rd_phase_kill_early_share', 'rd_phase_kill_mid_share', 'rd_phase_kill_late_share']:
if f'{c}_calc' in df.columns:
df[c] = df[f'{c}_calc'].fillna(df[c])
df.drop(columns=[f'{c}_calc'], inplace=True)
d_cnt = df_events.groupby(['victim_steam_id', 'phase_bucket']).size().unstack(fill_value=0)
d_tot = d_cnt.sum(axis=1).replace(0, 1)
d_share = d_cnt.div(d_tot, axis=0)
d_share.index.name = 'steam_id_64'
d_share = d_share.reset_index().rename(columns={
'early': 'rd_phase_death_early_share',
'mid': 'rd_phase_death_mid_share',
'late': 'rd_phase_death_late_share'
})
df = df.merge(
d_share[['steam_id_64', 'rd_phase_death_early_share', 'rd_phase_death_mid_share', 'rd_phase_death_late_share']],
on='steam_id_64',
how='left',
suffixes=('', '_calc')
)
for c in ['rd_phase_death_early_share', 'rd_phase_death_mid_share', 'rd_phase_death_late_share']:
if f'{c}_calc' in df.columns:
df[c] = df[f'{c}_calc'].fillna(df[c])
df.drop(columns=[f'{c}_calc'], inplace=True)
if 'victim_side' in df_events.columns and 'winner_side' in df_events.columns:
death_rows = df_events[['match_id', 'round_num', 'event_time', 'victim_steam_id', 'victim_side', 'winner_side']].copy()
death_rows = death_rows[death_rows['victim_side'].isin(['CT', 'T']) & death_rows['winner_side'].isin(['CT', 'T'])]
if not death_rows.empty:
min_death = death_rows.groupby(['match_id', 'round_num', 'victim_side'], as_index=False)['event_time'].min().rename(columns={'event_time': 'min_time'})
first_deaths = death_rows.merge(min_death, on=['match_id', 'round_num', 'victim_side'], how='inner')
first_deaths = first_deaths[first_deaths['event_time'] == first_deaths['min_time']]
first_deaths['is_win'] = (first_deaths['victim_side'] == first_deaths['winner_side']).astype(int)
fd_agg = first_deaths.groupby('victim_steam_id')['is_win'].agg(['count', 'mean']).reset_index()
fd_agg.rename(columns={
'victim_steam_id': 'steam_id_64',
'count': 'rd_firstdeath_team_first_death_rounds',
'mean': 'rd_firstdeath_team_first_death_win_rate'
}, inplace=True)
df = df.merge(fd_agg, on='steam_id_64', how='left', suffixes=('', '_calc'))
for c in ['rd_firstdeath_team_first_death_rounds', 'rd_firstdeath_team_first_death_win_rate']:
if f'{c}_calc' in df.columns:
df[c] = df[f'{c}_calc'].fillna(df[c])
df.drop(columns=[f'{c}_calc'], inplace=True)
kills_per_round = df_events.groupby(['match_id', 'round_num', 'attacker_steam_id']).size().reset_index(name='kills')
flash_round = df_events[df_events['flash_assist_steam_id'].notna() & (df_events['flash_assist_steam_id'] != '')] \
.groupby(['match_id', 'round_num', 'flash_assist_steam_id']).size().reset_index(name='flash_assists')
death_round = df_events.groupby(['match_id', 'round_num', 'victim_steam_id']).size().reset_index(name='deaths')
death_eval = death_round.rename(columns={'victim_steam_id': 'steam_id_64'}).merge(
kills_per_round.rename(columns={'attacker_steam_id': 'steam_id_64'})[['match_id', 'round_num', 'steam_id_64', 'kills']],
on=['match_id', 'round_num', 'steam_id_64'],
how='left'
).merge(
flash_round.rename(columns={'flash_assist_steam_id': 'steam_id_64'})[['match_id', 'round_num', 'steam_id_64', 'flash_assists']],
on=['match_id', 'round_num', 'steam_id_64'],
how='left'
).fillna({'kills': 0, 'flash_assists': 0})
death_eval['is_invalid'] = ((death_eval['kills'] <= 0) & (death_eval['flash_assists'] <= 0)).astype(int)
invalid_agg = death_eval.groupby('steam_id_64')['is_invalid'].agg(['sum', 'count']).reset_index()
invalid_agg.rename(columns={'sum': 'rd_invalid_death_rounds', 'count': 'death_rounds'}, inplace=True)
invalid_agg['rd_invalid_death_rate'] = invalid_agg['rd_invalid_death_rounds'] / invalid_agg['death_rounds'].replace(0, 1)
df = df.merge(
invalid_agg[['steam_id_64', 'rd_invalid_death_rounds', 'rd_invalid_death_rate']],
on='steam_id_64',
how='left',
suffixes=('', '_calc')
)
for c in ['rd_invalid_death_rounds', 'rd_invalid_death_rate']:
if f'{c}_calc' in df.columns:
df[c] = df[f'{c}_calc'].fillna(df[c])
df.drop(columns=[f'{c}_calc'], inplace=True)
if 'weapon' in df_events.columns:
w = df_events.copy()
w['weapon'] = w['weapon'].fillna('').astype(str)
w = w[w['weapon'] != '']
if not w.empty:
w_agg = w.groupby(['attacker_steam_id', 'weapon']).agg(
kills=('weapon', 'size'),
hs=('is_headshot', 'sum'),
).reset_index()
top_json = {}
for pid, g in w_agg.groupby('attacker_steam_id'):
g = g.sort_values('kills', ascending=False)
total = float(g['kills'].sum()) if g['kills'].sum() else 1.0
top = g.head(5)
items = []
for _, r in top.iterrows():
k = float(r['kills'])
hs = float(r['hs'])
wi = get_weapon_info(r['weapon'])
items.append({
'weapon': r['weapon'],
'kills': int(k),
'share': k / total,
'hs_rate': hs / k if k else 0.0,
'price': wi.price if wi else None,
'side': wi.side if wi else None,
'category': wi.category if wi else None,
})
top_json[str(pid)] = json.dumps(items, ensure_ascii=False)
if top_json:
df['rd_weapon_top_json'] = df['steam_id_64'].map(top_json).fillna("[]")
if not df_rounds.empty and not df_fh_sides.empty and not df_events.empty:
df_rounds2 = df_rounds.copy()
if not df_meta.empty:
df_rounds2 = df_rounds2.merge(df_meta[['match_id', 'halftime_round']], on='match_id', how='left')
df_rounds2 = df_rounds2.sort_values(['match_id', 'round_num'])
df_rounds2['prev_ct'] = df_rounds2.groupby('match_id')['ct_score'].shift(1).fillna(0)
df_rounds2['prev_t'] = df_rounds2.groupby('match_id')['t_score'].shift(1).fillna(0)
df_rounds2['ct_deficit'] = df_rounds2['prev_t'] - df_rounds2['prev_ct']
df_rounds2['t_deficit'] = df_rounds2['prev_ct'] - df_rounds2['prev_t']
df_rounds2['mp_score'] = df_rounds2['halftime_round'].fillna(15)
df_rounds2['is_match_point_round'] = (df_rounds2['prev_ct'] == df_rounds2['mp_score']) | (df_rounds2['prev_t'] == df_rounds2['mp_score'])
df_rounds2['reg_rounds'] = (df_rounds2['halftime_round'].fillna(15) * 2).astype(int)
df_rounds2['is_overtime_round'] = df_rounds2['round_num'] > df_rounds2['reg_rounds']
all_rounds = df_rounds2[['match_id', 'round_num']].drop_duplicates()
df_player_rounds = all_rounds.merge(df_fh_sides, on='match_id', how='inner')
if 'halftime_round' not in df_player_rounds.columns:
df_player_rounds['halftime_round'] = 15
df_player_rounds['halftime_round'] = pd.to_numeric(df_player_rounds['halftime_round'], errors='coerce').fillna(15).astype(int)
mask_fh = df_player_rounds['round_num'] <= df_player_rounds['halftime_round']
df_player_rounds['side'] = np.where(mask_fh, df_player_rounds['fh_side'], np.where(df_player_rounds['fh_side'] == 'CT', 'T', 'CT'))
df_player_rounds = df_player_rounds.merge(
df_rounds2[['match_id', 'round_num', 'ct_deficit', 't_deficit', 'is_match_point_round', 'is_overtime_round', 'reg_rounds']],
on=['match_id', 'round_num'],
how='left'
)
df_player_rounds['deficit'] = np.where(
df_player_rounds['side'] == 'CT',
df_player_rounds['ct_deficit'],
np.where(df_player_rounds['side'] == 'T', df_player_rounds['t_deficit'], 0)
)
df_player_rounds['is_pressure_round'] = (df_player_rounds['deficit'] >= 3).astype(int)
df_player_rounds['is_pistol_round'] = (
(df_player_rounds['round_num'] == 1) |
(df_player_rounds['round_num'] == df_player_rounds['halftime_round'] + 1)
).astype(int)
kills_per_round = df_events.groupby(['match_id', 'round_num', 'attacker_steam_id']).size().reset_index(name='kills')
df_player_rounds = df_player_rounds.merge(
kills_per_round.rename(columns={'attacker_steam_id': 'steam_id_64'}),
on=['match_id', 'round_num', 'steam_id_64'],
how='left'
)
df_player_rounds['kills'] = df_player_rounds['kills'].fillna(0)
grp = df_player_rounds.groupby(['steam_id_64', 'is_pressure_round'])['kills'].agg(['mean', 'count']).reset_index()
pressure = grp.pivot(index='steam_id_64', columns='is_pressure_round').fillna(0)
if ('mean', 1) in pressure.columns and ('mean', 0) in pressure.columns:
pressure_kpr_ratio = (pressure[('mean', 1)] / pressure[('mean', 0)].replace(0, 1)).reset_index()
pressure_kpr_ratio.columns = ['steam_id_64', 'rd_pressure_kpr_ratio']
df = df.merge(pressure_kpr_ratio, on='steam_id_64', how='left', suffixes=('', '_calc'))
if 'rd_pressure_kpr_ratio_calc' in df.columns:
df['rd_pressure_kpr_ratio'] = df['rd_pressure_kpr_ratio_calc'].fillna(df['rd_pressure_kpr_ratio'])
df.drop(columns=['rd_pressure_kpr_ratio_calc'], inplace=True)
if ('count', 1) in pressure.columns:
pr_cnt = pressure[('count', 1)].reset_index()
pr_cnt.columns = ['steam_id_64', 'rd_pressure_rounds_down3']
df = df.merge(pr_cnt, on='steam_id_64', how='left', suffixes=('', '_calc'))
if 'rd_pressure_rounds_down3_calc' in df.columns:
df['rd_pressure_rounds_down3'] = df['rd_pressure_rounds_down3_calc'].fillna(df['rd_pressure_rounds_down3'])
df.drop(columns=['rd_pressure_rounds_down3_calc'], inplace=True)
if ('count', 0) in pressure.columns:
nr_cnt = pressure[('count', 0)].reset_index()
nr_cnt.columns = ['steam_id_64', 'rd_pressure_rounds_normal']
df = df.merge(nr_cnt, on='steam_id_64', how='left', suffixes=('', '_calc'))
if 'rd_pressure_rounds_normal_calc' in df.columns:
df['rd_pressure_rounds_normal'] = df['rd_pressure_rounds_normal_calc'].fillna(df['rd_pressure_rounds_normal'])
df.drop(columns=['rd_pressure_rounds_normal_calc'], inplace=True)
mp_grp = df_player_rounds.groupby(['steam_id_64', 'is_match_point_round'])['kills'].agg(['mean', 'count']).reset_index()
mp = mp_grp.pivot(index='steam_id_64', columns='is_match_point_round').fillna(0)
if ('mean', 1) in mp.columns and ('mean', 0) in mp.columns:
mp_ratio = (mp[('mean', 1)] / mp[('mean', 0)].replace(0, 1)).reset_index()
mp_ratio.columns = ['steam_id_64', 'rd_matchpoint_kpr_ratio']
df = df.merge(mp_ratio, on='steam_id_64', how='left', suffixes=('', '_calc'))
if 'rd_matchpoint_kpr_ratio_calc' in df.columns:
df['rd_matchpoint_kpr_ratio'] = df['rd_matchpoint_kpr_ratio_calc'].fillna(df['rd_matchpoint_kpr_ratio'])
df.drop(columns=['rd_matchpoint_kpr_ratio_calc'], inplace=True)
if ('count', 1) in mp.columns:
mp_cnt = mp[('count', 1)].reset_index()
mp_cnt.columns = ['steam_id_64', 'rd_matchpoint_rounds']
df = df.merge(mp_cnt, on='steam_id_64', how='left', suffixes=('', '_calc'))
if 'rd_matchpoint_rounds_calc' in df.columns:
df['rd_matchpoint_rounds'] = df['rd_matchpoint_rounds_calc'].fillna(df['rd_matchpoint_rounds'])
df.drop(columns=['rd_matchpoint_rounds_calc'], inplace=True)
try:
q_player_team = f"SELECT match_id, steam_id_64, team_id FROM fact_match_players WHERE steam_id_64 IN ({placeholders})"
df_player_team = pd.read_sql_query(q_player_team, conn, params=valid_ids)
except Exception:
df_player_team = pd.DataFrame()
if not df_player_team.empty:
try:
q_team_roles = f"""
SELECT match_id, group_id as team_id, group_fh_role
FROM fact_match_teams
WHERE match_id IN (SELECT match_id FROM fact_match_players WHERE steam_id_64 IN ({placeholders}))
"""
df_team_roles = pd.read_sql_query(q_team_roles, conn, params=valid_ids)
except Exception:
df_team_roles = pd.DataFrame()
if not df_team_roles.empty:
team_round = df_rounds2[['match_id', 'round_num', 'ct_score', 't_score', 'prev_ct', 'prev_t', 'halftime_round']].merge(df_team_roles, on='match_id', how='inner')
fh_ct = team_round['group_fh_role'] == 1
mask_fh = team_round['round_num'] <= team_round['halftime_round']
team_round['team_side'] = np.where(mask_fh, np.where(fh_ct, 'CT', 'T'), np.where(fh_ct, 'T', 'CT'))
team_round['team_prev_score'] = np.where(team_round['team_side'] == 'CT', team_round['prev_ct'], team_round['prev_t'])
team_round['team_score_after'] = np.where(team_round['team_side'] == 'CT', team_round['ct_score'], team_round['t_score'])
team_round['opp_prev_score'] = np.where(team_round['team_side'] == 'CT', team_round['prev_t'], team_round['prev_ct'])
team_round['opp_score_after'] = np.where(team_round['team_side'] == 'CT', team_round['t_score'], team_round['ct_score'])
team_round['deficit_before'] = team_round['opp_prev_score'] - team_round['team_prev_score']
team_round['deficit_after'] = team_round['opp_score_after'] - team_round['team_score_after']
team_round['is_comeback_round'] = ((team_round['deficit_before'] > 0) & (team_round['deficit_after'] < team_round['deficit_before'])).astype(int)
comeback_keys = team_round[team_round['is_comeback_round'] == 1][['match_id', 'round_num', 'team_id']].drop_duplicates()
if not comeback_keys.empty:
ev_att = df_events[['match_id', 'round_num', 'attacker_steam_id', 'event_time']].merge(
df_player_team.rename(columns={'steam_id_64': 'attacker_steam_id', 'team_id': 'att_team_id'}),
on=['match_id', 'attacker_steam_id'],
how='left'
)
team_kills = ev_att[ev_att['att_team_id'].notna()].groupby(['match_id', 'round_num', 'att_team_id']).size().reset_index(name='team_kills')
player_kills = ev_att.groupby(['match_id', 'round_num', 'attacker_steam_id', 'att_team_id']).size().reset_index(name='player_kills')
player_kills = player_kills.merge(
comeback_keys.rename(columns={'team_id': 'att_team_id'}),
on=['match_id', 'round_num', 'att_team_id'],
how='inner'
)
if not player_kills.empty:
player_kills = player_kills.merge(team_kills, on=['match_id', 'round_num', 'att_team_id'], how='left').fillna({'team_kills': 0})
player_kills['share'] = player_kills['player_kills'] / player_kills['team_kills'].replace(0, 1)
cb_share = player_kills.groupby('attacker_steam_id')['share'].mean().reset_index()
cb_share.rename(columns={'attacker_steam_id': 'steam_id_64', 'share': 'rd_comeback_kill_share'}, inplace=True)
df = df.merge(cb_share, on='steam_id_64', how='left', suffixes=('', '_calc'))
if 'rd_comeback_kill_share_calc' in df.columns:
df['rd_comeback_kill_share'] = df['rd_comeback_kill_share_calc'].fillna(df['rd_comeback_kill_share'])
df.drop(columns=['rd_comeback_kill_share_calc'], inplace=True)
cb_rounds = comeback_keys.merge(df_player_team, left_on=['match_id', 'team_id'], right_on=['match_id', 'team_id'], how='inner')
cb_cnt = cb_rounds.groupby('steam_id_64').size().reset_index(name='rd_comeback_rounds')
df = df.merge(cb_cnt, on='steam_id_64', how='left', suffixes=('', '_calc'))
if 'rd_comeback_rounds_calc' in df.columns:
df['rd_comeback_rounds'] = df['rd_comeback_rounds_calc'].fillna(df['rd_comeback_rounds'])
df.drop(columns=['rd_comeback_rounds_calc'], inplace=True)
death_team = df_events[['match_id', 'round_num', 'event_time', 'victim_steam_id']].merge(
df_player_team.rename(columns={'steam_id_64': 'victim_steam_id', 'team_id': 'team_id'}),
on=['match_id', 'victim_steam_id'],
how='left'
)
death_team = death_team[death_team['team_id'].notna()]
if not death_team.empty:
roster = df_player_team.rename(columns={'steam_id_64': 'steam_id_64', 'team_id': 'team_id'})[['match_id', 'team_id', 'steam_id_64']].drop_duplicates()
opp = death_team.merge(roster, on=['match_id', 'team_id'], how='inner', suffixes=('', '_teammate'))
opp = opp[opp['steam_id_64'] != opp['victim_steam_id']]
opp_time = opp.groupby(['match_id', 'round_num', 'steam_id_64'], as_index=False)['event_time'].min().rename(columns={'event_time': 'teammate_death_time'})
kills_time = df_events[['match_id', 'round_num', 'event_time', 'attacker_steam_id']].rename(columns={'attacker_steam_id': 'steam_id_64', 'event_time': 'kill_time'})
m = opp_time.merge(kills_time, on=['match_id', 'round_num', 'steam_id_64'], how='left')
m['in_window'] = ((m['kill_time'] >= m['teammate_death_time']) & (m['kill_time'] <= m['teammate_death_time'] + 10)).astype(int)
success = m.groupby(['match_id', 'round_num', 'steam_id_64'], as_index=False)['in_window'].max()
rate = success.groupby('steam_id_64')['in_window'].mean().reset_index()
rate.rename(columns={'in_window': 'rd_trade_response_10s_rate'}, inplace=True)
df = df.merge(rate, on='steam_id_64', how='left', suffixes=('', '_calc'))
if 'rd_trade_response_10s_rate_calc' in df.columns:
df['rd_trade_response_10s_rate'] = df['rd_trade_response_10s_rate_calc'].fillna(df['rd_trade_response_10s_rate'])
df.drop(columns=['rd_trade_response_10s_rate_calc'], inplace=True)
eco_rows = []
try:
q_econ = f"""
SELECT match_id, round_num, steam_id_64, equipment_value, round_performance_score
FROM fact_round_player_economy
WHERE steam_id_64 IN ({placeholders})
"""
df_econ = pd.read_sql_query(q_econ, conn, params=valid_ids)
except Exception:
df_econ = pd.DataFrame()
if not df_econ.empty:
df_econ['equipment_value'] = pd.to_numeric(df_econ['equipment_value'], errors='coerce').fillna(0).astype(int)
df_econ['round_performance_score'] = pd.to_numeric(df_econ['round_performance_score'], errors='coerce').fillna(0.0)
df_econ = df_econ.merge(df_rounds2[['match_id', 'round_num', 'is_overtime_round', 'is_match_point_round', 'ct_deficit', 't_deficit', 'prev_ct', 'prev_t']], on=['match_id', 'round_num'], how='left')
df_econ = df_econ.merge(df_fh_sides[['match_id', 'steam_id_64', 'fh_side', 'halftime_round']], on=['match_id', 'steam_id_64'], how='left')
mask_fh = df_econ['round_num'] <= df_econ['halftime_round']
df_econ['side'] = np.where(mask_fh, df_econ['fh_side'], np.where(df_econ['fh_side'] == 'CT', 'T', 'CT'))
df_econ['deficit'] = np.where(df_econ['side'] == 'CT', df_econ['ct_deficit'], df_econ['t_deficit'])
df_econ['is_pressure_round'] = (df_econ['deficit'] >= 3).astype(int)
perf_grp = df_econ.groupby(['steam_id_64', 'is_pressure_round'])['round_performance_score'].agg(['mean', 'count']).reset_index()
perf = perf_grp.pivot(index='steam_id_64', columns='is_pressure_round').fillna(0)
if ('mean', 1) in perf.columns and ('mean', 0) in perf.columns:
perf_ratio = (perf[('mean', 1)] / perf[('mean', 0)].replace(0, 1)).reset_index()
perf_ratio.columns = ['steam_id_64', 'rd_pressure_perf_ratio']
df = df.merge(perf_ratio, on='steam_id_64', how='left', suffixes=('', '_calc'))
if 'rd_pressure_perf_ratio_calc' in df.columns:
df['rd_pressure_perf_ratio'] = df['rd_pressure_perf_ratio_calc'].fillna(df['rd_pressure_perf_ratio'])
df.drop(columns=['rd_pressure_perf_ratio_calc'], inplace=True)
mp_perf_grp = df_econ.groupby(['steam_id_64', 'is_match_point_round'])['round_performance_score'].agg(['mean', 'count']).reset_index()
mp_perf = mp_perf_grp.pivot(index='steam_id_64', columns='is_match_point_round').fillna(0)
if ('mean', 1) in mp_perf.columns and ('mean', 0) in mp_perf.columns:
mp_perf_ratio = (mp_perf[('mean', 1)] / mp_perf[('mean', 0)].replace(0, 1)).reset_index()
mp_perf_ratio.columns = ['steam_id_64', 'rd_matchpoint_perf_ratio']
df = df.merge(mp_perf_ratio, on='steam_id_64', how='left', suffixes=('', '_calc'))
if 'rd_matchpoint_perf_ratio_calc' in df.columns:
df['rd_matchpoint_perf_ratio'] = df['rd_matchpoint_perf_ratio_calc'].fillna(df['rd_matchpoint_perf_ratio'])
df.drop(columns=['rd_matchpoint_perf_ratio_calc'], inplace=True)
eco = df_econ.copy()
eco['round_type'] = np.select(
[
eco['is_overtime_round'] == 1,
eco['equipment_value'] < 2000,
eco['equipment_value'] >= 4000,
],
[
'overtime',
'eco',
'fullbuy',
],
default='rifle'
)
eco_rounds = eco.groupby(['steam_id_64', 'round_type']).size().reset_index(name='rounds')
perf_mean = eco.groupby(['steam_id_64', 'round_type'])['round_performance_score'].mean().reset_index(name='perf')
eco_rows = eco_rounds.merge(perf_mean, on=['steam_id_64', 'round_type'], how='left')
if eco_rows is not None and len(eco_rows) > 0:
kpr_rounds = df_player_rounds[['match_id', 'round_num', 'steam_id_64', 'kills', 'is_pistol_round', 'is_overtime_round']].copy()
kpr_rounds['round_type'] = np.select(
[
kpr_rounds['is_overtime_round'] == 1,
kpr_rounds['is_pistol_round'] == 1,
],
[
'overtime',
'pistol',
],
default='reg'
)
kpr = kpr_rounds.groupby(['steam_id_64', 'round_type']).agg(kpr=('kills', 'mean'), rounds=('kills', 'size')).reset_index()
kpr_dict = {}
for pid, g in kpr.groupby('steam_id_64'):
d = {}
for _, r in g.iterrows():
d[r['round_type']] = {'kpr': float(r['kpr']), 'rounds': int(r['rounds'])}
kpr_dict[str(pid)] = d
econ_dict = {}
if isinstance(eco_rows, pd.DataFrame) and not eco_rows.empty:
for pid, g in eco_rows.groupby('steam_id_64'):
d = {}
for _, r in g.iterrows():
d[r['round_type']] = {'perf': float(r['perf']) if r['perf'] is not None else 0.0, 'rounds': int(r['rounds'])}
econ_dict[str(pid)] = d
out = {}
for pid in df['steam_id_64'].astype(str).tolist():
merged = {}
if pid in kpr_dict:
merged.update(kpr_dict[pid])
if pid in econ_dict:
for k, v in econ_dict[pid].items():
merged.setdefault(k, {}).update(v)
out[pid] = json.dumps(merged, ensure_ascii=False)
df['rd_roundtype_split_json'] = df['steam_id_64'].astype(str).map(out).fillna("{}")
# Final Mappings # Final Mappings
df['total_matches'] = df['matches_played'] df['total_matches'] = df['matches_played']
return df.fillna(0) for c in df.columns:
if df[c].dtype.kind in "biufc":
df[c] = df[c].fillna(0)
else:
df[c] = df[c].fillna("")
return df
@staticmethod @staticmethod
def _calculate_economy_features(conn, player_ids): def _calculate_economy_features(conn, player_ids):

View File

@@ -725,6 +725,7 @@ class StatsService:
metrics = [ metrics = [
'basic_avg_rating', 'basic_avg_kd', 'basic_avg_kast', 'basic_avg_rws', 'basic_avg_adr', 'basic_avg_rating', 'basic_avg_kd', 'basic_avg_kast', 'basic_avg_rws', 'basic_avg_adr',
'basic_avg_headshot_kills', 'basic_headshot_rate', 'basic_avg_assisted_kill', 'basic_avg_awp_kill', 'basic_avg_jump_count', 'basic_avg_headshot_kills', 'basic_headshot_rate', 'basic_avg_assisted_kill', 'basic_avg_awp_kill', 'basic_avg_jump_count',
'basic_avg_knife_kill', 'basic_avg_zeus_kill', 'basic_zeus_pick_rate',
'basic_avg_mvps', 'basic_avg_plants', 'basic_avg_defuses', 'basic_avg_flash_assists', 'basic_avg_mvps', 'basic_avg_plants', 'basic_avg_defuses', 'basic_avg_flash_assists',
'basic_avg_first_kill', 'basic_avg_first_death', 'basic_first_kill_rate', 'basic_first_death_rate', 'basic_avg_first_kill', 'basic_avg_first_death', 'basic_first_kill_rate', 'basic_first_death_rate',
'basic_avg_kill_2', 'basic_avg_kill_3', 'basic_avg_kill_4', 'basic_avg_kill_5', 'basic_avg_kill_2', 'basic_avg_kill_3', 'basic_avg_kill_4', 'basic_avg_kill_5',
@@ -745,6 +746,13 @@ class StatsService:
# New: ECO & PACE # New: ECO & PACE
'eco_avg_damage_per_1k', 'eco_rating_eco_rounds', 'eco_kd_ratio', 'eco_avg_rounds', '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', 'pace_avg_time_to_first_contact', 'pace_trade_kill_rate', 'pace_opening_kill_time', 'pace_avg_life_time',
# New: ROUND (Round Dynamics)
'rd_phase_kill_early_share', 'rd_phase_kill_mid_share', 'rd_phase_kill_late_share',
'rd_phase_death_early_share', 'rd_phase_death_mid_share', 'rd_phase_death_late_share',
'rd_firstdeath_team_first_death_win_rate', 'rd_invalid_death_rate',
'rd_pressure_kpr_ratio', 'rd_matchpoint_kpr_ratio', 'rd_trade_response_10s_rate',
'rd_pressure_perf_ratio', 'rd_matchpoint_perf_ratio',
'rd_comeback_kill_share', 'map_stability_coef',
# 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',
@@ -766,7 +774,7 @@ class StatsService:
# But here we just use L3 columns directly. # But here we just use L3 columns directly.
# Define metrics where LOWER is BETTER # Define metrics where LOWER is BETTER
lower_is_better = ['pace_avg_time_to_first_contact', 'pace_opening_kill_time'] lower_is_better = ['pace_avg_time_to_first_contact', 'pace_opening_kill_time', 'rd_invalid_death_rate', 'map_stability_coef']
result = {} result = {}
@@ -808,6 +816,141 @@ class StatsService:
if m in legacy_map: if m in legacy_map:
result[legacy_map[m]] = result[m] result[legacy_map[m]] = result[m]
def build_roundtype_metric_distribution(metric_key, round_type, subkey):
values2 = []
for sid, p in stats_map.items():
raw = p.get('rd_roundtype_split_json') or ''
if not raw:
continue
try:
obj = json.loads(raw) if isinstance(raw, str) else raw
except:
continue
if not isinstance(obj, dict):
continue
bucket = obj.get(round_type)
if not isinstance(bucket, dict):
continue
v = bucket.get(subkey)
if v is None:
continue
try:
v = float(v)
except:
continue
values2.append(v)
raw_target = stats_map.get(target_steam_id, {}).get('rd_roundtype_split_json') or ''
target_val2 = None
if raw_target:
try:
obj_t = json.loads(raw_target) if isinstance(raw_target, str) else raw_target
if isinstance(obj_t, dict) and isinstance(obj_t.get(round_type), dict):
tv = obj_t[round_type].get(subkey)
if tv is not None:
target_val2 = float(tv)
except:
target_val2 = None
if not values2 or target_val2 is None:
return None
values2.sort(reverse=True)
try:
rank2 = values2.index(target_val2) + 1
except ValueError:
rank2 = len(values2)
return {
'val': target_val2,
'rank': rank2,
'total': len(values2),
'min': min(values2),
'max': max(values2),
'avg': sum(values2) / len(values2),
'inverted': False
}
rt_kpr_types = ['pistol', 'reg', 'overtime']
rt_perf_types = ['eco', 'rifle', 'fullbuy', 'overtime']
for t in rt_kpr_types:
result[f'rd_rt_kpr_{t}'] = build_roundtype_metric_distribution('rd_roundtype_split_json', t, 'kpr')
for t in rt_perf_types:
result[f'rd_rt_perf_{t}'] = build_roundtype_metric_distribution('rd_roundtype_split_json', t, 'perf')
top_weapon_rank_map = {}
try:
raw_tw = stats_map.get(target_steam_id, {}).get('rd_weapon_top_json') or '[]'
tw_items = json.loads(raw_tw) if isinstance(raw_tw, str) else raw_tw
weapons = []
if isinstance(tw_items, list):
for it in tw_items:
if isinstance(it, dict) and it.get('weapon'):
weapons.append(str(it.get('weapon')))
weapons = weapons[:5]
except Exception:
weapons = []
if weapons:
w_placeholders = ','.join('?' for _ in weapons)
sql_w = f"""
SELECT attacker_steam_id as steam_id_64,
weapon,
COUNT(*) as kills,
SUM(is_headshot) as hs
FROM fact_round_events
WHERE event_type='kill'
AND attacker_steam_id IN ({l2_placeholders})
AND weapon IN ({w_placeholders})
GROUP BY attacker_steam_id, weapon
"""
weapon_rows = query_db('l2', sql_w, active_roster_ids + weapons)
per_weapon = {}
for r in weapon_rows:
sid = str(r['steam_id_64'])
w = str(r['weapon'] or '')
if not w:
continue
kills = int(r['kills'] or 0)
hs = int(r['hs'] or 0)
mp = stats_map.get(sid, {}).get('total_matches') or 0
try:
mp = float(mp)
except Exception:
mp = 0
kpm = (kills / mp) if (kills > 0 and mp > 0) else None
hs_rate = (hs / kills) if kills > 0 else None
per_weapon.setdefault(w, {})[sid] = {"kpm": kpm, "hs_rate": hs_rate}
for w in weapons:
d = per_weapon.get(w) or {}
target_d = d.get(target_steam_id) or {}
target_kpm = target_d.get("kpm")
target_hs = target_d.get("hs_rate")
kpm_vals = [v.get("kpm") for v in d.values() if v.get("kpm") is not None]
hs_vals = [v.get("hs_rate") for v in d.values() if v.get("hs_rate") is not None]
kpm_rank = None
hs_rank = None
if kpm_vals and target_kpm is not None:
kpm_vals.sort(reverse=True)
try:
kpm_rank = kpm_vals.index(target_kpm) + 1
except ValueError:
kpm_rank = len(kpm_vals)
if hs_vals and target_hs is not None:
hs_vals.sort(reverse=True)
try:
hs_rank = hs_vals.index(target_hs) + 1
except ValueError:
hs_rank = len(hs_vals)
top_weapon_rank_map[w] = {
"kpm_rank": kpm_rank,
"kpm_total": len(kpm_vals),
"hs_rank": hs_rank,
"hs_total": len(hs_vals),
}
result['top_weapon_rank_map'] = top_weapon_rank_map
return result return result
@staticmethod @staticmethod

View File

@@ -0,0 +1,119 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
class WeaponInfo:
name: str
price: int
side: str
category: str
_WEAPON_TABLE = {
"glock": WeaponInfo(name="Glock-18", price=200, side="T", category="pistol"),
"hkp2000": WeaponInfo(name="P2000", price=200, side="CT", category="pistol"),
"usp_silencer": WeaponInfo(name="USP-S", price=200, side="CT", category="pistol"),
"elite": WeaponInfo(name="Dual Berettas", price=300, side="Both", category="pistol"),
"p250": WeaponInfo(name="P250", price=300, side="Both", category="pistol"),
"tec9": WeaponInfo(name="Tec-9", price=500, side="T", category="pistol"),
"fiveseven": WeaponInfo(name="Five-SeveN", price=500, side="CT", category="pistol"),
"cz75a": WeaponInfo(name="CZ75-Auto", price=500, side="Both", category="pistol"),
"revolver": WeaponInfo(name="R8 Revolver", price=600, side="Both", category="pistol"),
"deagle": WeaponInfo(name="Desert Eagle", price=700, side="Both", category="pistol"),
"mac10": WeaponInfo(name="MAC-10", price=1050, side="T", category="smg"),
"mp9": WeaponInfo(name="MP9", price=1250, side="CT", category="smg"),
"ump45": WeaponInfo(name="UMP-45", price=1200, side="Both", category="smg"),
"bizon": WeaponInfo(name="PP-Bizon", price=1400, side="Both", category="smg"),
"mp7": WeaponInfo(name="MP7", price=1500, side="Both", category="smg"),
"mp5sd": WeaponInfo(name="MP5-SD", price=1500, side="Both", category="smg"),
"nova": WeaponInfo(name="Nova", price=1050, side="Both", category="shotgun"),
"mag7": WeaponInfo(name="MAG-7", price=1300, side="CT", category="shotgun"),
"sawedoff": WeaponInfo(name="Sawed-Off", price=1100, side="T", category="shotgun"),
"xm1014": WeaponInfo(name="XM1014", price=2000, side="Both", category="shotgun"),
"galilar": WeaponInfo(name="Galil AR", price=1800, side="T", category="rifle"),
"famas": WeaponInfo(name="FAMAS", price=2050, side="CT", category="rifle"),
"ak47": WeaponInfo(name="AK-47", price=2700, side="T", category="rifle"),
"m4a1": WeaponInfo(name="M4A4", price=2900, side="CT", category="rifle"),
"m4a1_silencer": WeaponInfo(name="M4A1-S", price=2900, side="CT", category="rifle"),
"aug": WeaponInfo(name="AUG", price=3300, side="CT", category="rifle"),
"sg556": WeaponInfo(name="SG 553", price=3300, side="T", category="rifle"),
"awp": WeaponInfo(name="AWP", price=4750, side="Both", category="sniper"),
"scar20": WeaponInfo(name="SCAR-20", price=5000, side="CT", category="sniper"),
"g3sg1": WeaponInfo(name="G3SG1", price=5000, side="T", category="sniper"),
"negev": WeaponInfo(name="Negev", price=1700, side="Both", category="lmg"),
"m249": WeaponInfo(name="M249", price=5200, side="Both", category="lmg"),
}
_ALIASES = {
"weapon_glock": "glock",
"weapon_hkp2000": "hkp2000",
"weapon_usp_silencer": "usp_silencer",
"weapon_elite": "elite",
"weapon_p250": "p250",
"weapon_tec9": "tec9",
"weapon_fiveseven": "fiveseven",
"weapon_cz75a": "cz75a",
"weapon_revolver": "revolver",
"weapon_deagle": "deagle",
"weapon_mac10": "mac10",
"weapon_mp9": "mp9",
"weapon_ump45": "ump45",
"weapon_bizon": "bizon",
"weapon_mp7": "mp7",
"weapon_mp5sd": "mp5sd",
"weapon_nova": "nova",
"weapon_mag7": "mag7",
"weapon_sawedoff": "sawedoff",
"weapon_xm1014": "xm1014",
"weapon_galilar": "galilar",
"weapon_famas": "famas",
"weapon_ak47": "ak47",
"weapon_m4a1": "m4a1",
"weapon_m4a1_silencer": "m4a1_silencer",
"weapon_aug": "aug",
"weapon_sg556": "sg556",
"weapon_awp": "awp",
"weapon_scar20": "scar20",
"weapon_g3sg1": "g3sg1",
"weapon_negev": "negev",
"weapon_m249": "m249",
"m4a4": "m4a1",
"m4a1-s": "m4a1_silencer",
"m4a1s": "m4a1_silencer",
"sg553": "sg556",
"pp-bizon": "bizon",
}
def normalize_weapon_name(raw: Optional[str]) -> str:
if not raw:
return ""
s = str(raw).strip().lower()
if not s:
return ""
s = s.replace(" ", "").replace("\t", "").replace("\n", "")
s = s.replace("weapon_", "weapon_")
if s in _ALIASES:
return _ALIASES[s]
if s.startswith("weapon_") and s in _ALIASES:
return _ALIASES[s]
if s.startswith("weapon_"):
s2 = s[len("weapon_") :]
return _ALIASES.get(s2, s2)
return _ALIASES.get(s, s)
def get_weapon_info(raw: Optional[str]) -> Optional[WeaponInfo]:
key = normalize_weapon_name(raw)
if not key:
return None
return _WEAPON_TABLE.get(key)
def get_weapon_price(raw: Optional[str]) -> Optional[int]:
info = get_weapon_info(raw)
return info.price if info else None

View File

@@ -217,20 +217,21 @@
{{ detail_item('Assists (场均助攻)', features['basic_avg_assisted_kill'], 'basic_avg_assisted_kill') }} {{ detail_item('Assists (场均助攻)', features['basic_avg_assisted_kill'], 'basic_avg_assisted_kill') }}
{{ detail_item('AWP Kills (狙击击杀)', features['basic_avg_awp_kill'], 'basic_avg_awp_kill') }} {{ detail_item('AWP Kills (狙击击杀)', features['basic_avg_awp_kill'], 'basic_avg_awp_kill') }}
{{ detail_item('Jumps (场均跳跃)', features['basic_avg_jump_count'], 'basic_avg_jump_count', '{:.1f}') }} {{ detail_item('Jumps (场均跳跃)', features['basic_avg_jump_count'], 'basic_avg_jump_count', '{:.1f}') }}
{{ detail_item('Knife Kills (场均刀杀)', features['basic_avg_knife_kill'], 'basic_avg_knife_kill') }}
{{ detail_item('Zeus Kills (电击枪杀)', features['basic_avg_zeus_kill'], 'basic_avg_zeus_kill') }}
{{ detail_item('Zeus Buy% (起电击枪)', features['basic_zeus_pick_rate'], 'basic_zeus_pick_rate', '{:.1%}') }}
<!-- Row 3: Objective --> <!-- Row 3: Objective -->
{{ detail_item('MVP (最有价值)', features['basic_avg_mvps'], 'basic_avg_mvps') }} {{ detail_item('MVP (最有价值)', features['basic_avg_mvps'], 'basic_avg_mvps') }}
{{ detail_item('Plants (下包)', features['basic_avg_plants'], 'basic_avg_plants') }} {{ detail_item('Plants (下包)', features['basic_avg_plants'], 'basic_avg_plants') }}
{{ detail_item('Defuses (拆包)', features['basic_avg_defuses'], 'basic_avg_defuses') }} {{ detail_item('Defuses (拆包)', features['basic_avg_defuses'], 'basic_avg_defuses') }}
{{ detail_item('Flash Assist (闪光助攻)', features['basic_avg_flash_assists'], 'basic_avg_flash_assists') }} {{ detail_item('Flash Assist (闪光助攻)', features['basic_avg_flash_assists'], 'basic_avg_flash_assists') }}
<div class="hidden lg:block"></div> <!-- Spacer -->
<!-- Row 4: Opening --> <!-- Row 4: Opening -->
{{ detail_item('First Kill (场均首杀)', features['basic_avg_first_kill'], 'basic_avg_first_kill') }} {{ detail_item('First Kill (场均首杀)', features['basic_avg_first_kill'], 'basic_avg_first_kill') }}
{{ detail_item('First Death (场均首死)', features['basic_avg_first_death'], 'basic_avg_first_death') }} {{ detail_item('First Death (场均首死)', features['basic_avg_first_death'], 'basic_avg_first_death') }}
{{ detail_item('FK Rate (首杀率)', features['basic_first_kill_rate'], 'basic_first_kill_rate', '{:.1%}') }} {{ detail_item('FK Rate (首杀率)', features['basic_first_kill_rate'], 'basic_first_kill_rate', '{:.1%}') }}
{{ detail_item('FD Rate (首死率)', features['basic_first_death_rate'], 'basic_first_death_rate', '{:.1%}') }} {{ detail_item('FD Rate (首死率)', features['basic_first_death_rate'], 'basic_first_death_rate', '{:.1%}') }}
<div class="hidden lg:block"></div> <!-- Spacer -->
<!-- Row 5: Multi-Kills --> <!-- Row 5: Multi-Kills -->
{{ detail_item('2K Rounds (双杀)', features['basic_avg_kill_2'], 'basic_avg_kill_2') }} {{ detail_item('2K Rounds (双杀)', features['basic_avg_kill_2'], 'basic_avg_kill_2') }}
@@ -321,6 +322,51 @@
</div> </div>
</div> </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">
ROUND (Round Dynamics)
</h4>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-y-6 gap-x-4">
{{ detail_item('Kill Early (前30秒击杀)', features['rd_phase_kill_early_share'], 'rd_phase_kill_early_share', '{:.1%}') }}
{{ detail_item('Kill Mid (30-60秒击杀)', features['rd_phase_kill_mid_share'], 'rd_phase_kill_mid_share', '{:.1%}') }}
{{ detail_item('Kill Late (60秒后击杀)', features['rd_phase_kill_late_share'], 'rd_phase_kill_late_share', '{:.1%}') }}
{{ detail_item('Death Early (前30秒死亡)', features['rd_phase_death_early_share'], 'rd_phase_death_early_share', '{:.1%}') }}
{{ detail_item('Death Mid (30-60秒死亡)', features['rd_phase_death_mid_share'], 'rd_phase_death_mid_share', '{:.1%}') }}
{{ detail_item('Death Late (60秒后死亡)', features['rd_phase_death_late_share'], 'rd_phase_death_late_share', '{:.1%}') }}
{{ detail_item('FirstDeath Win% (首死后胜率)', features['rd_firstdeath_team_first_death_win_rate'], 'rd_firstdeath_team_first_death_win_rate', '{:.1%}', count_label=features['rd_firstdeath_team_first_death_rounds']) }}
{{ detail_item('Invalid Death% (无效死亡)', features['rd_invalid_death_rate'], 'rd_invalid_death_rate', '{:.1%}', count_label=features['rd_invalid_death_rounds']) }}
{{ detail_item('Pressure KPR (落后≥3)', features['rd_pressure_kpr_ratio'], 'rd_pressure_kpr_ratio', '{:.2f}x') }}
{{ detail_item('MatchPt KPR (赛点放大)', features['rd_matchpoint_kpr_ratio'], 'rd_matchpoint_kpr_ratio', '{:.2f}x', count_label=features['rd_matchpoint_rounds']) }}
{{ detail_item('Trade Resp (10s响应)', features['rd_trade_response_10s_rate'], 'rd_trade_response_10s_rate', '{:.1%}') }}
{{ detail_item('Pressure Perf (Leetify)', features['rd_pressure_perf_ratio'], 'rd_pressure_perf_ratio', '{:.2f}x') }}
{{ detail_item('MatchPt Perf (Leetify)', features['rd_matchpoint_perf_ratio'], 'rd_matchpoint_perf_ratio', '{:.2f}x') }}
{{ detail_item('Comeback KillShare (追分)', features['rd_comeback_kill_share'], 'rd_comeback_kill_share', '{:.1%}', count_label=features['rd_comeback_rounds']) }}
{{ detail_item('Map Stability (地图稳定)', features['map_stability_coef'], 'map_stability_coef', '{:.3f}') }}
</div>
<div class="mt-6 grid grid-cols-1 lg:grid-cols-3 gap-4">
<div class="bg-gray-50 dark:bg-slate-700/30 rounded-xl p-4 border border-gray-100 dark:border-slate-600">
<div class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">Phase Split</div>
<div class="h-40">
<canvas id="phaseChart"></canvas>
</div>
</div>
<div class="bg-gray-50 dark:bg-slate-700/30 rounded-xl p-4 border border-gray-100 dark:border-slate-600">
<div class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">Top Weapons</div>
<div id="weaponTopTable" class="text-sm"></div>
</div>
<div class="bg-gray-50 dark:bg-slate-700/30 rounded-xl p-4 border border-gray-100 dark:border-slate-600">
<div class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">Round Type Split</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400 mb-2">
KPR=Kills per Round每回合击杀 · Perf=Leetify Round Performance Score回合表现分
</div>
<div id="roundTypeTable" class="text-sm"></div>
</div>
</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">
@@ -951,7 +997,176 @@ document.addEventListener('DOMContentLoaded', function() {
} }
} }
}); });
const phaseCanvas = document.getElementById('phaseChart');
if (phaseCanvas) {
const ctxPhase = phaseCanvas.getContext('2d');
new Chart(ctxPhase, {
type: 'bar',
data: {
labels: ['Early', 'Mid', 'Late'],
datasets: [
{
label: 'Kills',
data: [
{{ features.get('rd_phase_kill_early_share', 0) }},
{{ features.get('rd_phase_kill_mid_share', 0) }},
{{ features.get('rd_phase_kill_late_share', 0) }}
],
backgroundColor: 'rgba(124, 58, 237, 0.55)'
},
{
label: 'Deaths',
data: [
{{ features.get('rd_phase_death_early_share', 0) }},
{{ features.get('rd_phase_death_mid_share', 0) }},
{{ features.get('rd_phase_death_late_share', 0) }}
],
backgroundColor: 'rgba(148, 163, 184, 0.55)'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
suggestedMax: 1,
ticks: {
callback: (v) => `${Math.round(v * 100)}%`
}
}
},
plugins: {
legend: { display: true, position: 'bottom' },
tooltip: {
callbacks: {
label: (ctx) => `${ctx.dataset.label}: ${(ctx.parsed.y * 100).toFixed(1)}%`
}
}
}
}
});
}
const weaponTop = JSON.parse({{ (features.get('rd_weapon_top_json', '[]') or '[]') | tojson }});
const weaponTopEl = document.getElementById('weaponTopTable');
if (weaponTopEl) {
if (!Array.isArray(weaponTop) || weaponTop.length === 0) {
weaponTopEl.innerHTML = '<div class="text-gray-500 dark:text-gray-400">No data</div>';
} else {
const matchesPlayed = Number({{ features.get('total_matches', 0) or 0 }}) || 0;
const weaponRankMap = {{ (distribution.get('top_weapon_rank_map', {}) or {}) | tojson }};
const rows = weaponTop.map(w => {
const kills = Number(w.kills || 0);
const hsRate = Number(w.hs_rate || 0);
const kpm = matchesPlayed > 0 ? (kills / matchesPlayed) : kills;
return { ...w, kills, hsRate, kpm };
});
rows.sort((a, b) => b.kpm - a.kpm);
const catMap = { pistol: '副武器', smg: '冲锋枪', shotgun: '霰弹枪', rifle: '步枪', sniper: '狙击枪', lmg: '重机枪' };
const fmtPct = (v) => `${(v * 100).toFixed(1)}%`;
weaponTopEl.innerHTML = `
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead class="text-gray-500 dark:text-gray-400">
<tr>
<th class="text-left font-bold py-1 pr-2">武器</th>
<th class="text-right font-bold py-1 px-2">击杀</th>
<th class="text-right font-bold py-1 px-2">爆头率</th>
<th class="text-left font-bold py-1 pl-2">价格/类型</th>
</tr>
</thead>
<tbody class="text-gray-700 dark:text-gray-200">
${rows.map((w) => {
const category = catMap[w.category] || (w.category || '');
const price = (w.price != null) ? `$${w.price}` : '—';
const info = weaponRankMap[w.weapon] || {};
const kpmRank = (info.kpm_rank != null && info.kpm_total != null) ? `#${info.kpm_rank}/${info.kpm_total}` : '—';
const hsRank = (info.hs_rank != null && info.hs_total != null) ? `#${info.hs_rank}/${info.hs_total}` : '—';
const killCell = `${w.kills} (场均 ${w.kpm.toFixed(2)} · ${kpmRank})`;
const hsCell = `${fmtPct(w.hsRate)} (${hsRank})`;
const priceType = `${price}${category ? '-' + category : ''}`;
return `
<tr class="border-t border-gray-100 dark:border-slate-600/40">
<td class="py-1 pr-2 font-mono">${w.weapon}</td>
<td class="py-1 px-2 text-right font-mono">${killCell}</td>
<td class="py-1 px-2 text-right font-mono">${hsCell}</td>
<td class="py-1 pl-2 font-mono">${priceType}</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
}
}
const roundSplit = JSON.parse({{ (features.get('rd_roundtype_split_json', '{}') or '{}') | tojson }});
const roundSplitEl = document.getElementById('roundTypeTable');
if (roundSplitEl) {
const keys = Object.keys(roundSplit || {});
if (keys.length === 0) {
roundSplitEl.innerHTML = '<div class="text-gray-500 dark:text-gray-400">No data</div>';
} else {
const order = ['pistol', 'reg', 'eco', 'rifle', 'fullbuy', 'overtime'];
keys.sort((a, b) => order.indexOf(a) - order.indexOf(b));
const rtRank = {
pistol: { kpr: { rank: {{ (distribution.get('rd_rt_kpr_pistol') or {}).get('rank', 'null') }}, total: {{ (distribution.get('rd_rt_kpr_pistol') or {}).get('total', 'null') }} } },
reg: { kpr: { rank: {{ (distribution.get('rd_rt_kpr_reg') or {}).get('rank', 'null') }}, total: {{ (distribution.get('rd_rt_kpr_reg') or {}).get('total', 'null') }} } },
overtime: { kpr: { rank: {{ (distribution.get('rd_rt_kpr_overtime') or {}).get('rank', 'null') }}, total: {{ (distribution.get('rd_rt_kpr_overtime') or {}).get('total', 'null') }} },
perf: { rank: {{ (distribution.get('rd_rt_perf_overtime') or {}).get('rank', 'null') }}, total: {{ (distribution.get('rd_rt_perf_overtime') or {}).get('total', 'null') }} } },
eco: { perf: { rank: {{ (distribution.get('rd_rt_perf_eco') or {}).get('rank', 'null') }}, total: {{ (distribution.get('rd_rt_perf_eco') or {}).get('total', 'null') }} } },
rifle: { perf: { rank: {{ (distribution.get('rd_rt_perf_rifle') or {}).get('rank', 'null') }}, total: {{ (distribution.get('rd_rt_perf_rifle') or {}).get('total', 'null') }} } },
fullbuy: { perf: { rank: {{ (distribution.get('rd_rt_perf_fullbuy') or {}).get('rank', 'null') }}, total: {{ (distribution.get('rd_rt_perf_fullbuy') or {}).get('total', 'null') }} } },
};
const fmtRank = (r) => (r && r.rank != null && r.total != null) ? `#${r.rank}/${r.total}` : '—';
roundSplitEl.innerHTML = `
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead class="text-gray-500 dark:text-gray-400">
<tr>
<th class="text-left font-bold py-1 pr-2">类型</th>
<th class="text-right font-bold py-1 px-2">KPR</th>
<th class="text-right font-bold py-1 px-2">队内</th>
<th class="text-right font-bold py-1 px-2">Perf</th>
<th class="text-right font-bold py-1 px-2">队内</th>
<th class="text-right font-bold py-1 pl-2">样本</th>
</tr>
</thead>
<tbody class="text-gray-700 dark:text-gray-200">
${keys.map(k => {
const v = roundSplit[k] || {};
const kpr = (v.kpr != null) ? Number(v.kpr).toFixed(2) : '—';
const perf = (v.perf != null) ? Number(v.perf).toFixed(2) : '—';
const rounds = v.rounds != null ? v.rounds : 0;
const rk = rtRank[k] || {};
const kprRank = fmtRank(rk.kpr);
const perfRank = fmtRank(rk.perf);
return `
<tr class="border-t border-gray-100 dark:border-slate-600/40">
<td class="py-1 pr-2 font-mono">${k}</td>
<td class="py-1 px-2 text-right font-mono">${kpr}</td>
<td class="py-1 px-2 text-right font-mono">${kprRank}</td>
<td class="py-1 px-2 text-right font-mono">${perf}</td>
<td class="py-1 px-2 text-right font-mono">${perfRank}</td>
<td class="py-1 pl-2 text-right font-mono">n=${rounds}</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
}
}
}); });
}); });
</script> </script>
{% endblock %} {% endblock %}