3.0.0 : Reconstructed Database System.

This commit is contained in:
2026-01-29 02:21:44 +08:00
parent 1642adb00e
commit 04ee957af6
69 changed files with 10258 additions and 6546 deletions

View File

@@ -493,9 +493,24 @@ class StatsService:
@staticmethod
def get_player_basic_stats(steam_id):
# Calculate stats from fact_match_players
# Prefer calculating from sums (kills/deaths) for K/D accuracy
# AVG(adr) is used as damage_total might be missing in some sources
l3 = query_db(
"l3",
"""
SELECT
total_matches as matches_played,
core_avg_rating as rating,
core_avg_kd as kd,
core_avg_kast as kast,
core_avg_adr as adr
FROM dm_player_features
WHERE steam_id_64 = ?
""",
[steam_id],
one=True,
)
if l3 and (l3["matches_played"] or 0) > 0:
return dict(l3)
sql = """
SELECT
AVG(rating) as rating,
@@ -508,28 +523,20 @@ class StatsService:
FROM fact_match_players
WHERE steam_id_64 = ?
"""
row = query_db('l2', sql, [steam_id], one=True)
if row and row['matches_played'] > 0:
row = query_db("l2", sql, [steam_id], one=True)
if row and row["matches_played"] > 0:
res = dict(row)
# Calculate K/D: Sum Kills / Sum Deaths
kills = res.get('total_kills') or 0
deaths = res.get('total_deaths') or 0
kills = res.get("total_kills") or 0
deaths = res.get("total_deaths") or 0
if deaths > 0:
res['kd'] = kills / deaths
res["kd"] = kills / deaths
else:
res['kd'] = kills # If 0 deaths, K/D is kills (or infinity, but kills is safer for display)
# Fallback to avg_kd if calculation failed (e.g. both 0) but avg_kd exists
if res['kd'] == 0 and res['avg_kd'] and res['avg_kd'] > 0:
res['kd'] = res['avg_kd']
# ADR validation
if res['adr'] is None:
res['adr'] = 0.0
res["kd"] = kills
if res["kd"] == 0 and res["avg_kd"] and res["avg_kd"] > 0:
res["kd"] = res["avg_kd"]
if res["adr"] is None:
res["adr"] = 0.0
return res
return None
@@ -599,8 +606,30 @@ class StatsService:
@staticmethod
def get_player_trend(steam_id, limit=20):
# We need party_size: count of players with same match_team_id in the same match
# Using a correlated subquery for party_size
l3_sql = """
SELECT *
FROM (
SELECT
match_date as start_time,
rating,
kd_ratio,
adr,
kast,
match_id,
map_name,
is_win,
match_sequence as match_index
FROM dm_player_match_history
WHERE steam_id_64 = ?
ORDER BY match_date DESC
LIMIT ?
)
ORDER BY start_time ASC
"""
l3_rows = query_db("l3", l3_sql, [steam_id, limit])
if l3_rows:
return l3_rows
sql = """
SELECT * FROM (
SELECT
@@ -616,7 +645,7 @@ class StatsService:
FROM fact_match_players p2
WHERE p2.match_id = mp.match_id
AND p2.match_team_id = mp.match_team_id
AND p2.match_team_id > 0 -- Ensure we don't count 0 (solo default) as a massive party
AND p2.match_team_id > 0
) as party_size,
(
SELECT COUNT(*)
@@ -630,7 +659,7 @@ class StatsService:
LIMIT ?
) ORDER BY start_time ASC
"""
return query_db('l2', sql, [steam_id, limit])
return query_db("l2", sql, [steam_id, limit])
@staticmethod
def get_recent_performance_stats(steam_id):
@@ -639,63 +668,59 @@ class StatsService:
- Last 5, 10, 15 matches
- Last 5, 10, 15 days
"""
import numpy as np
from datetime import datetime, timedelta
def avg_var(nums):
if not nums:
return 0.0, 0.0
n = len(nums)
avg = sum(nums) / n
var = sum((x - avg) ** 2 for x in nums) / n
return avg, var
rows = query_db(
"l3",
"""
SELECT match_date as t, rating
FROM dm_player_match_history
WHERE steam_id_64 = ?
ORDER BY match_date DESC
""",
[steam_id],
)
if not rows:
rows = query_db(
"l2",
"""
SELECT m.start_time as t, mp.rating
FROM fact_match_players mp
JOIN fact_matches m ON mp.match_id = m.match_id
WHERE mp.steam_id_64 = ?
ORDER BY m.start_time DESC
""",
[steam_id],
)
# Fetch all match ratings with timestamps
sql = """
SELECT m.start_time, mp.rating
FROM fact_match_players mp
JOIN fact_matches m ON mp.match_id = m.match_id
WHERE mp.steam_id_64 = ?
ORDER BY m.start_time DESC
"""
rows = query_db('l2', sql, [steam_id])
if not rows:
return {}
# Convert to list of dicts
matches = [{'time': r['start_time'], 'rating': r['rating'] or 0} for r in rows]
matches = [{"time": r["t"], "rating": float(r["rating"] or 0)} for r in rows]
stats = {}
# 1. Recent N Matches
for n in [5, 10, 15]:
subset = matches[:n]
if not subset:
stats[f'last_{n}_matches'] = {'avg': 0, 'var': 0, 'count': 0}
continue
ratings = [m['rating'] for m in subset]
stats[f'last_{n}_matches'] = {
'avg': np.mean(ratings),
'var': np.var(ratings),
'count': len(ratings)
}
ratings = [m["rating"] for m in subset]
avg, var = avg_var(ratings)
stats[f"last_{n}_matches"] = {"avg": avg, "var": var, "count": len(ratings)}
# 2. Recent N Days
# Use server time or max match time? usually server time 'now' is fine if data is fresh.
# But if data is old, 'last 5 days' might be empty.
# User asked for "recent 5/10/15 days", implying calendar days from now.
import time
now = time.time()
for d in [5, 10, 15]:
cutoff = now - (d * 24 * 3600)
subset = [m for m in matches if m['time'] >= cutoff]
if not subset:
stats[f'last_{d}_days'] = {'avg': 0, 'var': 0, 'count': 0}
continue
ratings = [m['rating'] for m in subset]
stats[f'last_{d}_days'] = {
'avg': np.mean(ratings),
'var': np.var(ratings),
'count': len(ratings)
}
subset = [m for m in matches if (m["time"] or 0) >= cutoff]
ratings = [m["rating"] for m in subset]
avg, var = avg_var(ratings)
stats[f"last_{d}_days"] = {"avg": avg, "var": var, "count": len(ratings)}
return stats
@staticmethod
@@ -707,7 +732,6 @@ class StatsService:
from web.services.web_service import WebService
from web.services.feature_service import FeatureService
import json
import numpy as np
# 1. Get Active Roster IDs
lineups = WebService.get_lineups()
@@ -722,136 +746,141 @@ class StatsService:
if not active_roster_ids:
return None
# 2. Fetch L3 features for all roster members
# We need to use FeatureService to get the full L3 set (including detailed stats)
# Assuming L3 data is up to date.
placeholders = ','.join('?' for _ in active_roster_ids)
sql = f"SELECT * FROM dm_player_features WHERE steam_id_64 IN ({placeholders})"
rows = query_db('l3', sql, active_roster_ids)
placeholders = ",".join("?" for _ in active_roster_ids)
rows = query_db("l3", f"SELECT * FROM dm_player_features WHERE steam_id_64 IN ({placeholders})", active_roster_ids)
if not rows:
return None
stats_map = {row['steam_id_64']: dict(row) for row in rows}
stats_map = {str(row["steam_id_64"]): FeatureService._normalize_features(dict(row)) for row in rows}
target_steam_id = str(target_steam_id)
# If target not in map (e.g. no L3 data), try to add empty default
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 = [
'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',
'basic_avg_perfect_kill', 'basic_avg_revenge_kill',
# L3 Advanced Dimensions
'sta_last_30_rating', 'sta_win_rating', 'sta_loss_rating', 'sta_rating_volatility', 'sta_time_rating_corr',
'bat_kd_diff_high_elo', 'bat_avg_duel_win_rate', 'bat_win_rate_vs_all',
'hps_clutch_win_rate_1v1', 'hps_clutch_win_rate_1v3_plus', 'hps_match_point_win_rate', 'hps_pressure_entry_rate', 'hps_comeback_kd_diff', 'hps_losing_streak_kd_diff',
'ptl_pistol_kills', 'ptl_pistol_win_rate', 'ptl_pistol_kd', 'ptl_pistol_util_efficiency',
'side_rating_ct', 'side_rating_t', 'side_first_kill_rate_ct', 'side_first_kill_rate_t', 'side_kd_diff_ct_t', 'side_hold_success_rate_ct', 'side_entry_success_rate_t',
'side_win_rate_ct', 'side_win_rate_t', 'side_kd_ct', 'side_kd_t',
'side_kast_ct', 'side_kast_t', 'side_rws_ct', 'side_rws_t',
'side_first_death_rate_ct', 'side_first_death_rate_t',
'side_multikill_rate_ct', 'side_multikill_rate_t',
'side_headshot_rate_ct', 'side_headshot_rate_t',
'side_defuses_ct', 'side_plants_t',
'util_avg_nade_dmg', 'util_avg_flash_time', 'util_avg_flash_enemy', 'util_usage_rate',
# 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_phase_kill_early_share_t', 'rd_phase_kill_mid_share_t', 'rd_phase_kill_late_share_t',
'rd_phase_kill_early_share_ct', 'rd_phase_kill_mid_share_ct', 'rd_phase_kill_late_share_ct',
'rd_phase_death_early_share_t', 'rd_phase_death_mid_share_t', 'rd_phase_death_late_share_t',
'rd_phase_death_early_share_ct', 'rd_phase_death_mid_share_ct', 'rd_phase_death_late_share_ct',
'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',
'party_3_win_rate', 'party_3_rating', 'party_3_adr',
'party_4_win_rate', 'party_4_rating', 'party_4_adr',
'party_5_win_rate', 'party_5_rating', 'party_5_adr',
# 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',
# 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'
# TIER 1: CORE
# Basic Performance
"core_avg_rating", "core_avg_rating2", "core_avg_kd", "core_avg_adr", "core_avg_kast",
"core_avg_rws", "core_avg_hs_kills", "core_hs_rate", "core_total_kills", "core_total_deaths",
"core_total_assists", "core_avg_assists", "core_kpr", "core_dpr", "core_survival_rate",
# Match Stats
"core_win_rate", "core_wins", "core_losses", "core_avg_match_duration", "core_avg_mvps",
"core_mvp_rate", "core_avg_elo_change", "core_total_elo_gained",
# Weapon Stats
"core_avg_awp_kills", "core_awp_usage_rate", "core_avg_knife_kills", "core_avg_zeus_kills",
"core_zeus_buy_rate", "core_top_weapon_kills", "core_top_weapon_hs_rate",
"core_weapon_diversity", "core_rifle_hs_rate", "core_pistol_hs_rate", "core_smg_kills_total",
# Objective Stats
"core_avg_plants", "core_avg_defuses", "core_avg_flash_assists", "core_plant_success_rate",
"core_defuse_success_rate", "core_objective_impact",
# TIER 2: TACTICAL
# Opening Impact
"tac_avg_fk", "tac_avg_fd", "tac_fk_rate", "tac_fd_rate", "tac_fk_success_rate",
"tac_entry_kill_rate", "tac_entry_death_rate", "tac_opening_duel_winrate",
# Multi-Kill
"tac_avg_2k", "tac_avg_3k", "tac_avg_4k", "tac_avg_5k", "tac_multikill_rate", "tac_ace_count",
# Clutch Performance
"tac_clutch_1v1_attempts", "tac_clutch_1v1_wins", "tac_clutch_1v1_rate",
"tac_clutch_1v2_attempts", "tac_clutch_1v2_wins", "tac_clutch_1v2_rate",
"tac_clutch_1v3_plus_attempts", "tac_clutch_1v3_plus_wins", "tac_clutch_1v3_plus_rate",
"tac_clutch_impact_score",
# Utility Mastery
"tac_util_flash_per_round", "tac_util_smoke_per_round", "tac_util_molotov_per_round",
"tac_util_he_per_round", "tac_util_usage_rate", "tac_util_nade_dmg_per_round",
"tac_util_nade_dmg_per_nade", "tac_util_flash_time_per_round", "tac_util_flash_enemies_per_round",
"tac_util_flash_efficiency", "tac_util_smoke_timing_score", "tac_util_impact_score",
# Economy Efficiency
"tac_eco_dmg_per_1k", "tac_eco_kpr_eco_rounds", "tac_eco_kd_eco_rounds",
"tac_eco_kpr_force_rounds", "tac_eco_kpr_full_rounds", "tac_eco_save_discipline",
"tac_eco_force_success_rate", "tac_eco_efficiency_score",
# TIER 3: INTELLIGENCE
# High IQ Kills
"int_wallbang_kills", "int_wallbang_rate", "int_smoke_kills", "int_smoke_kill_rate",
"int_blind_kills", "int_blind_kill_rate", "int_noscope_kills", "int_noscope_rate", "int_high_iq_score",
# Timing Analysis
"int_timing_early_kills", "int_timing_mid_kills", "int_timing_late_kills",
"int_timing_early_kill_share", "int_timing_mid_kill_share", "int_timing_late_kill_share",
"int_timing_avg_kill_time", "int_timing_early_deaths", "int_timing_early_death_rate",
"int_timing_aggression_index", "int_timing_patience_score", "int_timing_first_contact_time",
# Pressure Performance
"int_pressure_comeback_kd", "int_pressure_comeback_rating", "int_pressure_losing_streak_kd",
"int_pressure_matchpoint_kpr", "int_pressure_matchpoint_rating", "int_pressure_clutch_composure",
"int_pressure_entry_in_loss", "int_pressure_performance_index", "int_pressure_big_moment_score",
"int_pressure_tilt_resistance",
# Position Mastery
"int_pos_site_a_control_rate", "int_pos_site_b_control_rate", "int_pos_mid_control_rate",
"int_pos_position_diversity", "int_pos_rotation_speed", "int_pos_map_coverage",
"int_pos_lurk_tendency", "int_pos_site_anchor_score", "int_pos_entry_route_diversity",
"int_pos_retake_positioning", "int_pos_postplant_positioning", "int_pos_spatial_iq_score",
"int_pos_avg_distance_from_teammates",
# Trade Network
"int_trade_kill_count", "int_trade_kill_rate", "int_trade_response_time",
"int_trade_given_count", "int_trade_given_rate", "int_trade_balance",
"int_trade_efficiency", "int_teamwork_score",
# TIER 4: META
# Stability
"meta_rating_volatility", "meta_recent_form_rating", "meta_win_rating", "meta_loss_rating",
"meta_rating_consistency", "meta_time_rating_correlation", "meta_map_stability", "meta_elo_tier_stability",
# Side Preference
"meta_side_ct_rating", "meta_side_t_rating", "meta_side_ct_kd", "meta_side_t_kd",
"meta_side_ct_win_rate", "meta_side_t_win_rate", "meta_side_ct_fk_rate", "meta_side_t_fk_rate",
"meta_side_ct_kast", "meta_side_t_kast", "meta_side_rating_diff", "meta_side_kd_diff",
"meta_side_balance_score",
# Opponent Adaptation
"meta_opp_vs_lower_elo_rating", "meta_opp_vs_similar_elo_rating", "meta_opp_vs_higher_elo_rating",
"meta_opp_vs_lower_elo_kd", "meta_opp_vs_similar_elo_kd", "meta_opp_vs_higher_elo_kd",
"meta_opp_elo_adaptation", "meta_opp_stomping_score", "meta_opp_upset_score",
"meta_opp_consistency_across_elos", "meta_opp_rank_resistance", "meta_opp_smurf_detection",
# Map Specialization
"meta_map_best_rating", "meta_map_worst_rating", "meta_map_diversity", "meta_map_pool_size",
"meta_map_specialist_score", "meta_map_versatility", "meta_map_comfort_zone_rate", "meta_map_adaptation",
# Session Pattern
"meta_session_avg_matches_per_day", "meta_session_longest_streak", "meta_session_weekend_rating",
"meta_session_weekday_rating", "meta_session_morning_rating", "meta_session_afternoon_rating",
"meta_session_evening_rating", "meta_session_night_rating",
# TIER 5: COMPOSITE
"score_aim", "score_clutch", "score_pistol", "score_defense", "score_utility",
"score_stability", "score_economy", "score_pace", "score_overall", "tier_percentile",
# Legacy Mappings (keep for compatibility if needed, or remove if fully migrated)
"basic_avg_rating", "basic_avg_kd", "basic_avg_adr", "basic_avg_kast", "basic_avg_rws",
]
# Mapping for L2 legacy calls (if any) - mainly map 'rating' to 'basic_avg_rating' etc if needed
# 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', 'rd_invalid_death_rate', 'map_stability_coef']
lower_is_better = []
result = {}
for m in metrics:
values = [p.get(m, 0) or 0 for p in stats_map.values()]
target_val = stats_map[target_steam_id].get(m, 0) or 0
values = []
non_numeric = False
for p in stats_map.values():
raw = (p or {}).get(m)
if raw is None:
raw = 0
try:
values.append(float(raw))
except Exception:
non_numeric = True
break
raw_target = (stats_map.get(target_steam_id) or {}).get(m)
if raw_target is None:
raw_target = 0
try:
target_val = float(raw_target)
except Exception:
non_numeric = True
target_val = 0
if non_numeric:
result[m] = None
continue
if not values:
result[m] = None
continue
@@ -876,151 +905,15 @@ class StatsService:
'inverted': not is_reverse # Flag for frontend to invert bar
}
# Legacy mapping for top cards (rating, kd, adr, kast)
legacy_map = {
'basic_avg_rating': 'rating',
'basic_avg_kd': 'kd',
'basic_avg_adr': 'adr',
'basic_avg_kast': 'kast'
"basic_avg_rating": "rating",
"basic_avg_kd": "kd",
"basic_avg_adr": "adr",
"basic_avg_kast": "kast",
}
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