diff --git a/.gitignore b/.gitignore index 7f0e7f8..af70359 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,6 @@ venv.bak/ output/ output_arena/ arena/ +scripts/ +experiment +yrtv.zip \ No newline at end of file diff --git a/ETL/L2_Builder.py b/ETL/L2_Builder.py index 41b3d0e..50f13e1 100644 --- a/ETL/L2_Builder.py +++ b/ETL/L2_Builder.py @@ -157,6 +157,7 @@ class PlayerEconomy: main_weapon: str = "" has_helmet: bool = False has_defuser: bool = False + has_zeus: bool = False round_performance_score: float = 0.0 @dataclass @@ -865,6 +866,9 @@ class MatchParser: if evt.get('trade_score_change'): 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'): re.flash_assist_steam_id = list(evt['flash_assist_killer_score_change'].keys())[0] @@ -944,6 +948,7 @@ class MatchParser: has_helmet = False has_defuser = False + has_zeus = False if isinstance(items, list): for it in items: if isinstance(it, dict): @@ -952,6 +957,8 @@ class MatchParser: has_helmet = True elif name == 'item_defuser': has_defuser = True + elif name and ('taser' in name or 'zeus' in name): + has_zeus = True rd.economies.append(PlayerEconomy( steam_id_64=str(sid), @@ -961,6 +968,7 @@ class MatchParser: main_weapon=main_weapon, has_helmet=has_helmet, has_defuser=has_defuser, + has_zeus=has_zeus, 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)) ) 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) @@ -1325,14 +1355,14 @@ def save_match(cursor, m: MatchData): cursor.execute(""" 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, 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) - 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.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, ax, ay, az, vx, vy, vz )) @@ -1340,10 +1370,10 @@ def save_match(cursor, m: MatchData): for pe in r.economies: cursor.execute(""" 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) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (match_id, round_num, steam_id_64, side, start_money, equipment_value, main_weapon, has_helmet, has_defuser, has_zeus, round_performance_score) + 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 diff --git a/ETL/L3_Builder.py b/ETL/L3_Builder.py index 235bf4c..9f6a705 100644 --- a/ETL/L3_Builder.py +++ b/ETL/L3_Builder.py @@ -18,6 +18,17 @@ logger = logging.getLogger(__name__) L3_DB_PATH = Config.DB_L3_PATH 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(): l3_dir = os.path.dirname(L3_DB_PATH) if not os.path.exists(l3_dir): @@ -26,6 +37,40 @@ def init_db(): conn = sqlite3.connect(L3_DB_PATH) with open(SCHEMA_PATH, 'r', encoding='utf-8') as f: 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.close() logger.info("L3 DB Initialized/Updated with Schema.") diff --git a/database/L2/L2_Main.sqlite b/database/L2/L2_Main.sqlite index bb8414f..e56ee38 100644 Binary files a/database/L2/L2_Main.sqlite and b/database/L2/L2_Main.sqlite differ diff --git a/database/L2/schema.sql b/database/L2/schema.sql index 299fcb6..0d1d835 100644 --- a/database/L2/schema.sql +++ b/database/L2/schema.sql @@ -573,6 +573,7 @@ CREATE TABLE IF NOT EXISTS fact_round_player_economy ( main_weapon TEXT, has_helmet BOOLEAN, has_defuser BOOLEAN, + has_zeus BOOLEAN, -- Round Performance Summary (Leetify) round_performance_score REAL, diff --git a/database/L3/L3_Features.sqlite b/database/L3/L3_Features.sqlite index 4f65b92..3789a43 100644 Binary files a/database/L3/L3_Features.sqlite and b/database/L3/L3_Features.sqlite differ diff --git a/database/L3/schema.sql b/database/L3/schema.sql index a13ac00..d35db0d 100644 --- a/database/L3/schema.sql +++ b/database/L3/schema.sql @@ -32,6 +32,9 @@ CREATE TABLE IF NOT EXISTS dm_player_features ( basic_avg_revenge_kill REAL, basic_avg_awp_kill 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_plants 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_trade_kill_rate 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 diff --git a/requirements.txt b/requirements.txt index 9cb446d..0efa20b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ numpy playwright gunicorn gevent +matplotlib diff --git a/web/services/feature_service.py b/web/services/feature_service.py index a5114b3..a49540f 100644 --- a/web/services/feature_service.py +++ b/web/services/feature_service.py @@ -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): diff --git a/web/services/stats_service.py b/web/services/stats_service.py index 098a806..de17852 100644 --- a/web/services/stats_service.py +++ b/web/services/stats_service.py @@ -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 diff --git a/web/services/weapon_service.py b/web/services/weapon_service.py new file mode 100644 index 0000000..7b239dc --- /dev/null +++ b/web/services/weapon_service.py @@ -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 + diff --git a/web/templates/players/profile.html b/web/templates/players/profile.html index 6620ab4..d917c0d 100644 --- a/web/templates/players/profile.html +++ b/web/templates/players/profile.html @@ -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%}') }} {{ 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') }} - {{ 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%}') }} - {{ detail_item('2K Rounds (双杀)', features['basic_avg_kill_2'], 'basic_avg_kill_2') }} @@ -321,6 +322,51 @@ +
+

+ ROUND (Round Dynamics) +

+
+ {{ 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}') }} +
+ +
+
+
Phase Split
+
+ +
+
+
+
Top Weapons
+
+
+
+
Round Type Split
+
+ KPR=Kills per Round(每回合击杀) · Perf=Leetify Round Performance Score(回合表现分) +
+
+
+
+
+

@@ -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 = '
No data
'; + } 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 = ` +
+ + + + + + + + + + + ${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 ` + + + + + + + `; + }).join('')} + +
武器击杀爆头率价格/类型
${w.weapon}${killCell}${hsCell}${priceType}
+
+ `; + } + } + + 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 = '
No data
'; + } 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 = ` +
+ + + + + + + + + + + + + ${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 ` + + + + + + + + + `; + }).join('')} + +
类型KPR队内Perf队内样本
${k}${kpr}${kprRank}${perf}${perfRank}n=${rounds}
+
+ `; + } + } }); }); -{% endblock %} \ No newline at end of file +{% endblock %}