1.5.0: Clutch fully recovered.

This commit is contained in:
2026-01-27 17:53:09 +08:00
parent 0be68a86f6
commit 50428ae2ac
5 changed files with 187 additions and 29 deletions

View File

@@ -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 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__": if __name__ == "__main__":
process_matches() process_matches()

Binary file not shown.

View File

@@ -102,13 +102,15 @@ def detail(steam_id):
# --- New: Fetch Detailed Stats from L2 (Clutch, Multi-Kill, Multi-Assist) --- # --- New: Fetch Detailed Stats from L2 (Clutch, Multi-Kill, Multi-Assist) ---
sql_l2 = """ sql_l2 = """
SELECT 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(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(kill_2) as k2, SUM(kill_3) as k3, SUM(kill_4) as k4, SUM(kill_5) as k5, 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(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.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, COUNT(*) as matches,
SUM(round_total) as total_rounds SUM(p.round_total) as total_rounds
FROM fact_match_players FROM fact_match_players p
WHERE steam_id_64 = ? 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 = query_db('l2', sql_l2, [steam_id], one=True)
l2_stats = dict(l2_stats) if l2_stats else {} l2_stats = dict(l2_stats) if l2_stats else {}

View File

@@ -627,6 +627,52 @@ class StatsService:
if target_steam_id not in stats_map: if target_steam_id not in stats_map:
stats_map[target_steam_id] = {} stats_map[target_steam_id] = {}
# --- 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 # 3. Calculate Distribution for ALL metrics
# Define metrics list (must match Detailed Panel keys) # Define metrics list (must match Detailed Panel keys)
metrics = [ metrics = [
@@ -658,7 +704,12 @@ class StatsService:
# New: Rating Distribution # New: Rating Distribution
'rating_dist_carry_rate', 'rating_dist_normal_rate', 'rating_dist_sacrifice_rate', 'rating_dist_sleeping_rate', 'rating_dist_carry_rate', 'rating_dist_normal_rate', 'rating_dist_sacrifice_rate', 'rating_dist_sleeping_rate',
# New: ELO Stratification # 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 # Mapping for L2 legacy calls (if any) - mainly map 'rating' to 'basic_avg_rating' etc if needed

View File

@@ -162,12 +162,20 @@
{% endif %} {% endif %}
</div> </div>
<div class="flex items-baseline gap-1 mb-1"> <div class="flex justify-between items-end mb-1">
<span class="text-xl font-black text-gray-900 dark:text-white font-mono"> <div class="flex items-baseline gap-1">
{{ format_str.format(value if value is not none else 0) }} <span class="text-xl font-black text-gray-900 dark:text-white font-mono">
</span> {{ format_str.format(value if value is not none else 0) }}
{% if sublabel %} </span>
<span class="text-[10px] text-gray-400">{{ sublabel }}</span> {% if sublabel %}
<span class="text-[10px] text-gray-400">{{ sublabel }}</span>
{% endif %}
</div>
{% if count_label is not none %}
<div class="text-[10px] font-bold text-gray-400 font-mono mb-0.5">
{{ count_label }}
</div>
{% endif %} {% endif %}
</div> </div>
@@ -186,13 +194,6 @@
<span>H:{{ format_str.format(dist.max) }}</span> <span>H:{{ format_str.format(dist.max) }}</span>
</div> </div>
{% endif %} {% endif %}
<!-- Count Label (Bottom Right) -->
{% if count_label is not none %}
<div class="absolute bottom-0 right-0 text-[10px] font-bold text-gray-400 font-mono">
{{ count_label }}
</div>
{% endif %}
</div> </div>
{% endmacro %} {% endmacro %}
@@ -268,8 +269,8 @@
HPS (Clutch/Pressure) & PTL (Pistol) HPS (Clutch/Pressure) & PTL (Pistol)
</h4> </h4>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-y-6 gap-x-4"> <div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-y-6 gap-x-4">
{{ detail_item('1v1 Win% (1v1胜率)', features['hps_clutch_win_rate_1v1'], 'hps_clutch_win_rate_1v1', '{:.1%}') }} {{ detail_item('Avg 1v1 (场均1v1)', features['hps_clutch_win_rate_1v1'], 'hps_clutch_win_rate_1v1', '{:.2f}') }}
{{ detail_item('1v3+ Win% (残局大神)', features['hps_clutch_win_rate_1v3_plus'], 'hps_clutch_win_rate_1v3_plus', '{:.1%}') }} {{ 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('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('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') }} {{ detail_item('Comeback KD (翻盘KD)', features['hps_comeback_kd_diff'], 'hps_comeback_kd_diff') }}
@@ -300,19 +301,34 @@
<h4 class="text-xs font-black text-gray-400 uppercase tracking-widest mb-4 border-b border-gray-100 dark:border-slate-700 pb-2"> <h4 class="text-xs font-black text-gray-400 uppercase tracking-widest mb-4 border-b border-gray-100 dark:border-slate-700 pb-2">
SPECIAL (Clutch & Multi) SPECIAL (Clutch & Multi)
</h4> </h4>
{% set matches = l2_stats.get('matches', 0) or 1 %}
{% set rounds = l2_stats.get('total_rounds', 0) or 1 %} {% set rounds = l2_stats.get('total_rounds', 0) or 1 %}
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-y-6 gap-x-4"> <div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-y-6 gap-x-4">
{{ detail_item('1v1 Win%', (l2_stats.get('c1', 0) or 0) / rounds, 'l2_c1', '{:.1%}', count_label=l2_stats.get('c1', 0)) }} {% set c1 = l2_stats.get('c1', 0) or 0 %}
{{ detail_item('1v2 Win%', (l2_stats.get('c2', 0) or 0) / rounds, 'l2_c2', '{:.1%}', count_label=l2_stats.get('c2', 0)) }} {% set a1 = l2_stats.get('att1', 0) or 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('1v1 Win% (1v1胜率)', c1 / a1 if a1 > 0 else 0, 'clutch_rate_1v1', '{:.1%}', count_label=c1 ~ '/' ~ a1) }}
{{ 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 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 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) %} {% 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-K Rate (多杀率)', mk_count / rounds, 'total_multikill_rate', '{:.1%}', count_label=mk_count) }}
{{ detail_item('Multi-Assist Rate', ma_count / rounds, 'l2_ma', '{:.1%}', count_label=ma_count) }} {{ detail_item('Multi-A Rate (多助率)', ma_count / rounds, 'total_multiassist_rate', '{:.1%}', count_label=ma_count) }}
</div> </div>
</div> </div>