1.2.2: Profile Upgraded
This commit is contained in:
151
6D_README.md
151
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
|
||||||
|
|
||||||
能力六维图通过六个核心维度全面评估选手的综合实力:
|
The model evaluates players across 6 key dimensions:
|
||||||
1. **BAT (Battle / Aim)**: 正面交火与枪法能力
|
1. **BAT (Battle Power)**: Aim and direct combat ability.
|
||||||
2. **STA (Stability)**: 表现稳定性与抗压能力
|
2. **PTL (Pistol)**: Performance in pistol rounds.
|
||||||
3. **HPS (High Pressure / Clutch)**: 关键时刻与残局能力
|
3. **HPS (High Pressure)**: Performance in clutch and high-stakes situations.
|
||||||
4. **PTL (Pistol Specialist)**: 手枪局专项能力
|
4. **SIDE (Side Proficiency)**: T vs CT side performance balance and rating.
|
||||||
5. **SIDE (T/CT Preference)**: 攻防两端的均衡性与影响力
|
5. **UTIL (Utility)**: Usage and effectiveness of grenades/utility.
|
||||||
6. **UTIL (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).*
|
||||||
|
|
||||||
**权重公式:**
|
**Features & Weights:**
|
||||||
```python
|
- **Pistol KD (50%)**: Kill/Death ratio in pistol rounds.
|
||||||
Score = (
|
- **Pistol Util Efficiency (25%)**: Headshot rate in pistol rounds (proxy for precision).
|
||||||
0.25 * n('Rating') + # 基础 Rating
|
- **Pistol Multi-Kills (25%)**: Frequency of multi-kills in pistol rounds.
|
||||||
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+)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. STA - 稳定性 (Stability)
|
## 3. HPS (High Pressure)
|
||||||
衡量选手表现的波动性以及在顺风/逆风局的发挥。
|
*Focus: Clutching and performing under stress.*
|
||||||
|
|
||||||
**权重公式:**
|
**Features & Weights:**
|
||||||
```python
|
- **1v1 Win Rate (20%)**: Percentage of 1v1 clutches won.
|
||||||
Score = (
|
- **1v3+ Win Rate (30%)**: Percentage of 1vN (N>=3) clutches won (High impact).
|
||||||
0.30 * (100 - n('Rating_Volatility')) + # 评分波动性 (越低越好)
|
- **Match Point Win Rate (20%)**: Win rate in rounds where team is at match point.
|
||||||
0.30 * n('Loss_Rating') + # 败局 Rating (尽力局表现)
|
- **Comeback KD Diff (15%)**: KD difference when playing from behind (score gap >= 4).
|
||||||
0.20 * n('Win_Rating') + # 胜局 Rating
|
- **Undermanned Survival (15%)**: Ability to survive or trade when team is outnumbered.
|
||||||
0.10 * (100 - abs(n('Time_Corr'))) # 状态随时间下滑程度 (耐力)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. HPS - 关键局 (High Pressure)
|
## 4. SIDE (Side Proficiency)
|
||||||
衡量选手在残局、赛点等高压环境下的“大心脏”能力。
|
*Focus: Tactical versatility and side bias.*
|
||||||
|
|
||||||
**权重公式:**
|
**Features & Weights:**
|
||||||
```python
|
- **CT Rating (35%)**: Average Rating on CT side.
|
||||||
Score = (
|
- **T Rating (35%)**: Average Rating on T side.
|
||||||
0.30 * n('Clutch_1v3+') + # 1v3 及以上残局获胜数
|
- **Side Balance (15%)**: Penalty for high disparity between T and CT performance (1 - |T_Rating - CT_Rating|).
|
||||||
0.20 * n('Match_Point_Win_Rate') + # 赛点局胜率
|
- **Entry Rate T (15%)**: Frequency of attempting entry kills on T side.
|
||||||
0.20 * n('Comeback_KD_Diff') + # 翻盘局 KD 表现
|
|
||||||
0.15 * n('Pressure_Entry_Rate') + # 逆风局首杀率
|
|
||||||
0.15 * n('Rating') # 基础能力兜底
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. PTL - 手枪局 (Pistol Specialist)
|
## 5. UTIL (Utility)
|
||||||
衡量选手在手枪局(Round 1 & 13)的专项统治力。
|
*Focus: Strategic use of grenades.*
|
||||||
|
|
||||||
**权重公式:**
|
**Features & Weights:**
|
||||||
```python
|
- **Util Usage Rate (25%)**: Frequency of buying/using utility items.
|
||||||
Score = (
|
- **Flash Assists (20%)**: Average flash assists per match.
|
||||||
0.40 * n('Pistol_Kills_Avg') + # 手枪局场均击杀
|
- **Util Damage (20%)**: Average grenade damage per match.
|
||||||
0.40 * n('Pistol_Win_Rate') + # 手枪局胜率
|
- **Flash Blind Time (15%)**: Average enemy blind time per match.
|
||||||
0.20 * n('Headshot_Kills_Avg') # 场均爆头击杀 (手枪局极其依赖爆头)
|
- **Flash Efficiency (20%)**: Enemies blinded per flash thrown.
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. SIDE - 攻防偏好 (Side Preference)
|
## 6. STA (Stability)
|
||||||
衡量选手在 T (进攻) 和 CT (防守) 两端的均衡性与统治力。
|
*Focus: Consistency and mental resilience.*
|
||||||
|
|
||||||
**权重公式:**
|
**Features & Weights:**
|
||||||
```python
|
- **Rating Consistency (30%)**: Inverse of Rating Standard Deviation (Lower variance = Higher score).
|
||||||
Score = (
|
- **Fatigue Resistance (20%)**: Performance drop-off in later matches of the day (vs first 3 matches).
|
||||||
0.35 * n('CT_Rating') + # CT 方 Rating
|
- **Win/Loss Gap (30%)**: Difference in Rating between Won and Lost matches (Smaller gap = More stable).
|
||||||
0.35 * n('T_Rating') + # T 方 Rating
|
- **Time/Rating Correlation (20%)**: Ability to maintain rating in long matches.
|
||||||
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') # 场均致盲敌人数
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 数据更新机制
|
## Calculation Process (ETL)
|
||||||
|
1. **L2 Aggregation**: Raw match data is aggregated into `fact_match_players` (L2).
|
||||||
所有特征数据均由 ETL 流程 (`ETL/L3_Builder.py`) 每日自动计算更新。
|
2. **Feature Extraction**: Complex features (e.g., Pistol KD, Side Rating) are calculated per player.
|
||||||
- **源数据**: `fact_match_players`, `fact_round_events`, `fact_rounds` 等 L2 层事实表。
|
3. **Normalization**: Each feature is scaled to 0-100 based on population distribution.
|
||||||
- **存储**: 计算结果存储于 `database/L3/L3_Features.sqlite` 的 `dm_player_features` 表中。
|
4. **Weighted Sum**: Dimension scores are calculated using the weights above.
|
||||||
- **展示**: 前端 Profile 页面读取该表数据,并结合队内分布 (`radar_dist`) 进行可视化渲染。
|
5. **Radar Chart**: Final scores are displayed on the 6-axis radar chart in the player profile.
|
||||||
|
|||||||
@@ -589,6 +589,7 @@ class MatchParser:
|
|||||||
side_stats.rating2 = safe_float(fight_side.get('rating2'))
|
side_stats.rating2 = safe_float(fight_side.get('rating2'))
|
||||||
side_stats.rating3 = safe_float(fight_side.get('rating3'))
|
side_stats.rating3 = safe_float(fight_side.get('rating3'))
|
||||||
side_stats.rws = safe_float(fight_side.get('rws'))
|
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.mvp_count = safe_int(fight_side.get('is_mvp'))
|
||||||
side_stats.flash_duration = safe_float(fight_side.get('flash_enemy_time'))
|
side_stats.flash_duration = safe_float(fight_side.get('flash_enemy_time'))
|
||||||
side_stats.jump_count = safe_int(fight_side.get('jump_total'))
|
side_stats.jump_count = safe_int(fight_side.get('jump_total'))
|
||||||
|
|||||||
@@ -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.services.feature_service import FeatureService
|
||||||
from web.config import Config
|
from web.config import Config
|
||||||
|
from web.app import create_app
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
# Setup logging
|
# Setup logging
|
||||||
@@ -37,6 +38,8 @@ def main():
|
|||||||
|
|
||||||
# 2. Rebuild Features using the centralized logic
|
# 2. Rebuild Features using the centralized logic
|
||||||
try:
|
try:
|
||||||
|
app = create_app()
|
||||||
|
with app.app_context():
|
||||||
count = FeatureService.rebuild_all_features()
|
count = FeatureService.rebuild_all_features()
|
||||||
logger.info(f"Successfully rebuilt features for {count} players.")
|
logger.info(f"Successfully rebuilt features for {count} players.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
19
check_l3_final.py
Normal file
19
check_l3_final.py
Normal file
@@ -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()
|
||||||
Binary file not shown.
0
database/L2/L2.db
Normal file
0
database/L2/L2.db
Normal file
Binary file not shown.
Binary file not shown.
@@ -33,7 +33,7 @@
|
|||||||
2. 对位最低Rating对手的KD差(自身击杀-被该对手击杀)
|
2. 对位最低Rating对手的KD差(自身击杀-被该对手击杀)
|
||||||
3. 对位所有对手的胜率(自身击杀>被击杀的对手占比)
|
3. 对位所有对手的胜率(自身击杀>被击杀的对手占比)
|
||||||
4. 平均对枪成功率(对所有对手的对枪成功率求平均)
|
4. 平均对枪成功率(对所有对手的对枪成功率求平均)
|
||||||
5. 与单个对手的交火次数(相遇频率)
|
|
||||||
* ~~A. 对枪反应时间(遇敌到开火平均时长,需录像解析)~~ (Phase 5)
|
* ~~A. 对枪反应时间(遇敌到开火平均时长,需录像解析)~~ (Phase 5)
|
||||||
* B. 近/中/远距对枪占比及各自胜率 (仅 Classic 可行)
|
* B. 近/中/远距对枪占比及各自胜率 (仅 Classic 可行)
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ CREATE TABLE IF NOT EXISTS dm_player_features (
|
|||||||
-- ==========================================
|
-- ==========================================
|
||||||
bat_kd_diff_high_elo REAL,
|
bat_kd_diff_high_elo REAL,
|
||||||
bat_kd_diff_low_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_win_rate REAL,
|
||||||
bat_avg_duel_freq REAL,
|
bat_avg_duel_freq REAL,
|
||||||
-- Distance based stats (Placeholder for Classic data)
|
-- Distance based stats (Placeholder for Classic data)
|
||||||
@@ -87,13 +87,29 @@ CREATE TABLE IF NOT EXISTS dm_player_features (
|
|||||||
-- ==========================================
|
-- ==========================================
|
||||||
-- 5. T/CT: Side Preference
|
-- 5. T/CT: Side Preference
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
side_rating_ct REAL,
|
side_rating_ct REAL, -- Currently calculated as K/D
|
||||||
side_rating_t REAL,
|
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_ct REAL,
|
||||||
side_first_kill_rate_t 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
|
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_planted_bomb_count INTEGER,
|
||||||
side_defused_bomb_count INTEGER,
|
side_defused_bomb_count INTEGER,
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
14
run_rebuild_fix.py
Normal file
14
run_rebuild_fix.py
Normal file
@@ -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.")
|
||||||
82
scripts/update_l3_schema_full.py
Normal file
82
scripts/update_l3_schema_full.py
Normal file
@@ -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()
|
||||||
@@ -132,7 +132,32 @@ def detail(steam_id):
|
|||||||
history_asc = StatsService.get_player_trend(steam_id, limit=1000)
|
history_asc = StatsService.get_player_trend(steam_id, limit=1000)
|
||||||
history = history_asc[::-1] if history_asc else []
|
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/<int:comment_id>/like', methods=['POST'])
|
@bp.route('/comment/<int:comment_id>/like', methods=['POST'])
|
||||||
def like_comment(comment_id):
|
def like_comment(comment_id):
|
||||||
|
|||||||
@@ -199,15 +199,34 @@ class FeatureService:
|
|||||||
Refreshes the L3 Data Mart with full feature calculations.
|
Refreshes the L3 Data Mart with full feature calculations.
|
||||||
"""
|
"""
|
||||||
from web.config import Config
|
from web.config import Config
|
||||||
|
from web.services.web_service import WebService
|
||||||
|
import json
|
||||||
|
|
||||||
l3_db_path = Config.DB_L3_PATH
|
l3_db_path = Config.DB_L3_PATH
|
||||||
l2_db_path = Config.DB_L2_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 = sqlite3.connect(l2_db_path)
|
||||||
conn_l2.row_factory = sqlite3.Row
|
conn_l2.row_factory = sqlite3.Row
|
||||||
|
|
||||||
try:
|
try:
|
||||||
print("Loading L2 data...")
|
print(f"Loading L2 data for {len(team_player_ids)} players...")
|
||||||
df = FeatureService._load_and_calculate_dataframe(conn_l2, min_matches)
|
df = FeatureService._load_and_calculate_dataframe(conn_l2, list(team_player_ids))
|
||||||
|
|
||||||
if df is None or df.empty:
|
if df is None or df.empty:
|
||||||
print("No data to process.")
|
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')
|
df_to_save['updated_at'] = pd.Timestamp.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
# Generate Insert SQL
|
# 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))
|
placeholders = ','.join(['?'] * len(df_to_save.columns))
|
||||||
cols_str = ','.join(df_to_save.columns)
|
cols_str = ','.join(df_to_save.columns)
|
||||||
sql = f"INSERT OR REPLACE INTO dm_player_features ({cols_str}) VALUES ({placeholders})"
|
sql = f"INSERT OR REPLACE INTO dm_player_features ({cols_str}) VALUES ({placeholders})"
|
||||||
@@ -251,9 +271,14 @@ class FeatureService:
|
|||||||
conn_l2.close()
|
conn_l2.close()
|
||||||
|
|
||||||
@staticmethod
|
@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
|
# 1. Basic Stats
|
||||||
query_basic = """
|
query_basic = f"""
|
||||||
SELECT
|
SELECT
|
||||||
steam_id_64,
|
steam_id_64,
|
||||||
COUNT(*) as matches_played,
|
COUNT(*) as matches_played,
|
||||||
@@ -298,10 +323,10 @@ class FeatureService:
|
|||||||
SUM(util_he_usage) as sum_util_he,
|
SUM(util_he_usage) as sum_util_he,
|
||||||
SUM(util_decoy_usage) as sum_util_decoy
|
SUM(util_decoy_usage) as sum_util_decoy
|
||||||
FROM fact_match_players
|
FROM fact_match_players
|
||||||
|
WHERE steam_id_64 IN ({placeholders})
|
||||||
GROUP BY steam_id_64
|
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
|
if df.empty: return None
|
||||||
|
|
||||||
# Basic Derived
|
# Basic Derived
|
||||||
@@ -492,6 +517,9 @@ class FeatureService:
|
|||||||
# Force overwrite winner_side with calculated winner since DB data is unreliable (mostly NULL)
|
# Force overwrite winner_side with calculated winner since DB data is unreliable (mostly NULL)
|
||||||
df_rounds['winner_side'] = df_rounds['calculated_winner']
|
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)
|
# 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).
|
# Logic above handles Round 1 correctly (prev is 0).
|
||||||
|
|
||||||
@@ -533,6 +561,10 @@ class FeatureService:
|
|||||||
# Merge Scores
|
# Merge Scores
|
||||||
df_events = df_events.merge(df_rounds, on=['match_id', 'round_num'], how='left')
|
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 ---
|
# --- HPS: Match Point & Comeback ---
|
||||||
# Match Point Win Rate
|
# Match Point Win Rate
|
||||||
mp_rounds = df_rounds[((df_rounds['ct_score'] == 12) | (df_rounds['t_score'] == 12) |
|
mp_rounds = df_rounds[((df_rounds['ct_score'] == 12) | (df_rounds['t_score'] == 12) |
|
||||||
@@ -585,6 +617,85 @@ class FeatureService:
|
|||||||
kd_stats.index.name = 'steam_id_64'
|
kd_stats.index.name = 'steam_id_64'
|
||||||
df = df.merge(kd_stats[['hps_comeback_kd_diff']], on='steam_id_64', how='left')
|
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 ---
|
# --- PTL: Pistol Stats ---
|
||||||
pistol_rounds = [1, 13]
|
pistol_rounds = [1, 13]
|
||||||
df_pistol = df_events[df_events['round_num'].isin(pistol_rounds)]
|
df_pistol = df_events[df_events['round_num'].isin(pistol_rounds)]
|
||||||
@@ -606,70 +717,164 @@ class FeatureService:
|
|||||||
df['ptl_pistol_kd'] = 1.0
|
df['ptl_pistol_kd'] = 1.0
|
||||||
df['ptl_pistol_util_efficiency'] = 0.0
|
df['ptl_pistol_util_efficiency'] = 0.0
|
||||||
|
|
||||||
# --- T/CT Stats ---
|
# --- T/CT Stats (Directly from L2 Side Tables) ---
|
||||||
ct_k = df_events[df_events['attacker_side'] == 'CT'].groupby('attacker_steam_id').size()
|
query_sides_l2 = f"""
|
||||||
ct_d = df_events[df_events['victim_side'] == 'CT'].groupby('victim_steam_id').size()
|
SELECT
|
||||||
t_k = df_events[df_events['attacker_side'] == 'T'].groupby('attacker_steam_id').size()
|
steam_id_64,
|
||||||
t_d = df_events[df_events['victim_side'] == 'T'].groupby('victim_steam_id').size()
|
'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)
|
UNION ALL
|
||||||
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']
|
|
||||||
|
|
||||||
side_stats.index.name = 'steam_id_64'
|
SELECT
|
||||||
df = df.merge(side_stats[['side_rating_ct', 'side_rating_t', 'side_kd_diff_ct_t']], on='steam_id_64', how='left')
|
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
|
df_sides = pd.read_sql_query(query_sides_l2, conn, params=valid_ids + valid_ids)
|
||||||
# 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'))
|
|
||||||
|
|
||||||
rounds_per_side = df_player_rounds.groupby(['steam_id_64', 'side']).size().unstack(fill_value=0)
|
if not df_sides.empty:
|
||||||
if 'CT' not in rounds_per_side.columns: rounds_per_side['CT'] = 0
|
# Calculate Derived Rates per row before pivoting
|
||||||
if 'T' not in rounds_per_side.columns: rounds_per_side['T'] = 0
|
df_sides['rounds'] = df_sides['rounds'].replace(0, 1) # Avoid div by zero
|
||||||
|
|
||||||
# First Kills (Earliest event in round)
|
# KD Calculation (Sum of Kills / Sum of Deaths)
|
||||||
# Group by match, round -> min time.
|
df_sides['kd'] = df_sides['kills'] / df_sides['deaths'].replace(0, 1)
|
||||||
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)
|
# KAST Proxy (if KAST is 0)
|
||||||
fk_stats = fk_stats.join(rounds_per_side, how='outer').fillna(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
|
||||||
|
|
||||||
fk_stats['side_first_kill_rate_ct'] = fk_stats['fk_ct'] / fk_stats['CT'].replace(0, 1)
|
df_sides['fk_rate'] = df_sides['fk'] / df_sides['rounds']
|
||||||
fk_stats['side_first_kill_rate_t'] = fk_stats['fk_t'] / fk_stats['T'].replace(0, 1)
|
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)
|
||||||
|
|
||||||
fk_stats.index.name = 'steam_id_64'
|
# Pivot
|
||||||
df = df.merge(fk_stats[['side_first_kill_rate_ct', 'side_first_kill_rate_t']], on='steam_id_64', how='left')
|
# 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:
|
else:
|
||||||
# Fallbacks
|
# Fallbacks
|
||||||
cols = ['hps_match_point_win_rate', 'hps_comeback_kd_diff', 'ptl_pistol_kd', 'ptl_pistol_util_efficiency',
|
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:
|
for c in cols:
|
||||||
df[c] = 0
|
df[c] = 0
|
||||||
|
|
||||||
df['hps_match_point_win_rate'] = df['hps_match_point_win_rate'].fillna(0.5)
|
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)
|
# 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 FROM fact_match_players WHERE steam_id_64 IN ({placeholders})"
|
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)
|
df_mp_team = pd.read_sql_query(q_mp_team, conn, params=valid_ids)
|
||||||
if not df_mp_team.empty:
|
if not df_mp_team.empty:
|
||||||
losing_matches = df_mp_team[df_mp_team['is_win'] == 0]
|
losing_matches = df_mp_team[df_mp_team['is_win'] == 0]
|
||||||
if not losing_matches.empty:
|
if not losing_matches.empty:
|
||||||
# Average entry kills per losing match
|
# Sum Entry Kills / Sum Rounds
|
||||||
pressure_entry = losing_matches.groupby('steam_id_64')['entry_kills'].mean().reset_index()
|
pressure_entry = losing_matches.groupby('steam_id_64')[['entry_kills', 'round_total']].sum().reset_index()
|
||||||
pressure_entry.rename(columns={'entry_kills': 'hps_pressure_entry_rate'}, inplace=True)
|
pressure_entry['hps_pressure_entry_rate'] = pressure_entry['entry_kills'] / pressure_entry['round_total'].replace(0, 1)
|
||||||
df = df.merge(pressure_entry, on='steam_id_64', how='left')
|
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:
|
if 'hps_pressure_entry_rate' not in df.columns:
|
||||||
df['hps_pressure_entry_rate'] = 0
|
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'],
|
df_player_rounds['side'] = np.where(mask_fh, df_player_rounds['fh_side'],
|
||||||
np.where(df_player_rounds['fh_side'] == 'CT', 'T', 'CT'))
|
np.where(df_player_rounds['fh_side'] == 'CT', 'T', 'CT'))
|
||||||
|
|
||||||
# Filter for Pistol Rounds (1, 13)
|
# Filter for Pistol Rounds (1 and after halftime)
|
||||||
player_pistol = df_player_rounds[df_player_rounds['round_num'].isin([1, 13])].copy()
|
# 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
|
# 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')
|
player_pistol = player_pistol.merge(df_rounds[['match_id', 'round_num', 'winner_side']], on=['match_id', 'round_num'], how='left')
|
||||||
|
|
||||||
# Calculate Win
|
# Calculate Win
|
||||||
|
# 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)
|
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 = 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)
|
ptl_wins.rename(columns={'sum': 'pistol_wins', 'count': 'pistol_rounds'}, inplace=True)
|
||||||
@@ -800,18 +1013,19 @@ class FeatureService:
|
|||||||
|
|
||||||
# HPS (20%)
|
# HPS (20%)
|
||||||
df['score_hps'] = (
|
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_match_point_win_rate') +
|
||||||
0.20 * n('hps_comeback_kd_diff') +
|
0.20 * n('hps_comeback_kd_diff') +
|
||||||
0.15 * n('hps_pressure_entry_rate') +
|
0.15 * n('hps_pressure_entry_rate') +
|
||||||
0.15 * n('basic_avg_rating')
|
0.20 * n('basic_avg_rating')
|
||||||
)
|
)
|
||||||
|
|
||||||
# PTL (10%)
|
# PTL (10%)
|
||||||
df['score_ptl'] = (
|
df['score_ptl'] = (
|
||||||
0.40 * n('ptl_pistol_kills') +
|
0.30 * n('ptl_pistol_kills') +
|
||||||
0.40 * n('ptl_pistol_win_rate') +
|
0.30 * n('ptl_pistol_win_rate') +
|
||||||
0.20 * n('basic_avg_headshot_kills') # Pistol rounds rely on HS
|
0.20 * n('ptl_pistol_kd') +
|
||||||
|
0.20 * n('ptl_pistol_util_efficiency')
|
||||||
)
|
)
|
||||||
|
|
||||||
# T/CT (10%)
|
# T/CT (10%)
|
||||||
|
|||||||
@@ -638,10 +638,16 @@ class StatsService:
|
|||||||
'basic_avg_perfect_kill', 'basic_avg_revenge_kill',
|
'basic_avg_perfect_kill', 'basic_avg_revenge_kill',
|
||||||
# L3 Advanced Dimensions
|
# L3 Advanced Dimensions
|
||||||
'sta_last_30_rating', 'sta_win_rating', 'sta_loss_rating', 'sta_rating_volatility', 'sta_time_rating_corr',
|
'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_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_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_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_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'
|
'util_avg_nade_dmg', 'util_avg_flash_time', 'util_avg_flash_enemy', 'util_usage_rate'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
{{ icon }} {{ label }}
|
{{ icon }} {{ label }}
|
||||||
</div>
|
</div>
|
||||||
{% if dist %}
|
{% if dist %}
|
||||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold
|
||||||
{% if dist.rank == 1 %}bg-yellow-100 text-yellow-800 border border-yellow-200
|
{% if dist.rank == 1 %}bg-yellow-100 text-yellow-800 border border-yellow-200
|
||||||
{% elif dist.rank <= 3 %}bg-gray-100 text-gray-800 border border-gray-200
|
{% elif dist.rank <= 3 %}bg-gray-100 text-gray-800 border border-gray-200
|
||||||
{% else %}bg-slate-100 text-slate-600 border border-slate-200{% endif %}">
|
{% else %}bg-slate-100 text-slate-600 border border-slate-200{% endif %}">
|
||||||
@@ -153,7 +153,7 @@
|
|||||||
<div class="flex justify-between items-center mb-1">
|
<div class="flex justify-between items-center mb-1">
|
||||||
<span class="text-xs font-bold text-gray-400 uppercase tracking-wider">{{ label }}</span>
|
<span class="text-xs font-bold text-gray-400 uppercase tracking-wider">{{ label }}</span>
|
||||||
{% if dist %}
|
{% if dist %}
|
||||||
<span class="inline-flex items-center px-1 py-0.5 rounded text-[9px] font-bold
|
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-bold
|
||||||
{% if dist.rank == 1 %}bg-yellow-50 text-yellow-700 border border-yellow-100
|
{% if dist.rank == 1 %}bg-yellow-50 text-yellow-700 border border-yellow-100
|
||||||
{% elif dist.rank <= 3 %}bg-gray-50 text-gray-600 border border-gray-100
|
{% elif dist.rank <= 3 %}bg-gray-50 text-gray-600 border border-gray-100
|
||||||
{% else %}text-gray-300{% endif %}">
|
{% else %}text-gray-300{% endif %}">
|
||||||
@@ -232,7 +232,7 @@
|
|||||||
<!-- 2.6 Advanced Dimensions Breakdown -->
|
<!-- 2.6 Advanced Dimensions Breakdown -->
|
||||||
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-2xl p-6 border border-gray-100 dark:border-slate-700">
|
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-2xl p-6 border border-gray-100 dark:border-slate-700">
|
||||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-6 flex items-center gap-2">
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-6 flex items-center gap-2">
|
||||||
<span>🔬</span> 进阶能力分析 (Capabilities Breakdown)
|
<span>🔬</span> 深层能力维度 (Deep Capabilities Breakdown)
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<!-- Reusing detail_item macro, but with a different grid if needed -->
|
<!-- Reusing detail_item macro, but with a different grid if needed -->
|
||||||
@@ -266,31 +266,111 @@
|
|||||||
{{ 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') }}
|
||||||
|
{{ 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 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 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 KD (手枪KD)', features['ptl_pistol_kd'], 'ptl_pistol_kd') }}
|
||||||
|
{{ detail_item('Pistol Util Eff (手枪道具)', features['ptl_pistol_util_efficiency'], 'ptl_pistol_util_efficiency', '{:.1%}') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Group 3: SIDE & UTIL -->
|
<!-- Group 3: UTIL (Utility) -->
|
||||||
<div>
|
<div>
|
||||||
<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">
|
||||||
SIDE (T/CT Preference) & UTIL (Utility)
|
UTIL (Utility Usage)
|
||||||
</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('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('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('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 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') }}
|
{{ detail_item('Flash Enemy (致盲人数)', features['util_avg_flash_enemy'], 'util_avg_flash_enemy') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Group 4: SIDE (T/CT Preference) -->
|
||||||
|
<div>
|
||||||
|
<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">
|
||||||
|
SIDE (T/CT Preference)
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{% 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 %}
|
||||||
|
|
||||||
|
<div class="bg-gray-50 dark:bg-slate-700/30 rounded-xl p-4 border border-gray-100 dark:border-slate-600 relative overflow-hidden group hover:shadow-md transition-all">
|
||||||
|
<!-- Header with Diff -->
|
||||||
|
<div class="flex justify-between items-start mb-3">
|
||||||
|
<span class="text-xs font-bold text-gray-400 uppercase tracking-wider">{{ label }}</span>
|
||||||
|
|
||||||
|
{% if diff|abs > 0.001 %}
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-black tracking-wide
|
||||||
|
{% if diff > 0 %}bg-blue-100 text-blue-700 border border-blue-200
|
||||||
|
{% else %}bg-amber-100 text-amber-700 border border-amber-200{% endif %}">
|
||||||
|
{% if diff > 0 %}CT +{{ format_str.format(diff) }}
|
||||||
|
{% else %}T +{{ format_str.format(diff|abs) }}{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Values -->
|
||||||
|
<div class="flex items-end justify-between gap-2">
|
||||||
|
<!-- T Side -->
|
||||||
|
<div class="flex flex-col items-start">
|
||||||
|
<span class="text-xs font-bold text-amber-600/80 dark:text-amber-500 mb-0.5">T-Side</span>
|
||||||
|
<span class="{{ t_size }} font-black font-mono leading-none transition-all">
|
||||||
|
{{ format_str.format(t_val) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- VS Divider -->
|
||||||
|
<div class="h-8 w-px bg-gray-200 dark:bg-slate-600 mx-1"></div>
|
||||||
|
|
||||||
|
<!-- CT Side -->
|
||||||
|
<div class="flex flex-col items-end">
|
||||||
|
<span class="text-xs font-bold text-blue-600/80 dark:text-blue-400 mb-0.5">CT-Side</span>
|
||||||
|
<span class="{{ ct_size }} font-black font-mono leading-none transition-all">
|
||||||
|
{{ format_str.format(ct_val) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mini Bar for visual comparison -->
|
||||||
|
<div class="mt-3 flex h-1.5 w-full rounded-full overflow-hidden bg-gray-200 dark:bg-slate-600">
|
||||||
|
{% set total = t_val + ct_val %}
|
||||||
|
{% if total > 0 %}
|
||||||
|
{% set t_pct = (t_val / total) * 100 %}
|
||||||
|
<div class="h-full bg-amber-500" style="width: {{ t_pct }}%"></div>
|
||||||
|
<div class="h-full bg-blue-500 flex-1"></div>
|
||||||
|
{% else %}
|
||||||
|
<div class="h-full w-1/2 bg-gray-300"></div>
|
||||||
|
<div class="h-full w-1/2 bg-gray-400"></div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{{ 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%}') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -376,6 +456,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column: Map Stats & Comments -->
|
||||||
|
<div class="space-y-8">
|
||||||
|
<!-- Map Stats -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-2xl p-6 border border-gray-100 dark:border-slate-700">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-4">地图数据 (Map Stats)</h3>
|
||||||
|
<div class="space-y-3 max-h-[400px] overflow-y-auto custom-scroll pr-1">
|
||||||
|
{% for m in map_stats %}
|
||||||
|
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-slate-700/30 rounded-xl hover:bg-gray-100 transition-colors">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- Map Icon/Name -->
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-gray-200 dark:bg-slate-600 flex items-center justify-center text-xs font-black text-gray-500 uppercase">
|
||||||
|
{{ m.map_name[:3] }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-bold text-gray-900 dark:text-white">{{ m.map_name }}</div>
|
||||||
|
<div class="text-xs text-gray-500 font-mono">{{ m.matches }} matches</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="text-sm font-black font-mono {% if m.rating >= 1.1 %}text-green-600{% elif m.rating < 0.9 %}text-red-500{% else %}text-gray-700 dark:text-gray-300{% endif %}">
|
||||||
|
{{ "%.2f"|format(m.rating) }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-end gap-2 text-[10px] text-gray-400 font-mono">
|
||||||
|
<span class="{% if m.win_rate >= 0.5 %}text-green-600{% else %}text-red-500{% endif %}">{{ "%.0f"|format(m.win_rate * 100) }}% Win</span>
|
||||||
|
<span>{{ "%.1f"|format(m.adr) }} ADR</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-4 text-gray-400 text-sm">No map data available.</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Reviews / Comments -->
|
<!-- Reviews / Comments -->
|
||||||
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-2xl p-6 border border-gray-100 dark:border-slate-700">
|
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-2xl p-6 border border-gray-100 dark:border-slate-700">
|
||||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-6">留言板 (Comments)</h3>
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-6">留言板 (Comments)</h3>
|
||||||
@@ -441,6 +556,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
|||||||
Reference in New Issue
Block a user