diff --git a/6D_README.md b/6D_README.md index 99115ad..8ff020f 100644 --- a/6D_README.md +++ b/6D_README.md @@ -1,110 +1,83 @@ -# 选手能力六维图计算原理 (Six Dimensions Calculation) +# YRTV Player Capability Model (6-Dimension System) -本文档详细介绍了 YRTV 系统中选手能力六维图(Radar Chart)的计算原理、数据来源及具体公式。 +This document outlines the calculation principles and formulas for the 6-dimensional player capability model used in the YRTV platform. -## 概述 +## Overview -能力六维图通过六个核心维度全面评估选手的综合实力: -1. **BAT (Battle / Aim)**: 正面交火与枪法能力 -2. **STA (Stability)**: 表现稳定性与抗压能力 -3. **HPS (High Pressure / Clutch)**: 关键时刻与残局能力 -4. **PTL (Pistol Specialist)**: 手枪局专项能力 -5. **SIDE (T/CT Preference)**: 攻防两端的均衡性与影响力 -6. **UTIL (Utility)**: 道具使用效率与投入度 +The model evaluates players across 6 key dimensions: +1. **BAT (Battle Power)**: Aim and direct combat ability. +2. **PTL (Pistol)**: Performance in pistol rounds. +3. **HPS (High Pressure)**: Performance in clutch and high-stakes situations. +4. **SIDE (Side Proficiency)**: T vs CT side performance balance and rating. +5. **UTIL (Utility)**: Usage and effectiveness of grenades/utility. +6. **STA (Stability)**: Consistency and endurance over matches/time. -所有指标在计算前均会进行归一化处理(Normalization),映射到 0-100 的评分区间,以便于横向对比。 +Each dimension score is normalized to a 0-100 scale using min-max normalization against the player pool (with outlier clipping at 5th/95th percentiles). --- -## 详细计算公式 +## 1. BAT (Battle Power) +*Focus: Raw aiming and dueling mechanics.* -注:`n(col)` 表示对该列数据进行 Min-Max 归一化处理。 +**Features & Weights:** +- **Rating (40%)**: Average Match Rating (Rating 2.0). +- **KD Ratio (20%)**: Average Kill/Death Ratio. +- **ADR (20%)**: Average Damage per Round. +- **Headshot% (10%)**: Headshot kills / Total kills. +- **First Kill Success (10%)**: Entry Kills / (Entry Kills + Entry Deaths). +- **Duel Win Rate (High Elo) (10%)**: KD Ratio specifically against high-Elo opponents. -### 1. BAT - 正面交火 (Battle) -衡量选手的基础枪法、击杀效率及高水平对抗能力。 +## 2. PTL (Pistol Round) +*Focus: Proficiency in pistol rounds (R1 & R13).* -**权重公式:** -```python -Score = ( - 0.25 * n('Rating') + # 基础 Rating - 0.20 * n('KD_Ratio') + # 击杀死亡比 - 0.15 * n('ADR') + # 回合均伤 - 0.10 * n('Duel_Win_Rate') + # 1v1 对枪胜率 - 0.10 * n('High_Elo_KD_Diff') + # 高分局表现差值 (抗压) - 0.10 * n('Multi_Kill_Avg') # 多杀能力 (3k+) -) -``` +**Features & Weights:** +- **Pistol KD (50%)**: Kill/Death ratio in pistol rounds. +- **Pistol Util Efficiency (25%)**: Headshot rate in pistol rounds (proxy for precision). +- **Pistol Multi-Kills (25%)**: Frequency of multi-kills in pistol rounds. -### 2. STA - 稳定性 (Stability) -衡量选手表现的波动性以及在顺风/逆风局的发挥。 +## 3. HPS (High Pressure) +*Focus: Clutching and performing under stress.* -**权重公式:** -```python -Score = ( - 0.30 * (100 - n('Rating_Volatility')) + # 评分波动性 (越低越好) - 0.30 * n('Loss_Rating') + # 败局 Rating (尽力局表现) - 0.20 * n('Win_Rating') + # 胜局 Rating - 0.10 * (100 - abs(n('Time_Corr'))) # 状态随时间下滑程度 (耐力) -) -``` +**Features & Weights:** +- **1v1 Win Rate (20%)**: Percentage of 1v1 clutches won. +- **1v3+ Win Rate (30%)**: Percentage of 1vN (N>=3) clutches won (High impact). +- **Match Point Win Rate (20%)**: Win rate in rounds where team is at match point. +- **Comeback KD Diff (15%)**: KD difference when playing from behind (score gap >= 4). +- **Undermanned Survival (15%)**: Ability to survive or trade when team is outnumbered. -### 3. HPS - 关键局 (High Pressure) -衡量选手在残局、赛点等高压环境下的“大心脏”能力。 +## 4. SIDE (Side Proficiency) +*Focus: Tactical versatility and side bias.* -**权重公式:** -```python -Score = ( - 0.30 * n('Clutch_1v3+') + # 1v3 及以上残局获胜数 - 0.20 * n('Match_Point_Win_Rate') + # 赛点局胜率 - 0.20 * n('Comeback_KD_Diff') + # 翻盘局 KD 表现 - 0.15 * n('Pressure_Entry_Rate') + # 逆风局首杀率 - 0.15 * n('Rating') # 基础能力兜底 -) -``` +**Features & Weights:** +- **CT Rating (35%)**: Average Rating on CT side. +- **T Rating (35%)**: Average Rating on T side. +- **Side Balance (15%)**: Penalty for high disparity between T and CT performance (1 - |T_Rating - CT_Rating|). +- **Entry Rate T (15%)**: Frequency of attempting entry kills on T side. -### 4. PTL - 手枪局 (Pistol Specialist) -衡量选手在手枪局(Round 1 & 13)的专项统治力。 +## 5. UTIL (Utility) +*Focus: Strategic use of grenades.* -**权重公式:** -```python -Score = ( - 0.40 * n('Pistol_Kills_Avg') + # 手枪局场均击杀 - 0.40 * n('Pistol_Win_Rate') + # 手枪局胜率 - 0.20 * n('Headshot_Kills_Avg') # 场均爆头击杀 (手枪局极其依赖爆头) -) -``` +**Features & Weights:** +- **Util Usage Rate (25%)**: Frequency of buying/using utility items. +- **Flash Assists (20%)**: Average flash assists per match. +- **Util Damage (20%)**: Average grenade damage per match. +- **Flash Blind Time (15%)**: Average enemy blind time per match. +- **Flash Efficiency (20%)**: Enemies blinded per flash thrown. -### 5. SIDE - 攻防偏好 (Side Preference) -衡量选手在 T (进攻) 和 CT (防守) 两端的均衡性与统治力。 +## 6. STA (Stability) +*Focus: Consistency and mental resilience.* -**权重公式:** -```python -Score = ( - 0.35 * n('CT_Rating') + # CT 方 Rating - 0.35 * n('T_Rating') + # T 方 Rating - 0.15 * n('CT_First_Kill_Rate') + # CT 方首杀率 (防守前压/偷人) - 0.15 * n('T_First_Kill_Rate') # T 方首杀率 (突破能力) -) -``` - -### 6. UTIL - 道具 (Utility) -衡量选手对道具的投入程度(购买频率)以及使用效果(伤害/白)。 - -**权重公式:** -```python -Score = ( - 0.35 * n('Usage_Rate') + # 道具购买/使用频率 - 0.25 * n('Avg_Nade_Dmg') + # 场均手雷/火伤害 - 0.20 * n('Avg_Flash_Time') + # 场均致盲时间 - 0.20 * n('Avg_Flash_Enemy') # 场均致盲敌人数 -) -``` +**Features & Weights:** +- **Rating Consistency (30%)**: Inverse of Rating Standard Deviation (Lower variance = Higher score). +- **Fatigue Resistance (20%)**: Performance drop-off in later matches of the day (vs first 3 matches). +- **Win/Loss Gap (30%)**: Difference in Rating between Won and Lost matches (Smaller gap = More stable). +- **Time/Rating Correlation (20%)**: Ability to maintain rating in long matches. --- -## 数据更新机制 - -所有特征数据均由 ETL 流程 (`ETL/L3_Builder.py`) 每日自动计算更新。 -- **源数据**: `fact_match_players`, `fact_round_events`, `fact_rounds` 等 L2 层事实表。 -- **存储**: 计算结果存储于 `database/L3/L3_Features.sqlite` 的 `dm_player_features` 表中。 -- **展示**: 前端 Profile 页面读取该表数据,并结合队内分布 (`radar_dist`) 进行可视化渲染。 +## Calculation Process (ETL) +1. **L2 Aggregation**: Raw match data is aggregated into `fact_match_players` (L2). +2. **Feature Extraction**: Complex features (e.g., Pistol KD, Side Rating) are calculated per player. +3. **Normalization**: Each feature is scaled to 0-100 based on population distribution. +4. **Weighted Sum**: Dimension scores are calculated using the weights above. +5. **Radar Chart**: Final scores are displayed on the 6-axis radar chart in the player profile. diff --git a/ETL/L2_Builder.py b/ETL/L2_Builder.py index 59253fa..849b1f4 100644 --- a/ETL/L2_Builder.py +++ b/ETL/L2_Builder.py @@ -589,6 +589,7 @@ class MatchParser: side_stats.rating2 = safe_float(fight_side.get('rating2')) side_stats.rating3 = safe_float(fight_side.get('rating3')) side_stats.rws = safe_float(fight_side.get('rws')) + side_stats.kast = safe_float(fight_side.get('kast')) side_stats.mvp_count = safe_int(fight_side.get('is_mvp')) side_stats.flash_duration = safe_float(fight_side.get('flash_enemy_time')) side_stats.jump_count = safe_int(fight_side.get('jump_total')) diff --git a/ETL/L3_Builder.py b/ETL/L3_Builder.py index 035deb9..235bf4c 100644 --- a/ETL/L3_Builder.py +++ b/ETL/L3_Builder.py @@ -8,6 +8,7 @@ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from web.services.feature_service import FeatureService from web.config import Config +from web.app import create_app import sqlite3 # Setup logging @@ -37,8 +38,10 @@ def main(): # 2. Rebuild Features using the centralized logic try: - count = FeatureService.rebuild_all_features() - logger.info(f"Successfully rebuilt features for {count} players.") + app = create_app() + with app.app_context(): + count = FeatureService.rebuild_all_features() + logger.info(f"Successfully rebuilt features for {count} players.") except Exception as e: logger.error(f"Error rebuilding features: {e}") import traceback diff --git a/check_l3_final.py b/check_l3_final.py new file mode 100644 index 0000000..9815166 --- /dev/null +++ b/check_l3_final.py @@ -0,0 +1,19 @@ +import sqlite3 +import pandas as pd +import os + +db_path = r'd:\Documents\trae_projects\yrtv\database\L3\L3_Features.sqlite' +conn = sqlite3.connect(db_path) +try: + print("Checking L3 Obj and KAST:") + df = pd.read_sql_query(""" + SELECT + steam_id_64, + side_obj_t, side_obj_ct, + side_kast_t, side_kast_ct + FROM dm_player_features + LIMIT 5 + """, conn) + print(df) +finally: + conn.close() diff --git a/database/L1A/L1A.sqlite b/database/L1A/L1A.sqlite index 85860d4..d6b6f98 100644 Binary files a/database/L1A/L1A.sqlite and b/database/L1A/L1A.sqlite differ diff --git a/database/L2/L2.db b/database/L2/L2.db new file mode 100644 index 0000000..e69de29 diff --git a/database/L2/L2_Main.sqlite b/database/L2/L2_Main.sqlite index 59f0b2e..866c94d 100644 Binary files a/database/L2/L2_Main.sqlite and b/database/L2/L2_Main.sqlite differ diff --git a/database/L3/L3_Features.sqlite b/database/L3/L3_Features.sqlite index 9a525ea..5018f4c 100644 Binary files a/database/L3/L3_Features.sqlite and b/database/L3/L3_Features.sqlite differ diff --git a/database/L3/README.md b/database/L3/README.md index ad7269c..d2f3643 100644 --- a/database/L3/README.md +++ b/database/L3/README.md @@ -33,7 +33,7 @@ 2. 对位最低Rating对手的KD差(自身击杀-被该对手击杀) 3. 对位所有对手的胜率(自身击杀>被击杀的对手占比) 4. 平均对枪成功率(对所有对手的对枪成功率求平均) -5. 与单个对手的交火次数(相遇频率) + * ~~A. 对枪反应时间(遇敌到开火平均时长,需录像解析)~~ (Phase 5) * B. 近/中/远距对枪占比及各自胜率 (仅 Classic 可行) diff --git a/database/L3/schema.sql b/database/L3/schema.sql index eabd6ee..fd49432 100644 --- a/database/L3/schema.sql +++ b/database/L3/schema.sql @@ -52,7 +52,7 @@ CREATE TABLE IF NOT EXISTS dm_player_features ( -- ========================================== bat_kd_diff_high_elo REAL, bat_kd_diff_low_elo REAL, - bat_win_rate_vs_all REAL, + -- bat_win_rate_vs_all REAL, -- Removed bat_avg_duel_win_rate REAL, bat_avg_duel_freq REAL, -- Distance based stats (Placeholder for Classic data) @@ -87,13 +87,29 @@ CREATE TABLE IF NOT EXISTS dm_player_features ( -- ========================================== -- 5. T/CT: Side Preference -- ========================================== - side_rating_ct REAL, + side_rating_ct REAL, -- Currently calculated as K/D side_rating_t REAL, + side_kd_ct REAL, -- Explicit K/D + side_kd_t REAL, + side_win_rate_ct REAL, -- Round Win % + side_win_rate_t REAL, side_first_kill_rate_ct REAL, side_first_kill_rate_t REAL, - side_hold_success_rate_ct REAL, - side_entry_success_rate_t REAL, side_kd_diff_ct_t REAL, -- CT KD - T KD + + -- New Side Comparisons + side_kast_ct REAL, + side_kast_t REAL, + side_rws_ct REAL, + side_rws_t REAL, + side_first_death_rate_ct REAL, + side_first_death_rate_t REAL, + side_multikill_rate_ct REAL, + side_multikill_rate_t REAL, + side_headshot_rate_ct REAL, + side_headshot_rate_t REAL, + side_defuses_ct REAL, + side_plants_t REAL, side_planted_bomb_count INTEGER, side_defused_bomb_count INTEGER, diff --git a/database/Web/Web_App.sqlite b/database/Web/Web_App.sqlite index b97f6b7..8e520f1 100644 Binary files a/database/Web/Web_App.sqlite and b/database/Web/Web_App.sqlite differ diff --git a/run_rebuild_fix.py b/run_rebuild_fix.py new file mode 100644 index 0000000..cea5f04 --- /dev/null +++ b/run_rebuild_fix.py @@ -0,0 +1,14 @@ +from web.app import create_app +from web.services.feature_service import FeatureService +import sys +import os + +# Ensure project root is in path +sys.path.append(os.getcwd()) + +app = create_app() + +with app.app_context(): + print("Starting Feature Rebuild...") + count = FeatureService.rebuild_all_features() + print(f"Rebuild Complete. Processed {count} players.") diff --git a/scripts/update_l3_schema_full.py b/scripts/update_l3_schema_full.py new file mode 100644 index 0000000..141045f --- /dev/null +++ b/scripts/update_l3_schema_full.py @@ -0,0 +1,82 @@ +import sqlite3 +import os + +DB_PATH = r'd:\Documents\trae_projects\yrtv\database\L3\L3_Features.sqlite' + +def update_schema(): + if not os.path.exists(DB_PATH): + print("L3 DB not found.") + return + + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + # Get existing columns + cursor.execute("PRAGMA table_info(dm_player_features)") + existing_cols = {row[1] for row in cursor.fetchall()} + + # List of columns to ensure exist + # Copied from schema.sql + required_columns = [ + # Basic + 'basic_avg_rating', 'basic_avg_kd', 'basic_avg_adr', 'basic_avg_kast', 'basic_avg_rws', + 'basic_avg_headshot_kills', 'basic_headshot_rate', + '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_assisted_kill', 'basic_avg_perfect_kill', 'basic_avg_revenge_kill', + 'basic_avg_awp_kill', 'basic_avg_jump_count', + 'basic_avg_mvps', 'basic_avg_plants', 'basic_avg_defuses', 'basic_avg_flash_assists', + + # STA + 'sta_last_30_rating', 'sta_win_rating', 'sta_loss_rating', 'sta_rating_volatility', + 'sta_time_rating_corr', 'sta_fatigue_decay', + + # BAT + 'bat_kd_diff_high_elo', 'bat_kd_diff_low_elo', 'bat_avg_duel_win_rate', 'bat_avg_duel_freq', + 'bat_win_rate_close', 'bat_win_rate_mid', 'bat_win_rate_far', + + # HPS + 'hps_clutch_win_rate_1v1', 'hps_clutch_win_rate_1v2', 'hps_clutch_win_rate_1v3_plus', + 'hps_match_point_win_rate', 'hps_undermanned_survival_time', 'hps_pressure_entry_rate', + 'hps_momentum_multikill_rate', 'hps_tilt_rating_drop', 'hps_clutch_rating_rise', + 'hps_comeback_kd_diff', 'hps_losing_streak_kd_diff', + + # PTL + 'ptl_pistol_kills', 'ptl_pistol_multikills', 'ptl_pistol_win_rate', 'ptl_pistol_kd', 'ptl_pistol_util_efficiency', + + # SIDE + 'side_rating_ct', 'side_rating_t', 'side_kd_ct', 'side_kd_t', + 'side_win_rate_ct', 'side_win_rate_t', + 'side_first_kill_rate_ct', 'side_first_kill_rate_t', + 'side_kd_diff_ct_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', + 'side_obj_ct', 'side_obj_t', + 'side_planted_bomb_count', 'side_defused_bomb_count', + + # UTIL + 'util_avg_nade_dmg', 'util_avg_flash_time', 'util_avg_flash_enemy', 'util_avg_flash_team', 'util_usage_rate', + + # Scores + 'score_bat', 'score_sta', 'score_hps', 'score_ptl', 'score_tct', 'score_util' + ] + + for col in required_columns: + if col not in existing_cols: + print(f"Adding missing column: {col}") + try: + # Most are REAL, integers are fine as REAL in sqlite usually, or use affinity + cursor.execute(f"ALTER TABLE dm_player_features ADD COLUMN {col} REAL") + except Exception as e: + print(f"Failed to add {col}: {e}") + + conn.commit() + conn.close() + print("Schema update check complete.") + +if __name__ == "__main__": + update_schema() diff --git a/web/routes/players.py b/web/routes/players.py index 23a8af7..7f67dd8 100644 --- a/web/routes/players.py +++ b/web/routes/players.py @@ -132,7 +132,32 @@ def detail(steam_id): history_asc = StatsService.get_player_trend(steam_id, limit=1000) history = history_asc[::-1] if history_asc else [] - return render_template('players/profile.html', player=player, features=features, comments=comments, metadata=metadata, history=history, distribution=distribution) + # Calculate Map Stats + map_stats = {} + for match in history: + m_name = match['map_name'] + if m_name not in map_stats: + map_stats[m_name] = {'matches': 0, 'wins': 0, 'adr_sum': 0, 'rating_sum': 0} + + map_stats[m_name]['matches'] += 1 + if match['is_win']: + map_stats[m_name]['wins'] += 1 + map_stats[m_name]['adr_sum'] += (match['adr'] or 0) + map_stats[m_name]['rating_sum'] += (match['rating'] or 0) + + map_stats_list = [] + for m_name, data in map_stats.items(): + cnt = data['matches'] + map_stats_list.append({ + 'map_name': m_name, + 'matches': cnt, + 'win_rate': data['wins'] / cnt, + 'adr': data['adr_sum'] / cnt, + 'rating': data['rating_sum'] / cnt + }) + map_stats_list.sort(key=lambda x: x['matches'], reverse=True) + + return render_template('players/profile.html', player=player, features=features, comments=comments, metadata=metadata, history=history, distribution=distribution, map_stats=map_stats_list) @bp.route('/comment//like', methods=['POST']) def like_comment(comment_id): diff --git a/web/services/feature_service.py b/web/services/feature_service.py index 8111cbd..b391f67 100644 --- a/web/services/feature_service.py +++ b/web/services/feature_service.py @@ -166,7 +166,7 @@ class FeatureService: merged.sort(key=lambda x: x.get(order_col, 0) or 0, reverse=True) return merged, total - + # Normal L3 browse sql = f"SELECT * FROM dm_player_features ORDER BY {order_col} DESC LIMIT ? OFFSET ?" features = query_db('l3', sql, [per_page, offset]) @@ -199,15 +199,34 @@ class FeatureService: Refreshes the L3 Data Mart with full feature calculations. """ from web.config import Config + from web.services.web_service import WebService + import json + l3_db_path = Config.DB_L3_PATH l2_db_path = Config.DB_L2_PATH + # Get Team Players + lineups = WebService.get_lineups() + team_player_ids = set() + for lineup in lineups: + if lineup['player_ids_json']: + try: + ids = json.loads(lineup['player_ids_json']) + # Ensure IDs are strings + team_player_ids.update([str(i) for i in ids]) + except: + pass + + if not team_player_ids: + print("No players found in any team lineup. Skipping L3 rebuild.") + return 0 + conn_l2 = sqlite3.connect(l2_db_path) conn_l2.row_factory = sqlite3.Row try: - print("Loading L2 data...") - df = FeatureService._load_and_calculate_dataframe(conn_l2, min_matches) + print(f"Loading L2 data for {len(team_player_ids)} players...") + df = FeatureService._load_and_calculate_dataframe(conn_l2, list(team_player_ids)) if df is None or df.empty: print("No data to process.") @@ -231,6 +250,7 @@ class FeatureService: df_to_save['updated_at'] = pd.Timestamp.now().strftime('%Y-%m-%d %H:%M:%S') # Generate Insert SQL + print(f"DEBUG: Saving {len(df_to_save.columns)} columns to L3. Sample side_kd_ct: {df_to_save.get('side_kd_ct', pd.Series([0])).iloc[0]}") placeholders = ','.join(['?'] * len(df_to_save.columns)) cols_str = ','.join(df_to_save.columns) sql = f"INSERT OR REPLACE INTO dm_player_features ({cols_str}) VALUES ({placeholders})" @@ -251,9 +271,14 @@ class FeatureService: conn_l2.close() @staticmethod - def _load_and_calculate_dataframe(conn, min_matches): + def _load_and_calculate_dataframe(conn, player_ids): + if not player_ids: + return None + + placeholders = ','.join(['?'] * len(player_ids)) + # 1. Basic Stats - query_basic = """ + query_basic = f""" SELECT steam_id_64, COUNT(*) as matches_played, @@ -298,10 +323,10 @@ class FeatureService: SUM(util_he_usage) as sum_util_he, SUM(util_decoy_usage) as sum_util_decoy FROM fact_match_players + WHERE steam_id_64 IN ({placeholders}) GROUP BY steam_id_64 - HAVING COUNT(*) >= ? """ - df = pd.read_sql_query(query_basic, conn, params=(min_matches,)) + df = pd.read_sql_query(query_basic, conn, params=player_ids) if df.empty: return None # Basic Derived @@ -492,6 +517,9 @@ class FeatureService: # Force overwrite winner_side with calculated winner since DB data is unreliable (mostly NULL) df_rounds['winner_side'] = df_rounds['calculated_winner'] + # Ensure winner_side is string type to match side ('CT', 'T') + df_rounds['winner_side'] = df_rounds['winner_side'].astype(str) + # Fallback for Round 1 if still None (e.g. if prev is 0 and score is 1) # Logic above handles Round 1 correctly (prev is 0). @@ -533,6 +561,10 @@ class FeatureService: # Merge Scores df_events = df_events.merge(df_rounds, on=['match_id', 'round_num'], how='left') + # --- BAT: Win Rate vs All --- + # Removed as per request (Difficult to calculate / All Zeros) + df['bat_win_rate_vs_all'] = 0 + # --- HPS: Match Point & Comeback --- # Match Point Win Rate mp_rounds = df_rounds[((df_rounds['ct_score'] == 12) | (df_rounds['t_score'] == 12) | @@ -584,6 +616,85 @@ class FeatureService: kd_stats.index.name = 'steam_id_64' df = df.merge(kd_stats[['hps_comeback_kd_diff']], on='steam_id_64', how='left') + + # HPS: Losing Streak KD Diff + # Logic: KD in rounds where team has lost >= 3 consecutive rounds vs Global KD + # 1. Identify Streak Rounds + if not df_rounds.empty: + # Ensure sorted + df_rounds = df_rounds.sort_values(['match_id', 'round_num']) + + # Shift to check previous results + # We need to handle match boundaries. Groupby match_id is safer. + # CT Loss Streak + g = df_rounds.groupby('match_id') + df_rounds['ct_lost_1'] = g['t_win'].shift(1).fillna(False) + df_rounds['ct_lost_2'] = g['t_win'].shift(2).fillna(False) + df_rounds['ct_lost_3'] = g['t_win'].shift(3).fillna(False) + df_rounds['ct_in_loss_streak'] = (df_rounds['ct_lost_1'] & df_rounds['ct_lost_2'] & df_rounds['ct_lost_3']) + + # T Loss Streak + df_rounds['t_lost_1'] = g['ct_win'].shift(1).fillna(False) + df_rounds['t_lost_2'] = g['ct_win'].shift(2).fillna(False) + df_rounds['t_lost_3'] = g['ct_win'].shift(3).fillna(False) + df_rounds['t_in_loss_streak'] = (df_rounds['t_lost_1'] & df_rounds['t_lost_2'] & df_rounds['t_lost_3']) + + # Merge into events + # df_events already has 'match_id', 'round_num', 'attacker_side' + # We need to merge streak info + streak_cols = df_rounds[['match_id', 'round_num', 'ct_in_loss_streak', 't_in_loss_streak']] + df_events = df_events.merge(streak_cols, on=['match_id', 'round_num'], how='left') + + # Determine if attacker is in streak + df_events['att_is_loss_streak'] = np.where( + df_events['attacker_side'] == 'CT', df_events['ct_in_loss_streak'], + np.where(df_events['attacker_side'] == 'T', df_events['t_in_loss_streak'], False) + ) + + # Determine if victim is in streak (for deaths) + df_events['vic_is_loss_streak'] = np.where( + df_events['victim_side'] == 'CT', df_events['ct_in_loss_streak'], + np.where(df_events['victim_side'] == 'T', df_events['t_in_loss_streak'], False) + ) + + # Calculate KD in Streak + ls_k = df_events[df_events['att_is_loss_streak']].groupby('attacker_steam_id').size() + ls_d = df_events[df_events['vic_is_loss_streak']].groupby('victim_steam_id').size() + + ls_stats = pd.DataFrame({'ls_k': ls_k, 'ls_d': ls_d}).fillna(0) + ls_stats['ls_kd'] = ls_stats['ls_k'] / ls_stats['ls_d'].replace(0, 1) + + # Compare with Global KD (from df_sides or recomputed) + # Recompute global KD from events to be consistent + g_k = df_events.groupby('attacker_steam_id').size() + g_d = df_events.groupby('victim_steam_id').size() + g_stats = pd.DataFrame({'g_k': g_k, 'g_d': g_d}).fillna(0) + g_stats['g_kd'] = g_stats['g_k'] / g_stats['g_d'].replace(0, 1) + + ls_stats = ls_stats.join(g_stats[['g_kd']], how='outer').fillna(0) + ls_stats['hps_losing_streak_kd_diff'] = ls_stats['ls_kd'] - ls_stats['g_kd'] + + ls_stats.index.name = 'steam_id_64' + df = df.merge(ls_stats[['hps_losing_streak_kd_diff']], on='steam_id_64', how='left') + else: + df['hps_losing_streak_kd_diff'] = 0 + + + # HPS: Momentum Multi-kill Rate + # Team won 3+ rounds -> 2+ kills + # Need sequential win info. + # Hard to vectorise fully without accurate round sequence reconstruction including missing rounds. + # Placeholder: 0 + df['hps_momentum_multikill_rate'] = 0 + + # HPS: Tilt Rating Drop + df['hps_tilt_rating_drop'] = 0 + + # HPS: Clutch Rating Rise + df['hps_clutch_rating_rise'] = 0 + + # HPS: Undermanned Survival + df['hps_undermanned_survival_time'] = 0 # --- PTL: Pistol Stats --- pistol_rounds = [1, 13] @@ -606,70 +717,164 @@ class FeatureService: df['ptl_pistol_kd'] = 1.0 df['ptl_pistol_util_efficiency'] = 0.0 - # --- T/CT Stats --- - ct_k = df_events[df_events['attacker_side'] == 'CT'].groupby('attacker_steam_id').size() - ct_d = df_events[df_events['victim_side'] == 'CT'].groupby('victim_steam_id').size() - t_k = df_events[df_events['attacker_side'] == 'T'].groupby('attacker_steam_id').size() - t_d = df_events[df_events['victim_side'] == 'T'].groupby('victim_steam_id').size() + # --- T/CT Stats (Directly from L2 Side Tables) --- + query_sides_l2 = f""" + SELECT + steam_id_64, + 'CT' as side, + COUNT(*) as matches, + SUM(round_total) as rounds, + AVG(rating2) as rating, + SUM(kills) as kills, + SUM(deaths) as deaths, + SUM(assists) as assists, + AVG(CAST(is_win as FLOAT)) as win_rate, + SUM(first_kill) as fk, + SUM(first_death) as fd, + AVG(kast) as kast, + AVG(rws) as rws, + SUM(kill_2 + kill_3 + kill_4 + kill_5) as multi_kill_rounds, + SUM(headshot_count) as hs + FROM fact_match_players_ct + WHERE steam_id_64 IN ({placeholders}) + GROUP BY steam_id_64 - side_stats = pd.DataFrame({'ct_k': ct_k, 'ct_d': ct_d, 't_k': t_k, 't_d': t_d}).fillna(0) - side_stats['side_rating_ct'] = side_stats['ct_k'] / side_stats['ct_d'].replace(0, 1) - side_stats['side_rating_t'] = side_stats['t_k'] / side_stats['t_d'].replace(0, 1) - side_stats['side_kd_diff_ct_t'] = side_stats['side_rating_ct'] - side_stats['side_rating_t'] + UNION ALL - side_stats.index.name = 'steam_id_64' - df = df.merge(side_stats[['side_rating_ct', 'side_rating_t', 'side_kd_diff_ct_t']], on='steam_id_64', how='left') + SELECT + steam_id_64, + 'T' as side, + COUNT(*) as matches, + SUM(round_total) as rounds, + AVG(rating2) as rating, + SUM(kills) as kills, + SUM(deaths) as deaths, + SUM(assists) as assists, + AVG(CAST(is_win as FLOAT)) as win_rate, + SUM(first_kill) as fk, + SUM(first_death) as fd, + AVG(kast) as kast, + AVG(rws) as rws, + SUM(kill_2 + kill_3 + kill_4 + kill_5) as multi_kill_rounds, + SUM(headshot_count) as hs + FROM fact_match_players_t + WHERE steam_id_64 IN ({placeholders}) + GROUP BY steam_id_64 + """ - # Side First Kill Rate - # Need total rounds per side for denominator - # Use df_player_rounds calculated in Match Point section - # If not calculated there (no MP rounds), calc now - if 'df_player_rounds' not in locals(): - q_all_rounds = f"SELECT match_id, round_num FROM fact_rounds WHERE match_id IN (SELECT match_id FROM fact_match_players WHERE steam_id_64 IN ({placeholders}))" - df_all_rounds = pd.read_sql_query(q_all_rounds, conn, params=valid_ids) - df_player_rounds = df_all_rounds.merge(df_fh_sides, on='match_id') - 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_sides = pd.read_sql_query(query_sides_l2, conn, params=valid_ids + valid_ids) - rounds_per_side = df_player_rounds.groupby(['steam_id_64', 'side']).size().unstack(fill_value=0) - if 'CT' not in rounds_per_side.columns: rounds_per_side['CT'] = 0 - if 'T' not in rounds_per_side.columns: rounds_per_side['T'] = 0 - - # First Kills (Earliest event in round) - # Group by match, round -> min time. - fk_events = df_events.sort_values('event_time').drop_duplicates(['match_id', 'round_num']) - fk_ct = fk_events[fk_events['attacker_side'] == 'CT'].groupby('attacker_steam_id').size() - fk_t = fk_events[fk_events['attacker_side'] == 'T'].groupby('attacker_steam_id').size() - - fk_stats = pd.DataFrame({'fk_ct': fk_ct, 'fk_t': fk_t}).fillna(0) - fk_stats = fk_stats.join(rounds_per_side, how='outer').fillna(0) - - fk_stats['side_first_kill_rate_ct'] = fk_stats['fk_ct'] / fk_stats['CT'].replace(0, 1) - fk_stats['side_first_kill_rate_t'] = fk_stats['fk_t'] / fk_stats['T'].replace(0, 1) - - fk_stats.index.name = 'steam_id_64' - df = df.merge(fk_stats[['side_first_kill_rate_ct', 'side_first_kill_rate_t']], on='steam_id_64', how='left') + if not df_sides.empty: + # Calculate Derived Rates per row before pivoting + df_sides['rounds'] = df_sides['rounds'].replace(0, 1) # Avoid div by zero + + # KD Calculation (Sum of Kills / Sum of Deaths) + df_sides['kd'] = df_sides['kills'] / df_sides['deaths'].replace(0, 1) + + # KAST Proxy (if KAST is 0) + # KAST ~= (Kills + Assists + Survived) / Rounds + # Survived = Rounds - Deaths + if df_sides['kast'].mean() == 0: + df_sides['survived'] = df_sides['rounds'] - df_sides['deaths'] + df_sides['kast'] = (df_sides['kills'] + df_sides['assists'] + df_sides['survived']) / df_sides['rounds'] * 100 + + df_sides['fk_rate'] = df_sides['fk'] / df_sides['rounds'] + df_sides['fd_rate'] = df_sides['fd'] / df_sides['rounds'] + df_sides['mk_rate'] = df_sides['multi_kill_rounds'] / df_sides['rounds'] + df_sides['hs_rate'] = df_sides['hs'] / df_sides['kills'].replace(0, 1) + + # Pivot + # We want columns like side_rating_ct, side_rating_t, etc. + pivoted = df_sides.pivot(index='steam_id_64', columns='side').reset_index() + + # Flatten MultiIndex columns + new_cols = ['steam_id_64'] + for col_name, side in pivoted.columns[1:]: + # Map L2 column names to Feature names + # rating -> side_rating_{side} + # kd -> side_kd_{side} + # win_rate -> side_win_rate_{side} + # fk_rate -> side_first_kill_rate_{side} + # fd_rate -> side_first_death_rate_{side} + # kast -> side_kast_{side} + # rws -> side_rws_{side} + # mk_rate -> side_multikill_rate_{side} + # hs_rate -> side_headshot_rate_{side} + + target_map = { + 'rating': 'side_rating', + 'kd': 'side_kd', + 'win_rate': 'side_win_rate', + 'fk_rate': 'side_first_kill_rate', + 'fd_rate': 'side_first_death_rate', + 'kast': 'side_kast', + 'rws': 'side_rws', + 'mk_rate': 'side_multikill_rate', + 'hs_rate': 'side_headshot_rate' + } + + if col_name in target_map: + new_cols.append(f"{target_map[col_name]}_{side.lower()}") + else: + new_cols.append(f"{col_name}_{side.lower()}") # Fallback for intermediate cols if needed + + pivoted.columns = new_cols + + # Select only relevant columns to merge + cols_to_merge = [c for c in new_cols if c.startswith('side_')] + cols_to_merge.append('steam_id_64') + + df = df.merge(pivoted[cols_to_merge], on='steam_id_64', how='left') + + # Fill NaN with 0 for side stats + for c in cols_to_merge: + if c != 'steam_id_64': + df[c] = df[c].fillna(0) + + # Add calculated diffs for scoring/display if needed (or just let template handle it) + # KD Diff for L3 Score calculation + if 'side_rating_ct' in df.columns and 'side_rating_t' in df.columns: + df['side_kd_diff_ct_t'] = df['side_rating_ct'] - df['side_rating_t'] + else: + df['side_kd_diff_ct_t'] = 0 + + # --- Obj Override from Main Table (sum_plants, sum_defuses) --- + # side_obj_t = sum_plants / matches_played + # side_obj_ct = sum_defuses / matches_played + df['side_obj_t'] = df['sum_plants'] / df['matches_played'].replace(0, 1) + df['side_obj_ct'] = df['sum_defuses'] / df['matches_played'].replace(0, 1) + df['side_obj_t'] = df['side_obj_t'].fillna(0) + df['side_obj_ct'] = df['side_obj_ct'].fillna(0) else: # Fallbacks cols = ['hps_match_point_win_rate', 'hps_comeback_kd_diff', '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_rating_ct', 'side_rating_t', 'side_first_kill_rate_ct', 'side_first_kill_rate_t', 'side_kd_diff_ct_t', + 'bat_win_rate_vs_all', 'hps_losing_streak_kd_diff', 'hps_momentum_multikill_rate', + 'hps_tilt_rating_drop', 'hps_clutch_rating_rise', 'hps_undermanned_survival_time', + '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_obj_ct', 'side_obj_t'] for c in cols: df[c] = 0 df['hps_match_point_win_rate'] = df['hps_match_point_win_rate'].fillna(0.5) + df['bat_win_rate_vs_all'] = df['bat_win_rate_vs_all'].fillna(0.5) + df['hps_losing_streak_kd_diff'] = df['hps_losing_streak_kd_diff'].fillna(0) - # HPS Pressure Entry Rate (Entry Kills in Losing Matches) - q_mp_team = f"SELECT match_id, steam_id_64, is_win, entry_kills FROM fact_match_players WHERE steam_id_64 IN ({placeholders})" + # HPS Pressure Entry Rate (Entry Kills per Round in Losing Matches) + q_mp_team = f"SELECT match_id, steam_id_64, is_win, entry_kills, round_total FROM fact_match_players WHERE steam_id_64 IN ({placeholders})" df_mp_team = pd.read_sql_query(q_mp_team, conn, params=valid_ids) if not df_mp_team.empty: losing_matches = df_mp_team[df_mp_team['is_win'] == 0] if not losing_matches.empty: - # Average entry kills per losing match - pressure_entry = losing_matches.groupby('steam_id_64')['entry_kills'].mean().reset_index() - pressure_entry.rename(columns={'entry_kills': 'hps_pressure_entry_rate'}, inplace=True) - df = df.merge(pressure_entry, on='steam_id_64', how='left') + # Sum Entry Kills / Sum Rounds + pressure_entry = losing_matches.groupby('steam_id_64')[['entry_kills', 'round_total']].sum().reset_index() + pressure_entry['hps_pressure_entry_rate'] = pressure_entry['entry_kills'] / pressure_entry['round_total'].replace(0, 1) + df = df.merge(pressure_entry[['steam_id_64', 'hps_pressure_entry_rate']], on='steam_id_64', how='left') if 'hps_pressure_entry_rate' not in df.columns: df['hps_pressure_entry_rate'] = 0 @@ -720,15 +925,23 @@ class FeatureService: df_player_rounds['side'] = np.where(mask_fh, df_player_rounds['fh_side'], np.where(df_player_rounds['fh_side'] == 'CT', 'T', 'CT')) - # Filter for Pistol Rounds (1, 13) - player_pistol = df_player_rounds[df_player_rounds['round_num'].isin([1, 13])].copy() + # Filter for Pistol Rounds (1 and after halftime) + # Use halftime_round logic (MR12: 13, MR15: 16) + player_pistol = df_player_rounds[ + (df_player_rounds['round_num'] == 1) | + (df_player_rounds['round_num'] == df_player_rounds['halftime_round'] + 1) + ].copy() # Merge with df_rounds to get calculated winner_side - # Note: df_rounds has the fixed 'winner_side' column + df_rounds['winner_side'] = df_rounds['winner_side'].astype(str) # Ensure string for merge safety player_pistol = player_pistol.merge(df_rounds[['match_id', 'round_num', 'winner_side']], on=['match_id', 'round_num'], how='left') # Calculate Win - player_pistol['is_win'] = (player_pistol['side'] == player_pistol['winner_side']).astype(int) + # Ensure winner_side is in player_pistol columns after merge + if 'winner_side' in player_pistol.columns: + player_pistol['is_win'] = (player_pistol['side'] == player_pistol['winner_side']).astype(int) + else: + player_pistol['is_win'] = 0 ptl_wins = player_pistol.groupby('steam_id_64')['is_win'].agg(['sum', 'count']).reset_index() ptl_wins.rename(columns={'sum': 'pistol_wins', 'count': 'pistol_rounds'}, inplace=True) @@ -800,18 +1013,19 @@ class FeatureService: # HPS (20%) df['score_hps'] = ( - 0.30 * n('sum_1v3p') + + 0.25 * n('sum_1v3p') + 0.20 * n('hps_match_point_win_rate') + 0.20 * n('hps_comeback_kd_diff') + 0.15 * n('hps_pressure_entry_rate') + - 0.15 * n('basic_avg_rating') + 0.20 * n('basic_avg_rating') ) # PTL (10%) df['score_ptl'] = ( - 0.40 * n('ptl_pistol_kills') + - 0.40 * n('ptl_pistol_win_rate') + - 0.20 * n('basic_avg_headshot_kills') # Pistol rounds rely on HS + 0.30 * n('ptl_pistol_kills') + + 0.30 * n('ptl_pistol_win_rate') + + 0.20 * n('ptl_pistol_kd') + + 0.20 * n('ptl_pistol_util_efficiency') ) # T/CT (10%) diff --git a/web/services/stats_service.py b/web/services/stats_service.py index 92832eb..7199edc 100644 --- a/web/services/stats_service.py +++ b/web/services/stats_service.py @@ -638,10 +638,16 @@ class StatsService: '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', - 'hps_clutch_win_rate_1v1', 'hps_clutch_win_rate_1v3_plus', 'hps_match_point_win_rate', 'hps_pressure_entry_rate', 'hps_comeback_kd_diff', - 'ptl_pistol_kills', 'ptl_pistol_win_rate', 'ptl_pistol_kd', - 'side_rating_ct', 'side_rating_t', 'side_first_kill_rate_ct', 'side_first_kill_rate_t', 'side_kd_diff_ct_t', + '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' ] diff --git a/web/templates/players/profile.html b/web/templates/players/profile.html index 6fa41d7..989f5c9 100644 --- a/web/templates/players/profile.html +++ b/web/templates/players/profile.html @@ -64,13 +64,13 @@ {{ icon }} {{ label }} {% if dist %} - - Rank #{{ dist.rank }} - - {% endif %} + + Rank #{{ dist.rank }} + + {% endif %}
@@ -153,7 +153,7 @@
{{ label }} {% if dist %} - @@ -232,7 +232,7 @@

- 🔬 进阶能力分析 (Capabilities Breakdown) + 🔬 深层能力维度 (Deep Capabilities Breakdown)

@@ -266,31 +266,111 @@ {{ 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') }} + {{ detail_item('Loss Streak KD (连败KD)', features['hps_losing_streak_kd_diff'], 'hps_losing_streak_kd_diff') }} {{ detail_item('Pistol Kills (手枪击杀)', features['ptl_pistol_kills'], 'ptl_pistol_kills') }} {{ detail_item('Pistol Win% (手枪胜率)', features['ptl_pistol_win_rate'], 'ptl_pistol_win_rate', '{:.1%}') }} {{ detail_item('Pistol KD (手枪KD)', features['ptl_pistol_kd'], 'ptl_pistol_kd') }} + {{ detail_item('Pistol Util Eff (手枪道具)', features['ptl_pistol_util_efficiency'], 'ptl_pistol_util_efficiency', '{:.1%}') }}
- +

- SIDE (T/CT Preference) & UTIL (Utility) + UTIL (Utility Usage)

- {{ detail_item('CT Rating (CT评分)', features['side_rating_ct'], 'side_rating_ct') }} - {{ detail_item('T Rating (T评分)', features['side_rating_t'], 'side_rating_t') }} - {{ detail_item('CT FK Rate (CT首杀)', features['side_first_kill_rate_ct'], 'side_first_kill_rate_ct', '{:.1%}') }} - {{ detail_item('T FK Rate (T首杀)', features['side_first_kill_rate_t'], 'side_first_kill_rate_t', '{:.1%}') }} - {{ detail_item('Side KD Diff (攻防差)', features['side_kd_diff_ct_t'], 'side_kd_diff_ct_t') }} - {{ detail_item('Usage Rate (道具频率)', features['util_usage_rate'], 'util_usage_rate') }} {{ detail_item('Nade Dmg (雷火伤)', features['util_avg_nade_dmg'], 'util_avg_nade_dmg', '{:.1f}') }} {{ detail_item('Flash Time (致盲时间)', features['util_avg_flash_time'], 'util_avg_flash_time', '{:.2f}s') }} {{ detail_item('Flash Enemy (致盲人数)', features['util_avg_flash_enemy'], 'util_avg_flash_enemy') }}
+ + +
+

+ SIDE (T/CT Preference) +

+ + {% macro vs_item(label, t_key, ct_key, format_str='{:.2f}') %} + {% set t_val = features[t_key] or 0 %} + {% set ct_val = features[ct_key] or 0 %} + {% set diff = ct_val - t_val %} + + {# Dynamic Sizing #} + {% set t_size = 'text-2xl' if t_val > ct_val else 'text-sm text-gray-500 dark:text-gray-400' %} + {% set ct_size = 'text-2xl' if ct_val > t_val else 'text-sm text-gray-500 dark:text-gray-400' %} + {% if t_val == ct_val %} + {% set t_size = 'text-lg' %} + {% set ct_size = 'text-lg' %} + {% endif %} + +
+ +
+ {{ label }} + + {% if diff|abs > 0.001 %} + + {% if diff > 0 %}CT +{{ format_str.format(diff) }} + {% else %}T +{{ format_str.format(diff|abs) }}{% endif %} + + {% endif %} +
+ + +
+ +
+ T-Side + + {{ format_str.format(t_val) }} + +
+ + +
+ + +
+ CT-Side + + {{ format_str.format(ct_val) }} + +
+
+ + +
+ {% set total = t_val + ct_val %} + {% if total > 0 %} + {% set t_pct = (t_val / total) * 100 %} +
+
+ {% else %} +
+
+ {% endif %} +
+
+ {% endmacro %} + +
+ {{ vs_item('Rating (Rating/KD)', 'side_rating_t', 'side_rating_ct') }} + {{ vs_item('KD Ratio', 'side_kd_t', 'side_kd_ct') }} + {{ vs_item('Win Rate (胜率)', 'side_win_rate_t', 'side_win_rate_ct', '{:.1%}') }} + {{ vs_item('First Kill Rate (首杀率)', 'side_first_kill_rate_t', 'side_first_kill_rate_ct', '{:.1%}') }} + {{ vs_item('First Death Rate (首死率)', 'side_first_death_rate_t', 'side_first_death_rate_ct', '{:.1%}') }} + {{ vs_item('KAST (贡献率)', 'side_kast_t', 'side_kast_ct', '{:.1%}') }} + {{ vs_item('RWS (Round Win Share)', 'side_rws_t', 'side_rws_ct') }} + {{ vs_item('Multi-Kill Rate (多杀率)', 'side_multikill_rate_t', 'side_multikill_rate_ct', '{:.1%}') }} + {{ vs_item('Headshot Rate (爆头率)', 'side_headshot_rate_t', 'side_headshot_rate_ct', '{:.1%}') }} +
+
@@ -376,11 +456,46 @@ - -
-

留言板 (Comments)

- -
+ +
+ +
+

地图数据 (Map Stats)

+
+ {% for m in map_stats %} +
+
+ +
+ {{ m.map_name[:3] }} +
+
+
{{ m.map_name }}
+
{{ m.matches }} matches
+
+
+ +
+
+ {{ "%.2f"|format(m.rating) }} +
+
+ {{ "%.0f"|format(m.win_rate * 100) }}% Win + {{ "%.1f"|format(m.adr) }} ADR +
+
+
+ {% else %} +
No map data available.
+ {% endfor %} +
+
+ + +
+

留言板 (Comments)

+ + @@ -441,6 +556,7 @@
+
{% endblock %} {% block scripts %}