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

View File

@@ -2,6 +2,7 @@ from web.database import query_db, get_db, execute_db
import sqlite3
import pandas as pd
import numpy as np
from web.services.weapon_service import get_weapon_info
class FeatureService:
@staticmethod
@@ -357,6 +358,46 @@ class FeatureService:
valid_ids = tuple(df['steam_id_64'].tolist())
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)
query_sta = f"""
@@ -481,12 +522,18 @@ class FeatureService:
break
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')
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
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
WHERE event_type='kill'
AND (attacker_steam_id IN ({placeholders}) OR victim_steam_id IN ({placeholders}))
@@ -495,7 +542,7 @@ class FeatureService:
# C. Get Round Scores
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
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
q_new_feats = f"""
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
WHERE mp.steam_id_64 IN ({placeholders})
"""
@@ -1139,10 +1186,448 @@ class FeatureService:
if df_pace is not None:
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
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
def _calculate_economy_features(conn, player_ids):

View File

@@ -725,6 +725,7 @@ class StatsService:
metrics = [
'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_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_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',
@@ -745,6 +746,13 @@ class StatsService:
# 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: 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
'party_1_win_rate', 'party_1_rating', 'party_1_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.
# 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 = {}
@@ -808,6 +816,141 @@ class StatsService:
if m in legacy_map:
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
@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('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('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 -->
{{ detail_item('MVP (最有价值)', features['basic_avg_mvps'], 'basic_avg_mvps') }}
{{ detail_item('Plants (下包)', features['basic_avg_plants'], 'basic_avg_plants') }}
{{ detail_item('Defuses (拆包)', features['basic_avg_defuses'], 'basic_avg_defuses') }}
{{ detail_item('Flash Assist (闪光助攻)', features['basic_avg_flash_assists'], 'basic_avg_flash_assists') }}
<div class="hidden lg:block"></div> <!-- Spacer -->
<!-- Row 4: Opening -->
{{ 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('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%}') }}
<div class="hidden lg:block"></div> <!-- Spacer -->
<!-- Row 5: Multi-Kills -->
{{ detail_item('2K Rounds (双杀)', features['basic_avg_kill_2'], 'basic_avg_kill_2') }}
@@ -321,6 +322,51 @@
</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) -->
<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">
@@ -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>
{% endblock %}
{% endblock %}