diff --git a/ETL/L2_Builder.py b/ETL/L2_Builder.py index 849b1f4..83b0dce 100644 --- a/ETL/L2_Builder.py +++ b/ETL/L2_Builder.py @@ -1342,5 +1342,94 @@ def save_match(cursor, m: MatchData): 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 )) + # 6. Calculate & Save Clutch Attempts + _calculate_and_save_clutch_attempts(cursor, m.match_id, m.round_list_raw) + +def _calculate_and_save_clutch_attempts(cursor, match_id, round_list_raw): + if not round_list_raw: + return + + try: + round_list = json.loads(round_list_raw) + except: + return + + player_attempts = {} + + for round_data in round_list: + all_kills = round_data.get('all_kill', []) + if not all_kills: + continue + + team_members = {1: set(), 2: set()} + + # Scan for team members + for k in all_kills: + if k.get('attacker') and k['attacker'].get('steamid_64'): + tid = k['attacker'].get('team') + if tid in [1, 2]: + team_members[tid].add(k['attacker']['steamid_64']) + if k.get('victim') and k['victim'].get('steamid_64'): + tid = k['victim'].get('team') + if tid in [1, 2]: + team_members[tid].add(k['victim']['steamid_64']) + + if not team_members[1] or not team_members[2]: + continue + + alive = {1: team_members[1].copy(), 2: team_members[2].copy()} + clutch_triggered_players = set() + + # Sort kills by time + sorted_kills = sorted(all_kills, key=lambda x: x.get('pasttime', 0)) + + for k in sorted_kills: + victim = k.get('victim') + if not victim: continue + + v_sid = victim.get('steamid_64') + v_team = victim.get('team') + + if v_team not in [1, 2] or v_sid not in alive[v_team]: + continue + + alive[v_team].remove(v_sid) + + if len(alive[v_team]) == 1: + survivor_sid = list(alive[v_team])[0] + if survivor_sid not in clutch_triggered_players: + opponent_team = 3 - v_team + opponents_alive_count = len(alive[opponent_team]) + + if opponents_alive_count >= 1: + if survivor_sid not in player_attempts: + player_attempts[survivor_sid] = {'1v1': 0, '1v2': 0, '1v3': 0, '1v4': 0, '1v5': 0} + + n = min(opponents_alive_count, 5) + key = f'1v{n}' + player_attempts[survivor_sid][key] += 1 + clutch_triggered_players.add(survivor_sid) + + # Save to DB + cursor.execute(""" + CREATE TABLE IF NOT EXISTS fact_match_clutch_attempts ( + match_id TEXT, + steam_id_64 TEXT, + attempt_1v1 INTEGER DEFAULT 0, + attempt_1v2 INTEGER DEFAULT 0, + attempt_1v3 INTEGER DEFAULT 0, + attempt_1v4 INTEGER DEFAULT 0, + attempt_1v5 INTEGER DEFAULT 0, + PRIMARY KEY (match_id, steam_id_64) + ) + """) + + for pid, att in player_attempts.items(): + cursor.execute(""" + INSERT OR REPLACE INTO fact_match_clutch_attempts + (match_id, steam_id_64, attempt_1v1, attempt_1v2, attempt_1v3, attempt_1v4, attempt_1v5) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, (match_id, pid, att['1v1'], att['1v2'], att['1v3'], att['1v4'], att['1v5'])) + if __name__ == "__main__": process_matches() diff --git a/database/L2/L2_Main.sqlite b/database/L2/L2_Main.sqlite index 81f8ab7..d1f04bb 100644 Binary files a/database/L2/L2_Main.sqlite and b/database/L2/L2_Main.sqlite differ diff --git a/web/routes/players.py b/web/routes/players.py index 3fb7344..00eb9af 100644 --- a/web/routes/players.py +++ b/web/routes/players.py @@ -102,13 +102,15 @@ def detail(steam_id): # --- New: Fetch Detailed Stats from L2 (Clutch, Multi-Kill, Multi-Assist) --- sql_l2 = """ SELECT - SUM(clutch_1v1) as c1, SUM(clutch_1v2) as c2, SUM(clutch_1v3) as c3, SUM(clutch_1v4) as c4, SUM(clutch_1v5) as c5, - SUM(kill_2) as k2, SUM(kill_3) as k3, SUM(kill_4) as k4, SUM(kill_5) as k5, - SUM(many_assists_cnt2) as a2, SUM(many_assists_cnt3) as a3, SUM(many_assists_cnt4) as a4, SUM(many_assists_cnt5) as a5, + SUM(p.clutch_1v1) as c1, SUM(p.clutch_1v2) as c2, SUM(p.clutch_1v3) as c3, SUM(p.clutch_1v4) as c4, SUM(p.clutch_1v5) as c5, + SUM(a.attempt_1v1) as att1, SUM(a.attempt_1v2) as att2, SUM(a.attempt_1v3) as att3, SUM(a.attempt_1v4) as att4, SUM(a.attempt_1v5) as att5, + SUM(p.kill_2) as k2, SUM(p.kill_3) as k3, SUM(p.kill_4) as k4, SUM(p.kill_5) as k5, + SUM(p.many_assists_cnt2) as a2, SUM(p.many_assists_cnt3) as a3, SUM(p.many_assists_cnt4) as a4, SUM(p.many_assists_cnt5) as a5, COUNT(*) as matches, - SUM(round_total) as total_rounds - FROM fact_match_players - WHERE steam_id_64 = ? + SUM(p.round_total) as total_rounds + FROM fact_match_players p + LEFT JOIN fact_match_clutch_attempts a ON p.match_id = a.match_id AND p.steam_id_64 = a.steam_id_64 + WHERE p.steam_id_64 = ? """ l2_stats = query_db('l2', sql_l2, [steam_id], one=True) l2_stats = dict(l2_stats) if l2_stats else {} diff --git a/web/services/stats_service.py b/web/services/stats_service.py index 9393060..e82906b 100644 --- a/web/services/stats_service.py +++ b/web/services/stats_service.py @@ -627,6 +627,52 @@ class StatsService: if target_steam_id not in stats_map: stats_map[target_steam_id] = {} + # --- New: Enrich with L2 Clutch/Multi Stats for Distribution --- + l2_placeholders = ','.join('?' for _ in active_roster_ids) + sql_l2 = f""" + SELECT + p.steam_id_64, + SUM(p.clutch_1v1) as c1, SUM(p.clutch_1v2) as c2, SUM(p.clutch_1v3) as c3, SUM(p.clutch_1v4) as c4, SUM(p.clutch_1v5) as c5, + SUM(a.attempt_1v1) as att1, SUM(a.attempt_1v2) as att2, SUM(a.attempt_1v3) as att3, SUM(a.attempt_1v4) as att4, SUM(a.attempt_1v5) as att5, + SUM(p.kill_2) as k2, SUM(p.kill_3) as k3, SUM(p.kill_4) as k4, SUM(p.kill_5) as k5, + SUM(p.many_assists_cnt2) as a2, SUM(p.many_assists_cnt3) as a3, SUM(p.many_assists_cnt4) as a4, SUM(p.many_assists_cnt5) as a5, + SUM(p.round_total) as total_rounds + FROM fact_match_players p + LEFT JOIN fact_match_clutch_attempts a ON p.match_id = a.match_id AND p.steam_id_64 = a.steam_id_64 + WHERE CAST(p.steam_id_64 AS TEXT) IN ({l2_placeholders}) + GROUP BY p.steam_id_64 + """ + l2_rows = query_db('l2', sql_l2, active_roster_ids) + + for r in l2_rows: + sid = str(r['steam_id_64']) + if sid not in stats_map: + stats_map[sid] = {} + + # Clutch Rates + for i in range(1, 6): + c = r[f'c{i}'] or 0 + att = r[f'att{i}'] or 0 + rate = (c / att) if att > 0 else 0 + stats_map[sid][f'clutch_rate_1v{i}'] = rate + + # Multi-Kill Rates + rounds = r['total_rounds'] or 1 # Avoid div by 0 + total_mk = 0 + for i in range(2, 6): + k = r[f'k{i}'] or 0 + total_mk += k + stats_map[sid][f'multikill_rate_{i}k'] = k / rounds + stats_map[sid]['total_multikill_rate'] = total_mk / rounds + + # Multi-Assist Rates + total_ma = 0 + for i in range(2, 6): + a = r[f'a{i}'] or 0 + total_ma += a + stats_map[sid][f'multiassist_rate_{i}a'] = a / rounds + stats_map[sid]['total_multiassist_rate'] = total_ma / rounds + # 3. Calculate Distribution for ALL metrics # Define metrics list (must match Detailed Panel keys) metrics = [ @@ -658,7 +704,12 @@ class StatsService: # New: Rating Distribution 'rating_dist_carry_rate', 'rating_dist_normal_rate', 'rating_dist_sacrifice_rate', 'rating_dist_sleeping_rate', # New: ELO Stratification - 'elo_lt1200_rating', 'elo_1200_1400_rating', 'elo_1400_1600_rating', 'elo_1600_1800_rating', 'elo_1800_2000_rating', 'elo_gt2000_rating' + 'elo_lt1200_rating', 'elo_1200_1400_rating', 'elo_1400_1600_rating', 'elo_1600_1800_rating', 'elo_1800_2000_rating', 'elo_gt2000_rating', + # New: Clutch & Multi (Real Calculation) + 'clutch_rate_1v1', 'clutch_rate_1v2', 'clutch_rate_1v3', 'clutch_rate_1v4', 'clutch_rate_1v5', + 'multikill_rate_2k', 'multikill_rate_3k', 'multikill_rate_4k', 'multikill_rate_5k', + 'multiassist_rate_2a', 'multiassist_rate_3a', 'multiassist_rate_4a', 'multiassist_rate_5a', + 'total_multikill_rate', 'total_multiassist_rate' ] # Mapping for L2 legacy calls (if any) - mainly map 'rating' to 'basic_avg_rating' etc if needed diff --git a/web/templates/players/profile.html b/web/templates/players/profile.html index 7126d04..1b75614 100644 --- a/web/templates/players/profile.html +++ b/web/templates/players/profile.html @@ -162,12 +162,20 @@ {% endif %} -
- - {{ format_str.format(value if value is not none else 0) }} - - {% if sublabel %} - {{ sublabel }} +
+
+ + {{ format_str.format(value if value is not none else 0) }} + + {% if sublabel %} + {{ sublabel }} + {% endif %} +
+ + {% if count_label is not none %} +
+ {{ count_label }} +
{% endif %}
@@ -186,13 +194,6 @@ H:{{ format_str.format(dist.max) }}
{% endif %} - - - {% if count_label is not none %} -
- {{ count_label }} -
- {% endif %} {% endmacro %} @@ -268,8 +269,8 @@ HPS (Clutch/Pressure) & PTL (Pistol)
- {{ detail_item('1v1 Win% (1v1胜率)', features['hps_clutch_win_rate_1v1'], 'hps_clutch_win_rate_1v1', '{:.1%}') }} - {{ detail_item('1v3+ Win% (残局大神)', features['hps_clutch_win_rate_1v3_plus'], 'hps_clutch_win_rate_1v3_plus', '{:.1%}') }} + {{ detail_item('Avg 1v1 (场均1v1)', features['hps_clutch_win_rate_1v1'], 'hps_clutch_win_rate_1v1', '{:.2f}') }} + {{ detail_item('Avg 1v3+ (场均1v3+)', features['hps_clutch_win_rate_1v3_plus'], 'hps_clutch_win_rate_1v3_plus', '{:.2f}') }} {{ detail_item('Match Pt Win% (赛点胜率)', features['hps_match_point_win_rate'], 'hps_match_point_win_rate', '{:.1%}') }} {{ detail_item('Pressure Entry (逆风首杀)', features['hps_pressure_entry_rate'], 'hps_pressure_entry_rate', '{:.1%}') }} {{ detail_item('Comeback KD (翻盘KD)', features['hps_comeback_kd_diff'], 'hps_comeback_kd_diff') }} @@ -300,19 +301,34 @@

SPECIAL (Clutch & Multi)

+ {% set matches = l2_stats.get('matches', 0) or 1 %} {% set rounds = l2_stats.get('total_rounds', 0) or 1 %}
- {{ detail_item('1v1 Win%', (l2_stats.get('c1', 0) or 0) / rounds, 'l2_c1', '{:.1%}', count_label=l2_stats.get('c1', 0)) }} - {{ detail_item('1v2 Win%', (l2_stats.get('c2', 0) or 0) / rounds, 'l2_c2', '{:.1%}', count_label=l2_stats.get('c2', 0)) }} - {{ detail_item('1v3 Win%', (l2_stats.get('c3', 0) or 0) / rounds, 'l2_c3', '{:.1%}', count_label=l2_stats.get('c3', 0)) }} - {{ detail_item('1v4 Win%', (l2_stats.get('c4', 0) or 0) / rounds, 'l2_c4', '{:.1%}', count_label=l2_stats.get('c4', 0)) }} - {{ detail_item('1v5 Win%', (l2_stats.get('c5', 0) or 0) / rounds, 'l2_c5', '{:.1%}', count_label=l2_stats.get('c5', 0)) }} + {% set c1 = l2_stats.get('c1', 0) or 0 %} + {% set a1 = l2_stats.get('att1', 0) or 0 %} + {{ detail_item('1v1 Win% (1v1胜率)', c1 / a1 if a1 > 0 else 0, 'clutch_rate_1v1', '{:.1%}', count_label=c1 ~ '/' ~ a1) }} + + {% set c2 = l2_stats.get('c2', 0) or 0 %} + {% set a2 = l2_stats.get('att2', 0) or 0 %} + {{ detail_item('1v2 Win% (1v2胜率)', c2 / a2 if a2 > 0 else 0, 'clutch_rate_1v2', '{:.1%}', count_label=c2 ~ '/' ~ a2) }} + + {% set c3 = l2_stats.get('c3', 0) or 0 %} + {% set a3 = l2_stats.get('att3', 0) or 0 %} + {{ detail_item('1v3 Win% (1v3胜率)', c3 / a3 if a3 > 0 else 0, 'clutch_rate_1v3', '{:.1%}', count_label=c3 ~ '/' ~ a3) }} + + {% set c4 = l2_stats.get('c4', 0) or 0 %} + {% set a4 = l2_stats.get('att4', 0) or 0 %} + {{ detail_item('1v4 Win% (1v4胜率)', c4 / a4 if a4 > 0 else 0, 'clutch_rate_1v4', '{:.1%}', count_label=c4 ~ '/' ~ a4) }} + + {% set c5 = l2_stats.get('c5', 0) or 0 %} + {% set a5 = l2_stats.get('att5', 0) or 0 %} + {{ detail_item('1v5 Win% (1v5胜率)', c5 / a5 if a5 > 0 else 0, 'clutch_rate_1v5', '{:.1%}', count_label=c5 ~ '/' ~ a5) }} {% set mk_count = (l2_stats.get('k2', 0) or 0) + (l2_stats.get('k3', 0) or 0) + (l2_stats.get('k4', 0) or 0) + (l2_stats.get('k5', 0) or 0) %} {% set ma_count = (l2_stats.get('a2', 0) or 0) + (l2_stats.get('a3', 0) or 0) + (l2_stats.get('a4', 0) or 0) + (l2_stats.get('a5', 0) or 0) %} - {{ detail_item('Multi-Kill Rate', mk_count / rounds, 'l2_mk', '{:.1%}', count_label=mk_count) }} - {{ detail_item('Multi-Assist Rate', ma_count / rounds, 'l2_ma', '{:.1%}', count_label=ma_count) }} + {{ detail_item('Multi-K Rate (多杀率)', mk_count / rounds, 'total_multikill_rate', '{:.1%}', count_label=mk_count) }} + {{ detail_item('Multi-A Rate (多助率)', ma_count / rounds, 'total_multiassist_rate', '{:.1%}', count_label=ma_count) }}