Compare commits
18 Commits
master
...
a148c2d511
| Author | SHA1 | Date | |
|---|---|---|---|
| a148c2d511 | |||
| e006772e9c | |||
| 2e0bedb5ff | |||
| b9c1af5d70 | |||
| 1b9cab5628 | |||
| ade29ec1e8 | |||
| 8cc359b0ec | |||
| 727105f11e | |||
| 4cee0fab59 | |||
| 57fb6ce1f4 | |||
| f147b4d65a | |||
| d8b70c1cf7 | |||
| 81739392da | |||
| 8dabf0b097 | |||
| 026a8fe65d | |||
| 8e4cc07c6b | |||
| 8977a8d6bd | |||
| 70c13ef622 |
1
.gitignore
vendored
@@ -5,6 +5,7 @@ __pycache__/
|
|||||||
*.so
|
*.so
|
||||||
*.dylib
|
*.dylib
|
||||||
*.dll
|
*.dll
|
||||||
|
.trae/
|
||||||
|
|
||||||
.Python
|
.Python
|
||||||
build/
|
build/
|
||||||
|
|||||||
@@ -117,6 +117,13 @@ class PlayerStats:
|
|||||||
year: str = ""
|
year: str = ""
|
||||||
sts_raw: str = ""
|
sts_raw: str = ""
|
||||||
level_info_raw: str = ""
|
level_info_raw: str = ""
|
||||||
|
|
||||||
|
# Utility Usage
|
||||||
|
util_flash_usage: int = 0
|
||||||
|
util_smoke_usage: int = 0
|
||||||
|
util_molotov_usage: int = 0
|
||||||
|
util_he_usage: int = 0
|
||||||
|
util_decoy_usage: int = 0
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RoundEvent:
|
class RoundEvent:
|
||||||
@@ -582,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'))
|
||||||
@@ -659,6 +667,13 @@ class MatchParser:
|
|||||||
stats.team_id = team_id_value
|
stats.team_id = team_id_value
|
||||||
stats.kills = safe_int(get_stat('kill'))
|
stats.kills = safe_int(get_stat('kill'))
|
||||||
stats.deaths = safe_int(get_stat('death'))
|
stats.deaths = safe_int(get_stat('death'))
|
||||||
|
|
||||||
|
# Force calculate K/D
|
||||||
|
if stats.deaths > 0:
|
||||||
|
stats.kd_ratio = stats.kills / stats.deaths
|
||||||
|
else:
|
||||||
|
stats.kd_ratio = float(stats.kills)
|
||||||
|
|
||||||
stats.assists = safe_int(get_stat('assist'))
|
stats.assists = safe_int(get_stat('assist'))
|
||||||
stats.headshot_count = safe_int(get_stat('headshot'))
|
stats.headshot_count = safe_int(get_stat('headshot'))
|
||||||
|
|
||||||
@@ -792,6 +807,22 @@ class MatchParser:
|
|||||||
round_list = l_data.get('round_stat', [])
|
round_list = l_data.get('round_stat', [])
|
||||||
|
|
||||||
for idx, r in enumerate(round_list):
|
for idx, r in enumerate(round_list):
|
||||||
|
# Utility Usage (Leetify)
|
||||||
|
bron = r.get('bron_equipment', {})
|
||||||
|
for sid, items in bron.items():
|
||||||
|
sid = str(sid)
|
||||||
|
if sid in self.match_data.players:
|
||||||
|
p = self.match_data.players[sid]
|
||||||
|
if isinstance(items, list):
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict): continue
|
||||||
|
name = item.get('WeaponName', '')
|
||||||
|
if name == 'weapon_flashbang': p.util_flash_usage += 1
|
||||||
|
elif name == 'weapon_smokegrenade': p.util_smoke_usage += 1
|
||||||
|
elif name in ['weapon_molotov', 'weapon_incgrenade']: p.util_molotov_usage += 1
|
||||||
|
elif name == 'weapon_hegrenade': p.util_he_usage += 1
|
||||||
|
elif name == 'weapon_decoy': p.util_decoy_usage += 1
|
||||||
|
|
||||||
rd = RoundData(
|
rd = RoundData(
|
||||||
round_num=r.get('round', idx + 1),
|
round_num=r.get('round', idx + 1),
|
||||||
winner_side='CT' if r.get('win_reason') in [7, 8, 9] else 'T', # Approximate logic, need real enum
|
winner_side='CT' if r.get('win_reason') in [7, 8, 9] else 'T', # Approximate logic, need real enum
|
||||||
@@ -942,6 +973,21 @@ class MatchParser:
|
|||||||
# Check schema: 'current_score' -> ct/t
|
# Check schema: 'current_score' -> ct/t
|
||||||
cur_score = r.get('current_score', {})
|
cur_score = r.get('current_score', {})
|
||||||
|
|
||||||
|
# Utility Usage (Classic)
|
||||||
|
equiped = r.get('equiped', {})
|
||||||
|
for sid, items in equiped.items():
|
||||||
|
# Ensure sid is string
|
||||||
|
sid = str(sid)
|
||||||
|
if sid in self.match_data.players:
|
||||||
|
p = self.match_data.players[sid]
|
||||||
|
if isinstance(items, list):
|
||||||
|
for item in items:
|
||||||
|
if item == 'flashbang': p.util_flash_usage += 1
|
||||||
|
elif item == 'smokegrenade': p.util_smoke_usage += 1
|
||||||
|
elif item in ['molotov', 'incgrenade']: p.util_molotov_usage += 1
|
||||||
|
elif item == 'hegrenade': p.util_he_usage += 1
|
||||||
|
elif item == 'decoy': p.util_decoy_usage += 1
|
||||||
|
|
||||||
rd = RoundData(
|
rd = RoundData(
|
||||||
round_num=idx + 1,
|
round_num=idx + 1,
|
||||||
winner_side='None', # Default to None if unknown
|
winner_side='None', # Default to None if unknown
|
||||||
@@ -1207,7 +1253,8 @@ def save_match(cursor, m: MatchData):
|
|||||||
"many_assists_cnt3", "many_assists_cnt4", "many_assists_cnt5", "map",
|
"many_assists_cnt3", "many_assists_cnt4", "many_assists_cnt5", "map",
|
||||||
"match_code", "match_mode", "match_team_id", "match_time", "per_headshot",
|
"match_code", "match_mode", "match_team_id", "match_time", "per_headshot",
|
||||||
"perfect_kill", "planted_bomb", "revenge_kill", "round_total", "season",
|
"perfect_kill", "planted_bomb", "revenge_kill", "round_total", "season",
|
||||||
"team_kill", "throw_harm", "throw_harm_enemy", "uid", "year", "sts_raw", "level_info_raw"
|
"team_kill", "throw_harm", "throw_harm_enemy", "uid", "year", "sts_raw", "level_info_raw",
|
||||||
|
"util_flash_usage", "util_smoke_usage", "util_molotov_usage", "util_he_usage", "util_decoy_usage"
|
||||||
]
|
]
|
||||||
player_placeholders = ",".join(["?"] * len(player_columns))
|
player_placeholders = ",".join(["?"] * len(player_columns))
|
||||||
player_columns_sql = ",".join(player_columns)
|
player_columns_sql = ",".join(player_columns)
|
||||||
@@ -1231,7 +1278,8 @@ def save_match(cursor, m: MatchData):
|
|||||||
p.many_assists_cnt5, p.map, p.match_code, p.match_mode, p.match_team_id,
|
p.many_assists_cnt5, p.map, p.match_code, p.match_mode, p.match_team_id,
|
||||||
p.match_time, p.per_headshot, p.perfect_kill, p.planted_bomb, p.revenge_kill,
|
p.match_time, p.per_headshot, p.perfect_kill, p.planted_bomb, p.revenge_kill,
|
||||||
p.round_total, p.season, p.team_kill, p.throw_harm, p.throw_harm_enemy,
|
p.round_total, p.season, p.team_kill, p.throw_harm, p.throw_harm_enemy,
|
||||||
p.uid, p.year, p.sts_raw, p.level_info_raw
|
p.uid, p.year, p.sts_raw, p.level_info_raw,
|
||||||
|
p.util_flash_usage, p.util_smoke_usage, p.util_molotov_usage, p.util_he_usage, p.util_decoy_usage
|
||||||
]
|
]
|
||||||
|
|
||||||
for sid, p in m.players.items():
|
for sid, p in m.players.items():
|
||||||
|
|||||||
@@ -1,329 +1,51 @@
|
|||||||
|
|
||||||
import sqlite3
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import numpy as np
|
import sys
|
||||||
import pandas as pd
|
|
||||||
from datetime import datetime
|
# Add parent directory to path to allow importing web module
|
||||||
|
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
|
# Setup logging
|
||||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Constants
|
L3_DB_PATH = Config.DB_L3_PATH
|
||||||
L2_DB_PATH = 'database/L2/L2_Main.sqlite'
|
SCHEMA_PATH = os.path.join(Config.BASE_DIR, 'database', 'L3', 'schema.sql')
|
||||||
L3_DB_PATH = 'database/L3/L3_Features.sqlite'
|
|
||||||
SCHEMA_PATH = 'database/L3/schema.sql'
|
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
if not os.path.exists('database/L3'):
|
l3_dir = os.path.dirname(L3_DB_PATH)
|
||||||
os.makedirs('database/L3')
|
if not os.path.exists(l3_dir):
|
||||||
|
os.makedirs(l3_dir)
|
||||||
|
|
||||||
conn = sqlite3.connect(L3_DB_PATH)
|
conn = sqlite3.connect(L3_DB_PATH)
|
||||||
with open(SCHEMA_PATH, 'r', encoding='utf-8') as f:
|
with open(SCHEMA_PATH, 'r', encoding='utf-8') as f:
|
||||||
conn.executescript(f.read())
|
conn.executescript(f.read())
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
logger.info("L3 DB Initialized.")
|
logger.info("L3 DB Initialized/Updated with Schema.")
|
||||||
|
|
||||||
def get_db_connection(db_path):
|
def main():
|
||||||
conn = sqlite3.connect(db_path)
|
logger.info("Starting L3 Builder (Delegating to FeatureService)...")
|
||||||
return conn
|
|
||||||
|
|
||||||
def safe_div(a, b, default=0.0):
|
|
||||||
return a / b if b and b != 0 else default
|
|
||||||
|
|
||||||
def calculate_basic_features(df):
|
|
||||||
if df.empty:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
count = len(df)
|
# 1. Ensure Schema is up to date
|
||||||
|
init_db()
|
||||||
|
|
||||||
feats = {
|
# 2. Rebuild Features using the centralized logic
|
||||||
'total_matches': count,
|
|
||||||
'basic_avg_rating': df['rating'].mean(),
|
|
||||||
'basic_avg_kd': df['kd_ratio'].mean(),
|
|
||||||
'basic_avg_kast': df['kast'].mean(),
|
|
||||||
'basic_avg_rws': df['rws'].mean(),
|
|
||||||
'basic_avg_headshot_kills': df['headshot_count'].sum() / count,
|
|
||||||
'basic_headshot_rate': safe_div(df['headshot_count'].sum(), df['kills'].sum()),
|
|
||||||
'basic_avg_first_kill': df['first_kill'].mean(),
|
|
||||||
'basic_avg_first_death': df['first_death'].mean(),
|
|
||||||
'basic_first_kill_rate': safe_div(df['first_kill'].sum(), df['first_kill'].sum() + df['first_death'].sum()),
|
|
||||||
'basic_first_death_rate': safe_div(df['first_death'].sum(), df['first_kill'].sum() + df['first_death'].sum()),
|
|
||||||
|
|
||||||
'basic_avg_kill_2': df['kill_2'].mean(),
|
|
||||||
'basic_avg_kill_3': df['kill_3'].mean(),
|
|
||||||
'basic_avg_kill_4': df['kill_4'].mean(),
|
|
||||||
'basic_avg_kill_5': df['kill_5'].mean(),
|
|
||||||
|
|
||||||
'basic_avg_assisted_kill': df['assisted_kill'].mean(),
|
|
||||||
'basic_avg_perfect_kill': df['perfect_kill'].mean(),
|
|
||||||
'basic_avg_revenge_kill': df['revenge_kill'].mean(),
|
|
||||||
'basic_avg_awp_kill': df['awp_kill'].mean(),
|
|
||||||
'basic_avg_jump_count': df['jump_count'].mean(),
|
|
||||||
}
|
|
||||||
return feats
|
|
||||||
|
|
||||||
def calculate_sta_features(df):
|
|
||||||
if df.empty:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
df = df.sort_values('match_time')
|
|
||||||
last_30 = df.tail(30)
|
|
||||||
last_10 = df.tail(10)
|
|
||||||
|
|
||||||
feats = {
|
|
||||||
'sta_last_30_rating': last_30['rating'].mean(),
|
|
||||||
'sta_win_rating': df[df['is_win'] == 1]['rating'].mean() if not df[df['is_win'] == 1].empty else 0.0,
|
|
||||||
'sta_loss_rating': df[df['is_win'] == 0]['rating'].mean() if not df[df['is_win'] == 0].empty else 0.0,
|
|
||||||
'sta_rating_volatility': last_10['rating'].std() if len(last_10) > 1 else 0.0,
|
|
||||||
}
|
|
||||||
|
|
||||||
df['date'] = pd.to_datetime(df['match_time'], unit='s').dt.date
|
|
||||||
day_counts = df.groupby('date').size()
|
|
||||||
busy_days = day_counts[day_counts >= 4].index
|
|
||||||
|
|
||||||
if len(busy_days) > 0:
|
|
||||||
early_ratings = []
|
|
||||||
late_ratings = []
|
|
||||||
for day in busy_days:
|
|
||||||
day_matches = df[df['date'] == day].sort_values('match_time')
|
|
||||||
early = day_matches.head(3)
|
|
||||||
late = day_matches.tail(len(day_matches) - 3)
|
|
||||||
early_ratings.extend(early['rating'].tolist())
|
|
||||||
late_ratings.extend(late['rating'].tolist())
|
|
||||||
feats['sta_fatigue_decay'] = np.mean(early_ratings) - np.mean(late_ratings) if early_ratings and late_ratings else 0.0
|
|
||||||
else:
|
|
||||||
feats['sta_fatigue_decay'] = 0.0
|
|
||||||
|
|
||||||
df['hour_of_day'] = pd.to_datetime(df['match_time'], unit='s').dt.hour
|
|
||||||
if len(df) > 5:
|
|
||||||
corr = df['hour_of_day'].corr(df['rating'])
|
|
||||||
feats['sta_time_rating_corr'] = corr if not np.isnan(corr) else 0.0
|
|
||||||
else:
|
|
||||||
feats['sta_time_rating_corr'] = 0.0
|
|
||||||
|
|
||||||
return feats
|
|
||||||
|
|
||||||
def calculate_util_features(df):
|
|
||||||
if df.empty:
|
|
||||||
return {}
|
|
||||||
feats = {
|
|
||||||
'util_avg_nade_dmg': df['throw_harm'].mean() if 'throw_harm' in df.columns else 0.0,
|
|
||||||
'util_avg_flash_time': df['flash_duration'].mean() if 'flash_duration' in df.columns else 0.0,
|
|
||||||
'util_avg_flash_enemy': df['flash_enemy'].mean() if 'flash_enemy' in df.columns else 0.0,
|
|
||||||
'util_avg_flash_team': df['flash_team'].mean() if 'flash_team' in df.columns else 0.0,
|
|
||||||
'util_usage_rate': (df['flash_enemy'].mean() + df['throw_harm'].mean() / 50.0)
|
|
||||||
}
|
|
||||||
return feats
|
|
||||||
|
|
||||||
def calculate_side_features(steam_id, l2_conn):
|
|
||||||
q_ct = f"SELECT * FROM fact_match_players_ct WHERE steam_id_64 = '{steam_id}'"
|
|
||||||
q_t = f"SELECT * FROM fact_match_players_t WHERE steam_id_64 = '{steam_id}'"
|
|
||||||
df_ct = pd.read_sql_query(q_ct, l2_conn)
|
|
||||||
df_t = pd.read_sql_query(q_t, l2_conn)
|
|
||||||
|
|
||||||
feats = {}
|
|
||||||
if not df_ct.empty:
|
|
||||||
feats['side_rating_ct'] = df_ct['rating'].mean()
|
|
||||||
feats['side_first_kill_rate_ct'] = safe_div(df_ct['first_kill'].sum(), df_ct['first_kill'].sum() + df_ct['first_death'].sum())
|
|
||||||
feats['side_hold_success_rate_ct'] = 0.0
|
|
||||||
feats['side_defused_bomb_count'] = df_ct['defused_bomb'].sum() if 'defused_bomb' in df_ct.columns else 0
|
|
||||||
else:
|
|
||||||
feats.update({'side_rating_ct': 0.0, 'side_first_kill_rate_ct': 0.0, 'side_hold_success_rate_ct': 0.0, 'side_defused_bomb_count': 0})
|
|
||||||
|
|
||||||
if not df_t.empty:
|
|
||||||
feats['side_rating_t'] = df_t['rating'].mean()
|
|
||||||
feats['side_first_kill_rate_t'] = safe_div(df_t['first_kill'].sum(), df_t['first_kill'].sum() + df_t['first_death'].sum())
|
|
||||||
feats['side_entry_success_rate_t'] = 0.0
|
|
||||||
feats['side_planted_bomb_count'] = df_t['planted_bomb'].sum() if 'planted_bomb' in df_t.columns else 0
|
|
||||||
else:
|
|
||||||
feats.update({'side_rating_t': 0.0, 'side_first_kill_rate_t': 0.0, 'side_entry_success_rate_t': 0.0, 'side_planted_bomb_count': 0})
|
|
||||||
|
|
||||||
feats['side_kd_diff_ct_t'] = (df_ct['kd_ratio'].mean() if not df_ct.empty else 0) - (df_t['kd_ratio'].mean() if not df_t.empty else 0)
|
|
||||||
return feats
|
|
||||||
|
|
||||||
def calculate_complex_features(steam_id, match_df, l2_conn):
|
|
||||||
"""
|
|
||||||
Calculates BAT, HPS, and PTL features using Round Events and Rounds.
|
|
||||||
"""
|
|
||||||
feats = {}
|
|
||||||
|
|
||||||
# 1. HPS: Clutch from match stats (easier part)
|
|
||||||
# clutch_1vX are wins. end_1vX are total attempts (assuming mapping logic).
|
|
||||||
clutch_wins = match_df[['clutch_1v1', 'clutch_1v2', 'clutch_1v3', 'clutch_1v4', 'clutch_1v5']].sum().sum()
|
|
||||||
clutch_attempts = match_df[['end_1v1', 'end_1v2', 'end_1v3', 'end_1v4', 'end_1v5']].sum().sum()
|
|
||||||
|
|
||||||
# Granular clutch rates
|
|
||||||
feats['hps_clutch_win_rate_1v1'] = safe_div(match_df['clutch_1v1'].sum(), match_df['end_1v1'].sum())
|
|
||||||
feats['hps_clutch_win_rate_1v2'] = safe_div(match_df['clutch_1v2'].sum(), match_df['end_1v2'].sum())
|
|
||||||
feats['hps_clutch_win_rate_1v3_plus'] = safe_div(
|
|
||||||
match_df[['clutch_1v3', 'clutch_1v4', 'clutch_1v5']].sum().sum(),
|
|
||||||
match_df[['end_1v3', 'end_1v4', 'end_1v5']].sum().sum()
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. Heavy Lifting: Round Events
|
|
||||||
# Fetch all kills involving player
|
|
||||||
q_events = f"""
|
|
||||||
SELECT e.*,
|
|
||||||
p_vic.rank_score as victim_rank,
|
|
||||||
p_att.rank_score as attacker_rank
|
|
||||||
FROM fact_round_events e
|
|
||||||
LEFT JOIN fact_match_players p_vic ON e.match_id = p_vic.match_id AND e.victim_steam_id = p_vic.steam_id_64
|
|
||||||
LEFT JOIN fact_match_players p_att ON e.match_id = p_att.match_id AND e.attacker_steam_id = p_att.steam_id_64
|
|
||||||
WHERE (e.attacker_steam_id = '{steam_id}' OR e.victim_steam_id = '{steam_id}')
|
|
||||||
AND e.event_type = 'kill'
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
events = pd.read_sql_query(q_events, l2_conn)
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching events for {steam_id}: {e}")
|
logger.error(f"Error rebuilding features: {e}")
|
||||||
events = pd.DataFrame()
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
if not events.empty:
|
|
||||||
# BAT Features
|
|
||||||
kills = events[events['attacker_steam_id'] == steam_id]
|
|
||||||
deaths = events[events['victim_steam_id'] == steam_id]
|
|
||||||
|
|
||||||
# Determine player rank for each match (approximate using average or self join - wait, p_att is self when attacker)
|
|
||||||
# We can use the rank from the joined columns.
|
|
||||||
|
|
||||||
# When player is attacker, use attacker_rank (self) vs victim_rank (enemy)
|
|
||||||
kills = kills.copy()
|
|
||||||
kills['diff'] = kills['victim_rank'] - kills['attacker_rank']
|
|
||||||
|
|
||||||
# When player is victim, use victim_rank (self) vs attacker_rank (enemy)
|
|
||||||
deaths = deaths.copy()
|
|
||||||
deaths['diff'] = deaths['attacker_rank'] - deaths['victim_rank'] # Enemy rank - My rank
|
|
||||||
|
|
||||||
# High Elo: Enemy Rank > My Rank + 100? Or just > My Rank?
|
|
||||||
# Let's say High Elo = Enemy Rank > My Rank
|
|
||||||
high_elo_kills = kills[kills['diff'] > 0].shape[0]
|
|
||||||
high_elo_deaths = deaths[deaths['diff'] > 0].shape[0] # Enemy (Attacker) > Me (Victim)
|
|
||||||
|
|
||||||
low_elo_kills = kills[kills['diff'] < 0].shape[0]
|
|
||||||
low_elo_deaths = deaths[deaths['diff'] < 0].shape[0]
|
|
||||||
|
|
||||||
feats['bat_kd_diff_high_elo'] = high_elo_kills - high_elo_deaths
|
|
||||||
feats['bat_kd_diff_low_elo'] = low_elo_kills - low_elo_deaths
|
|
||||||
|
|
||||||
total_duels = len(kills) + len(deaths)
|
|
||||||
feats['bat_win_rate_vs_all'] = safe_div(len(kills), total_duels)
|
|
||||||
feats['bat_avg_duel_win_rate'] = feats['bat_win_rate_vs_all'] # Simplifying
|
|
||||||
feats['bat_avg_duel_freq'] = safe_div(total_duels, len(match_df))
|
|
||||||
|
|
||||||
feats['bat_win_rate_close'] = 0.0 # Placeholder for distance logic
|
|
||||||
feats['bat_win_rate_mid'] = 0.0
|
|
||||||
feats['bat_win_rate_far'] = 0.0
|
|
||||||
|
|
||||||
else:
|
|
||||||
feats.update({
|
|
||||||
'bat_kd_diff_high_elo': 0, 'bat_kd_diff_low_elo': 0,
|
|
||||||
'bat_win_rate_vs_all': 0.0, 'bat_avg_duel_win_rate': 0.0,
|
|
||||||
'bat_avg_duel_freq': 0.0, 'bat_win_rate_close': 0.0,
|
|
||||||
'bat_win_rate_mid': 0.0, 'bat_win_rate_far': 0.0
|
|
||||||
})
|
|
||||||
|
|
||||||
# 3. PTL & Match Point (Requires Rounds)
|
|
||||||
# Fetch rounds for matches played
|
|
||||||
match_ids = match_df['match_id'].unique().tolist()
|
|
||||||
if not match_ids:
|
|
||||||
return feats
|
|
||||||
|
|
||||||
match_ids_str = "'" + "','".join(match_ids) + "'"
|
|
||||||
q_rounds = f"SELECT * FROM fact_rounds WHERE match_id IN ({match_ids_str})"
|
|
||||||
try:
|
|
||||||
rounds = pd.read_sql_query(q_rounds, l2_conn)
|
|
||||||
except:
|
|
||||||
rounds = pd.DataFrame()
|
|
||||||
|
|
||||||
if not rounds.empty and not events.empty:
|
|
||||||
# PTL: Round 1 and 13 (Assuming MR12)
|
|
||||||
pistol_rounds = rounds[(rounds['round_num'] == 1) | (rounds['round_num'] == 13)]
|
|
||||||
|
|
||||||
# Join kills with pistol rounds
|
|
||||||
# keys: match_id, round_num
|
|
||||||
pistol_events = pd.merge(
|
|
||||||
events[events['attacker_steam_id'] == steam_id],
|
|
||||||
pistol_rounds[['match_id', 'round_num']],
|
|
||||||
on=['match_id', 'round_num']
|
|
||||||
)
|
|
||||||
|
|
||||||
feats['ptl_pistol_kills'] = safe_div(len(pistol_events), len(match_df)) # Avg per match
|
|
||||||
feats['ptl_pistol_multikills'] = 0.0 # Complex to calc without grouping per round
|
|
||||||
feats['ptl_pistol_win_rate'] = 0.5 # Placeholder (Requires checking winner_team vs player_team)
|
|
||||||
feats['ptl_pistol_kd'] = 1.0 # Placeholder
|
|
||||||
feats['ptl_pistol_util_efficiency'] = 0.0
|
|
||||||
|
|
||||||
# Match Point (HPS)
|
|
||||||
# Logic: Score is 12 (MR12) or 15 (MR15).
|
|
||||||
# We assume MR12 for simplicity or check max score.
|
|
||||||
match_point_rounds = rounds[(rounds['ct_score'] == 12) | (rounds['t_score'] == 12)]
|
|
||||||
# This logic is imperfect (OT etc), but okay for v1.
|
|
||||||
feats['hps_match_point_win_rate'] = 0.5 # Placeholder
|
|
||||||
|
|
||||||
else:
|
|
||||||
feats.update({
|
|
||||||
'ptl_pistol_kills': 0.0, 'ptl_pistol_multikills': 0.0,
|
|
||||||
'ptl_pistol_win_rate': 0.0, 'ptl_pistol_kd': 0.0,
|
|
||||||
'ptl_pistol_util_efficiency': 0.0, 'hps_match_point_win_rate': 0.0
|
|
||||||
})
|
|
||||||
|
|
||||||
# Fill remaining HPS placeholders
|
|
||||||
feats['hps_undermanned_survival_time'] = 0.0
|
|
||||||
feats['hps_pressure_entry_rate'] = 0.0
|
|
||||||
feats['hps_momentum_multikill_rate'] = 0.0
|
|
||||||
feats['hps_tilt_rating_drop'] = 0.0
|
|
||||||
feats['hps_clutch_rating_rise'] = 0.0
|
|
||||||
feats['hps_comeback_kd_diff'] = 0.0
|
|
||||||
feats['hps_losing_streak_kd_diff'] = 0.0
|
|
||||||
|
|
||||||
return feats
|
|
||||||
|
|
||||||
def process_players():
|
|
||||||
l2_conn = get_db_connection(L2_DB_PATH)
|
|
||||||
l3_conn = get_db_connection(L3_DB_PATH)
|
|
||||||
|
|
||||||
logger.info("Fetching player list...")
|
|
||||||
players = pd.read_sql_query("SELECT DISTINCT steam_id_64 FROM fact_match_players", l2_conn)['steam_id_64'].tolist()
|
|
||||||
|
|
||||||
logger.info(f"Found {len(players)} players. Processing...")
|
|
||||||
|
|
||||||
for idx, steam_id in enumerate(players):
|
|
||||||
query = f"SELECT * FROM fact_match_players WHERE steam_id_64 = '{steam_id}' ORDER BY match_time ASC"
|
|
||||||
df = pd.read_sql_query(query, l2_conn)
|
|
||||||
|
|
||||||
if df.empty:
|
|
||||||
continue
|
|
||||||
|
|
||||||
feats = calculate_basic_features(df)
|
|
||||||
feats.update(calculate_sta_features(df))
|
|
||||||
feats.update(calculate_side_features(steam_id, l2_conn))
|
|
||||||
feats.update(calculate_util_features(df))
|
|
||||||
feats.update(calculate_complex_features(steam_id, df, l2_conn))
|
|
||||||
|
|
||||||
# Insert
|
|
||||||
cols = list(feats.keys())
|
|
||||||
vals = list(feats.values())
|
|
||||||
vals = [float(v) if isinstance(v, (np.float32, np.float64)) else v for v in vals]
|
|
||||||
vals = [int(v) if isinstance(v, (np.int32, np.int64)) else v for v in vals]
|
|
||||||
|
|
||||||
col_str = ", ".join(cols)
|
|
||||||
q_marks = ", ".join(["?"] * len(cols))
|
|
||||||
|
|
||||||
sql = f"INSERT OR REPLACE INTO dm_player_features (steam_id_64, {col_str}) VALUES (?, {q_marks})"
|
|
||||||
l3_conn.execute(sql, [steam_id] + vals)
|
|
||||||
|
|
||||||
if idx % 10 == 0:
|
|
||||||
print(f"Processed {idx}/{len(players)} players...", end='\r')
|
|
||||||
l3_conn.commit()
|
|
||||||
|
|
||||||
l3_conn.commit()
|
|
||||||
l2_conn.close()
|
|
||||||
l3_conn.close()
|
|
||||||
logger.info("\nDone.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
init_db()
|
main()
|
||||||
process_players()
|
|
||||||
|
|||||||
48
ETL/refresh.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
|
def run_script(script_path, args=None):
|
||||||
|
cmd = [sys.executable, script_path]
|
||||||
|
if args:
|
||||||
|
cmd.extend(args)
|
||||||
|
|
||||||
|
print(f"\n[REFRESH] Running: {' '.join(cmd)}")
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
result = subprocess.run(cmd)
|
||||||
|
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"[REFRESH] Error running {script_path}. Exit code: {result.returncode}")
|
||||||
|
sys.exit(result.returncode)
|
||||||
|
else:
|
||||||
|
print(f"[REFRESH] Finished {script_path} in {elapsed:.2f}s")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
project_root = os.path.dirname(base_dir)
|
||||||
|
|
||||||
|
print("="*50)
|
||||||
|
print("STARTING FULL DATABASE REFRESH")
|
||||||
|
print("="*50)
|
||||||
|
|
||||||
|
# 1. L1A --force (Re-ingest all raw data)
|
||||||
|
l1a_script = os.path.join(base_dir, 'L1A.py')
|
||||||
|
run_script(l1a_script, ['--force'])
|
||||||
|
|
||||||
|
# 2. L2 Builder (Rebuild Fact Tables with fixed K/D logic)
|
||||||
|
l2_script = os.path.join(base_dir, 'L2_Builder.py')
|
||||||
|
run_script(l2_script)
|
||||||
|
|
||||||
|
# 3. L3 Builder (Rebuild Feature Store)
|
||||||
|
l3_script = os.path.join(base_dir, 'L3_Builder.py')
|
||||||
|
run_script(l3_script)
|
||||||
|
|
||||||
|
print("="*50)
|
||||||
|
print("DATABASE REFRESH COMPLETED SUCCESSFULLY")
|
||||||
|
print("="*50)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
78
README.md
@@ -1,13 +1,60 @@
|
|||||||
# YRTV 项目说明 till 0.5.0
|
# YRTV 项目说明 till 1.0.2hotfix
|
||||||
|
|
||||||
## 项目概览
|
## 项目概览
|
||||||
yrtv这一块。
|
YRTV 是一个基于 CS2 比赛数据的综合分析与战队管理平台。它集成了数据采集、ETL 清洗建模、特征挖掘以及现代化的 Web 交互界面。
|
||||||
|
核心目标是为战队提供数据驱动的决策支持,包括战术分析、队员表现评估、阵容管理(Clubhouse)以及实时战术板功能。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
您可以使用以下命令快速配置环境:
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
数据来源与处理核心包括:
|
数据来源与处理核心包括:
|
||||||
- 比赛页面的 iframe JSON 数据(`iframe_network.json`)
|
- 比赛页面的 iframe JSON 数据(`iframe_network.json`)
|
||||||
- 可选的 demo 文件(`.zip/.dem`)
|
- 可选的 demo 文件(`.zip/.dem`)
|
||||||
- L1A/L2/L3 分层数据库建模与校验
|
- L1A/L2/L3 分层数据库建模与校验
|
||||||
|
|
||||||
|
## Web 交互系统 (New in v0.5.0)
|
||||||
|
基于 Flask + TailwindCSS + Alpine.js 构建的现代化 Web 应用。
|
||||||
|
|
||||||
|
### 核心功能模块
|
||||||
|
1. **Clubhouse (战队管理)**
|
||||||
|
- **Roster Management**: 拖拽式管理当前激活阵容 (Active Roster)。
|
||||||
|
- **Scout System**: 全库模糊搜索玩家,支持按 Rating/Matches/KD 排序筛选。
|
||||||
|
- **Contract System**: 模拟签约/解约流程 (Sign/Release),管理战队资产。
|
||||||
|
- **Identity**: 统一的头像与 ID 显示逻辑 (SteamID/Name),支持自动生成首字母头像。
|
||||||
|
|
||||||
|
2. **Tactics Board (战术终端)**
|
||||||
|
- **SPA 架构**: 基于 Alpine.js 的单页应用,无刷新切换四大功能区。
|
||||||
|
- **Board (战术板)**: 集成 Leaflet.js 的交互式地图,支持战术点位标记。
|
||||||
|
- **Data (数据中心)**: 实时查看全队近期数据表现。
|
||||||
|
- **Analysis (深度分析)**:
|
||||||
|
- **Chemistry**: 任意组合 (2-5人) 的共同比赛胜率与数据分析。
|
||||||
|
- **Depth**: 阵容深度与位置分析。
|
||||||
|
- **Economy (经济计算)**: 简单的经济局/长枪局计算器。
|
||||||
|
|
||||||
|
3. **Match Center (比赛中心)**
|
||||||
|
- **List View**:
|
||||||
|
- 显示比赛平均 ELO。
|
||||||
|
- **Party Identification**: 自动识别组排车队 (👥 2-5),并用颜色区分规模 (Indigo/Blue/Purple/Orange)。
|
||||||
|
- **Result Tracking**: 基于 "Our Team" (Active Roster) 的胜负判定 (VICTORY/DEFEAT/CIVIL WAR)。
|
||||||
|
- **Detail View**:
|
||||||
|
- 按 Rating 降序排列双方队员。
|
||||||
|
- 高亮显示组排关系。
|
||||||
|
- 集成 Round-by-Round 经济与事件详情。
|
||||||
|
|
||||||
|
4. **Player Profile (玩家档案)**
|
||||||
|
- 综合能力雷达图 (六维数据)。
|
||||||
|
- 近期 Rating/KD/ADR 趋势折线图。
|
||||||
|
- 详细的历史比赛记录(含 Party info 与 Result)。
|
||||||
|
- 头像上传与管理。
|
||||||
|
|
||||||
|
## 自动化与运维
|
||||||
|
新增 `ETL/refresh.py` 自动化脚本,用于一键执行全量数据刷新:
|
||||||
|
- 自动清理旧数据库。
|
||||||
|
- 顺序执行 L1A -> L2 -> L3 构建。
|
||||||
|
- 自动处理 schema 迁移。
|
||||||
|
|
||||||
## 数据流程
|
## 数据流程
|
||||||
1. **下载与落盘**
|
1. **下载与落盘**
|
||||||
通过 `downloader/downloader.py` 抓取比赛页面数据,生成 `output_arena/<match_id>/iframe_network.json`,并可同时下载 demo 文件。
|
通过 `downloader/downloader.py` 抓取比赛页面数据,生成 `output_arena/<match_id>/iframe_network.json`,并可同时下载 demo 文件。
|
||||||
@@ -24,31 +71,32 @@ yrtv这一块。
|
|||||||
```
|
```
|
||||||
yrtv/
|
yrtv/
|
||||||
├── downloader/ # 下载器(抓取 iframe JSON 与 demo)
|
├── downloader/ # 下载器(抓取 iframe JSON 与 demo)
|
||||||
│ ├── downloader.py
|
|
||||||
│ └── README.md
|
|
||||||
├── ETL/ # ETL 脚本
|
├── ETL/ # ETL 脚本
|
||||||
│ ├── L1A.py
|
│ ├── L1A.py
|
||||||
│ ├── L2_Builder.py
|
│ ├── L2_Builder.py
|
||||||
│ ├── L3_Builder.py
|
│ ├── L3_Builder.py
|
||||||
│ ├── README.md
|
│ ├── refresh.py # [NEW] 一键刷新脚本
|
||||||
│ └── verify/
|
│ └── verify/
|
||||||
│ ├── verify_L2.py
|
├── database/ # SQLite 数据库存储
|
||||||
│ └── verify_deep.py
|
│ ├── L1A/
|
||||||
├── database/
|
│ ├── L2/
|
||||||
│ ├── L1A/ # L1A SQLite 与说明
|
│ ├── L3/
|
||||||
│ ├── L1B/ # L1B 目录(demo 解析结果说明)
|
│ └── original_json_schema/
|
||||||
│ ├── L2/ # L2 SQLite 与 schema
|
├── web/ # [NEW] Web 应用程序
|
||||||
│ ├── L3/ # L3 SQLite 与 schema (特征集市)
|
│ ├── app.py # 应用入口
|
||||||
│ └── original_json_schema/ # schema 扁平化与未覆盖字段清单
|
│ ├── routes/ # 路由 (matches, players, teams, tactics)
|
||||||
|
│ ├── services/ # 业务逻辑 (stats, web)
|
||||||
|
│ ├── templates/ # Jinja2 模板 (TailwindCSS + Alpine.js)
|
||||||
|
│ └── static/ # 静态资源 (CSS, JS, Uploads)
|
||||||
└── utils/
|
└── utils/
|
||||||
└── json_extractor/ # JSON Schema 抽取工具
|
└── json_extractor/ # JSON Schema 抽取工具
|
||||||
```
|
```
|
||||||
|
|
||||||
## 环境要求
|
## 环境要求
|
||||||
- Python 3.11.4+
|
- Python 3.11.4+
|
||||||
|
- Flask, Jinja2
|
||||||
- Playwright(下载器依赖)
|
- Playwright(下载器依赖)
|
||||||
- pandas、numpy(校验脚本依赖)
|
- pandas, numpy(数据处理依赖)
|
||||||
|
|
||||||
|
|
||||||
## 数据库层级说明
|
## 数据库层级说明
|
||||||
### L1A
|
### L1A
|
||||||
|
|||||||
0
database/L2/L2.db
Normal file
@@ -195,6 +195,13 @@ CREATE TABLE IF NOT EXISTS fact_match_players (
|
|||||||
flash_assists INTEGER,
|
flash_assists INTEGER,
|
||||||
flash_duration REAL,
|
flash_duration REAL,
|
||||||
jump_count INTEGER,
|
jump_count INTEGER,
|
||||||
|
|
||||||
|
-- Utility Usage Stats (Parsed from round details)
|
||||||
|
util_flash_usage INTEGER DEFAULT 0,
|
||||||
|
util_smoke_usage INTEGER DEFAULT 0,
|
||||||
|
util_molotov_usage INTEGER DEFAULT 0,
|
||||||
|
util_he_usage INTEGER DEFAULT 0,
|
||||||
|
util_decoy_usage INTEGER DEFAULT 0,
|
||||||
damage_total INTEGER,
|
damage_total INTEGER,
|
||||||
damage_received INTEGER,
|
damage_received INTEGER,
|
||||||
damage_receive INTEGER,
|
damage_receive INTEGER,
|
||||||
@@ -365,6 +372,14 @@ CREATE TABLE IF NOT EXISTS fact_match_players_t (
|
|||||||
year TEXT,
|
year TEXT,
|
||||||
sts_raw TEXT,
|
sts_raw TEXT,
|
||||||
level_info_raw TEXT,
|
level_info_raw TEXT,
|
||||||
|
|
||||||
|
-- Utility Usage Stats (Parsed from round details)
|
||||||
|
util_flash_usage INTEGER DEFAULT 0,
|
||||||
|
util_smoke_usage INTEGER DEFAULT 0,
|
||||||
|
util_molotov_usage INTEGER DEFAULT 0,
|
||||||
|
util_he_usage INTEGER DEFAULT 0,
|
||||||
|
util_decoy_usage INTEGER DEFAULT 0,
|
||||||
|
|
||||||
PRIMARY KEY (match_id, steam_id_64),
|
PRIMARY KEY (match_id, steam_id_64),
|
||||||
FOREIGN KEY (match_id) REFERENCES fact_matches(match_id) ON DELETE CASCADE
|
FOREIGN KEY (match_id) REFERENCES fact_matches(match_id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
@@ -466,6 +481,14 @@ CREATE TABLE IF NOT EXISTS fact_match_players_ct (
|
|||||||
year TEXT,
|
year TEXT,
|
||||||
sts_raw TEXT,
|
sts_raw TEXT,
|
||||||
level_info_raw TEXT,
|
level_info_raw TEXT,
|
||||||
|
|
||||||
|
-- Utility Usage Stats (Parsed from round details)
|
||||||
|
util_flash_usage INTEGER DEFAULT 0,
|
||||||
|
util_smoke_usage INTEGER DEFAULT 0,
|
||||||
|
util_molotov_usage INTEGER DEFAULT 0,
|
||||||
|
util_he_usage INTEGER DEFAULT 0,
|
||||||
|
util_decoy_usage INTEGER DEFAULT 0,
|
||||||
|
|
||||||
PRIMARY KEY (match_id, steam_id_64),
|
PRIMARY KEY (match_id, steam_id_64),
|
||||||
FOREIGN KEY (match_id) REFERENCES fact_matches(match_id) ON DELETE CASCADE
|
FOREIGN KEY (match_id) REFERENCES fact_matches(match_id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 可行)
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ CREATE TABLE IF NOT EXISTS dm_player_features (
|
|||||||
-- ==========================================
|
-- ==========================================
|
||||||
basic_avg_rating REAL,
|
basic_avg_rating REAL,
|
||||||
basic_avg_kd REAL,
|
basic_avg_kd REAL,
|
||||||
|
basic_avg_adr REAL,
|
||||||
basic_avg_kast REAL,
|
basic_avg_kast REAL,
|
||||||
basic_avg_rws REAL,
|
basic_avg_rws REAL,
|
||||||
basic_avg_headshot_kills REAL,
|
basic_avg_headshot_kills REAL,
|
||||||
@@ -31,6 +32,10 @@ CREATE TABLE IF NOT EXISTS dm_player_features (
|
|||||||
basic_avg_revenge_kill REAL,
|
basic_avg_revenge_kill REAL,
|
||||||
basic_avg_awp_kill REAL,
|
basic_avg_awp_kill REAL,
|
||||||
basic_avg_jump_count REAL,
|
basic_avg_jump_count REAL,
|
||||||
|
basic_avg_mvps REAL,
|
||||||
|
basic_avg_plants REAL,
|
||||||
|
basic_avg_defuses REAL,
|
||||||
|
basic_avg_flash_assists REAL,
|
||||||
|
|
||||||
-- ==========================================
|
-- ==========================================
|
||||||
-- 1. STA: Stability & Time Series
|
-- 1. STA: Stability & Time Series
|
||||||
@@ -47,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)
|
||||||
@@ -82,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,
|
||||||
|
|
||||||
@@ -99,7 +120,17 @@ CREATE TABLE IF NOT EXISTS dm_player_features (
|
|||||||
util_avg_flash_time REAL,
|
util_avg_flash_time REAL,
|
||||||
util_avg_flash_enemy REAL,
|
util_avg_flash_enemy REAL,
|
||||||
util_avg_flash_team REAL,
|
util_avg_flash_team REAL,
|
||||||
util_usage_rate REAL
|
util_usage_rate REAL,
|
||||||
|
|
||||||
|
-- ==========================================
|
||||||
|
-- 7. Scores (0-100)
|
||||||
|
-- ==========================================
|
||||||
|
score_bat REAL,
|
||||||
|
score_sta REAL,
|
||||||
|
score_hps REAL,
|
||||||
|
score_ptl REAL,
|
||||||
|
score_tct REAL,
|
||||||
|
score_util REAL
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Optional: Detailed per-match feature table for time-series analysis
|
-- Optional: Detailed per-match feature table for time-series analysis
|
||||||
|
|||||||
BIN
database/Web/Web_App.sqlite
Normal file
83
docs/6D_README.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# YRTV Player Capability Model (6-Dimension System)
|
||||||
|
|
||||||
|
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 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.
|
||||||
|
|
||||||
|
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.*
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
## 2. PTL (Pistol Round)
|
||||||
|
*Focus: Proficiency in pistol rounds (R1 & R13).*
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
## 3. HPS (High Pressure)
|
||||||
|
*Focus: Clutching and performing under stress.*
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
## 4. SIDE (Side Proficiency)
|
||||||
|
*Focus: Tactical versatility and side bias.*
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
## 5. UTIL (Utility)
|
||||||
|
*Focus: Strategic use of grenades.*
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
## 6. STA (Stability)
|
||||||
|
*Focus: Consistency and mental resilience.*
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
44
docs/FeatureDemoRDD.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## demo维度:
|
||||||
|
|
||||||
|
### d1、经济管理特征
|
||||||
|
1. 每局平均道具数量与使用率(烟雾、闪光、燃烧弹、手雷)
|
||||||
|
2. 伤害性道具效率(手雷/燃烧弹造成伤害值/投掷次数)
|
||||||
|
3. 细分武器KD(AWP、AK-47、M4A4等)
|
||||||
|
4. 武器选择与回合胜率相关系数(某武器使用时胜率-整体胜率)
|
||||||
|
5. 保枪成功率(需保枪回合中成功保下武器次数/总机会)
|
||||||
|
6. 经济溢出率(每局剩余金钱>3000的回合占比)
|
||||||
|
|
||||||
|
### d2、团队协同特征(后续进行详细设计计算,暂时有较大缺陷)
|
||||||
|
1. 补枪成功次数(队友阵亡后10秒内完成击杀)
|
||||||
|
2. 补枪反应时间(队友阵亡到自身补枪击杀的平均时长)
|
||||||
|
3. 与队友A的补枪成功率(对队友A的补枪成功次数/其阵亡次数)
|
||||||
|
4. 被补枪率(自身阵亡后10秒内被队友补枪次数/总阵亡次数)
|
||||||
|
5. 道具配合得分(被队友闪光致盲后击杀的敌人数量)
|
||||||
|
6. 辅助道具价值(自身烟雾/燃烧弹帮助队友下包/拆包次数)
|
||||||
|
7. 拉枪线贡献(自身阵亡后队友获得多杀的次数)
|
||||||
|
8. 疑似卖队友次数(自身附近队友存活但未补枪的阵亡次数)
|
||||||
|
|
||||||
|
### d3、经济影响力特征(自定义计算方案)
|
||||||
|
1. 累计缴获敌方武器的经济价值(如AWP按4750计算)
|
||||||
|
2. 保枪致胜次数(保下的武器在下一回合帮助获胜的次数)
|
||||||
|
3. 单局经济扭转值(因自身行为导致的双方经济差变化)
|
||||||
|
4. 回合致胜首杀贡献分(首杀为胜利带来的权重分,如5v4优势计0.3分)
|
||||||
|
5. 回合致胜道具贡献分(关键烟雾/闪光为胜利带来的权重分)
|
||||||
|
6. 回合致胜残局贡献分(1vN残局胜利的权重分,1v3+计1分)
|
||||||
|
|
||||||
|
### d4、热图与站位特征(预留demoparser阶段开发)
|
||||||
|
1. 各地图区域击杀数(如Inferno的A区、B区、中路等)
|
||||||
|
2. 各地图区域死亡数(同上区域划分)
|
||||||
|
3. 常用站位区域占比(某区域停留时间/总回合时间)
|
||||||
|
4. 区域对枪胜率(某区域内击杀数/死亡数)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
完整了解代码库与web端需求文档 WebRDD.md ,开始计划开发web端,完成web端的所有需求。
|
||||||
|
注意不需要实现注册登录系统,最好核心是token系统。
|
||||||
|
严格按照需求部分规划开发方案与开发顺序。不要忽略内容。
|
||||||
|
|
||||||
|
utils下还会有哪些需要打包成可快速调用的工具?针对这个项目,你有什么先见?
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
11. 每局2+杀/3+杀/4+杀/5杀次数(多杀)
|
11. 每局2+杀/3+杀/4+杀/5杀次数(多杀)
|
||||||
12. 连续击杀累计次数(连杀)
|
12. 连续击杀累计次数(连杀)
|
||||||
15. **(New) 助攻次数 (assisted_kill)**
|
15. **(New) 助攻次数 (assisted_kill)**
|
||||||
16. **(New) 无伤击杀 (perfect_kill)**
|
16. **(New) 完美击杀 (perfect_kill)**
|
||||||
17. **(New) 复仇击杀 (revenge_kill)**
|
17. **(New) 复仇击杀 (revenge_kill)**
|
||||||
18. **(New) AWP击杀数 (awp_kill)**
|
18. **(New) AWP击杀数 (awp_kill)**
|
||||||
19. **(New) 总跳跃次数 (jump_count)**
|
19. **(New) 总跳跃次数 (jump_count)**
|
||||||
@@ -75,45 +75,6 @@
|
|||||||
4. 每局平均道具数量与使用率(烟雾、闪光、燃烧弹、手雷)
|
4. 每局平均道具数量与使用率(烟雾、闪光、燃烧弹、手雷)
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
## demo维度:
|
|
||||||
|
|
||||||
### d1、经济管理特征
|
|
||||||
1. 每局平均道具数量与使用率(烟雾、闪光、燃烧弹、手雷)
|
|
||||||
2. 伤害性道具效率(手雷/燃烧弹造成伤害值/投掷次数)
|
|
||||||
3. 细分武器KD(AWP、AK-47、M4A4等)
|
|
||||||
4. 武器选择与回合胜率相关系数(某武器使用时胜率-整体胜率)
|
|
||||||
5. 保枪成功率(需保枪回合中成功保下武器次数/总机会)
|
|
||||||
6. 经济溢出率(每局剩余金钱>3000的回合占比)
|
|
||||||
|
|
||||||
### d2、团队协同特征(后续进行详细设计计算,暂时有较大缺陷)
|
|
||||||
1. 补枪成功次数(队友阵亡后10秒内完成击杀)
|
|
||||||
2. 补枪反应时间(队友阵亡到自身补枪击杀的平均时长)
|
|
||||||
3. 与队友A的补枪成功率(对队友A的补枪成功次数/其阵亡次数)
|
|
||||||
4. 被补枪率(自身阵亡后10秒内被队友补枪次数/总阵亡次数)
|
|
||||||
5. 道具配合得分(被队友闪光致盲后击杀的敌人数量)
|
|
||||||
6. 辅助道具价值(自身烟雾/燃烧弹帮助队友下包/拆包次数)
|
|
||||||
7. 拉枪线贡献(自身阵亡后队友获得多杀的次数)
|
|
||||||
8. 疑似卖队友次数(自身附近队友存活但未补枪的阵亡次数)
|
|
||||||
|
|
||||||
### d3、经济影响力特征(自定义计算方案)
|
|
||||||
1. 累计缴获敌方武器的经济价值(如AWP按4750计算)
|
|
||||||
2. 保枪致胜次数(保下的武器在下一回合帮助获胜的次数)
|
|
||||||
3. 单局经济扭转值(因自身行为导致的双方经济差变化)
|
|
||||||
4. 回合致胜首杀贡献分(首杀为胜利带来的权重分,如5v4优势计0.3分)
|
|
||||||
5. 回合致胜道具贡献分(关键烟雾/闪光为胜利带来的权重分)
|
|
||||||
6. 回合致胜残局贡献分(1vN残局胜利的权重分,1v3+计1分)
|
|
||||||
|
|
||||||
### d4、热图与站位特征(预留demoparser阶段开发)
|
|
||||||
1. 各地图区域击杀数(如Inferno的A区、B区、中路等)
|
|
||||||
2. 各地图区域死亡数(同上区域划分)
|
|
||||||
3. 常用站位区域占比(某区域停留时间/总回合时间)
|
|
||||||
4. 区域对枪胜率(某区域内击杀数/死亡数)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 手调1.、指挥手动调节因子(主观评价,0-10分)
|
### 手调1.、指挥手动调节因子(主观评价,0-10分)
|
||||||
1. 沟通量(信息传递频率与有效性)
|
1. 沟通量(信息传递频率与有效性)
|
||||||
2. 辅助决策能力(半区决策建议的合理性)
|
2. 辅助决策能力(半区决策建议的合理性)
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
## 1. 项目概述 (Overview)
|
## 1. 项目概述 (Overview)
|
||||||
|
|
||||||
### 1.1 项目背景
|
### 1.1 项目背景
|
||||||
YRTV 是一个面向 CS2 战队数据洞察与战术研判的 Web 平台。该平台基于现有的 `ETL` 数据管线与 `L2_Main.sqlite` 核心数据库,旨在通过 Web 界面提供可视化的数据查询、战队管理、战术模拟及深度分析功能。
|
YRTV 是一个面向 CS2 战队数据洞察与战术研判的 Web 平台,旨在通过 Web 界面提供可视化的数据查询、战队管理、战术模拟及深度分析功能。
|
||||||
|
|
||||||
### 1.2 核心目标
|
### 1.2 核心目标
|
||||||
* **数据可视化**: 将复杂的 SQLite 比赛数据转化为易读的图表、雷达图和趋势线。
|
* **数据可视化**: 将复杂的 SQLite 比赛数据转化为易读的图表、雷达图和趋势线。
|
||||||
@@ -18,7 +18,7 @@ YRTV 是一个面向 CS2 战队数据洞察与战术研判的 Web 平台。该
|
|||||||
* **L3**: SQLite (`database/L3/L3_Features.sqlite`) - 高级衍生特征 (Read-Only for Web)
|
* **L3**: SQLite (`database/L3/L3_Features.sqlite`) - 高级衍生特征 (Read-Only for Web)
|
||||||
* **Web**: SQLite (`database/Web/Web_App.sqlite`) - [新增] 业务数据 (用户、评论、阵容配置、策略板存档)
|
* **Web**: SQLite (`database/Web/Web_App.sqlite`) - [新增] 业务数据 (用户、评论、阵容配置、策略板存档)
|
||||||
* **模板引擎**: Jinja2 (服务端渲染)
|
* **模板引擎**: Jinja2 (服务端渲染)
|
||||||
* **前端样式**: Tailwind CSS (CDN 引入,快速开发)
|
* **前端样式**: Tailwind CSS (CDN 引入,快速开发) + PC-First 响应式设计 (适配手机、平板与桌面端),主题色紫色,可切换黑白模式。
|
||||||
* **前端交互**:
|
* **前端交互**:
|
||||||
* **图表**: Chart.js / ECharts (雷达图、趋势图)
|
* **图表**: Chart.js / ECharts (雷达图、趋势图)
|
||||||
* **交互**: Alpine.js 或原生 JS (处理模态框、异步请求)
|
* **交互**: Alpine.js 或原生 JS (处理模态框、异步请求)
|
||||||
@@ -36,16 +36,18 @@ yrtv/
|
|||||||
│ ├── app.py # Flask 应用入口
|
│ ├── app.py # Flask 应用入口
|
||||||
│ ├── config.py # 配置文件
|
│ ├── config.py # 配置文件
|
||||||
│ ├── routes/ # 路由模块
|
│ ├── routes/ # 路由模块
|
||||||
│ │ ├── main.py # 首页与通用
|
│ │ ├── main.py # 首页与通用 (Home)
|
||||||
│ │ ├── players.py # 玩家模块
|
│ │ ├── players.py # 玩家模块 (List, Detail, Compare)
|
||||||
│ │ ├── teams.py # 战队模块
|
│ │ ├── teams.py # 战队模块 (Lineup, Stats)
|
||||||
│ │ ├── matches.py # 比赛模块
|
│ │ ├── matches.py # 比赛模块 (List, Detail, Demo)
|
||||||
│ │ ├── tactics.py # 战术与分析模块
|
│ │ ├── tactics.py # 战术模块 (Lineup Builder, Map, Nade)
|
||||||
│ │ └── admin.py # 管理后台
|
│ │ ├── wiki.py # 知识库模块 (Wiki, Docs)
|
||||||
│ ├── services/ # 业务逻辑层 (数据计算)
|
│ │ └── admin.py # 管理后台 (ETL Trigger, User Mgmt)
|
||||||
│ │ ├── stats_service.py # 核心指标计算
|
│ ├── services/ # 业务逻辑层 (连接 L2/L3/Web DB)
|
||||||
│ │ ├── feature_store.py # L3 特征读取与计算
|
│ │ ├── stats_service.py # 基础数据查询 (L2)
|
||||||
│ │ └── etl_trigger.py # ETL 调度
|
│ │ ├── feature_service.py # 高级特征查询 (L3)
|
||||||
|
│ │ ├── wiki_service.py # 知识库管理
|
||||||
|
│ │ └── user_service.py # 用户与评论管理
|
||||||
│ ├── static/ # 静态资源
|
│ ├── static/ # 静态资源
|
||||||
│ │ ├── css/
|
│ │ ├── css/
|
||||||
│ │ ├── js/
|
│ │ ├── js/
|
||||||
@@ -53,25 +55,37 @@ yrtv/
|
|||||||
│ └── templates/ # Jinja2 模板
|
│ └── templates/ # Jinja2 模板
|
||||||
│ ├── base.html
|
│ ├── base.html
|
||||||
│ ├── components/
|
│ ├── components/
|
||||||
│ └── ... (各页面模板)
|
│ ├── home/
|
||||||
|
│ ├── players/
|
||||||
|
│ ├── teams/
|
||||||
|
│ ├── matches/
|
||||||
|
│ ├── tactics/
|
||||||
|
│ ├── wiki/
|
||||||
|
│ └── admin/
|
||||||
├── database/ # 数据存储
|
├── database/ # 数据存储
|
||||||
│ ├── L1A/ # 原始爬虫数据
|
│ ├── L1A/ # 原始爬虫数据
|
||||||
│ ├── L2/ # 结构化事实数据
|
│ ├── L2/ # 结构化事实数据
|
||||||
│ └── L3/ # [新增] 衍生特征库 (Feature Store)
|
│ ├── L3/ # 衍生特征库 (Feature Store)
|
||||||
└── ETL/ # ETL 脚本
|
│ └── Web/ # [新增] 业务数据库 (User, Comment, Wiki)
|
||||||
├── L1A.py
|
└── ETL/ # 数据处理层 (ETL Pipeline)
|
||||||
├── L2_Builder.py
|
├── L1A.py # L1A Ingest
|
||||||
└── L3_FeatureEng.py # [新增] L3 特征工程脚本
|
├── L2_Builder.py # L2 Transform
|
||||||
|
└── L3_Builder.py # L3 Feature Engineering (原 feature_store.py 逻辑)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.2 数据流向
|
### 2.2 数据流向
|
||||||
1. **ETL 层**:
|
1. **ETL 层 (数据处理核心)**:
|
||||||
* L1 (Raw): 爬虫 -> JSON 存储。
|
* L1 (Raw): 爬虫 -> JSON 存储。
|
||||||
* L2 (Fact): JSON -> 清洗/标准化 -> Fact/Dim Tables。
|
* L2 (Fact): JSON -> 清洗/标准化 -> Fact/Dim Tables。
|
||||||
* **L3 (Features)**: L2 -> 聚合/滑窗计算/模型推理 -> Player/Team Derived Features (e.g., 近期状态分, 地图熟练度, 关键局胜率)。
|
* **L3 (Features)**: L2 -> 聚合/滑窗计算/模型推理 -> Player/Team Derived Features。**数据处理逻辑收敛于 ETL 目录下的脚本,Web 端仅负责读取 L2/L3 结果。**
|
||||||
2. **Service 层**: Flask Service 读取 L2 (基础数据) 和 L3 (高级特征),执行业务逻辑。
|
2. **Service 层**: Flask Service 仅负责 SQL 查询与简单的业务组装(如评论关联),不再包含复杂的数据计算逻辑。
|
||||||
3. **View 层**: Jinja2 渲染 HTML,嵌入计算后的数据。
|
3. **View 层**: Jinja2 渲染 HTML。
|
||||||
4. **Client 层**: 浏览器展示页面,JS 处理局部交互。
|
4. **Client 层**: 浏览器交互。
|
||||||
|
|
||||||
|
### 2.3 开发与启动 (Development & Startup)
|
||||||
|
* **启动方式**:
|
||||||
|
* 在项目根目录下运行: `python web/app.py`
|
||||||
|
* 访问地址: `http://127.0.0.1:5000`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -92,10 +106,10 @@ yrtv/
|
|||||||
* **比赛解析器**: 输入 5E 比赛链接,点击按钮触发后台 ETL 任务(异步),前端显示 Loading 状态或 Toast 提示。
|
* **比赛解析器**: 输入 5E 比赛链接,点击按钮触发后台 ETL 任务(异步),前端显示 Loading 状态或 Toast 提示。
|
||||||
|
|
||||||
### 3.2 玩家模块 (Players)
|
### 3.2 玩家模块 (Players)
|
||||||
#### 3.2.1 玩家列表
|
#### 3.2.1 玩家列表 PlayerList
|
||||||
* **筛选/搜索**: 按 ID/昵称搜索,按 K/D、Rating、MVP 等指标排序。
|
* **筛选/搜索**: 按 ID/昵称搜索,按 K/D、Rating、MVP 等指标排序。
|
||||||
* **展示**: 卡片式布局,显示头像、ID、主队、核心数据 (Rating, K/D, ADR)。
|
* **展示**: 卡片式布局,显示头像、ID、主队、核心数据 (Rating, K/D, ADR)。
|
||||||
#### 3.2.2 玩家详情
|
#### 3.2.2 玩家详情 PlayerProfile
|
||||||
* **基础信息**: 头像、SteamID、5E ID、注册时间。可以手动分配Tag。
|
* **基础信息**: 头像、SteamID、5E ID、注册时间。可以手动分配Tag。
|
||||||
* **核心指标**: 赛季平均 Rating, ADR, KAST, 首杀成功率等。
|
* **核心指标**: 赛季平均 Rating, ADR, KAST, 首杀成功率等。
|
||||||
* **能力雷达图**: *计算规则需在 Service 层定义*。
|
* **能力雷达图**: *计算规则需在 Service 层定义*。
|
||||||
@@ -109,11 +123,11 @@ yrtv/
|
|||||||
* **统计概览**: 战队整体胜率、近期战绩、地图胜率分布,个人关键数据。
|
* **统计概览**: 战队整体胜率、近期战绩、地图胜率分布,个人关键数据。
|
||||||
|
|
||||||
### 3.4 比赛模块 (Matches)
|
### 3.4 比赛模块 (Matches)
|
||||||
#### 3.4.1 比赛列表
|
#### 3.4.1 比赛列表 MatchList
|
||||||
* **筛选**: 按地图、日期范围筛选。
|
* **筛选**: 按地图、日期范围筛选。
|
||||||
* **展示**: 列表视图,显示时间、地图、比分、胜负、MVP。
|
* **展示**: 列表视图,显示时间、地图、比分、胜负、MVP。
|
||||||
|
|
||||||
#### 3.4.2 比赛详情
|
#### 3.4.2 比赛详情 MatchDetail
|
||||||
* **头部**: 比分板(CT/T 分数)、地图、时长、Demo 下载链接。
|
* **头部**: 比分板(CT/T 分数)、地图、时长、Demo 下载链接。
|
||||||
* **数据表**: 双方队伍的完整数据表(K, D, A, FK, FD, ADR, Rating, KAST, AWP Kills 等)。
|
* **数据表**: 双方队伍的完整数据表(K, D, A, FK, FD, ADR, Rating, KAST, AWP Kills 等)。
|
||||||
* *利用 `fact_match_players` 中的丰富字段*。
|
* *利用 `fact_match_players` 中的丰富字段*。
|
||||||
@@ -126,7 +140,7 @@ yrtv/
|
|||||||
* **共同经历**: 查询这 5 人共同参与过的比赛场次及胜率。
|
* **共同经历**: 查询这 5 人共同参与过的比赛场次及胜率。
|
||||||
* **协同矩阵**: 选择特定阵容,展示两两之间的协同数据(如:A 补枪 B 的次数,A 与 B 同时在场时的胜率)。
|
* **协同矩阵**: 选择特定阵容,展示两两之间的协同数据(如:A 补枪 B 的次数,A 与 B 同时在场时的胜率)。
|
||||||
* **最佳/短板分析**: 基于历史数据分析该阵容在特定地图上的强弱项。
|
* **最佳/短板分析**: 基于历史数据分析该阵容在特定地图上的强弱项。
|
||||||
#### 3.5.2 数据对比
|
#### 3.5.2 数据对比 Data Center
|
||||||
* **多选对比**: 选择多名玩家,并在同一雷达图/柱状图中对比各项数据。
|
* **多选对比**: 选择多名玩家,并在同一雷达图/柱状图中对比各项数据。
|
||||||
* **地图筛选**: 查看特定玩家在特定地图上的表现差异。
|
* **地图筛选**: 查看特定玩家在特定地图上的表现差异。
|
||||||
#### 3.5.3 道具与策略板 (Grenades & Strategy Board)
|
#### 3.5.3 道具与策略板 (Grenades & Strategy Board)
|
||||||
@@ -159,7 +173,7 @@ yrtv/
|
|||||||
* 上传 demo 文件或修正比赛数据。
|
* 上传 demo 文件或修正比赛数据。
|
||||||
* **配置**: 管理员账号管理、全局公告设置。查看网站访问数等后台统计。
|
* **配置**: 管理员账号管理、全局公告设置。查看网站访问数等后台统计。
|
||||||
|
|
||||||
### 3.7E 管理后台查询工具 (SQL Runner)
|
### 3.8 管理后台查询工具 (SQL Runner)
|
||||||
* **功能**: 提供一个 Web 版的 SQLite 查询窗口。
|
* **功能**: 提供一个 Web 版的 SQLite 查询窗口。
|
||||||
* **限制**: 只读权限(防止 `DROP/DELETE`),仅供高级用户进行自定义数据挖掘。
|
* **限制**: 只读权限(防止 `DROP/DELETE`),仅供高级用户进行自定义数据挖掘。
|
||||||
|
|
||||||
6
requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
Flask
|
||||||
|
pandas
|
||||||
|
numpy
|
||||||
|
playwright
|
||||||
|
gunicorn
|
||||||
|
gevent
|
||||||
1
scripts/README.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
用于测试脚本目录。
|
||||||
214
scripts/analyze_features.py
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import sqlite3
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
import os
|
||||||
|
|
||||||
|
DB_L2_PATH = r'd:\Documents\trae_projects\yrtv\database\L2\L2_Main.sqlite'
|
||||||
|
|
||||||
|
def get_db_connection():
|
||||||
|
conn = sqlite3.connect(DB_L2_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def load_data_and_calculate(conn, min_matches=5):
|
||||||
|
print("Loading Basic Stats...")
|
||||||
|
|
||||||
|
# 1. Basic Stats
|
||||||
|
query_basic = """
|
||||||
|
SELECT
|
||||||
|
steam_id_64,
|
||||||
|
COUNT(*) as matches_played,
|
||||||
|
AVG(rating) as avg_rating,
|
||||||
|
AVG(kd_ratio) as avg_kd,
|
||||||
|
AVG(adr) as avg_adr,
|
||||||
|
AVG(kast) as avg_kast,
|
||||||
|
SUM(first_kill) as total_fk,
|
||||||
|
SUM(first_death) as total_fd,
|
||||||
|
SUM(clutch_1v1) + SUM(clutch_1v2) + SUM(clutch_1v3) + SUM(clutch_1v4) + SUM(clutch_1v5) as total_clutches,
|
||||||
|
SUM(throw_harm) as total_util_dmg,
|
||||||
|
SUM(flash_time) as total_flash_time,
|
||||||
|
SUM(flash_enemy) as total_flash_enemy
|
||||||
|
FROM fact_match_players
|
||||||
|
GROUP BY steam_id_64
|
||||||
|
HAVING COUNT(*) >= ?
|
||||||
|
"""
|
||||||
|
df_basic = pd.read_sql_query(query_basic, conn, params=(min_matches,))
|
||||||
|
|
||||||
|
valid_ids = tuple(df_basic['steam_id_64'].tolist())
|
||||||
|
if not valid_ids:
|
||||||
|
print("No players found.")
|
||||||
|
return None
|
||||||
|
placeholders = ','.join(['?'] * len(valid_ids))
|
||||||
|
|
||||||
|
# 2. Side Stats (T/CT) via Economy Table (which has side info)
|
||||||
|
print("Loading Side Stats via Round Map...")
|
||||||
|
# Map each round+player to a side
|
||||||
|
query_side_map = f"""
|
||||||
|
SELECT match_id, round_num, steam_id_64, side
|
||||||
|
FROM fact_round_player_economy
|
||||||
|
WHERE steam_id_64 IN ({placeholders})
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
df_sides = pd.read_sql_query(query_side_map, conn, params=valid_ids)
|
||||||
|
|
||||||
|
# Get all Kills
|
||||||
|
query_kills = f"""
|
||||||
|
SELECT match_id, round_num, attacker_steam_id as steam_id_64, COUNT(*) as kills
|
||||||
|
FROM fact_round_events
|
||||||
|
WHERE event_type = 'kill'
|
||||||
|
AND attacker_steam_id IN ({placeholders})
|
||||||
|
GROUP BY match_id, round_num, attacker_steam_id
|
||||||
|
"""
|
||||||
|
df_kills = pd.read_sql_query(query_kills, conn, params=valid_ids)
|
||||||
|
|
||||||
|
# Merge to get Kills per Side
|
||||||
|
df_merged = df_kills.merge(df_sides, on=['match_id', 'round_num', 'steam_id_64'], how='inner')
|
||||||
|
|
||||||
|
# Aggregate
|
||||||
|
side_stats = df_merged.groupby(['steam_id_64', 'side'])['kills'].sum().unstack(fill_value=0)
|
||||||
|
side_stats.columns = [f'kills_{c.lower()}' for c in side_stats.columns]
|
||||||
|
|
||||||
|
# Also need deaths to calc KD (approx)
|
||||||
|
# Assuming deaths are in events as victim
|
||||||
|
query_deaths = f"""
|
||||||
|
SELECT match_id, round_num, victim_steam_id as steam_id_64, COUNT(*) as deaths
|
||||||
|
FROM fact_round_events
|
||||||
|
WHERE event_type = 'kill'
|
||||||
|
AND victim_steam_id IN ({placeholders})
|
||||||
|
GROUP BY match_id, round_num, victim_steam_id
|
||||||
|
"""
|
||||||
|
df_deaths = pd.read_sql_query(query_deaths, conn, params=valid_ids)
|
||||||
|
df_merged_d = df_deaths.merge(df_sides, on=['match_id', 'round_num', 'steam_id_64'], how='inner')
|
||||||
|
side_stats_d = df_merged_d.groupby(['steam_id_64', 'side'])['deaths'].sum().unstack(fill_value=0)
|
||||||
|
side_stats_d.columns = [f'deaths_{c.lower()}' for c in side_stats_d.columns]
|
||||||
|
|
||||||
|
# Combine
|
||||||
|
df_side_final = side_stats.join(side_stats_d).fillna(0)
|
||||||
|
df_side_final['ct_kd'] = df_side_final.get('kills_ct', 0) / df_side_final.get('deaths_ct', 1).replace(0, 1)
|
||||||
|
df_side_final['t_kd'] = df_side_final.get('kills_t', 0) / df_side_final.get('deaths_t', 1).replace(0, 1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Side stats failed: {e}")
|
||||||
|
df_side_final = pd.DataFrame({'steam_id_64': list(valid_ids)})
|
||||||
|
|
||||||
|
# 3. PTL (Pistol) via Rounds 1 and 13
|
||||||
|
print("Loading Pistol Stats via Rounds...")
|
||||||
|
query_pistol_kills = f"""
|
||||||
|
SELECT
|
||||||
|
ev.attacker_steam_id as steam_id_64,
|
||||||
|
COUNT(*) as pistol_kills
|
||||||
|
FROM fact_round_events ev
|
||||||
|
WHERE ev.attacker_steam_id IN ({placeholders})
|
||||||
|
AND ev.event_type = 'kill'
|
||||||
|
AND ev.round_num IN (1, 13)
|
||||||
|
GROUP BY ev.attacker_steam_id
|
||||||
|
"""
|
||||||
|
df_ptl = pd.read_sql_query(query_pistol_kills, conn, params=valid_ids)
|
||||||
|
|
||||||
|
# 4. HPS
|
||||||
|
print("Loading HPS Stats...")
|
||||||
|
query_close = f"""
|
||||||
|
SELECT mp.steam_id_64, AVG(mp.rating) as close_match_rating
|
||||||
|
FROM fact_match_players mp
|
||||||
|
JOIN fact_matches m ON mp.match_id = m.match_id
|
||||||
|
WHERE mp.steam_id_64 IN ({placeholders})
|
||||||
|
AND ABS(m.score_team1 - m.score_team2) <= 3
|
||||||
|
GROUP BY mp.steam_id_64
|
||||||
|
"""
|
||||||
|
df_hps = pd.read_sql_query(query_close, conn, params=valid_ids)
|
||||||
|
|
||||||
|
# 5. STA
|
||||||
|
query_sta = f"""
|
||||||
|
SELECT mp.steam_id_64, mp.rating, mp.is_win
|
||||||
|
FROM fact_match_players mp
|
||||||
|
WHERE mp.steam_id_64 IN ({placeholders})
|
||||||
|
"""
|
||||||
|
df_matches = pd.read_sql_query(query_sta, conn, params=valid_ids)
|
||||||
|
sta_data = []
|
||||||
|
for pid, group in df_matches.groupby('steam_id_64'):
|
||||||
|
rating_std = group['rating'].std()
|
||||||
|
win_rating = group[group['is_win']==1]['rating'].mean()
|
||||||
|
loss_rating = group[group['is_win']==0]['rating'].mean()
|
||||||
|
sta_data.append({'steam_id_64': pid, 'rating_std': rating_std, 'win_rating': win_rating, 'loss_rating': loss_rating})
|
||||||
|
df_sta = pd.DataFrame(sta_data)
|
||||||
|
|
||||||
|
# --- Merge All ---
|
||||||
|
df = df_basic.merge(df_side_final, on='steam_id_64', how='left')
|
||||||
|
df = df.merge(df_hps, on='steam_id_64', how='left')
|
||||||
|
df = df.merge(df_ptl, on='steam_id_64', how='left').fillna(0)
|
||||||
|
df = df.merge(df_sta, on='steam_id_64', how='left')
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
def normalize_series(series):
|
||||||
|
min_v = series.min()
|
||||||
|
max_v = series.max()
|
||||||
|
if pd.isna(min_v) or pd.isna(max_v) or min_v == max_v:
|
||||||
|
return pd.Series([50]*len(series), index=series.index)
|
||||||
|
return (series - min_v) / (max_v - min_v) * 100
|
||||||
|
|
||||||
|
def calculate_scores(df):
|
||||||
|
df = df.copy()
|
||||||
|
|
||||||
|
# BAT
|
||||||
|
df['n_rating'] = normalize_series(df['avg_rating'])
|
||||||
|
df['n_kd'] = normalize_series(df['avg_kd'])
|
||||||
|
df['n_adr'] = normalize_series(df['avg_adr'])
|
||||||
|
df['n_kast'] = normalize_series(df['avg_kast'])
|
||||||
|
df['score_BAT'] = 0.4*df['n_rating'] + 0.3*df['n_kd'] + 0.2*df['n_adr'] + 0.1*df['n_kast']
|
||||||
|
|
||||||
|
# STA
|
||||||
|
df['n_std'] = normalize_series(df['rating_std'].fillna(0))
|
||||||
|
df['n_win_r'] = normalize_series(df['win_rating'].fillna(0))
|
||||||
|
df['n_loss_r'] = normalize_series(df['loss_rating'].fillna(0))
|
||||||
|
df['score_STA'] = 0.5*(100 - df['n_std']) + 0.25*df['n_win_r'] + 0.25*df['n_loss_r']
|
||||||
|
|
||||||
|
# UTIL
|
||||||
|
df['n_util_dmg'] = normalize_series(df['total_util_dmg'] / df['matches_played'])
|
||||||
|
df['n_flash'] = normalize_series(df['total_flash_time'] / df['matches_played'])
|
||||||
|
df['score_UTIL'] = 0.6*df['n_util_dmg'] + 0.4*df['n_flash']
|
||||||
|
|
||||||
|
# T/CT (Calculated from Event Logs)
|
||||||
|
df['n_ct_kd'] = normalize_series(df['ct_kd'].fillna(0))
|
||||||
|
df['n_t_kd'] = normalize_series(df['t_kd'].fillna(0))
|
||||||
|
df['score_TCT'] = 0.5*df['n_ct_kd'] + 0.5*df['n_t_kd']
|
||||||
|
|
||||||
|
# HPS
|
||||||
|
df['n_clutch'] = normalize_series(df['total_clutches'] / df['matches_played'])
|
||||||
|
df['n_close_r'] = normalize_series(df['close_match_rating'].fillna(0))
|
||||||
|
df['score_HPS'] = 0.5*df['n_clutch'] + 0.5*df['n_close_r']
|
||||||
|
|
||||||
|
# PTL
|
||||||
|
df['n_pistol'] = normalize_series(df['pistol_kills'] / df['matches_played'])
|
||||||
|
df['score_PTL'] = df['n_pistol']
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
def main():
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
df = load_data_and_calculate(conn)
|
||||||
|
if df is None: return
|
||||||
|
|
||||||
|
# Debug: Print raw stats for checking T/CT issue
|
||||||
|
print("\n--- Raw T/CT Stats Sample ---")
|
||||||
|
if 'ct_kd' in df.columns:
|
||||||
|
print(df[['steam_id_64', 'ct_kd', 't_kd']].head())
|
||||||
|
else:
|
||||||
|
print("CT/KD columns missing")
|
||||||
|
|
||||||
|
results = calculate_scores(df)
|
||||||
|
|
||||||
|
print("\n--- Final Dimension Scores (Top 5 by BAT) ---")
|
||||||
|
cols = ['steam_id_64', 'score_BAT', 'score_STA', 'score_UTIL', 'score_TCT', 'score_HPS', 'score_PTL']
|
||||||
|
print(results[cols].sort_values('score_BAT', ascending=False).head(5))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
304
scripts/analyze_l3_full.py
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import sqlite3
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
import os
|
||||||
|
|
||||||
|
DB_L2_PATH = r'd:\Documents\trae_projects\yrtv\database\L2\L2_Main.sqlite'
|
||||||
|
|
||||||
|
def get_db_connection():
|
||||||
|
conn = sqlite3.connect(DB_L2_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def load_comprehensive_data(conn, min_matches=5):
|
||||||
|
print("Loading Comprehensive Data...")
|
||||||
|
|
||||||
|
# 1. Base Player List & Basic Stats
|
||||||
|
query_basic = """
|
||||||
|
SELECT
|
||||||
|
steam_id_64,
|
||||||
|
COUNT(*) as total_matches,
|
||||||
|
AVG(rating) as basic_avg_rating,
|
||||||
|
AVG(kd_ratio) as basic_avg_kd,
|
||||||
|
AVG(adr) as basic_avg_adr,
|
||||||
|
AVG(kast) as basic_avg_kast,
|
||||||
|
AVG(rws) as basic_avg_rws,
|
||||||
|
SUM(headshot_count) as sum_headshot,
|
||||||
|
SUM(kills) as sum_kills,
|
||||||
|
SUM(deaths) as sum_deaths,
|
||||||
|
SUM(first_kill) as sum_fk,
|
||||||
|
SUM(first_death) as sum_fd,
|
||||||
|
SUM(kill_2) as sum_2k,
|
||||||
|
SUM(kill_3) as sum_3k,
|
||||||
|
SUM(kill_4) as sum_4k,
|
||||||
|
SUM(kill_5) as sum_5k,
|
||||||
|
SUM(assisted_kill) as sum_assist,
|
||||||
|
SUM(perfect_kill) as sum_perfect,
|
||||||
|
SUM(revenge_kill) as sum_revenge,
|
||||||
|
SUM(awp_kill) as sum_awp,
|
||||||
|
SUM(jump_count) as sum_jump,
|
||||||
|
SUM(clutch_1v1)+SUM(clutch_1v2)+SUM(clutch_1v3)+SUM(clutch_1v4)+SUM(clutch_1v5) as sum_clutches,
|
||||||
|
SUM(throw_harm) as sum_util_dmg,
|
||||||
|
SUM(flash_time) as sum_flash_time,
|
||||||
|
SUM(flash_enemy) as sum_flash_enemy,
|
||||||
|
SUM(flash_team) as sum_flash_team
|
||||||
|
FROM fact_match_players
|
||||||
|
GROUP BY steam_id_64
|
||||||
|
HAVING COUNT(*) >= ?
|
||||||
|
"""
|
||||||
|
df = pd.read_sql_query(query_basic, conn, params=(min_matches,))
|
||||||
|
|
||||||
|
valid_ids = tuple(df['steam_id_64'].tolist())
|
||||||
|
if not valid_ids:
|
||||||
|
print("No players found.")
|
||||||
|
return None
|
||||||
|
placeholders = ','.join(['?'] * len(valid_ids))
|
||||||
|
|
||||||
|
# --- Derived Basic Features ---
|
||||||
|
df['basic_headshot_rate'] = df['sum_headshot'] / df['sum_kills'].replace(0, 1)
|
||||||
|
df['basic_avg_headshot_kills'] = df['sum_headshot'] / df['total_matches']
|
||||||
|
df['basic_avg_first_kill'] = df['sum_fk'] / df['total_matches']
|
||||||
|
df['basic_avg_first_death'] = df['sum_fd'] / df['total_matches']
|
||||||
|
df['basic_first_kill_rate'] = df['sum_fk'] / (df['sum_fk'] + df['sum_fd']).replace(0, 1) # Opening Success
|
||||||
|
df['basic_first_death_rate'] = df['sum_fd'] / (df['sum_fk'] + df['sum_fd']).replace(0, 1)
|
||||||
|
df['basic_avg_kill_2'] = df['sum_2k'] / df['total_matches']
|
||||||
|
df['basic_avg_kill_3'] = df['sum_3k'] / df['total_matches']
|
||||||
|
df['basic_avg_kill_4'] = df['sum_4k'] / df['total_matches']
|
||||||
|
df['basic_avg_kill_5'] = df['sum_5k'] / df['total_matches']
|
||||||
|
df['basic_avg_assisted_kill'] = df['sum_assist'] / df['total_matches']
|
||||||
|
df['basic_avg_perfect_kill'] = df['sum_perfect'] / df['total_matches']
|
||||||
|
df['basic_avg_revenge_kill'] = df['sum_revenge'] / df['total_matches']
|
||||||
|
df['basic_avg_awp_kill'] = df['sum_awp'] / df['total_matches']
|
||||||
|
df['basic_avg_jump_count'] = df['sum_jump'] / df['total_matches']
|
||||||
|
|
||||||
|
# 2. STA (Stability) - Detailed
|
||||||
|
print("Calculating STA...")
|
||||||
|
query_sta = f"""
|
||||||
|
SELECT mp.steam_id_64, mp.rating, mp.is_win, m.start_time
|
||||||
|
FROM fact_match_players mp
|
||||||
|
JOIN fact_matches m ON mp.match_id = m.match_id
|
||||||
|
WHERE mp.steam_id_64 IN ({placeholders})
|
||||||
|
ORDER BY mp.steam_id_64, m.start_time
|
||||||
|
"""
|
||||||
|
df_matches = pd.read_sql_query(query_sta, conn, params=valid_ids)
|
||||||
|
|
||||||
|
sta_list = []
|
||||||
|
for pid, group in df_matches.groupby('steam_id_64'):
|
||||||
|
# Last 30
|
||||||
|
last_30 = group.tail(30)
|
||||||
|
sta_last_30 = last_30['rating'].mean()
|
||||||
|
# Win/Loss
|
||||||
|
sta_win = group[group['is_win']==1]['rating'].mean()
|
||||||
|
sta_loss = group[group['is_win']==0]['rating'].mean()
|
||||||
|
# Volatility (Last 10)
|
||||||
|
sta_vol = group.tail(10)['rating'].std()
|
||||||
|
|
||||||
|
# Time Decay (Simulated): Avg rating of 1st match of day vs >3rd match of day
|
||||||
|
# Need date conversion.
|
||||||
|
group['date'] = pd.to_datetime(group['start_time'], unit='s').dt.date
|
||||||
|
daily_counts = group.groupby('date').cumcount()
|
||||||
|
# Early: index 0, Late: index >= 2
|
||||||
|
early_ratings = group[daily_counts == 0]['rating']
|
||||||
|
late_ratings = group[daily_counts >= 2]['rating']
|
||||||
|
|
||||||
|
if len(late_ratings) > 0:
|
||||||
|
sta_fatigue = early_ratings.mean() - late_ratings.mean() # Positive means fatigue (drop)
|
||||||
|
else:
|
||||||
|
sta_fatigue = 0
|
||||||
|
|
||||||
|
sta_list.append({
|
||||||
|
'steam_id_64': pid,
|
||||||
|
'sta_last_30_rating': sta_last_30,
|
||||||
|
'sta_win_rating': sta_win,
|
||||||
|
'sta_loss_rating': sta_loss,
|
||||||
|
'sta_rating_volatility': sta_vol,
|
||||||
|
'sta_fatigue_decay': sta_fatigue
|
||||||
|
})
|
||||||
|
df_sta = pd.DataFrame(sta_list)
|
||||||
|
df = df.merge(df_sta, on='steam_id_64', how='left')
|
||||||
|
|
||||||
|
# 3. BAT (Battle) - Detailed
|
||||||
|
print("Calculating BAT...")
|
||||||
|
# Need Match ELO
|
||||||
|
query_bat = f"""
|
||||||
|
SELECT mp.steam_id_64, mp.kd_ratio, mp.entry_kills, mp.entry_deaths,
|
||||||
|
(SELECT AVG(group_origin_elo) FROM fact_match_teams fmt WHERE fmt.match_id = mp.match_id AND group_origin_elo > 0) as match_elo
|
||||||
|
FROM fact_match_players mp
|
||||||
|
WHERE mp.steam_id_64 IN ({placeholders})
|
||||||
|
"""
|
||||||
|
df_bat_raw = pd.read_sql_query(query_bat, conn, params=valid_ids)
|
||||||
|
|
||||||
|
bat_list = []
|
||||||
|
for pid, group in df_bat_raw.groupby('steam_id_64'):
|
||||||
|
avg_elo = group['match_elo'].mean()
|
||||||
|
if pd.isna(avg_elo): avg_elo = 1500
|
||||||
|
|
||||||
|
high_elo_kd = group[group['match_elo'] > avg_elo]['kd_ratio'].mean()
|
||||||
|
low_elo_kd = group[group['match_elo'] <= avg_elo]['kd_ratio'].mean()
|
||||||
|
|
||||||
|
sum_entry_k = group['entry_kills'].sum()
|
||||||
|
sum_entry_d = group['entry_deaths'].sum()
|
||||||
|
duel_win_rate = sum_entry_k / (sum_entry_k + sum_entry_d) if (sum_entry_k+sum_entry_d) > 0 else 0
|
||||||
|
|
||||||
|
bat_list.append({
|
||||||
|
'steam_id_64': pid,
|
||||||
|
'bat_kd_diff_high_elo': high_elo_kd, # Higher is better
|
||||||
|
'bat_kd_diff_low_elo': low_elo_kd,
|
||||||
|
'bat_avg_duel_win_rate': duel_win_rate
|
||||||
|
})
|
||||||
|
df_bat = pd.DataFrame(bat_list)
|
||||||
|
df = df.merge(df_bat, on='steam_id_64', how='left')
|
||||||
|
|
||||||
|
# 4. HPS (Pressure) - Detailed
|
||||||
|
print("Calculating HPS...")
|
||||||
|
# Complex query for Match Point and Pressure situations
|
||||||
|
# Logic: Round score diff.
|
||||||
|
# Since we don't have round-by-round player stats in L2 easily (economy table is sparse on stats),
|
||||||
|
# We use Matches for "Close Match" and "Comeback"
|
||||||
|
|
||||||
|
# Comeback/Close Match Logic on MATCH level
|
||||||
|
query_hps_match = f"""
|
||||||
|
SELECT mp.steam_id_64, mp.kd_ratio, mp.rating, m.score_team1, m.score_team2, mp.team_id, m.winner_team
|
||||||
|
FROM fact_match_players mp
|
||||||
|
JOIN fact_matches m ON mp.match_id = m.match_id
|
||||||
|
WHERE mp.steam_id_64 IN ({placeholders})
|
||||||
|
"""
|
||||||
|
df_hps_raw = pd.read_sql_query(query_hps_match, conn, params=valid_ids)
|
||||||
|
|
||||||
|
hps_list = []
|
||||||
|
for pid, group in df_hps_raw.groupby('steam_id_64'):
|
||||||
|
# Close Match: Score diff <= 3
|
||||||
|
group['score_diff'] = abs(group['score_team1'] - group['score_team2'])
|
||||||
|
close_rating = group[group['score_diff'] <= 3]['rating'].mean()
|
||||||
|
|
||||||
|
# Comeback: Won match where score was close?
|
||||||
|
# Actually without round history, we can't define "Comeback" (was behind then won).
|
||||||
|
# We can define "Underdog Win": Won when ELO was lower? Or just Close Win.
|
||||||
|
# Let's use Close Match Rating as primary HPS metric from matches.
|
||||||
|
|
||||||
|
hps_list.append({
|
||||||
|
'steam_id_64': pid,
|
||||||
|
'hps_close_match_rating': close_rating
|
||||||
|
})
|
||||||
|
df_hps = pd.DataFrame(hps_list)
|
||||||
|
|
||||||
|
# HPS Clutch (from Basic)
|
||||||
|
df['hps_clutch_rate'] = df['sum_clutches'] / df['total_matches']
|
||||||
|
|
||||||
|
df = df.merge(df_hps, on='steam_id_64', how='left')
|
||||||
|
|
||||||
|
# 5. PTL (Pistol)
|
||||||
|
print("Calculating PTL...")
|
||||||
|
# R1/R13 Kills
|
||||||
|
query_ptl = f"""
|
||||||
|
SELECT ev.attacker_steam_id as steam_id_64, COUNT(*) as pistol_kills
|
||||||
|
FROM fact_round_events ev
|
||||||
|
WHERE ev.event_type = 'kill' AND ev.round_num IN (1, 13)
|
||||||
|
AND ev.attacker_steam_id IN ({placeholders})
|
||||||
|
GROUP BY ev.attacker_steam_id
|
||||||
|
"""
|
||||||
|
df_ptl = pd.read_sql_query(query_ptl, conn, params=valid_ids)
|
||||||
|
# Pistol Win Rate (Team)
|
||||||
|
# Need to join rounds. Too slow?
|
||||||
|
# Simplify: Just use Pistol Kills per Match (normalized)
|
||||||
|
|
||||||
|
df = df.merge(df_ptl, on='steam_id_64', how='left')
|
||||||
|
df['ptl_pistol_kills_per_match'] = df['pistol_kills'] / df['total_matches']
|
||||||
|
|
||||||
|
# 6. T/CT
|
||||||
|
print("Calculating T/CT...")
|
||||||
|
query_ct = f"SELECT steam_id_64, AVG(rating) as ct_rating, AVG(kd_ratio) as ct_kd FROM fact_match_players_ct WHERE steam_id_64 IN ({placeholders}) GROUP BY steam_id_64"
|
||||||
|
query_t = f"SELECT steam_id_64, AVG(rating) as t_rating, AVG(kd_ratio) as t_kd FROM fact_match_players_t WHERE steam_id_64 IN ({placeholders}) GROUP BY steam_id_64"
|
||||||
|
df_ct = pd.read_sql_query(query_ct, conn, params=valid_ids)
|
||||||
|
df_t = pd.read_sql_query(query_t, conn, params=valid_ids)
|
||||||
|
df = df.merge(df_ct, on='steam_id_64', how='left').merge(df_t, on='steam_id_64', how='left')
|
||||||
|
|
||||||
|
# 7. UTIL
|
||||||
|
print("Calculating UTIL...")
|
||||||
|
df['util_avg_dmg'] = df['sum_util_dmg'] / df['total_matches']
|
||||||
|
df['util_avg_flash_time'] = df['sum_flash_time'] / df['total_matches']
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
def normalize(series):
|
||||||
|
s = series.fillna(series.mean())
|
||||||
|
if s.max() == s.min(): return pd.Series([50]*len(s), index=s.index)
|
||||||
|
return (s - s.min()) / (s.max() - s.min()) * 100
|
||||||
|
|
||||||
|
def calculate_full_scores(df):
|
||||||
|
df = df.copy()
|
||||||
|
|
||||||
|
# --- BAT Calculation ---
|
||||||
|
# Components: Rating, KD, ADR, KAST, Duel Win Rate, High ELO KD
|
||||||
|
# Weights: Rating(30), KD(20), ADR(15), KAST(10), Duel(15), HighELO(10)
|
||||||
|
df['n_bat_rating'] = normalize(df['basic_avg_rating'])
|
||||||
|
df['n_bat_kd'] = normalize(df['basic_avg_kd'])
|
||||||
|
df['n_bat_adr'] = normalize(df['basic_avg_adr'])
|
||||||
|
df['n_bat_kast'] = normalize(df['basic_avg_kast'])
|
||||||
|
df['n_bat_duel'] = normalize(df['bat_avg_duel_win_rate'])
|
||||||
|
df['n_bat_high'] = normalize(df['bat_kd_diff_high_elo'])
|
||||||
|
|
||||||
|
df['score_BAT'] = (0.3*df['n_bat_rating'] + 0.2*df['n_bat_kd'] + 0.15*df['n_bat_adr'] +
|
||||||
|
0.1*df['n_bat_kast'] + 0.15*df['n_bat_duel'] + 0.1*df['n_bat_high'])
|
||||||
|
|
||||||
|
# --- STA Calculation ---
|
||||||
|
# Components: Volatility (Neg), Win Rating, Loss Rating, Fatigue (Neg)
|
||||||
|
# Weights: Consistency(40), WinPerf(20), LossPerf(30), Fatigue(10)
|
||||||
|
df['n_sta_vol'] = normalize(df['sta_rating_volatility']) # Lower is better -> 100 - X
|
||||||
|
df['n_sta_win'] = normalize(df['sta_win_rating'])
|
||||||
|
df['n_sta_loss'] = normalize(df['sta_loss_rating'])
|
||||||
|
df['n_sta_fat'] = normalize(df['sta_fatigue_decay']) # Lower (less drop) is better -> 100 - X
|
||||||
|
|
||||||
|
df['score_STA'] = (0.4*(100-df['n_sta_vol']) + 0.2*df['n_sta_win'] +
|
||||||
|
0.3*df['n_sta_loss'] + 0.1*(100-df['n_sta_fat']))
|
||||||
|
|
||||||
|
# --- HPS Calculation ---
|
||||||
|
# Components: Clutch Rate, Close Match Rating
|
||||||
|
df['n_hps_clutch'] = normalize(df['hps_clutch_rate'])
|
||||||
|
df['n_hps_close'] = normalize(df['hps_close_match_rating'])
|
||||||
|
|
||||||
|
df['score_HPS'] = 0.5*df['n_hps_clutch'] + 0.5*df['n_hps_close']
|
||||||
|
|
||||||
|
# --- PTL Calculation ---
|
||||||
|
# Components: Pistol Kills/Match
|
||||||
|
df['score_PTL'] = normalize(df['ptl_pistol_kills_per_match'])
|
||||||
|
|
||||||
|
# --- T/CT Calculation ---
|
||||||
|
# Components: CT Rating, T Rating
|
||||||
|
df['n_ct'] = normalize(df['ct_rating'])
|
||||||
|
df['n_t'] = normalize(df['t_rating'])
|
||||||
|
df['score_TCT'] = 0.5*df['n_ct'] + 0.5*df['n_t']
|
||||||
|
|
||||||
|
# --- UTIL Calculation ---
|
||||||
|
# Components: Dmg, Flash Time
|
||||||
|
df['n_util_dmg'] = normalize(df['util_avg_dmg'])
|
||||||
|
df['n_util_flash'] = normalize(df['util_avg_flash_time'])
|
||||||
|
df['score_UTIL'] = 0.6*df['n_util_dmg'] + 0.4*df['n_util_flash']
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
def main():
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
df = load_comprehensive_data(conn)
|
||||||
|
if df is None: return
|
||||||
|
|
||||||
|
results = calculate_full_scores(df)
|
||||||
|
|
||||||
|
print("\n--- Final Full Scores ---")
|
||||||
|
cols = ['steam_id_64', 'score_BAT', 'score_STA', 'score_UTIL', 'score_TCT', 'score_HPS', 'score_PTL']
|
||||||
|
print(results[cols].sort_values('score_BAT', ascending=False).head(5))
|
||||||
|
|
||||||
|
print("\n--- Available Features Used ---")
|
||||||
|
print("BAT: Rating, KD, ADR, KAST, Duel Win Rate, High ELO Performance")
|
||||||
|
print("STA: Volatility, Win Rating, Loss Rating, Fatigue Decay")
|
||||||
|
print("HPS: Clutch Rate, Close Match Rating")
|
||||||
|
print("PTL: Pistol Kills per Match")
|
||||||
|
print("T/CT: CT Rating, T Rating")
|
||||||
|
print("UTIL: Util Dmg, Flash Duration")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
499
scripts/analyze_l3_ultimate.py
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
import sqlite3
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
import os
|
||||||
|
|
||||||
|
DB_L2_PATH = r'd:\Documents\trae_projects\yrtv\database\L2\L2_Main.sqlite'
|
||||||
|
|
||||||
|
def get_db_connection():
|
||||||
|
conn = sqlite3.connect(DB_L2_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def safe_div(a, b):
|
||||||
|
if b == 0: return 0
|
||||||
|
return a / b
|
||||||
|
|
||||||
|
def load_and_calculate_ultimate(conn, min_matches=5):
|
||||||
|
print("Loading Ultimate Data Set...")
|
||||||
|
|
||||||
|
# 1. Basic Stats (Already have)
|
||||||
|
query_basic = """
|
||||||
|
SELECT
|
||||||
|
steam_id_64,
|
||||||
|
COUNT(*) as matches_played,
|
||||||
|
SUM(round_total) as rounds_played,
|
||||||
|
AVG(rating) as basic_avg_rating,
|
||||||
|
AVG(kd_ratio) as basic_avg_kd,
|
||||||
|
AVG(adr) as basic_avg_adr,
|
||||||
|
AVG(kast) as basic_avg_kast,
|
||||||
|
AVG(rws) as basic_avg_rws,
|
||||||
|
SUM(headshot_count) as sum_hs,
|
||||||
|
SUM(kills) as sum_kills,
|
||||||
|
SUM(deaths) as sum_deaths,
|
||||||
|
SUM(first_kill) as sum_fk,
|
||||||
|
SUM(first_death) as sum_fd,
|
||||||
|
SUM(clutch_1v1) as sum_1v1,
|
||||||
|
SUM(clutch_1v2) as sum_1v2,
|
||||||
|
SUM(clutch_1v3) + SUM(clutch_1v4) + SUM(clutch_1v5) as sum_1v3p,
|
||||||
|
SUM(kill_2) as sum_2k,
|
||||||
|
SUM(kill_3) as sum_3k,
|
||||||
|
SUM(kill_4) as sum_4k,
|
||||||
|
SUM(kill_5) as sum_5k,
|
||||||
|
SUM(assisted_kill) as sum_assist,
|
||||||
|
SUM(perfect_kill) as sum_perfect,
|
||||||
|
SUM(revenge_kill) as sum_revenge,
|
||||||
|
SUM(awp_kill) as sum_awp,
|
||||||
|
SUM(jump_count) as sum_jump,
|
||||||
|
SUM(throw_harm) as sum_util_dmg,
|
||||||
|
SUM(flash_time) as sum_flash_time,
|
||||||
|
SUM(flash_enemy) as sum_flash_enemy,
|
||||||
|
SUM(flash_team) as sum_flash_team
|
||||||
|
FROM fact_match_players
|
||||||
|
GROUP BY steam_id_64
|
||||||
|
HAVING COUNT(*) >= ?
|
||||||
|
"""
|
||||||
|
df = pd.read_sql_query(query_basic, conn, params=(min_matches,))
|
||||||
|
valid_ids = tuple(df['steam_id_64'].tolist())
|
||||||
|
if not valid_ids: return None
|
||||||
|
placeholders = ','.join(['?'] * len(valid_ids))
|
||||||
|
|
||||||
|
# --- Basic Derived ---
|
||||||
|
df['basic_headshot_rate'] = df['sum_hs'] / df['sum_kills'].replace(0, 1)
|
||||||
|
df['basic_avg_headshot_kills'] = df['sum_hs'] / df['matches_played']
|
||||||
|
df['basic_avg_first_kill'] = df['sum_fk'] / df['matches_played']
|
||||||
|
df['basic_avg_first_death'] = df['sum_fd'] / df['matches_played']
|
||||||
|
df['basic_first_kill_rate'] = df['sum_fk'] / (df['sum_fk'] + df['sum_fd']).replace(0, 1)
|
||||||
|
df['basic_first_death_rate'] = df['sum_fd'] / (df['sum_fk'] + df['sum_fd']).replace(0, 1)
|
||||||
|
df['basic_avg_kill_2'] = df['sum_2k'] / df['matches_played']
|
||||||
|
df['basic_avg_kill_3'] = df['sum_3k'] / df['matches_played']
|
||||||
|
df['basic_avg_kill_4'] = df['sum_4k'] / df['matches_played']
|
||||||
|
df['basic_avg_kill_5'] = df['sum_5k'] / df['matches_played']
|
||||||
|
df['basic_avg_assisted_kill'] = df['sum_assist'] / df['matches_played']
|
||||||
|
df['basic_avg_perfect_kill'] = df['sum_perfect'] / df['matches_played']
|
||||||
|
df['basic_avg_revenge_kill'] = df['sum_revenge'] / df['matches_played']
|
||||||
|
df['basic_avg_awp_kill'] = df['sum_awp'] / df['matches_played']
|
||||||
|
df['basic_avg_jump_count'] = df['sum_jump'] / df['matches_played']
|
||||||
|
|
||||||
|
# 2. STA - Detailed Time Series
|
||||||
|
print("Calculating STA (Detailed)...")
|
||||||
|
query_sta = f"""
|
||||||
|
SELECT mp.steam_id_64, mp.rating, mp.is_win, m.start_time, m.duration
|
||||||
|
FROM fact_match_players mp
|
||||||
|
JOIN fact_matches m ON mp.match_id = m.match_id
|
||||||
|
WHERE mp.steam_id_64 IN ({placeholders})
|
||||||
|
ORDER BY mp.steam_id_64, m.start_time
|
||||||
|
"""
|
||||||
|
df_matches = pd.read_sql_query(query_sta, conn, params=valid_ids)
|
||||||
|
|
||||||
|
sta_list = []
|
||||||
|
for pid, group in df_matches.groupby('steam_id_64'):
|
||||||
|
group = group.sort_values('start_time')
|
||||||
|
# Last 30
|
||||||
|
last_30 = group.tail(30)
|
||||||
|
sta_last_30 = last_30['rating'].mean()
|
||||||
|
# Win/Loss
|
||||||
|
sta_win = group[group['is_win']==1]['rating'].mean()
|
||||||
|
sta_loss = group[group['is_win']==0]['rating'].mean()
|
||||||
|
# Volatility
|
||||||
|
sta_vol = group.tail(10)['rating'].std()
|
||||||
|
# Time Correlation (Duration vs Rating)
|
||||||
|
sta_time_corr = group['duration'].corr(group['rating']) if len(group) > 2 else 0
|
||||||
|
# Fatigue
|
||||||
|
group['date'] = pd.to_datetime(group['start_time'], unit='s').dt.date
|
||||||
|
daily = group.groupby('date')['rating'].agg(['first', 'last', 'count'])
|
||||||
|
daily_fatigue = daily[daily['count'] >= 3]
|
||||||
|
if len(daily_fatigue) > 0:
|
||||||
|
fatigue_decay = (daily_fatigue['first'] - daily_fatigue['last']).mean()
|
||||||
|
else:
|
||||||
|
fatigue_decay = 0
|
||||||
|
|
||||||
|
sta_list.append({
|
||||||
|
'steam_id_64': pid,
|
||||||
|
'sta_last_30_rating': sta_last_30,
|
||||||
|
'sta_win_rating': sta_win,
|
||||||
|
'sta_loss_rating': sta_loss,
|
||||||
|
'sta_rating_volatility': sta_vol,
|
||||||
|
'sta_time_rating_corr': sta_time_corr,
|
||||||
|
'sta_fatigue_decay': fatigue_decay
|
||||||
|
})
|
||||||
|
df = df.merge(pd.DataFrame(sta_list), on='steam_id_64', how='left')
|
||||||
|
|
||||||
|
# 3. BAT - Distance & Advanced
|
||||||
|
print("Calculating BAT (Distance & Context)...")
|
||||||
|
# Distance Logic: Get all kills with positions
|
||||||
|
# We need to map positions.
|
||||||
|
query_dist = f"""
|
||||||
|
SELECT attacker_steam_id as steam_id_64,
|
||||||
|
attacker_pos_x, attacker_pos_y, attacker_pos_z,
|
||||||
|
victim_pos_x, victim_pos_y, victim_pos_z
|
||||||
|
FROM fact_round_events
|
||||||
|
WHERE event_type = 'kill'
|
||||||
|
AND attacker_steam_id IN ({placeholders})
|
||||||
|
AND attacker_pos_x IS NOT NULL AND victim_pos_x IS NOT NULL
|
||||||
|
"""
|
||||||
|
# Note: This might be heavy. If memory issue, sample or chunk.
|
||||||
|
try:
|
||||||
|
df_dist = pd.read_sql_query(query_dist, conn, params=valid_ids)
|
||||||
|
if not df_dist.empty:
|
||||||
|
# Calc Euclidian Distance
|
||||||
|
df_dist['dist'] = np.sqrt(
|
||||||
|
(df_dist['attacker_pos_x'] - df_dist['victim_pos_x'])**2 +
|
||||||
|
(df_dist['attacker_pos_y'] - df_dist['victim_pos_y'])**2 +
|
||||||
|
(df_dist['attacker_pos_z'] - df_dist['victim_pos_z'])**2
|
||||||
|
)
|
||||||
|
# Units: 1 unit ~ 1 inch.
|
||||||
|
# Close: < 500 (~12m)
|
||||||
|
# Mid: 500 - 1500 (~12m - 38m)
|
||||||
|
# Far: > 1500
|
||||||
|
df_dist['is_close'] = df_dist['dist'] < 500
|
||||||
|
df_dist['is_mid'] = (df_dist['dist'] >= 500) & (df_dist['dist'] <= 1500)
|
||||||
|
df_dist['is_far'] = df_dist['dist'] > 1500
|
||||||
|
|
||||||
|
bat_dist = df_dist.groupby('steam_id_64').agg({
|
||||||
|
'is_close': 'mean', # % of kills that are close
|
||||||
|
'is_mid': 'mean',
|
||||||
|
'is_far': 'mean'
|
||||||
|
}).reset_index()
|
||||||
|
bat_dist.columns = ['steam_id_64', 'bat_kill_share_close', 'bat_kill_share_mid', 'bat_kill_share_far']
|
||||||
|
|
||||||
|
# Note: "Win Rate" by distance requires Deaths by distance.
|
||||||
|
# We can try to get deaths too, but for now Share of Kills is a good proxy for "Preference/Style"
|
||||||
|
# To get "Win Rate", we need to know how many duels occurred at that distance.
|
||||||
|
# Approximation: Win Rate = Kills_at_dist / (Kills_at_dist + Deaths_at_dist)
|
||||||
|
|
||||||
|
# Fetch Deaths
|
||||||
|
query_dist_d = f"""
|
||||||
|
SELECT victim_steam_id as steam_id_64,
|
||||||
|
attacker_pos_x, attacker_pos_y, attacker_pos_z,
|
||||||
|
victim_pos_x, victim_pos_y, victim_pos_z
|
||||||
|
FROM fact_round_events
|
||||||
|
WHERE event_type = 'kill'
|
||||||
|
AND victim_steam_id IN ({placeholders})
|
||||||
|
AND attacker_pos_x IS NOT NULL AND victim_pos_x IS NOT NULL
|
||||||
|
"""
|
||||||
|
df_dist_d = pd.read_sql_query(query_dist_d, conn, params=valid_ids)
|
||||||
|
df_dist_d['dist'] = np.sqrt(
|
||||||
|
(df_dist_d['attacker_pos_x'] - df_dist_d['victim_pos_x'])**2 +
|
||||||
|
(df_dist_d['attacker_pos_y'] - df_dist_d['victim_pos_y'])**2 +
|
||||||
|
(df_dist_d['attacker_pos_z'] - df_dist_d['victim_pos_z'])**2
|
||||||
|
)
|
||||||
|
|
||||||
|
# Aggregate Kills Counts
|
||||||
|
k_counts = df_dist.groupby('steam_id_64').agg(
|
||||||
|
k_close=('is_close', 'sum'),
|
||||||
|
k_mid=('is_mid', 'sum'),
|
||||||
|
k_far=('is_far', 'sum')
|
||||||
|
)
|
||||||
|
# Aggregate Deaths Counts
|
||||||
|
df_dist_d['is_close'] = df_dist_d['dist'] < 500
|
||||||
|
df_dist_d['is_mid'] = (df_dist_d['dist'] >= 500) & (df_dist_d['dist'] <= 1500)
|
||||||
|
df_dist_d['is_far'] = df_dist_d['dist'] > 1500
|
||||||
|
d_counts = df_dist_d.groupby('steam_id_64').agg(
|
||||||
|
d_close=('is_close', 'sum'),
|
||||||
|
d_mid=('is_mid', 'sum'),
|
||||||
|
d_far=('is_far', 'sum')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Merge
|
||||||
|
bat_rates = k_counts.join(d_counts, how='outer').fillna(0)
|
||||||
|
bat_rates['bat_win_rate_close'] = bat_rates['k_close'] / (bat_rates['k_close'] + bat_rates['d_close']).replace(0, 1)
|
||||||
|
bat_rates['bat_win_rate_mid'] = bat_rates['k_mid'] / (bat_rates['k_mid'] + bat_rates['d_mid']).replace(0, 1)
|
||||||
|
bat_rates['bat_win_rate_far'] = bat_rates['k_far'] / (bat_rates['k_far'] + bat_rates['d_far']).replace(0, 1)
|
||||||
|
bat_rates['bat_win_rate_vs_all'] = (bat_rates['k_close']+bat_rates['k_mid']+bat_rates['k_far']) / (bat_rates['k_close']+bat_rates['d_close']+bat_rates['k_mid']+bat_rates['d_mid']+bat_rates['k_far']+bat_rates['d_far']).replace(0, 1)
|
||||||
|
|
||||||
|
df = df.merge(bat_rates[['bat_win_rate_close', 'bat_win_rate_mid', 'bat_win_rate_far', 'bat_win_rate_vs_all']], on='steam_id_64', how='left')
|
||||||
|
else:
|
||||||
|
print("No position data found.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Dist calculation error: {e}")
|
||||||
|
|
||||||
|
# High/Low ELO KD
|
||||||
|
query_elo = f"""
|
||||||
|
SELECT mp.steam_id_64, mp.kd_ratio,
|
||||||
|
(SELECT AVG(group_origin_elo) FROM fact_match_teams fmt WHERE fmt.match_id = mp.match_id AND group_origin_elo > 0) as elo
|
||||||
|
FROM fact_match_players mp
|
||||||
|
WHERE mp.steam_id_64 IN ({placeholders})
|
||||||
|
"""
|
||||||
|
df_elo = pd.read_sql_query(query_elo, conn, params=valid_ids)
|
||||||
|
elo_list = []
|
||||||
|
for pid, group in df_elo.groupby('steam_id_64'):
|
||||||
|
avg = group['elo'].mean()
|
||||||
|
if pd.isna(avg): avg = 1000
|
||||||
|
elo_list.append({
|
||||||
|
'steam_id_64': pid,
|
||||||
|
'bat_kd_diff_high_elo': group[group['elo'] > avg]['kd_ratio'].mean(),
|
||||||
|
'bat_kd_diff_low_elo': group[group['elo'] <= avg]['kd_ratio'].mean()
|
||||||
|
})
|
||||||
|
df = df.merge(pd.DataFrame(elo_list), on='steam_id_64', how='left')
|
||||||
|
|
||||||
|
# Avg Duel Freq
|
||||||
|
df['bat_avg_duel_freq'] = (df['sum_fk'] + df['sum_fd']) / df['rounds_played']
|
||||||
|
|
||||||
|
# 4. HPS - High Pressure Contexts
|
||||||
|
print("Calculating HPS (Contexts)...")
|
||||||
|
# We need round-by-round score evolution.
|
||||||
|
# Join rounds and economy(side) and matches
|
||||||
|
query_hps_ctx = f"""
|
||||||
|
SELECT r.match_id, r.round_num, r.ct_score, r.t_score, r.winner_side,
|
||||||
|
m.score_team1, m.score_team2, m.winner_team,
|
||||||
|
e.steam_id_64, e.side as player_side,
|
||||||
|
(SELECT COUNT(*) FROM fact_round_events ev WHERE ev.match_id=r.match_id AND ev.round_num=r.round_num AND ev.attacker_steam_id=e.steam_id_64 AND ev.event_type='kill') as kills,
|
||||||
|
(SELECT COUNT(*) FROM fact_round_events ev WHERE ev.match_id=r.match_id AND ev.round_num=r.round_num AND ev.victim_steam_id=e.steam_id_64 AND ev.event_type='kill') as deaths
|
||||||
|
FROM fact_rounds r
|
||||||
|
JOIN fact_matches m ON r.match_id = m.match_id
|
||||||
|
JOIN fact_round_player_economy e ON r.match_id = e.match_id AND r.round_num = e.round_num
|
||||||
|
WHERE e.steam_id_64 IN ({placeholders})
|
||||||
|
"""
|
||||||
|
# This is heavy.
|
||||||
|
try:
|
||||||
|
# Optimization: Process per match or use SQL aggregation?
|
||||||
|
# SQL aggregation for specific conditions is better.
|
||||||
|
|
||||||
|
# 4.1 Match Point Win Rate
|
||||||
|
# Condition: (player_side='CT' AND ct_score >= 12) OR (player_side='T' AND t_score >= 12) (Assuming MR12)
|
||||||
|
# Or just max score of match?
|
||||||
|
# Let's approximate: Rounds where total_score >= 23 (MR12) or 29 (MR15)
|
||||||
|
# Actually, let's use: round_num >= match.round_total - 1? No.
|
||||||
|
# Use: Rounds where One Team Score = Match Win Score - 1.
|
||||||
|
# Since we don't know MR12/MR15 per match easily (some are short), check `game_mode`.
|
||||||
|
# Fallback: Rounds where `ct_score` or `t_score` >= 12.
|
||||||
|
|
||||||
|
# 4.2 Pressure Entry Rate (Losing Streak)
|
||||||
|
# Condition: Team score < Enemy score - 3.
|
||||||
|
|
||||||
|
# 4.3 Momentum Multi-kill (Winning Streak)
|
||||||
|
# Condition: Team score > Enemy score + 3.
|
||||||
|
|
||||||
|
# Let's load a simplified dataframe of rounds
|
||||||
|
df_rounds = pd.read_sql_query(query_hps_ctx, conn, params=valid_ids)
|
||||||
|
|
||||||
|
hps_stats = []
|
||||||
|
for pid, group in df_rounds.groupby('steam_id_64'):
|
||||||
|
# Determine Player Team Score and Enemy Team Score
|
||||||
|
# If player_side == 'CT', player_score = ct_score
|
||||||
|
group['my_score'] = np.where(group['player_side'] == 'CT', group['ct_score'], group['t_score'])
|
||||||
|
group['enemy_score'] = np.where(group['player_side'] == 'CT', group['t_score'], group['ct_score'])
|
||||||
|
|
||||||
|
# Match Point (My team or Enemy team at match point)
|
||||||
|
# Simple heuristic: Score >= 12
|
||||||
|
is_match_point = (group['my_score'] >= 12) | (group['enemy_score'] >= 12)
|
||||||
|
mp_rounds = group[is_match_point]
|
||||||
|
# Did we win?
|
||||||
|
# winner_side matches player_side
|
||||||
|
mp_wins = mp_rounds[mp_rounds['winner_side'] == mp_rounds['player_side']]
|
||||||
|
mp_win_rate = len(mp_wins) / len(mp_rounds) if len(mp_rounds) > 0 else 0.5
|
||||||
|
|
||||||
|
# Pressure (Losing by 3+)
|
||||||
|
is_pressure = (group['enemy_score'] - group['my_score']) >= 3
|
||||||
|
# Entry Rate in pressure? Need FK data.
|
||||||
|
# We only loaded kills. Let's use Kills per round in pressure.
|
||||||
|
pressure_kpr = group[is_pressure]['kills'].mean() if len(group[is_pressure]) > 0 else 0
|
||||||
|
|
||||||
|
# Momentum (Winning by 3+)
|
||||||
|
is_momentum = (group['my_score'] - group['enemy_score']) >= 3
|
||||||
|
# Multi-kill rate (>=2 kills)
|
||||||
|
momentum_rounds = group[is_momentum]
|
||||||
|
momentum_multikills = len(momentum_rounds[momentum_rounds['kills'] >= 2])
|
||||||
|
momentum_mk_rate = momentum_multikills / len(momentum_rounds) if len(momentum_rounds) > 0 else 0
|
||||||
|
|
||||||
|
# Comeback KD Diff
|
||||||
|
# Avg KD in Pressure rounds vs Avg KD overall
|
||||||
|
pressure_deaths = group[is_pressure]['deaths'].sum()
|
||||||
|
pressure_kills = group[is_pressure]['kills'].sum()
|
||||||
|
pressure_kd = pressure_kills / pressure_deaths if pressure_deaths > 0 else pressure_kills
|
||||||
|
|
||||||
|
overall_deaths = group['deaths'].sum()
|
||||||
|
overall_kills = group['kills'].sum()
|
||||||
|
overall_kd = overall_kills / overall_deaths if overall_deaths > 0 else overall_kills
|
||||||
|
|
||||||
|
comeback_diff = pressure_kd - overall_kd
|
||||||
|
|
||||||
|
hps_stats.append({
|
||||||
|
'steam_id_64': pid,
|
||||||
|
'hps_match_point_win_rate': mp_win_rate,
|
||||||
|
'hps_pressure_entry_rate': pressure_kpr, # Proxy
|
||||||
|
'hps_momentum_multikill_rate': momentum_mk_rate,
|
||||||
|
'hps_comeback_kd_diff': comeback_diff,
|
||||||
|
'hps_losing_streak_kd_diff': comeback_diff # Same metric
|
||||||
|
})
|
||||||
|
|
||||||
|
df = df.merge(pd.DataFrame(hps_stats), on='steam_id_64', how='left')
|
||||||
|
|
||||||
|
# 4.4 Clutch Win Rates (Detailed)
|
||||||
|
df['hps_clutch_win_rate_1v1'] = df['sum_1v1'] / df['matches_played'] # Normalizing by match for now, ideal is by 1v1 opportunities
|
||||||
|
df['hps_clutch_win_rate_1v2'] = df['sum_1v2'] / df['matches_played']
|
||||||
|
df['hps_clutch_win_rate_1v3_plus'] = df['sum_1v3p'] / df['matches_played']
|
||||||
|
|
||||||
|
# 4.5 Close Match Rating (from previous)
|
||||||
|
# ... (Already have logic in previous script, reusing)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"HPS Error: {e}")
|
||||||
|
|
||||||
|
# 5. PTL - Pistol Detailed
|
||||||
|
print("Calculating PTL...")
|
||||||
|
# Filter Round 1, 13 (and 16 for MR15?)
|
||||||
|
# Just use 1 and 13 (common for MR12)
|
||||||
|
query_ptl = f"""
|
||||||
|
SELECT
|
||||||
|
e.steam_id_64,
|
||||||
|
(SELECT COUNT(*) FROM fact_round_events ev WHERE ev.match_id=e.match_id AND ev.round_num=e.round_num AND ev.attacker_steam_id=e.steam_id_64 AND ev.event_type='kill') as kills,
|
||||||
|
(SELECT COUNT(*) FROM fact_round_events ev WHERE ev.match_id=e.match_id AND ev.round_num=e.round_num AND ev.victim_steam_id=e.steam_id_64 AND ev.event_type='kill') as deaths,
|
||||||
|
r.winner_side, e.side as player_side,
|
||||||
|
e.equipment_value
|
||||||
|
FROM fact_round_player_economy e
|
||||||
|
JOIN fact_rounds r ON e.match_id = r.match_id AND e.round_num = r.round_num
|
||||||
|
WHERE e.steam_id_64 IN ({placeholders})
|
||||||
|
AND e.round_num IN (1, 13)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
df_ptl_raw = pd.read_sql_query(query_ptl, conn, params=valid_ids)
|
||||||
|
ptl_stats = []
|
||||||
|
for pid, group in df_ptl_raw.groupby('steam_id_64'):
|
||||||
|
kills = group['kills'].sum()
|
||||||
|
deaths = group['deaths'].sum()
|
||||||
|
kd = kills / deaths if deaths > 0 else kills
|
||||||
|
|
||||||
|
wins = len(group[group['winner_side'] == group['player_side']])
|
||||||
|
win_rate = wins / len(group)
|
||||||
|
|
||||||
|
multikills = len(group[group['kills'] >= 2])
|
||||||
|
|
||||||
|
# Util Efficiency: Not easy here.
|
||||||
|
|
||||||
|
ptl_stats.append({
|
||||||
|
'steam_id_64': pid,
|
||||||
|
'ptl_pistol_kills': kills, # Total? Or Avg? Schema says REAL. Let's use Avg per Match later.
|
||||||
|
'ptl_pistol_kd': kd,
|
||||||
|
'ptl_pistol_win_rate': win_rate,
|
||||||
|
'ptl_pistol_multikills': multikills
|
||||||
|
})
|
||||||
|
|
||||||
|
df_ptl = pd.DataFrame(ptl_stats)
|
||||||
|
df_ptl['ptl_pistol_kills'] = df_ptl['ptl_pistol_kills'] / df['matches_played'].mean() # Approximate
|
||||||
|
df = df.merge(df_ptl, on='steam_id_64', how='left')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"PTL Error: {e}")
|
||||||
|
|
||||||
|
# 6. T/CT & UTIL (Straightforward)
|
||||||
|
print("Calculating T/CT & UTIL...")
|
||||||
|
# T/CT Side Stats
|
||||||
|
query_side = f"""
|
||||||
|
SELECT steam_id_64,
|
||||||
|
SUM(CASE WHEN side='CT' THEN 1 ELSE 0 END) as ct_rounds,
|
||||||
|
SUM(CASE WHEN side='T' THEN 1 ELSE 0 END) as t_rounds
|
||||||
|
FROM fact_round_player_economy
|
||||||
|
WHERE steam_id_64 IN ({placeholders})
|
||||||
|
GROUP BY steam_id_64
|
||||||
|
"""
|
||||||
|
# Combine with aggregated ratings from fact_match_players_ct/t
|
||||||
|
query_side_r = f"""
|
||||||
|
SELECT steam_id_64, AVG(rating) as ct_rating, AVG(kd_ratio) as ct_kd, SUM(first_kill) as ct_fk
|
||||||
|
FROM fact_match_players_ct WHERE steam_id_64 IN ({placeholders}) GROUP BY steam_id_64
|
||||||
|
"""
|
||||||
|
df_ct = pd.read_sql_query(query_side_r, conn, params=valid_ids)
|
||||||
|
# Similar for T...
|
||||||
|
|
||||||
|
# Merge...
|
||||||
|
|
||||||
|
# UTIL
|
||||||
|
df['util_avg_nade_dmg'] = df['sum_util_dmg'] / df['matches_played']
|
||||||
|
df['util_avg_flash_time'] = df['sum_flash_time'] / df['matches_played']
|
||||||
|
df['util_avg_flash_enemy'] = df['sum_flash_enemy'] / df['matches_played']
|
||||||
|
|
||||||
|
# Fill NaN
|
||||||
|
df = df.fillna(0)
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
def calculate_ultimate_scores(df):
|
||||||
|
# Normalize Helper
|
||||||
|
def n(col):
|
||||||
|
if col not in df.columns: return 50
|
||||||
|
s = df[col]
|
||||||
|
if s.max() == s.min(): return 50
|
||||||
|
return (s - s.min()) / (s.max() - s.min()) * 100
|
||||||
|
|
||||||
|
df = df.copy()
|
||||||
|
|
||||||
|
# 1. BAT: Battle (30%)
|
||||||
|
# Weights: Rating(25), KD(20), ADR(15), Duel(10), HighELO(10), CloseRange(10), MultiKill(10)
|
||||||
|
df['score_BAT'] = (
|
||||||
|
0.25 * n('basic_avg_rating') +
|
||||||
|
0.20 * n('basic_avg_kd') +
|
||||||
|
0.15 * n('basic_avg_adr') +
|
||||||
|
0.10 * n('bat_avg_duel_win_rate') + # Need to ensure col exists
|
||||||
|
0.10 * n('bat_kd_diff_high_elo') +
|
||||||
|
0.10 * n('bat_win_rate_close') +
|
||||||
|
0.10 * n('basic_avg_kill_3') # Multi-kill proxy
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. STA: Stability (15%)
|
||||||
|
# Weights: Volatility(30), LossRating(30), WinRating(20), TimeCorr(10), Fatigue(10)
|
||||||
|
df['score_STA'] = (
|
||||||
|
0.30 * (100 - n('sta_rating_volatility')) +
|
||||||
|
0.30 * n('sta_loss_rating') +
|
||||||
|
0.20 * n('sta_win_rating') +
|
||||||
|
0.10 * (100 - n('sta_time_rating_corr').abs()) + # Closer to 0 is better (independent of duration)
|
||||||
|
0.10 * (100 - n('sta_fatigue_decay'))
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. HPS: Pressure (20%)
|
||||||
|
# Weights: Clutch(30), MatchPoint(20), Comeback(20), PressureEntry(15), CloseMatch(15)
|
||||||
|
df['score_HPS'] = (
|
||||||
|
0.30 * n('sum_1v3p') + # Using high tier clutches
|
||||||
|
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') # Fallback if close match rating missing
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. PTL: Pistol (10%)
|
||||||
|
# Weights: Kills(40), WinRate(30), KD(30)
|
||||||
|
df['score_PTL'] = (
|
||||||
|
0.40 * n('ptl_pistol_kills') +
|
||||||
|
0.30 * n('ptl_pistol_win_rate') +
|
||||||
|
0.30 * n('ptl_pistol_kd')
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. T/CT (15%)
|
||||||
|
# Weights: CT(50), T(50)
|
||||||
|
# Need to load CT/T ratings properly, using basic rating as placeholder if missing
|
||||||
|
df['score_TCT'] = 0.5 * n('basic_avg_rating') + 0.5 * n('basic_avg_rating')
|
||||||
|
|
||||||
|
# 6. UTIL (10%)
|
||||||
|
# Weights: Dmg(50), Flash(30), EnemiesFlashed(20)
|
||||||
|
df['score_UTIL'] = (
|
||||||
|
0.50 * n('util_avg_nade_dmg') +
|
||||||
|
0.30 * n('util_avg_flash_time') +
|
||||||
|
0.20 * n('util_avg_flash_enemy')
|
||||||
|
)
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
def main():
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
df = load_and_calculate_ultimate(conn)
|
||||||
|
if df is None: return
|
||||||
|
|
||||||
|
results = calculate_ultimate_scores(df)
|
||||||
|
|
||||||
|
print("\n--- Ultimate Scores (Top 5 BAT) ---")
|
||||||
|
cols = ['steam_id_64', 'score_BAT', 'score_STA', 'score_HPS', 'score_PTL', 'score_UTIL']
|
||||||
|
print(results[cols].sort_values('score_BAT', ascending=False).head(5))
|
||||||
|
|
||||||
|
# Verify coverage
|
||||||
|
print("\n--- Feature Coverage ---")
|
||||||
|
print(f"Total Columns: {len(results.columns)}")
|
||||||
|
print("BAT Distances:", 'bat_win_rate_close' in results.columns)
|
||||||
|
print("HPS Contexts:", 'hps_match_point_win_rate' in results.columns)
|
||||||
|
print("PTL Detailed:", 'ptl_pistol_kd' in results.columns)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
22
scripts/check_l1a.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
L1A_DB_PATH = r'd:\Documents\trae_projects\yrtv\database\L1A\L1A.sqlite'
|
||||||
|
|
||||||
|
print("Checking L1A...")
|
||||||
|
if os.path.exists(L1A_DB_PATH):
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(L1A_DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
tables = cursor.fetchall()
|
||||||
|
print(f"Tables: {tables}")
|
||||||
|
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM raw_iframe_network")
|
||||||
|
count = cursor.fetchone()[0]
|
||||||
|
print(f"L1A Records: {count}")
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error checking L1A: {e}")
|
||||||
|
else:
|
||||||
|
print(f"L1A DB not found at {L1A_DB_PATH}")
|
||||||
19
scripts/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()
|
||||||
55
scripts/check_l3_variance.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import sqlite3
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Config to match your project structure
|
||||||
|
class Config:
|
||||||
|
DB_L3_PATH = r'd:\Documents\trae_projects\yrtv\database\L3\L3_Features.sqlite'
|
||||||
|
|
||||||
|
def check_variance():
|
||||||
|
db_path = Config.DB_L3_PATH
|
||||||
|
if not os.path.exists(db_path):
|
||||||
|
print(f"L3 DB not found at {db_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
# Read all features
|
||||||
|
df = pd.read_sql_query("SELECT * FROM dm_player_features", conn)
|
||||||
|
|
||||||
|
print(f"Total rows: {len(df)}")
|
||||||
|
if len(df) == 0:
|
||||||
|
print("Table is empty.")
|
||||||
|
return
|
||||||
|
|
||||||
|
numeric_cols = df.select_dtypes(include=['number']).columns
|
||||||
|
|
||||||
|
print("\n--- Variance Analysis ---")
|
||||||
|
for col in numeric_cols:
|
||||||
|
if col in ['steam_id_64']: continue # Skip ID
|
||||||
|
|
||||||
|
# Check for all zeros
|
||||||
|
if (df[col] == 0).all():
|
||||||
|
print(f"[ALL ZERO] {col}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for single value (variance = 0)
|
||||||
|
if df[col].nunique() <= 1:
|
||||||
|
val = df[col].iloc[0]
|
||||||
|
print(f"[SINGLE VAL] {col} = {val}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for mostly zeros
|
||||||
|
zero_pct = (df[col] == 0).mean()
|
||||||
|
if zero_pct > 0.9:
|
||||||
|
print(f"[MOSTLY ZERO] {col} ({zero_pct:.1%} zeros)")
|
||||||
|
|
||||||
|
# Basic stats for valid ones
|
||||||
|
# print(f"{col}: min={df[col].min():.2f}, max={df[col].max():.2f}, mean={df[col].mean():.2f}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
check_variance()
|
||||||
45
scripts/check_round_data.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
match_id = 'g161-n-20251222204652101389654'
|
||||||
|
|
||||||
|
def check_data():
|
||||||
|
conn = sqlite3.connect('database/L2/L2_Main.sqlite')
|
||||||
|
|
||||||
|
print(f"--- Check Match: {match_id} ---")
|
||||||
|
|
||||||
|
# 1. Source Type
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("SELECT data_source_type FROM fact_matches WHERE match_id = ?", (match_id,))
|
||||||
|
row = c.fetchone()
|
||||||
|
if row:
|
||||||
|
print(f"Data Source: {row[0]}")
|
||||||
|
else:
|
||||||
|
print("Match not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. Round Events (Sample)
|
||||||
|
print("\n--- Round Events Sample ---")
|
||||||
|
try:
|
||||||
|
df = pd.read_sql(f"SELECT round_num, event_type, attacker_steam_id, victim_steam_id, weapon FROM fact_round_events WHERE match_id = '{match_id}' LIMIT 5", conn)
|
||||||
|
print(df)
|
||||||
|
if df.empty:
|
||||||
|
print("WARNING: No events found.")
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
|
||||||
|
# 3. Economy (Sample)
|
||||||
|
print("\n--- Economy Sample ---")
|
||||||
|
try:
|
||||||
|
df_eco = pd.read_sql(f"SELECT round_num, steam_id_64, equipment_value FROM fact_round_player_economy WHERE match_id = '{match_id}' LIMIT 5", conn)
|
||||||
|
print(df_eco)
|
||||||
|
if df_eco.empty:
|
||||||
|
print("Info: No economy data (Likely Classic source).")
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
check_data()
|
||||||
63
scripts/check_side_mapping.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import sqlite3
|
||||||
|
import pandas as pd
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Add parent directory
|
||||||
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
from web.config import Config
|
||||||
|
|
||||||
|
def check_mapping():
|
||||||
|
conn = sqlite3.connect(Config.DB_L2_PATH)
|
||||||
|
|
||||||
|
# Join economy and teams via match_id
|
||||||
|
# We need to match steam_id (in eco) to group_uids (in teams)
|
||||||
|
|
||||||
|
# 1. Get Economy R1 samples
|
||||||
|
query_eco = """
|
||||||
|
SELECT match_id, steam_id_64, side
|
||||||
|
FROM fact_round_player_economy
|
||||||
|
WHERE round_num = 1
|
||||||
|
LIMIT 10
|
||||||
|
"""
|
||||||
|
eco_rows = pd.read_sql_query(query_eco, conn)
|
||||||
|
|
||||||
|
if eco_rows.empty:
|
||||||
|
print("No Economy R1 data found.")
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Checking Mapping...")
|
||||||
|
for _, row in eco_rows.iterrows():
|
||||||
|
mid = row['match_id']
|
||||||
|
sid = row['steam_id_64']
|
||||||
|
side = row['side']
|
||||||
|
|
||||||
|
# Get Teams for this match
|
||||||
|
query_teams = "SELECT group_id, group_fh_role, group_uids FROM fact_match_teams WHERE match_id = ?"
|
||||||
|
team_rows = pd.read_sql_query(query_teams, conn, params=(mid,))
|
||||||
|
|
||||||
|
for _, t_row in team_rows.iterrows():
|
||||||
|
# Check if sid is in group_uids (which contains UIDs, not SteamIDs!)
|
||||||
|
# We need to map SteamID -> UID
|
||||||
|
# Use dim_players or fact_match_players
|
||||||
|
q_uid = "SELECT uid FROM fact_match_players WHERE match_id = ? AND steam_id_64 = ?"
|
||||||
|
uid_res = conn.execute(q_uid, (mid, sid)).fetchone()
|
||||||
|
if not uid_res:
|
||||||
|
continue
|
||||||
|
|
||||||
|
uid = str(uid_res[0])
|
||||||
|
group_uids = str(t_row['group_uids']).split(',')
|
||||||
|
|
||||||
|
if uid in group_uids:
|
||||||
|
role = t_row['group_fh_role']
|
||||||
|
print(f"Match {mid}: Steam {sid} (UID {uid}) is on Side {side} in R1.")
|
||||||
|
print(f" Found in Group {t_row['group_id']} with FH Role {role}.")
|
||||||
|
print(f" MAPPING: Role {role} = {side}")
|
||||||
|
break
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
check_mapping()
|
||||||
43
scripts/check_tables.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
DB_PATH = r'd:\Documents\trae_projects\yrtv\database\L2\L2_Main.sqlite'
|
||||||
|
|
||||||
|
def check_tables():
|
||||||
|
if not os.path.exists(DB_PATH):
|
||||||
|
print(f"DB not found: {DB_PATH}")
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
tables = [
|
||||||
|
'dim_players', 'dim_maps',
|
||||||
|
'fact_matches', 'fact_match_teams',
|
||||||
|
'fact_match_players', 'fact_match_players_ct', 'fact_match_players_t',
|
||||||
|
'fact_rounds', 'fact_round_events', 'fact_round_player_economy'
|
||||||
|
]
|
||||||
|
|
||||||
|
print(f"--- L2 Database Check: {DB_PATH} ---")
|
||||||
|
for table in tables:
|
||||||
|
try:
|
||||||
|
cursor.execute(f"SELECT COUNT(*) FROM {table}")
|
||||||
|
count = cursor.fetchone()[0]
|
||||||
|
print(f"{table:<25}: {count:>6} rows")
|
||||||
|
|
||||||
|
# Simple column check for recently added columns
|
||||||
|
if table == 'fact_match_players':
|
||||||
|
cursor.execute(f"PRAGMA table_info({table})")
|
||||||
|
cols = [info[1] for info in cursor.fetchall()]
|
||||||
|
if 'util_flash_usage' in cols:
|
||||||
|
print(f" [OK] util_flash_usage exists")
|
||||||
|
else:
|
||||||
|
print(f" [ERR] util_flash_usage MISSING")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"{table:<25}: [ERROR] {e}")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
check_tables()
|
||||||
63
scripts/debug_db.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import sqlite3
|
||||||
|
import pandas as pd
|
||||||
|
import os
|
||||||
|
|
||||||
|
L2_PATH = r'd:\Documents\trae_projects\yrtv\database\L2\L2_Main.sqlite'
|
||||||
|
WEB_PATH = r'd:\Documents\trae_projects\yrtv\database\Web\Web_App.sqlite'
|
||||||
|
|
||||||
|
def debug_db():
|
||||||
|
# --- L2 Checks ---
|
||||||
|
conn = sqlite3.connect(L2_PATH)
|
||||||
|
|
||||||
|
print("--- Data Source Type Distribution ---")
|
||||||
|
try:
|
||||||
|
df = pd.read_sql_query("SELECT data_source_type, COUNT(*) as cnt FROM fact_matches GROUP BY data_source_type", conn)
|
||||||
|
print(df)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
|
||||||
|
print("\n--- Economy Table Count ---")
|
||||||
|
try:
|
||||||
|
count = conn.execute("SELECT COUNT(*) FROM fact_round_player_economy").fetchone()[0]
|
||||||
|
print(f"Rows: {count}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
|
||||||
|
print("\n--- Check util_flash_usage in fact_match_players ---")
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("PRAGMA table_info(fact_match_players)")
|
||||||
|
cols = [info[1] for info in cursor.fetchall()]
|
||||||
|
if 'util_flash_usage' in cols:
|
||||||
|
print("Column 'util_flash_usage' EXISTS.")
|
||||||
|
nz = conn.execute("SELECT COUNT(*) FROM fact_match_players WHERE util_flash_usage > 0").fetchone()[0]
|
||||||
|
print(f"Rows with util_flash_usage > 0: {nz}")
|
||||||
|
else:
|
||||||
|
print("Column 'util_flash_usage' MISSING.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# --- Web DB Checks ---
|
||||||
|
print("\n--- Web DB Check ---")
|
||||||
|
if not os.path.exists(WEB_PATH):
|
||||||
|
print(f"Web DB not found at {WEB_PATH}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn_web = sqlite3.connect(WEB_PATH)
|
||||||
|
cursor = conn_web.cursor()
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
tables = cursor.fetchall()
|
||||||
|
print(f"Tables: {[t[0] for t in tables]}")
|
||||||
|
|
||||||
|
if 'player_metadata' in [t[0] for t in tables]:
|
||||||
|
count = conn_web.execute("SELECT COUNT(*) FROM player_metadata").fetchone()[0]
|
||||||
|
print(f"player_metadata rows: {count}")
|
||||||
|
conn_web.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error checking Web DB: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
debug_db()
|
||||||
34
scripts/debug_integrity.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||||
|
L2_PATH = os.path.join(BASE_DIR, 'database', 'L2', 'L2_Main.sqlite')
|
||||||
|
|
||||||
|
def check_db_integrity():
|
||||||
|
print(f"Checking DB at: {L2_PATH}")
|
||||||
|
if not os.path.exists(L2_PATH):
|
||||||
|
print("CRITICAL: Database file does not exist!")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(L2_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check integrity
|
||||||
|
print("Running PRAGMA integrity_check...")
|
||||||
|
cursor.execute("PRAGMA integrity_check")
|
||||||
|
print(f"Integrity: {cursor.fetchone()}")
|
||||||
|
|
||||||
|
# Check specific user again
|
||||||
|
cursor.execute("SELECT steam_id_64, username FROM dim_players WHERE username LIKE '%jacky%'")
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
print(f"Direct DB check found {len(rows)} rows matching '%jacky%':")
|
||||||
|
for r in rows:
|
||||||
|
print(r)
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"DB Error: {e}")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
check_db_integrity()
|
||||||
39
scripts/debug_jacky.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||||
|
L2_PATH = os.path.join(BASE_DIR, 'database', 'L2', 'L2_Main.sqlite')
|
||||||
|
|
||||||
|
def check_jacky():
|
||||||
|
print(f"Checking L2 database at: {L2_PATH}")
|
||||||
|
conn = sqlite3.connect(L2_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
search_term = 'jacky'
|
||||||
|
print(f"\nSearching for '%{search_term}%' (Case Insensitive test):")
|
||||||
|
|
||||||
|
# Standard LIKE
|
||||||
|
cursor.execute("SELECT steam_id_64, username FROM dim_players WHERE username LIKE ?", (f'%{search_term}%',))
|
||||||
|
results = cursor.fetchall()
|
||||||
|
print(f"LIKE results: {len(results)}")
|
||||||
|
for r in results:
|
||||||
|
print(r)
|
||||||
|
|
||||||
|
# Case insensitive explicit
|
||||||
|
print("\nSearching with LOWER():")
|
||||||
|
cursor.execute("SELECT steam_id_64, username FROM dim_players WHERE LOWER(username) LIKE LOWER(?)", (f'%{search_term}%',))
|
||||||
|
results_lower = cursor.fetchall()
|
||||||
|
print(f"LOWER() results: {len(results_lower)}")
|
||||||
|
for r in results_lower:
|
||||||
|
print(r)
|
||||||
|
|
||||||
|
# Check jacky0987 specifically
|
||||||
|
print("\nChecking specific username 'jacky0987':")
|
||||||
|
cursor.execute("SELECT steam_id_64, username FROM dim_players WHERE username = 'jacky0987'")
|
||||||
|
specific = cursor.fetchone()
|
||||||
|
print(f"Specific match: {specific}")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
check_jacky()
|
||||||
84
scripts/init_web_db.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Define database path
|
||||||
|
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||||
|
DB_PATH = os.path.join(BASE_DIR, 'database', 'Web', 'Web_App.sqlite')
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
print(f"Initializing Web database at: {DB_PATH}")
|
||||||
|
|
||||||
|
# Create directory if not exists
|
||||||
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Create Tables
|
||||||
|
tables = [
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS team_lineups (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
player_ids_json TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS player_metadata (
|
||||||
|
steam_id_64 TEXT PRIMARY KEY,
|
||||||
|
notes TEXT,
|
||||||
|
tags TEXT,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS strategy_boards (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT,
|
||||||
|
map_name TEXT,
|
||||||
|
data_json TEXT,
|
||||||
|
created_by TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS wiki_pages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
path TEXT UNIQUE,
|
||||||
|
title TEXT,
|
||||||
|
content TEXT,
|
||||||
|
updated_by TEXT,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS comments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT,
|
||||||
|
username TEXT,
|
||||||
|
target_type TEXT,
|
||||||
|
target_id TEXT,
|
||||||
|
content TEXT,
|
||||||
|
likes INTEGER DEFAULT 0,
|
||||||
|
is_hidden INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
]
|
||||||
|
|
||||||
|
for sql in tables:
|
||||||
|
try:
|
||||||
|
cursor.execute(sql)
|
||||||
|
print("Executed SQL successfully.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error executing SQL: {e}")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print("Web database initialized successfully.")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
init_db()
|
||||||
18
scripts/run_rebuild.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Add project root to path
|
||||||
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
project_root = os.path.dirname(current_dir)
|
||||||
|
sys.path.append(project_root)
|
||||||
|
|
||||||
|
from web.services.feature_service import FeatureService
|
||||||
|
|
||||||
|
print("Starting Rebuild...")
|
||||||
|
try:
|
||||||
|
count = FeatureService.rebuild_all_features(min_matches=1)
|
||||||
|
print(f"Rebuild Complete. Processed {count} players.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
14
scripts/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.")
|
||||||
30
scripts/update_l2_schema_utility.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from web.config import Config
|
||||||
|
|
||||||
|
conn = sqlite3.connect(Config.DB_L2_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
columns = [
|
||||||
|
'util_flash_usage',
|
||||||
|
'util_smoke_usage',
|
||||||
|
'util_molotov_usage',
|
||||||
|
'util_he_usage',
|
||||||
|
'util_decoy_usage'
|
||||||
|
]
|
||||||
|
|
||||||
|
for col in columns:
|
||||||
|
try:
|
||||||
|
cursor.execute(f"ALTER TABLE fact_match_players ADD COLUMN {col} INTEGER DEFAULT 0")
|
||||||
|
print(f"Added column {col}")
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
if "duplicate column name" in str(e):
|
||||||
|
print(f"Column {col} already exists.")
|
||||||
|
else:
|
||||||
|
print(f"Error adding {col}: {e}")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
39
scripts/update_l3_schema.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
DB_PATH = r'd:\Documents\trae_projects\yrtv\database\L3\L3_Features.sqlite'
|
||||||
|
|
||||||
|
def add_columns():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check existing columns
|
||||||
|
cursor.execute("PRAGMA table_info(dm_player_features)")
|
||||||
|
columns = [row[1] for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
new_columns = [
|
||||||
|
'score_bat', 'score_sta', 'score_hps', 'score_ptl', 'score_tct', 'score_util',
|
||||||
|
'bat_avg_duel_win_rate', 'bat_kd_diff_high_elo', 'bat_win_rate_close',
|
||||||
|
'sta_time_rating_corr', 'sta_fatigue_decay',
|
||||||
|
'hps_match_point_win_rate', 'hps_comeback_kd_diff', 'hps_pressure_entry_rate',
|
||||||
|
'ptl_pistol_win_rate', 'ptl_pistol_kd',
|
||||||
|
'util_avg_flash_enemy'
|
||||||
|
]
|
||||||
|
|
||||||
|
for col in new_columns:
|
||||||
|
if col not in columns:
|
||||||
|
print(f"Adding column: {col}")
|
||||||
|
try:
|
||||||
|
cursor.execute(f"ALTER TABLE dm_player_features ADD COLUMN {col} REAL")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error adding {col}: {e}")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print("Schema update complete.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if not os.path.exists(DB_PATH):
|
||||||
|
print("L3 DB not found, skipping schema update (will be created by build script).")
|
||||||
|
else:
|
||||||
|
add_columns()
|
||||||
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()
|
||||||
35
web/app.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Add the project root directory to sys.path
|
||||||
|
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||||
|
|
||||||
|
from flask import Flask, render_template
|
||||||
|
from web.config import Config
|
||||||
|
from web.database import close_dbs
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config.from_object(Config)
|
||||||
|
|
||||||
|
app.teardown_appcontext(close_dbs)
|
||||||
|
|
||||||
|
# Register Blueprints
|
||||||
|
from web.routes import main, matches, players, teams, tactics, admin, wiki
|
||||||
|
app.register_blueprint(main.bp)
|
||||||
|
app.register_blueprint(matches.bp)
|
||||||
|
app.register_blueprint(players.bp)
|
||||||
|
app.register_blueprint(teams.bp)
|
||||||
|
app.register_blueprint(tactics.bp)
|
||||||
|
app.register_blueprint(admin.bp)
|
||||||
|
app.register_blueprint(wiki.bp)
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
return render_template('home/index.html')
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app = create_app()
|
||||||
|
app.run(debug=True, port=5000)
|
||||||
11
web/auth.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from functools import wraps
|
||||||
|
from flask import session, redirect, url_for, flash
|
||||||
|
|
||||||
|
def admin_required(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
if session.get('is_admin'):
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
flash('Admin access required', 'warning')
|
||||||
|
return redirect(url_for('admin.login'))
|
||||||
|
return decorated_function
|
||||||
14
web/config.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
SECRET_KEY = os.environ.get('SECRET_KEY') or 'yrtv-secret-key-dev'
|
||||||
|
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||||
|
|
||||||
|
DB_L2_PATH = os.path.join(BASE_DIR, 'database', 'L2', 'L2_Main.sqlite')
|
||||||
|
DB_L3_PATH = os.path.join(BASE_DIR, 'database', 'L3', 'L3_Features.sqlite')
|
||||||
|
DB_WEB_PATH = os.path.join(BASE_DIR, 'database', 'Web', 'Web_App.sqlite')
|
||||||
|
|
||||||
|
ADMIN_TOKEN = 'jackyyang0929'
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
ITEMS_PER_PAGE = 20
|
||||||
47
web/database.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import sqlite3
|
||||||
|
from flask import g
|
||||||
|
from web.config import Config
|
||||||
|
|
||||||
|
def get_db(db_name):
|
||||||
|
"""
|
||||||
|
db_name: 'l2', 'l3', or 'web'
|
||||||
|
"""
|
||||||
|
db_attr = f'db_{db_name}'
|
||||||
|
db = getattr(g, db_attr, None)
|
||||||
|
|
||||||
|
if db is None:
|
||||||
|
if db_name == 'l2':
|
||||||
|
path = Config.DB_L2_PATH
|
||||||
|
elif db_name == 'l3':
|
||||||
|
path = Config.DB_L3_PATH
|
||||||
|
elif db_name == 'web':
|
||||||
|
path = Config.DB_WEB_PATH
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown database: {db_name}")
|
||||||
|
|
||||||
|
# Connect with check_same_thread=False if needed for dev, but default is safer per thread
|
||||||
|
db = sqlite3.connect(path)
|
||||||
|
db.row_factory = sqlite3.Row
|
||||||
|
setattr(g, db_attr, db)
|
||||||
|
|
||||||
|
return db
|
||||||
|
|
||||||
|
def close_dbs(e=None):
|
||||||
|
for db_name in ['l2', 'l3', 'web']:
|
||||||
|
db_attr = f'db_{db_name}'
|
||||||
|
db = getattr(g, db_attr, None)
|
||||||
|
if db is not None:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def query_db(db_name, query, args=(), one=False):
|
||||||
|
cur = get_db(db_name).execute(query, args)
|
||||||
|
rv = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
return (rv[0] if rv else None) if one else rv
|
||||||
|
|
||||||
|
def execute_db(db_name, query, args=()):
|
||||||
|
db = get_db(db_name)
|
||||||
|
cur = db.execute(query, args)
|
||||||
|
db.commit()
|
||||||
|
cur.close()
|
||||||
|
return cur.lastrowid
|
||||||
38
web/debug_roster.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from web.services.web_service import WebService
|
||||||
|
from web.services.stats_service import StatsService
|
||||||
|
import json
|
||||||
|
|
||||||
|
def debug_roster():
|
||||||
|
print("--- Debugging Roster Stats ---")
|
||||||
|
lineups = WebService.get_lineups()
|
||||||
|
if not lineups:
|
||||||
|
print("No lineups found via WebService.")
|
||||||
|
return
|
||||||
|
|
||||||
|
raw_json = lineups[0]['player_ids_json']
|
||||||
|
print(f"Raw JSON: {raw_json}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
roster_ids = json.loads(raw_json)
|
||||||
|
print(f"Parsed IDs (List): {roster_ids}")
|
||||||
|
print(f"Type of first ID: {type(roster_ids[0])}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"JSON Parse Error: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
target_id = roster_ids[0] # Pick first one
|
||||||
|
print(f"\nTesting for Target ID: {target_id} (Type: {type(target_id)})")
|
||||||
|
|
||||||
|
# Test StatsService
|
||||||
|
dist = StatsService.get_roster_stats_distribution(target_id)
|
||||||
|
print(f"\nDistribution Result: {dist}")
|
||||||
|
|
||||||
|
# Test Basic Stats
|
||||||
|
basic = StatsService.get_player_basic_stats(str(target_id))
|
||||||
|
print(f"\nBasic Stats for {target_id}: {basic}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
from web.app import create_app
|
||||||
|
app = create_app()
|
||||||
|
with app.app_context():
|
||||||
|
debug_roster()
|
||||||
75
web/routes/admin.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, session, flash
|
||||||
|
from web.config import Config
|
||||||
|
from web.auth import admin_required
|
||||||
|
from web.database import query_db
|
||||||
|
import os
|
||||||
|
|
||||||
|
bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||||
|
|
||||||
|
@bp.route('/login', methods=['GET', 'POST'])
|
||||||
|
def login():
|
||||||
|
if request.method == 'POST':
|
||||||
|
token = request.form.get('token')
|
||||||
|
if token == Config.ADMIN_TOKEN:
|
||||||
|
session['is_admin'] = True
|
||||||
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
else:
|
||||||
|
flash('Invalid Token', 'error')
|
||||||
|
return render_template('admin/login.html')
|
||||||
|
|
||||||
|
@bp.route('/logout')
|
||||||
|
def logout():
|
||||||
|
session.pop('is_admin', None)
|
||||||
|
return redirect(url_for('main.index'))
|
||||||
|
|
||||||
|
@bp.route('/')
|
||||||
|
@admin_required
|
||||||
|
def dashboard():
|
||||||
|
return render_template('admin/dashboard.html')
|
||||||
|
|
||||||
|
from web.services.etl_service import EtlService
|
||||||
|
|
||||||
|
@bp.route('/trigger_etl', methods=['POST'])
|
||||||
|
@admin_required
|
||||||
|
def trigger_etl():
|
||||||
|
script_name = request.form.get('script')
|
||||||
|
allowed = ['L1A.py', 'L2_Builder.py', 'L3_Builder.py']
|
||||||
|
if script_name not in allowed:
|
||||||
|
return "Invalid script", 400
|
||||||
|
|
||||||
|
success, message = EtlService.run_script(script_name)
|
||||||
|
status_code = 200 if success else 500
|
||||||
|
return message, status_code
|
||||||
|
|
||||||
|
@bp.route('/sql', methods=['GET', 'POST'])
|
||||||
|
@admin_required
|
||||||
|
def sql_runner():
|
||||||
|
result = None
|
||||||
|
error = None
|
||||||
|
query = ""
|
||||||
|
db_name = "l2"
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
query = request.form.get('query')
|
||||||
|
db_name = request.form.get('db_name', 'l2')
|
||||||
|
|
||||||
|
# Safety check
|
||||||
|
forbidden = ['DELETE', 'DROP', 'UPDATE', 'INSERT', 'ALTER', 'GRANT', 'REVOKE']
|
||||||
|
if any(x in query.upper() for x in forbidden):
|
||||||
|
error = "Only SELECT queries allowed in Web Runner."
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
# Enforce limit if not present
|
||||||
|
if 'LIMIT' not in query.upper():
|
||||||
|
query += " LIMIT 50"
|
||||||
|
|
||||||
|
rows = query_db(db_name, query)
|
||||||
|
if rows:
|
||||||
|
columns = rows[0].keys()
|
||||||
|
result = {'columns': columns, 'rows': rows}
|
||||||
|
else:
|
||||||
|
result = {'columns': [], 'rows': []}
|
||||||
|
except Exception as e:
|
||||||
|
error = str(e)
|
||||||
|
|
||||||
|
return render_template('admin/sql.html', result=result, error=error, query=query, db_name=db_name)
|
||||||
35
web/routes/main.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from flask import Blueprint, render_template, request, jsonify
|
||||||
|
from web.services.stats_service import StatsService
|
||||||
|
import time
|
||||||
|
|
||||||
|
bp = Blueprint('main', __name__)
|
||||||
|
|
||||||
|
@bp.route('/')
|
||||||
|
def index():
|
||||||
|
recent_matches = StatsService.get_recent_matches(limit=5)
|
||||||
|
daily_counts = StatsService.get_daily_match_counts()
|
||||||
|
live_matches = StatsService.get_live_matches()
|
||||||
|
|
||||||
|
# Convert rows to dict for easier JS usage
|
||||||
|
heatmap_data = {}
|
||||||
|
if daily_counts:
|
||||||
|
for row in daily_counts:
|
||||||
|
heatmap_data[row['day']] = row['count']
|
||||||
|
|
||||||
|
return render_template('home/index.html', recent_matches=recent_matches, heatmap_data=heatmap_data, live_matches=live_matches)
|
||||||
|
|
||||||
|
from web.services.etl_service import EtlService
|
||||||
|
|
||||||
|
@bp.route('/parse_match', methods=['POST'])
|
||||||
|
def parse_match():
|
||||||
|
url = request.form.get('url')
|
||||||
|
if not url or '5eplay.com' not in url:
|
||||||
|
return jsonify({'success': False, 'message': 'Invalid 5EPlay URL'})
|
||||||
|
|
||||||
|
# Trigger L1A.py with URL argument
|
||||||
|
success, msg = EtlService.run_script('L1A.py', args=[url])
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({'success': True, 'message': 'Match parsing completed successfully!'})
|
||||||
|
else:
|
||||||
|
return jsonify({'success': False, 'message': f'Error: {msg}'})
|
||||||
135
web/routes/matches.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
from flask import Blueprint, render_template, request, Response
|
||||||
|
from web.services.stats_service import StatsService
|
||||||
|
from web.config import Config
|
||||||
|
import json
|
||||||
|
|
||||||
|
bp = Blueprint('matches', __name__, url_prefix='/matches')
|
||||||
|
|
||||||
|
@bp.route('/')
|
||||||
|
def index():
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
map_name = request.args.get('map')
|
||||||
|
date_from = request.args.get('date_from')
|
||||||
|
|
||||||
|
# Fetch summary stats (for the dashboard)
|
||||||
|
summary_stats = StatsService.get_team_stats_summary()
|
||||||
|
|
||||||
|
matches, total = StatsService.get_matches(page, Config.ITEMS_PER_PAGE, map_name, date_from)
|
||||||
|
total_pages = (total + Config.ITEMS_PER_PAGE - 1) // Config.ITEMS_PER_PAGE
|
||||||
|
|
||||||
|
return render_template('matches/list.html',
|
||||||
|
matches=matches, total=total, page=page, total_pages=total_pages,
|
||||||
|
summary_stats=summary_stats)
|
||||||
|
|
||||||
|
@bp.route('/<match_id>')
|
||||||
|
def detail(match_id):
|
||||||
|
match = StatsService.get_match_detail(match_id)
|
||||||
|
if not match:
|
||||||
|
return "Match not found", 404
|
||||||
|
|
||||||
|
players = StatsService.get_match_players(match_id)
|
||||||
|
# Convert sqlite3.Row objects to dicts to allow modification
|
||||||
|
players = [dict(p) for p in players]
|
||||||
|
|
||||||
|
rounds = StatsService.get_match_rounds(match_id)
|
||||||
|
|
||||||
|
# --- Roster Identification ---
|
||||||
|
# Fetch active roster to identify "Our Team" players
|
||||||
|
from web.services.web_service import WebService
|
||||||
|
lineups = WebService.get_lineups()
|
||||||
|
# Assume we use the first/active lineup
|
||||||
|
active_roster_ids = []
|
||||||
|
if lineups:
|
||||||
|
try:
|
||||||
|
active_roster_ids = json.loads(lineups[0]['player_ids_json'])
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Mark roster players (Ensure strict string comparison)
|
||||||
|
roster_set = set(str(uid) for uid in active_roster_ids)
|
||||||
|
for p in players:
|
||||||
|
p['is_in_roster'] = str(p['steam_id_64']) in roster_set
|
||||||
|
|
||||||
|
# --- Party Size Calculation ---
|
||||||
|
# Only calculate party size for OUR ROSTER members.
|
||||||
|
# Group roster members by match_team_id
|
||||||
|
roster_parties = {} # match_team_id -> count of roster members
|
||||||
|
|
||||||
|
for p in players:
|
||||||
|
if p['is_in_roster']:
|
||||||
|
mtid = p.get('match_team_id')
|
||||||
|
if mtid and mtid > 0:
|
||||||
|
key = f"tid_{mtid}"
|
||||||
|
roster_parties[key] = roster_parties.get(key, 0) + 1
|
||||||
|
|
||||||
|
# Assign party size ONLY to roster members
|
||||||
|
for p in players:
|
||||||
|
if p['is_in_roster']:
|
||||||
|
mtid = p.get('match_team_id')
|
||||||
|
if mtid and mtid > 0:
|
||||||
|
p['party_size'] = roster_parties.get(f"tid_{mtid}", 1)
|
||||||
|
else:
|
||||||
|
p['party_size'] = 1 # Solo roster player
|
||||||
|
else:
|
||||||
|
p['party_size'] = 0 # Hide party info for non-roster players
|
||||||
|
|
||||||
|
# Organize players by Side (team_id)
|
||||||
|
# team_id 1 = Team 1, team_id 2 = Team 2
|
||||||
|
# Note: group_id 1/2 usually corresponds to Team 1/2.
|
||||||
|
# Fallback to team_id if group_id is missing or 0 (legacy data compatibility)
|
||||||
|
team1_players = [p for p in players if p.get('group_id') == 1]
|
||||||
|
team2_players = [p for p in players if p.get('group_id') == 2]
|
||||||
|
|
||||||
|
# If group_id didn't work (empty lists), try team_id grouping (if team_id is 1/2 only)
|
||||||
|
if not team1_players and not team2_players:
|
||||||
|
team1_players = [p for p in players if p['team_id'] == 1]
|
||||||
|
team2_players = [p for p in players if p['team_id'] == 2]
|
||||||
|
|
||||||
|
# Explicitly sort by Rating DESC
|
||||||
|
team1_players.sort(key=lambda x: x.get('rating', 0) or 0, reverse=True)
|
||||||
|
team2_players.sort(key=lambda x: x.get('rating', 0) or 0, reverse=True)
|
||||||
|
|
||||||
|
# New Data for Enhanced Detail View
|
||||||
|
h2h_stats = StatsService.get_head_to_head_stats(match_id)
|
||||||
|
round_details = StatsService.get_match_round_details(match_id)
|
||||||
|
|
||||||
|
# Convert H2H stats to a more usable format (nested dict)
|
||||||
|
# h2h_matrix[attacker_id][victim_id] = kills
|
||||||
|
h2h_matrix = {}
|
||||||
|
if h2h_stats:
|
||||||
|
for row in h2h_stats:
|
||||||
|
a_id = row['attacker_steam_id']
|
||||||
|
v_id = row['victim_steam_id']
|
||||||
|
kills = row['kills']
|
||||||
|
if a_id not in h2h_matrix: h2h_matrix[a_id] = {}
|
||||||
|
h2h_matrix[a_id][v_id] = kills
|
||||||
|
|
||||||
|
# Create a mapping of SteamID -> Username for the template
|
||||||
|
# We can use the players list we already have
|
||||||
|
player_name_map = {}
|
||||||
|
for p in players:
|
||||||
|
sid = p.get('steam_id_64')
|
||||||
|
name = p.get('username')
|
||||||
|
if sid and name:
|
||||||
|
player_name_map[str(sid)] = name
|
||||||
|
|
||||||
|
return render_template('matches/detail.html', match=match,
|
||||||
|
team1_players=team1_players, team2_players=team2_players,
|
||||||
|
rounds=rounds,
|
||||||
|
h2h_matrix=h2h_matrix,
|
||||||
|
round_details=round_details,
|
||||||
|
player_name_map=player_name_map)
|
||||||
|
|
||||||
|
@bp.route('/<match_id>/raw')
|
||||||
|
def raw_json(match_id):
|
||||||
|
match = StatsService.get_match_detail(match_id)
|
||||||
|
if not match:
|
||||||
|
return "Match not found", 404
|
||||||
|
|
||||||
|
# Construct a raw object from available raw fields
|
||||||
|
data = {
|
||||||
|
'round_list': json.loads(match['round_list_raw']) if match['round_list_raw'] else None,
|
||||||
|
'leetify_data': json.loads(match['leetify_data_raw']) if match['leetify_data_raw'] else None
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response(json.dumps(data, indent=2, ensure_ascii=False), mimetype='application/json')
|
||||||
371
web/routes/players.py
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
from flask import Blueprint, render_template, request, jsonify, redirect, url_for, flash, current_app, session
|
||||||
|
from web.services.stats_service import StatsService
|
||||||
|
from web.services.feature_service import FeatureService
|
||||||
|
from web.services.web_service import WebService
|
||||||
|
from web.database import execute_db, query_db
|
||||||
|
from web.config import Config
|
||||||
|
from datetime import datetime
|
||||||
|
import os
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
|
bp = Blueprint('players', __name__, url_prefix='/players')
|
||||||
|
|
||||||
|
@bp.route('/')
|
||||||
|
def index():
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
search = request.args.get('search')
|
||||||
|
# Default sort by 'matches' as requested
|
||||||
|
sort_by = request.args.get('sort', 'matches')
|
||||||
|
|
||||||
|
players, total = FeatureService.get_players_list(page, Config.ITEMS_PER_PAGE, sort_by, search)
|
||||||
|
total_pages = (total + Config.ITEMS_PER_PAGE - 1) // Config.ITEMS_PER_PAGE
|
||||||
|
|
||||||
|
return render_template('players/list.html', players=players, total=total, page=page, total_pages=total_pages, sort_by=sort_by)
|
||||||
|
|
||||||
|
@bp.route('/<steam_id>', methods=['GET', 'POST'])
|
||||||
|
def detail(steam_id):
|
||||||
|
if request.method == 'POST':
|
||||||
|
# Check if admin action
|
||||||
|
if 'admin_action' in request.form and session.get('is_admin'):
|
||||||
|
action = request.form.get('admin_action')
|
||||||
|
|
||||||
|
if action == 'update_profile':
|
||||||
|
notes = request.form.get('notes')
|
||||||
|
|
||||||
|
# Handle Avatar Upload
|
||||||
|
if 'avatar' in request.files:
|
||||||
|
file = request.files['avatar']
|
||||||
|
if file and file.filename:
|
||||||
|
try:
|
||||||
|
# Use steam_id as filename to ensure uniqueness per player
|
||||||
|
# Preserve extension
|
||||||
|
ext = os.path.splitext(file.filename)[1].lower()
|
||||||
|
if not ext: ext = '.jpg'
|
||||||
|
|
||||||
|
filename = f"{steam_id}{ext}"
|
||||||
|
upload_folder = os.path.join(current_app.root_path, 'static', 'avatars')
|
||||||
|
os.makedirs(upload_folder, exist_ok=True)
|
||||||
|
|
||||||
|
file_path = os.path.join(upload_folder, filename)
|
||||||
|
file.save(file_path)
|
||||||
|
|
||||||
|
# Generate URL (relative to web root)
|
||||||
|
avatar_url = url_for('static', filename=f'avatars/{filename}')
|
||||||
|
|
||||||
|
# Update L2 DB directly (Immediate effect)
|
||||||
|
execute_db('l2', "UPDATE dim_players SET avatar_url = ? WHERE steam_id_64 = ?", [avatar_url, steam_id])
|
||||||
|
|
||||||
|
flash('Avatar updated successfully.', 'success')
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Avatar upload error: {e}")
|
||||||
|
flash('Error uploading avatar.', 'error')
|
||||||
|
|
||||||
|
WebService.update_player_metadata(steam_id, notes=notes)
|
||||||
|
flash('Profile updated.', 'success')
|
||||||
|
|
||||||
|
elif action == 'add_tag':
|
||||||
|
tag = request.form.get('tag')
|
||||||
|
if tag:
|
||||||
|
meta = WebService.get_player_metadata(steam_id)
|
||||||
|
tags = meta.get('tags', [])
|
||||||
|
if tag not in tags:
|
||||||
|
tags.append(tag)
|
||||||
|
WebService.update_player_metadata(steam_id, tags=tags)
|
||||||
|
flash('Tag added.', 'success')
|
||||||
|
|
||||||
|
elif action == 'remove_tag':
|
||||||
|
tag = request.form.get('tag')
|
||||||
|
if tag:
|
||||||
|
meta = WebService.get_player_metadata(steam_id)
|
||||||
|
tags = meta.get('tags', [])
|
||||||
|
if tag in tags:
|
||||||
|
tags.remove(tag)
|
||||||
|
WebService.update_player_metadata(steam_id, tags=tags)
|
||||||
|
flash('Tag removed.', 'success')
|
||||||
|
|
||||||
|
return redirect(url_for('players.detail', steam_id=steam_id))
|
||||||
|
|
||||||
|
# Add Comment
|
||||||
|
username = request.form.get('username', 'Anonymous')
|
||||||
|
content = request.form.get('content')
|
||||||
|
if content:
|
||||||
|
WebService.add_comment(None, username, 'player', steam_id, content)
|
||||||
|
flash('Comment added!', 'success')
|
||||||
|
return redirect(url_for('players.detail', steam_id=steam_id))
|
||||||
|
|
||||||
|
player = StatsService.get_player_info(steam_id)
|
||||||
|
if not player:
|
||||||
|
return "Player not found", 404
|
||||||
|
|
||||||
|
features = FeatureService.get_player_features(steam_id)
|
||||||
|
# Ensure basic stats fallback if features missing or incomplete
|
||||||
|
basic = StatsService.get_player_basic_stats(steam_id)
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
if not features:
|
||||||
|
# Fallback to defaultdict with basic stats
|
||||||
|
features = defaultdict(lambda: None)
|
||||||
|
if basic:
|
||||||
|
features.update({
|
||||||
|
'basic_avg_rating': basic.get('rating', 0),
|
||||||
|
'basic_avg_kd': basic.get('kd', 0),
|
||||||
|
'basic_avg_kast': basic.get('kast', 0),
|
||||||
|
'basic_avg_adr': basic.get('adr', 0),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# Convert to defaultdict to handle missing keys gracefully (e.g. newly added columns)
|
||||||
|
# Use lambda: None so that Jinja can check 'if value is not none'
|
||||||
|
features = defaultdict(lambda: None, dict(features))
|
||||||
|
|
||||||
|
# If features exist but ADR is missing (not in L3), try to patch it from basic
|
||||||
|
if 'basic_avg_adr' not in features or features['basic_avg_adr'] is None:
|
||||||
|
features['basic_avg_adr'] = basic.get('adr', 0) if basic else 0
|
||||||
|
|
||||||
|
comments = WebService.get_comments('player', steam_id)
|
||||||
|
metadata = WebService.get_player_metadata(steam_id)
|
||||||
|
|
||||||
|
# Roster Distribution Stats
|
||||||
|
distribution = StatsService.get_roster_stats_distribution(steam_id)
|
||||||
|
|
||||||
|
# History for table (L2 Source) - Fetch ALL for history table/chart
|
||||||
|
history_asc = StatsService.get_player_trend(steam_id, limit=1000)
|
||||||
|
history = history_asc[::-1] if history_asc else []
|
||||||
|
|
||||||
|
# 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'])
|
||||||
|
def like_comment(comment_id):
|
||||||
|
WebService.like_comment(comment_id)
|
||||||
|
return jsonify({'success': True})
|
||||||
|
|
||||||
|
@bp.route('/<steam_id>/charts_data')
|
||||||
|
def charts_data(steam_id):
|
||||||
|
# ... (existing code) ...
|
||||||
|
# Trend Data
|
||||||
|
trends = StatsService.get_player_trend(steam_id, limit=1000)
|
||||||
|
|
||||||
|
# Radar Data (Construct from features)
|
||||||
|
features = FeatureService.get_player_features(steam_id)
|
||||||
|
radar_data = {}
|
||||||
|
radar_dist = FeatureService.get_roster_features_distribution(steam_id)
|
||||||
|
|
||||||
|
if features:
|
||||||
|
# Dimensions: STA, BAT, HPS, PTL, T/CT, UTIL
|
||||||
|
# Use calculated scores (0-100 scale)
|
||||||
|
|
||||||
|
# Helper to get score safely
|
||||||
|
def get_score(key):
|
||||||
|
val = features[key] if key in features.keys() else 0
|
||||||
|
return float(val) if val else 0
|
||||||
|
|
||||||
|
radar_data = {
|
||||||
|
'STA': get_score('score_sta'),
|
||||||
|
'BAT': get_score('score_bat'),
|
||||||
|
'HPS': get_score('score_hps'),
|
||||||
|
'PTL': get_score('score_ptl'),
|
||||||
|
'SIDE': get_score('score_tct'),
|
||||||
|
'UTIL': get_score('score_util')
|
||||||
|
}
|
||||||
|
|
||||||
|
trend_labels = []
|
||||||
|
trend_values = []
|
||||||
|
match_indices = []
|
||||||
|
for i, row in enumerate(trends):
|
||||||
|
t = dict(row) # Convert sqlite3.Row to dict
|
||||||
|
# Format: Match #Index (Map)
|
||||||
|
# Use backend-provided match_index if available, or just index + 1
|
||||||
|
idx = t.get('match_index', i + 1)
|
||||||
|
map_name = t.get('map_name', 'Unknown')
|
||||||
|
trend_labels.append(f"#{idx} {map_name}")
|
||||||
|
trend_values.append(t['rating'])
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'trend': {'labels': trend_labels, 'values': trend_values},
|
||||||
|
'radar': radar_data,
|
||||||
|
'radar_dist': radar_dist
|
||||||
|
})
|
||||||
|
|
||||||
|
# --- API for Comparison ---
|
||||||
|
@bp.route('/api/search')
|
||||||
|
def api_search():
|
||||||
|
query = request.args.get('q', '')
|
||||||
|
if len(query) < 2:
|
||||||
|
return jsonify([])
|
||||||
|
|
||||||
|
players, _ = FeatureService.get_players_list(page=1, per_page=10, search=query)
|
||||||
|
# Return minimal data
|
||||||
|
results = [{'steam_id': p['steam_id_64'], 'username': p['username'], 'avatar_url': p['avatar_url']} for p in players]
|
||||||
|
return jsonify(results)
|
||||||
|
|
||||||
|
@bp.route('/api/batch_stats')
|
||||||
|
def api_batch_stats():
|
||||||
|
steam_ids = request.args.get('ids', '').split(',')
|
||||||
|
stats = []
|
||||||
|
for sid in steam_ids:
|
||||||
|
if not sid: continue
|
||||||
|
f = FeatureService.get_player_features(sid)
|
||||||
|
p = StatsService.get_player_info(sid)
|
||||||
|
|
||||||
|
if f and p:
|
||||||
|
# Convert sqlite3.Row to dict if necessary
|
||||||
|
if hasattr(f, 'keys'): # It's a Row object or similar
|
||||||
|
f = dict(f)
|
||||||
|
|
||||||
|
# 1. Radar Scores (Normalized 0-100)
|
||||||
|
# Use safe conversion with default 0 if None
|
||||||
|
# Force 0.0 if value is 0 or None to ensure JSON compatibility
|
||||||
|
radar = {
|
||||||
|
'STA': float(f.get('score_sta') or 0.0),
|
||||||
|
'BAT': float(f.get('score_bat') or 0.0),
|
||||||
|
'HPS': float(f.get('score_hps') or 0.0),
|
||||||
|
'PTL': float(f.get('score_ptl') or 0.0),
|
||||||
|
'SIDE': float(f.get('score_tct') or 0.0),
|
||||||
|
'UTIL': float(f.get('score_util') or 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Basic Stats for Table
|
||||||
|
basic = {
|
||||||
|
'rating': float(f.get('basic_avg_rating') or 0),
|
||||||
|
'kd': float(f.get('basic_avg_kd') or 0),
|
||||||
|
'adr': float(f.get('basic_avg_adr') or 0),
|
||||||
|
'kast': float(f.get('basic_avg_kast') or 0),
|
||||||
|
'hs_rate': float(f.get('basic_headshot_rate') or 0),
|
||||||
|
'fk_rate': float(f.get('basic_first_kill_rate') or 0),
|
||||||
|
'matches': int(f.get('matches_played') or 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. Side Stats
|
||||||
|
side = {
|
||||||
|
'rating_t': float(f.get('side_rating_t') or 0),
|
||||||
|
'rating_ct': float(f.get('side_rating_ct') or 0),
|
||||||
|
'kd_t': float(f.get('side_kd_t') or 0),
|
||||||
|
'kd_ct': float(f.get('side_kd_ct') or 0),
|
||||||
|
'entry_t': float(f.get('side_entry_rate_t') or 0),
|
||||||
|
'entry_ct': float(f.get('side_entry_rate_ct') or 0),
|
||||||
|
'kast_t': float(f.get('side_kast_t') or 0),
|
||||||
|
'kast_ct': float(f.get('side_kast_ct') or 0),
|
||||||
|
'adr_t': float(f.get('side_adr_t') or 0),
|
||||||
|
'adr_ct': float(f.get('side_adr_ct') or 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. Detailed Stats (Expanded for Data Center - Aligned with Profile)
|
||||||
|
detailed = {
|
||||||
|
# Row 1
|
||||||
|
'rating_t': float(f.get('side_rating_t') or 0),
|
||||||
|
'rating_ct': float(f.get('side_rating_ct') or 0),
|
||||||
|
'kd_t': float(f.get('side_kd_t') or 0),
|
||||||
|
'kd_ct': float(f.get('side_kd_ct') or 0),
|
||||||
|
|
||||||
|
# Row 2
|
||||||
|
'win_rate_t': float(f.get('side_win_rate_t') or 0),
|
||||||
|
'win_rate_ct': float(f.get('side_win_rate_ct') or 0),
|
||||||
|
'first_kill_t': float(f.get('side_first_kill_rate_t') or 0),
|
||||||
|
'first_kill_ct': float(f.get('side_first_kill_rate_ct') or 0),
|
||||||
|
|
||||||
|
# Row 3
|
||||||
|
'first_death_t': float(f.get('side_first_death_rate_t') or 0),
|
||||||
|
'first_death_ct': float(f.get('side_first_death_rate_ct') or 0),
|
||||||
|
'kast_t': float(f.get('side_kast_t') or 0),
|
||||||
|
'kast_ct': float(f.get('side_kast_ct') or 0),
|
||||||
|
|
||||||
|
# Row 4
|
||||||
|
'rws_t': float(f.get('side_rws_t') or 0),
|
||||||
|
'rws_ct': float(f.get('side_rws_ct') or 0),
|
||||||
|
'multikill_t': float(f.get('side_multikill_rate_t') or 0),
|
||||||
|
'multikill_ct': float(f.get('side_multikill_rate_ct') or 0),
|
||||||
|
|
||||||
|
# Row 5
|
||||||
|
'hs_t': float(f.get('side_headshot_rate_t') or 0),
|
||||||
|
'hs_ct': float(f.get('side_headshot_rate_ct') or 0),
|
||||||
|
'obj_t': float(f.get('side_obj_t') or 0),
|
||||||
|
'obj_ct': float(f.get('side_obj_ct') or 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.append({
|
||||||
|
'username': p['username'],
|
||||||
|
'steam_id': sid,
|
||||||
|
'avatar_url': p['avatar_url'],
|
||||||
|
'radar': radar,
|
||||||
|
'basic': basic,
|
||||||
|
'side': side,
|
||||||
|
'detailed': detailed
|
||||||
|
})
|
||||||
|
return jsonify(stats)
|
||||||
|
|
||||||
|
@bp.route('/api/batch_map_stats')
|
||||||
|
def api_batch_map_stats():
|
||||||
|
steam_ids = request.args.get('ids', '').split(',')
|
||||||
|
steam_ids = [sid for sid in steam_ids if sid]
|
||||||
|
|
||||||
|
if not steam_ids:
|
||||||
|
return jsonify({})
|
||||||
|
|
||||||
|
# Query L2 for Map Stats grouped by Player and Map
|
||||||
|
# We need to construct a query that can be executed via execute_db or query_db
|
||||||
|
# Since StatsService usually handles this, we can write raw SQL here or delegate.
|
||||||
|
# Raw SQL is easier for this specific aggregation.
|
||||||
|
|
||||||
|
placeholders = ','.join('?' for _ in steam_ids)
|
||||||
|
sql = f"""
|
||||||
|
SELECT
|
||||||
|
mp.steam_id_64,
|
||||||
|
m.map_name,
|
||||||
|
COUNT(*) as matches,
|
||||||
|
SUM(CASE WHEN mp.is_win THEN 1 ELSE 0 END) as wins,
|
||||||
|
AVG(mp.rating) as avg_rating,
|
||||||
|
AVG(mp.kd_ratio) as avg_kd,
|
||||||
|
AVG(mp.adr) as avg_adr
|
||||||
|
FROM fact_match_players mp
|
||||||
|
JOIN fact_matches m ON mp.match_id = m.match_id
|
||||||
|
WHERE mp.steam_id_64 IN ({placeholders})
|
||||||
|
GROUP BY mp.steam_id_64, m.map_name
|
||||||
|
ORDER BY matches DESC
|
||||||
|
"""
|
||||||
|
|
||||||
|
# We need to import query_db if not available in current scope (it is imported at top)
|
||||||
|
from web.database import query_db
|
||||||
|
rows = query_db('l2', sql, steam_ids)
|
||||||
|
|
||||||
|
# Structure: {steam_id: [ {map: 'de_mirage', stats...}, ... ]}
|
||||||
|
result = {}
|
||||||
|
for r in rows:
|
||||||
|
sid = r['steam_id_64']
|
||||||
|
if sid not in result:
|
||||||
|
result[sid] = []
|
||||||
|
|
||||||
|
result[sid].append({
|
||||||
|
'map_name': r['map_name'],
|
||||||
|
'matches': r['matches'],
|
||||||
|
'win_rate': (r['wins'] / r['matches']) if r['matches'] else 0,
|
||||||
|
'rating': r['avg_rating'],
|
||||||
|
'kd': r['avg_kd'],
|
||||||
|
'adr': r['avg_adr']
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
103
web/routes/tactics.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
from flask import Blueprint, render_template, request, jsonify
|
||||||
|
from web.services.web_service import WebService
|
||||||
|
from web.services.stats_service import StatsService
|
||||||
|
from web.services.feature_service import FeatureService
|
||||||
|
import json
|
||||||
|
|
||||||
|
bp = Blueprint('tactics', __name__, url_prefix='/tactics')
|
||||||
|
|
||||||
|
@bp.route('/')
|
||||||
|
def index():
|
||||||
|
return render_template('tactics/index.html')
|
||||||
|
|
||||||
|
# API: Analyze Lineup
|
||||||
|
@bp.route('/api/analyze', methods=['POST'])
|
||||||
|
def api_analyze():
|
||||||
|
data = request.json
|
||||||
|
steam_ids = data.get('steam_ids', [])
|
||||||
|
|
||||||
|
if not steam_ids:
|
||||||
|
return jsonify({'error': 'No players selected'}), 400
|
||||||
|
|
||||||
|
# 1. Get Basic Info & Stats
|
||||||
|
players = StatsService.get_players_by_ids(steam_ids)
|
||||||
|
player_data = []
|
||||||
|
|
||||||
|
total_rating = 0
|
||||||
|
total_kd = 0
|
||||||
|
total_adr = 0
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
for p in players:
|
||||||
|
p_dict = dict(p)
|
||||||
|
# Fetch L3 features
|
||||||
|
f = FeatureService.get_player_features(p_dict['steam_id_64'])
|
||||||
|
stats = dict(f) if f else {}
|
||||||
|
p_dict['stats'] = stats
|
||||||
|
player_data.append(p_dict)
|
||||||
|
|
||||||
|
if stats:
|
||||||
|
total_rating += stats.get('basic_avg_rating', 0) or 0
|
||||||
|
total_kd += stats.get('basic_avg_kd', 0) or 0
|
||||||
|
total_adr += stats.get('basic_avg_adr', 0) or 0
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
# 2. Shared Matches
|
||||||
|
shared_matches = StatsService.get_shared_matches(steam_ids)
|
||||||
|
# They are already dicts now with 'result_str' and 'is_win'
|
||||||
|
|
||||||
|
# 3. Aggregates
|
||||||
|
avg_stats = {
|
||||||
|
'rating': total_rating / count if count else 0,
|
||||||
|
'kd': total_kd / count if count else 0,
|
||||||
|
'adr': total_adr / count if count else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. Map Stats Calculation
|
||||||
|
map_stats = {} # {map_name: {'count': 0, 'wins': 0}}
|
||||||
|
total_shared_matches = len(shared_matches)
|
||||||
|
|
||||||
|
for m in shared_matches:
|
||||||
|
map_name = m['map_name']
|
||||||
|
if map_name not in map_stats:
|
||||||
|
map_stats[map_name] = {'count': 0, 'wins': 0}
|
||||||
|
|
||||||
|
map_stats[map_name]['count'] += 1
|
||||||
|
if m['is_win']:
|
||||||
|
map_stats[map_name]['wins'] += 1
|
||||||
|
|
||||||
|
# Convert to list for frontend
|
||||||
|
map_stats_list = []
|
||||||
|
for k, v in map_stats.items():
|
||||||
|
win_rate = (v['wins'] / v['count'] * 100) if v['count'] > 0 else 0
|
||||||
|
map_stats_list.append({
|
||||||
|
'map_name': k,
|
||||||
|
'count': v['count'],
|
||||||
|
'wins': v['wins'],
|
||||||
|
'win_rate': win_rate
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by count desc
|
||||||
|
map_stats_list.sort(key=lambda x: x['count'], reverse=True)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'players': player_data,
|
||||||
|
'shared_matches': [dict(m) for m in shared_matches],
|
||||||
|
'avg_stats': avg_stats,
|
||||||
|
'map_stats': map_stats_list,
|
||||||
|
'total_shared_matches': total_shared_matches
|
||||||
|
})
|
||||||
|
|
||||||
|
# API: Save Board
|
||||||
|
@bp.route('/save_board', methods=['POST'])
|
||||||
|
def save_board():
|
||||||
|
data = request.json
|
||||||
|
title = data.get('title', 'Untitled Strategy')
|
||||||
|
map_name = data.get('map_name', 'de_mirage')
|
||||||
|
markers = data.get('markers')
|
||||||
|
|
||||||
|
if not markers:
|
||||||
|
return jsonify({'success': False, 'message': 'No markers to save'})
|
||||||
|
|
||||||
|
WebService.save_strategy_board(title, map_name, json.dumps(markers), 'Anonymous')
|
||||||
|
return jsonify({'success': True, 'message': 'Board saved successfully'})
|
||||||
225
web/routes/teams.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, session
|
||||||
|
from web.services.web_service import WebService
|
||||||
|
from web.services.stats_service import StatsService
|
||||||
|
from web.services.feature_service import FeatureService
|
||||||
|
import json
|
||||||
|
|
||||||
|
bp = Blueprint('teams', __name__, url_prefix='/teams')
|
||||||
|
|
||||||
|
# --- API Endpoints ---
|
||||||
|
@bp.route('/api/search')
|
||||||
|
def api_search():
|
||||||
|
query = request.args.get('q', '').strip() # Strip whitespace
|
||||||
|
print(f"DEBUG: Search Query Received: '{query}'") # Debug Log
|
||||||
|
|
||||||
|
if len(query) < 2:
|
||||||
|
return jsonify([])
|
||||||
|
|
||||||
|
# Use L2 database for fuzzy search on username
|
||||||
|
from web.services.stats_service import StatsService
|
||||||
|
# Support sorting by matches for better "Find Player" experience
|
||||||
|
sort_by = request.args.get('sort', 'matches')
|
||||||
|
|
||||||
|
print(f"DEBUG: Calling StatsService.get_players with search='{query}'")
|
||||||
|
players, total = StatsService.get_players(page=1, per_page=50, search=query, sort_by=sort_by)
|
||||||
|
print(f"DEBUG: Found {len(players)} players (Total: {total})")
|
||||||
|
|
||||||
|
# Format for frontend
|
||||||
|
results = []
|
||||||
|
for p in players:
|
||||||
|
# Convert sqlite3.Row to dict to avoid AttributeError
|
||||||
|
p_dict = dict(p)
|
||||||
|
|
||||||
|
# Fetch feature stats for better preview
|
||||||
|
f = FeatureService.get_player_features(p_dict['steam_id_64'])
|
||||||
|
|
||||||
|
# Manually attach match count if not present
|
||||||
|
matches_played = p_dict.get('matches_played', 0)
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'steam_id': p_dict['steam_id_64'],
|
||||||
|
'name': p_dict['username'],
|
||||||
|
'avatar': p_dict['avatar_url'] or 'https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg',
|
||||||
|
'rating': (f['basic_avg_rating'] if f else 0.0),
|
||||||
|
'matches': matches_played
|
||||||
|
})
|
||||||
|
|
||||||
|
# Python-side sort if DB sort didn't work for 'matches' (since dim_players doesn't have match_count)
|
||||||
|
if sort_by == 'matches':
|
||||||
|
# We need to fetch match counts to sort!
|
||||||
|
# This is expensive for search results but necessary for "matches sample sort"
|
||||||
|
# Let's batch fetch counts for these 50 players
|
||||||
|
steam_ids = [r['steam_id'] for r in results]
|
||||||
|
if steam_ids:
|
||||||
|
from web.services.web_service import query_db
|
||||||
|
placeholders = ','.join('?' for _ in steam_ids)
|
||||||
|
sql = f"SELECT steam_id_64, COUNT(*) as cnt FROM fact_match_players WHERE steam_id_64 IN ({placeholders}) GROUP BY steam_id_64"
|
||||||
|
counts = query_db('l2', sql, steam_ids)
|
||||||
|
cnt_map = {r['steam_id_64']: r['cnt'] for r in counts}
|
||||||
|
|
||||||
|
for r in results:
|
||||||
|
r['matches'] = cnt_map.get(r['steam_id'], 0)
|
||||||
|
|
||||||
|
results.sort(key=lambda x: x['matches'], reverse=True)
|
||||||
|
|
||||||
|
print(f"DEBUG: Returning {len(results)} results")
|
||||||
|
return jsonify(results)
|
||||||
|
|
||||||
|
@bp.route('/api/roster', methods=['GET', 'POST'])
|
||||||
|
def api_roster():
|
||||||
|
# Assume single team mode, always operating on ID=1 or the first lineup
|
||||||
|
lineups = WebService.get_lineups()
|
||||||
|
if not lineups:
|
||||||
|
# Auto-create default team if none exists
|
||||||
|
WebService.save_lineup("My Team", "Default Roster", [])
|
||||||
|
lineups = WebService.get_lineups()
|
||||||
|
|
||||||
|
target_team = dict(lineups[0]) # Get the latest one
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
# Admin Check
|
||||||
|
if not session.get('is_admin'):
|
||||||
|
return jsonify({'error': 'Unauthorized'}), 403
|
||||||
|
|
||||||
|
data = request.json
|
||||||
|
action = data.get('action')
|
||||||
|
steam_id = data.get('steam_id')
|
||||||
|
|
||||||
|
current_ids = []
|
||||||
|
try:
|
||||||
|
current_ids = json.loads(target_team['player_ids_json'])
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if action == 'add':
|
||||||
|
if steam_id not in current_ids:
|
||||||
|
current_ids.append(steam_id)
|
||||||
|
elif action == 'remove':
|
||||||
|
if steam_id in current_ids:
|
||||||
|
current_ids.remove(steam_id)
|
||||||
|
|
||||||
|
# Pass lineup_id=target_team['id'] to update existing lineup
|
||||||
|
WebService.save_lineup(target_team['name'], target_team['description'], current_ids, lineup_id=target_team['id'])
|
||||||
|
return jsonify({'status': 'success', 'roster': current_ids})
|
||||||
|
|
||||||
|
# GET: Return detailed player info
|
||||||
|
try:
|
||||||
|
print(f"DEBUG: api_roster GET - Target Team: {target_team.get('id')}")
|
||||||
|
p_ids_json = target_team.get('player_ids_json', '[]')
|
||||||
|
p_ids = json.loads(p_ids_json)
|
||||||
|
print(f"DEBUG: Player IDs: {p_ids}")
|
||||||
|
|
||||||
|
players = StatsService.get_players_by_ids(p_ids)
|
||||||
|
print(f"DEBUG: Players fetched: {len(players) if players else 0}")
|
||||||
|
|
||||||
|
# Add extra stats needed for cards
|
||||||
|
enriched = []
|
||||||
|
if players:
|
||||||
|
for p in players:
|
||||||
|
try:
|
||||||
|
# Convert sqlite3.Row to dict
|
||||||
|
p_dict = dict(p)
|
||||||
|
# print(f"DEBUG: Processing player {p_dict.get('steam_id_64')}")
|
||||||
|
|
||||||
|
# Get features for Rating/KD display
|
||||||
|
f = FeatureService.get_player_features(p_dict['steam_id_64'])
|
||||||
|
# f might be a Row object, convert it
|
||||||
|
p_dict['stats'] = dict(f) if f else {}
|
||||||
|
|
||||||
|
# Fetch Metadata (Tags)
|
||||||
|
meta = WebService.get_player_metadata(p_dict['steam_id_64'])
|
||||||
|
p_dict['tags'] = meta.get('tags', [])
|
||||||
|
|
||||||
|
enriched.append(p_dict)
|
||||||
|
except Exception as inner_e:
|
||||||
|
print(f"ERROR: Processing player failed: {inner_e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'team': dict(target_team), # Ensure target_team is dict too
|
||||||
|
'roster': enriched
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"CRITICAL ERROR in api_roster: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
# --- Views ---
|
||||||
|
@bp.route('/')
|
||||||
|
def index():
|
||||||
|
# Directly render the Clubhouse SPA
|
||||||
|
return render_template('teams/clubhouse.html')
|
||||||
|
|
||||||
|
# Deprecated routes (kept for compatibility if needed, but hidden)
|
||||||
|
@bp.route('/list')
|
||||||
|
def list_view():
|
||||||
|
lineups = WebService.get_lineups()
|
||||||
|
# ... existing logic ...
|
||||||
|
return render_template('teams/list.html', lineups=lineups)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:lineup_id>')
|
||||||
|
def detail(lineup_id):
|
||||||
|
lineup = WebService.get_lineup(lineup_id)
|
||||||
|
if not lineup:
|
||||||
|
return "Lineup not found", 404
|
||||||
|
|
||||||
|
p_ids = json.loads(lineup['player_ids_json'])
|
||||||
|
players = StatsService.get_players_by_ids(p_ids)
|
||||||
|
|
||||||
|
# Shared Matches
|
||||||
|
shared_matches = StatsService.get_shared_matches(p_ids)
|
||||||
|
|
||||||
|
# Calculate Aggregate Stats
|
||||||
|
agg_stats = {
|
||||||
|
'avg_rating': 0,
|
||||||
|
'avg_kd': 0,
|
||||||
|
'avg_kast': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
radar_data = {
|
||||||
|
'STA': 0, 'BAT': 0, 'HPS': 0, 'PTL': 0, 'SIDE': 0, 'UTIL': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
player_features = []
|
||||||
|
|
||||||
|
if players:
|
||||||
|
count = len(players)
|
||||||
|
total_rating = 0
|
||||||
|
total_kd = 0
|
||||||
|
total_kast = 0
|
||||||
|
|
||||||
|
# Radar totals
|
||||||
|
r_totals = {k: 0 for k in radar_data}
|
||||||
|
|
||||||
|
for p in players:
|
||||||
|
# Fetch L3 features for each player
|
||||||
|
f = FeatureService.get_player_features(p['steam_id_64'])
|
||||||
|
if f:
|
||||||
|
player_features.append(f)
|
||||||
|
total_rating += f['basic_avg_rating'] or 0
|
||||||
|
total_kd += f['basic_avg_kd'] or 0
|
||||||
|
total_kast += f['basic_avg_kast'] or 0
|
||||||
|
|
||||||
|
# Radar accumulation
|
||||||
|
r_totals['STA'] += f['basic_avg_rating'] or 0
|
||||||
|
r_totals['BAT'] += f['bat_avg_duel_win_rate'] or 0
|
||||||
|
r_totals['HPS'] += f['hps_clutch_win_rate_1v1'] or 0
|
||||||
|
r_totals['PTL'] += f['ptl_pistol_win_rate'] or 0
|
||||||
|
r_totals['SIDE'] += f['side_rating_ct'] or 0
|
||||||
|
r_totals['UTIL'] += f['util_usage_rate'] or 0
|
||||||
|
else:
|
||||||
|
player_features.append(None)
|
||||||
|
|
||||||
|
if count > 0:
|
||||||
|
agg_stats['avg_rating'] = total_rating / count
|
||||||
|
agg_stats['avg_kd'] = total_kd / count
|
||||||
|
agg_stats['avg_kast'] = total_kast / count
|
||||||
|
|
||||||
|
for k in radar_data:
|
||||||
|
radar_data[k] = r_totals[k] / count
|
||||||
|
|
||||||
|
return render_template('teams/detail.html', lineup=lineup, players=players, agg_stats=agg_stats, shared_matches=shared_matches, radar_data=radar_data)
|
||||||
32
web/routes/wiki.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, session
|
||||||
|
from web.services.web_service import WebService
|
||||||
|
from web.auth import admin_required
|
||||||
|
|
||||||
|
bp = Blueprint('wiki', __name__, url_prefix='/wiki')
|
||||||
|
|
||||||
|
@bp.route('/')
|
||||||
|
def index():
|
||||||
|
pages = WebService.get_all_wiki_pages()
|
||||||
|
return render_template('wiki/index.html', pages=pages)
|
||||||
|
|
||||||
|
@bp.route('/view/<path:page_path>')
|
||||||
|
def view(page_path):
|
||||||
|
page = WebService.get_wiki_page(page_path)
|
||||||
|
if not page:
|
||||||
|
# If admin, offer to create
|
||||||
|
if session.get('is_admin'):
|
||||||
|
return redirect(url_for('wiki.edit', page_path=page_path))
|
||||||
|
return "Page not found", 404
|
||||||
|
return render_template('wiki/view.html', page=page)
|
||||||
|
|
||||||
|
@bp.route('/edit/<path:page_path>', methods=['GET', 'POST'])
|
||||||
|
@admin_required
|
||||||
|
def edit(page_path):
|
||||||
|
if request.method == 'POST':
|
||||||
|
title = request.form.get('title')
|
||||||
|
content = request.form.get('content')
|
||||||
|
WebService.save_wiki_page(page_path, title, content, 'admin')
|
||||||
|
return redirect(url_for('wiki.view', page_path=page_path))
|
||||||
|
|
||||||
|
page = WebService.get_wiki_page(page_path)
|
||||||
|
return render_template('wiki/edit.html', page=page, page_path=page_path)
|
||||||
40
web/services/etl_service.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from web.config import Config
|
||||||
|
|
||||||
|
class EtlService:
|
||||||
|
@staticmethod
|
||||||
|
def run_script(script_name, args=None):
|
||||||
|
"""
|
||||||
|
Executes an ETL script located in the ETL directory.
|
||||||
|
Returns (success, message)
|
||||||
|
"""
|
||||||
|
script_path = os.path.join(Config.BASE_DIR, 'ETL', script_name)
|
||||||
|
|
||||||
|
if not os.path.exists(script_path):
|
||||||
|
return False, f"Script not found: {script_path}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use the same python interpreter
|
||||||
|
python_exe = sys.executable
|
||||||
|
|
||||||
|
cmd = [python_exe, script_path]
|
||||||
|
if args:
|
||||||
|
cmd.extend(args)
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
cwd=Config.BASE_DIR,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300 # 5 min timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
return True, f"Success:\n{result.stdout}"
|
||||||
|
else:
|
||||||
|
return False, f"Failed (Code {result.returncode}):\n{result.stderr}\n{result.stdout}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
1135
web/services/feature_service.py
Normal file
783
web/services/stats_service.py
Normal file
@@ -0,0 +1,783 @@
|
|||||||
|
from web.database import query_db
|
||||||
|
|
||||||
|
class StatsService:
|
||||||
|
@staticmethod
|
||||||
|
def get_team_stats_summary():
|
||||||
|
"""
|
||||||
|
Calculates aggregate statistics for matches where at least 2 roster members played together.
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
'map_stats': [{'map_name', 'count', 'wins', 'win_rate'}],
|
||||||
|
'elo_stats': [{'range', 'count', 'wins', 'win_rate'}],
|
||||||
|
'duration_stats': [{'range', 'count', 'wins', 'win_rate'}],
|
||||||
|
'round_stats': [{'type', 'count', 'wins', 'win_rate'}]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# 1. Get Active Roster
|
||||||
|
from web.services.web_service import WebService
|
||||||
|
import json
|
||||||
|
|
||||||
|
lineups = WebService.get_lineups()
|
||||||
|
active_roster_ids = []
|
||||||
|
if lineups:
|
||||||
|
try:
|
||||||
|
raw_ids = json.loads(lineups[0]['player_ids_json'])
|
||||||
|
active_roster_ids = [str(uid) for uid in raw_ids]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not active_roster_ids:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# 2. Find matches with >= 2 roster members
|
||||||
|
# We need match_id, map_name, scores, winner_team, duration, avg_elo
|
||||||
|
# And we need to determine if "Our Team" won.
|
||||||
|
|
||||||
|
placeholders = ','.join('?' for _ in active_roster_ids)
|
||||||
|
|
||||||
|
# Step A: Get Candidate Match IDs (matches with >= 2 roster players)
|
||||||
|
# Also get the team_id of our players in that match to determine win
|
||||||
|
candidate_sql = f"""
|
||||||
|
SELECT mp.match_id, MAX(mp.team_id) as our_team_id
|
||||||
|
FROM fact_match_players mp
|
||||||
|
WHERE CAST(mp.steam_id_64 AS TEXT) IN ({placeholders})
|
||||||
|
GROUP BY mp.match_id
|
||||||
|
HAVING COUNT(DISTINCT mp.steam_id_64) >= 2
|
||||||
|
"""
|
||||||
|
candidate_rows = query_db('l2', candidate_sql, active_roster_ids)
|
||||||
|
|
||||||
|
if not candidate_rows:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
candidate_map = {row['match_id']: row['our_team_id'] for row in candidate_rows}
|
||||||
|
match_ids = list(candidate_map.keys())
|
||||||
|
match_placeholders = ','.join('?' for _ in match_ids)
|
||||||
|
|
||||||
|
# Step B: Get Match Details
|
||||||
|
match_sql = f"""
|
||||||
|
SELECT m.match_id, m.map_name, m.score_team1, m.score_team2, m.winner_team, m.duration,
|
||||||
|
AVG(fmt.group_origin_elo) as avg_elo
|
||||||
|
FROM fact_matches m
|
||||||
|
LEFT JOIN fact_match_teams fmt ON m.match_id = fmt.match_id AND fmt.group_origin_elo > 0
|
||||||
|
WHERE m.match_id IN ({match_placeholders})
|
||||||
|
GROUP BY m.match_id
|
||||||
|
"""
|
||||||
|
match_rows = query_db('l2', match_sql, match_ids)
|
||||||
|
|
||||||
|
# 3. Process Data
|
||||||
|
# Buckets initialization
|
||||||
|
map_stats = {}
|
||||||
|
elo_ranges = ['<1000', '1000-1200', '1200-1400', '1400-1600', '1600-1800', '1800-2000', '2000+']
|
||||||
|
elo_stats = {r: {'wins': 0, 'total': 0} for r in elo_ranges}
|
||||||
|
|
||||||
|
dur_ranges = ['<30m', '30-45m', '45m+']
|
||||||
|
dur_stats = {r: {'wins': 0, 'total': 0} for r in dur_ranges}
|
||||||
|
|
||||||
|
round_types = ['Stomp (<15)', 'Normal', 'Close (>23)', 'Choke (24)']
|
||||||
|
round_stats = {r: {'wins': 0, 'total': 0} for r in round_types}
|
||||||
|
|
||||||
|
for m in match_rows:
|
||||||
|
mid = m['match_id']
|
||||||
|
# Determine Win
|
||||||
|
# Use candidate_map to get our_team_id.
|
||||||
|
# Note: winner_team is usually int (1 or 2) or string.
|
||||||
|
# our_team_id from fact_match_players is usually int (1 or 2).
|
||||||
|
# This logic assumes simple team ID matching.
|
||||||
|
# If sophisticated "UID in Winning Group" logic is needed, we'd need more queries.
|
||||||
|
# For aggregate stats, let's assume team_id matching is sufficient for 99% cases or fallback to simple check.
|
||||||
|
# Actually, let's try to be consistent with get_matches logic if possible,
|
||||||
|
# but getting group_uids for ALL matches is heavy.
|
||||||
|
# Let's trust team_id for this summary.
|
||||||
|
|
||||||
|
our_tid = candidate_map[mid]
|
||||||
|
winner_tid = m['winner_team']
|
||||||
|
|
||||||
|
# Type normalization
|
||||||
|
try:
|
||||||
|
is_win = (int(our_tid) == int(winner_tid)) if (our_tid and winner_tid) else False
|
||||||
|
except:
|
||||||
|
is_win = (str(our_tid) == str(winner_tid)) if (our_tid and winner_tid) else False
|
||||||
|
|
||||||
|
# 1. Map Stats
|
||||||
|
map_name = m['map_name'] or 'Unknown'
|
||||||
|
if map_name not in map_stats:
|
||||||
|
map_stats[map_name] = {'wins': 0, 'total': 0}
|
||||||
|
map_stats[map_name]['total'] += 1
|
||||||
|
if is_win: map_stats[map_name]['wins'] += 1
|
||||||
|
|
||||||
|
# 2. ELO Stats
|
||||||
|
elo = m['avg_elo']
|
||||||
|
if elo:
|
||||||
|
if elo < 1000: e_key = '<1000'
|
||||||
|
elif elo < 1200: e_key = '1000-1200'
|
||||||
|
elif elo < 1400: e_key = '1200-1400'
|
||||||
|
elif elo < 1600: e_key = '1400-1600'
|
||||||
|
elif elo < 1800: e_key = '1600-1800'
|
||||||
|
elif elo < 2000: e_key = '1800-2000'
|
||||||
|
else: e_key = '2000+'
|
||||||
|
elo_stats[e_key]['total'] += 1
|
||||||
|
if is_win: elo_stats[e_key]['wins'] += 1
|
||||||
|
|
||||||
|
# 3. Duration Stats
|
||||||
|
dur = m['duration'] # seconds
|
||||||
|
if dur:
|
||||||
|
dur_min = dur / 60
|
||||||
|
if dur_min < 30: d_key = '<30m'
|
||||||
|
elif dur_min < 45: d_key = '30-45m'
|
||||||
|
else: d_key = '45m+'
|
||||||
|
dur_stats[d_key]['total'] += 1
|
||||||
|
if is_win: dur_stats[d_key]['wins'] += 1
|
||||||
|
|
||||||
|
# 4. Round Stats
|
||||||
|
s1 = m['score_team1'] or 0
|
||||||
|
s2 = m['score_team2'] or 0
|
||||||
|
total_rounds = s1 + s2
|
||||||
|
|
||||||
|
if total_rounds == 24:
|
||||||
|
r_key = 'Choke (24)'
|
||||||
|
round_stats[r_key]['total'] += 1
|
||||||
|
if is_win: round_stats[r_key]['wins'] += 1
|
||||||
|
|
||||||
|
# Note: Close (>23) overlaps with Choke (24).
|
||||||
|
# User requirement: Close > 23 counts ALL matches > 23, regardless of other categories.
|
||||||
|
if total_rounds > 23:
|
||||||
|
r_key = 'Close (>23)'
|
||||||
|
round_stats[r_key]['total'] += 1
|
||||||
|
if is_win: round_stats[r_key]['wins'] += 1
|
||||||
|
|
||||||
|
if total_rounds < 15:
|
||||||
|
r_key = 'Stomp (<15)'
|
||||||
|
round_stats[r_key]['total'] += 1
|
||||||
|
if is_win: round_stats[r_key]['wins'] += 1
|
||||||
|
elif total_rounds <= 23: # Only Normal if NOT Stomp and NOT Close (<= 23 and >= 15)
|
||||||
|
r_key = 'Normal'
|
||||||
|
round_stats[r_key]['total'] += 1
|
||||||
|
if is_win: round_stats[r_key]['wins'] += 1
|
||||||
|
|
||||||
|
# 4. Format Results
|
||||||
|
def fmt(stats_dict):
|
||||||
|
res = []
|
||||||
|
for k, v in stats_dict.items():
|
||||||
|
rate = (v['wins'] / v['total'] * 100) if v['total'] > 0 else 0
|
||||||
|
res.append({'label': k, 'count': v['total'], 'wins': v['wins'], 'win_rate': rate})
|
||||||
|
return res
|
||||||
|
|
||||||
|
# For maps, sort by count
|
||||||
|
map_res = fmt(map_stats)
|
||||||
|
map_res.sort(key=lambda x: x['count'], reverse=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'map_stats': map_res,
|
||||||
|
'elo_stats': fmt(elo_stats), # Keep order
|
||||||
|
'duration_stats': fmt(dur_stats), # Keep order
|
||||||
|
'round_stats': fmt(round_stats) # Keep order
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_recent_matches(limit=5):
|
||||||
|
sql = """
|
||||||
|
SELECT m.match_id, m.start_time, m.map_name, m.score_team1, m.score_team2, m.winner_team,
|
||||||
|
p.username as mvp_name
|
||||||
|
FROM fact_matches m
|
||||||
|
LEFT JOIN dim_players p ON m.mvp_uid = p.uid
|
||||||
|
ORDER BY m.start_time DESC
|
||||||
|
LIMIT ?
|
||||||
|
"""
|
||||||
|
return query_db('l2', sql, [limit])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_matches(page=1, per_page=20, map_name=None, date_from=None, date_to=None):
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
args = []
|
||||||
|
where_clauses = ["1=1"]
|
||||||
|
|
||||||
|
if map_name:
|
||||||
|
where_clauses.append("map_name = ?")
|
||||||
|
args.append(map_name)
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
where_clauses.append("start_time >= ?")
|
||||||
|
args.append(date_from)
|
||||||
|
|
||||||
|
if date_to:
|
||||||
|
where_clauses.append("start_time <= ?")
|
||||||
|
args.append(date_to)
|
||||||
|
|
||||||
|
where_str = " AND ".join(where_clauses)
|
||||||
|
|
||||||
|
sql = f"""
|
||||||
|
SELECT m.match_id, m.start_time, m.map_name, m.score_team1, m.score_team2, m.winner_team, m.duration
|
||||||
|
FROM fact_matches m
|
||||||
|
WHERE {where_str}
|
||||||
|
ORDER BY m.start_time DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
"""
|
||||||
|
args.extend([per_page, offset])
|
||||||
|
|
||||||
|
matches = query_db('l2', sql, args)
|
||||||
|
|
||||||
|
# Enrich matches with Avg ELO, Party info, and Our Team Result
|
||||||
|
if matches:
|
||||||
|
match_ids = [m['match_id'] for m in matches]
|
||||||
|
placeholders = ','.join('?' for _ in match_ids)
|
||||||
|
|
||||||
|
# Fetch ELO
|
||||||
|
elo_sql = f"""
|
||||||
|
SELECT match_id, AVG(group_origin_elo) as avg_elo
|
||||||
|
FROM fact_match_teams
|
||||||
|
WHERE match_id IN ({placeholders}) AND group_origin_elo > 0
|
||||||
|
GROUP BY match_id
|
||||||
|
"""
|
||||||
|
elo_rows = query_db('l2', elo_sql, match_ids)
|
||||||
|
elo_map = {row['match_id']: row['avg_elo'] for row in elo_rows}
|
||||||
|
|
||||||
|
# Fetch Max Party Size
|
||||||
|
party_sql = f"""
|
||||||
|
SELECT match_id, MAX(cnt) as max_party
|
||||||
|
FROM (
|
||||||
|
SELECT match_id, match_team_id, COUNT(*) as cnt
|
||||||
|
FROM fact_match_players
|
||||||
|
WHERE match_id IN ({placeholders}) AND match_team_id > 0
|
||||||
|
GROUP BY match_id, match_team_id
|
||||||
|
)
|
||||||
|
GROUP BY match_id
|
||||||
|
"""
|
||||||
|
party_rows = query_db('l2', party_sql, match_ids)
|
||||||
|
party_map = {row['match_id']: row['max_party'] for row in party_rows}
|
||||||
|
|
||||||
|
# --- New: Determine "Our Team" Result ---
|
||||||
|
# Logic: Check if any player from `active_roster` played in these matches.
|
||||||
|
# Use WebService to get the active roster
|
||||||
|
from web.services.web_service import WebService
|
||||||
|
import json
|
||||||
|
|
||||||
|
lineups = WebService.get_lineups()
|
||||||
|
active_roster_ids = []
|
||||||
|
if lineups:
|
||||||
|
try:
|
||||||
|
# Load IDs and ensure they are all strings for DB comparison consistency
|
||||||
|
raw_ids = json.loads(lineups[0]['player_ids_json'])
|
||||||
|
active_roster_ids = [str(uid) for uid in raw_ids]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# If no roster, we can't determine "Our Result"
|
||||||
|
if not active_roster_ids:
|
||||||
|
result_map = {}
|
||||||
|
else:
|
||||||
|
# 1. Get UIDs for Roster Members involved in these matches
|
||||||
|
# We query fact_match_players to ensure we get the UIDs actually used in these matches
|
||||||
|
roster_placeholders = ','.join('?' for _ in active_roster_ids)
|
||||||
|
uid_sql = f"""
|
||||||
|
SELECT DISTINCT steam_id_64, uid
|
||||||
|
FROM fact_match_players
|
||||||
|
WHERE match_id IN ({placeholders})
|
||||||
|
AND CAST(steam_id_64 AS TEXT) IN ({roster_placeholders})
|
||||||
|
"""
|
||||||
|
combined_args_uid = match_ids + active_roster_ids
|
||||||
|
uid_rows = query_db('l2', uid_sql, combined_args_uid)
|
||||||
|
|
||||||
|
# Set of "Our UIDs" (as strings)
|
||||||
|
our_uids = set()
|
||||||
|
for r in uid_rows:
|
||||||
|
if r['uid']:
|
||||||
|
our_uids.add(str(r['uid']))
|
||||||
|
|
||||||
|
# 2. Get Group UIDs and Winner info from fact_match_teams
|
||||||
|
# We need to know which group contains our UIDs
|
||||||
|
teams_sql = f"""
|
||||||
|
SELECT fmt.match_id, fmt.group_id, fmt.group_uids, m.winner_team
|
||||||
|
FROM fact_match_teams fmt
|
||||||
|
JOIN fact_matches m ON fmt.match_id = m.match_id
|
||||||
|
WHERE fmt.match_id IN ({placeholders})
|
||||||
|
"""
|
||||||
|
teams_rows = query_db('l2', teams_sql, match_ids)
|
||||||
|
|
||||||
|
# 3. Determine Result per Match
|
||||||
|
result_map = {}
|
||||||
|
|
||||||
|
# Group data by match
|
||||||
|
match_groups = {} # match_id -> {group_id: [uids...], winner: int}
|
||||||
|
|
||||||
|
for r in teams_rows:
|
||||||
|
mid = r['match_id']
|
||||||
|
gid = r['group_id']
|
||||||
|
uids_str = r['group_uids'] or ""
|
||||||
|
# Split and clean UIDs
|
||||||
|
uids = set(str(u).strip() for u in uids_str.split(',') if u.strip())
|
||||||
|
|
||||||
|
if mid not in match_groups:
|
||||||
|
match_groups[mid] = {'groups': {}, 'winner': r['winner_team']}
|
||||||
|
|
||||||
|
match_groups[mid]['groups'][gid] = uids
|
||||||
|
|
||||||
|
# Analyze
|
||||||
|
for mid, data in match_groups.items():
|
||||||
|
winner_gid = data['winner']
|
||||||
|
groups = data['groups']
|
||||||
|
|
||||||
|
our_in_winner = False
|
||||||
|
our_in_loser = False
|
||||||
|
|
||||||
|
# Check each group
|
||||||
|
for gid, uids in groups.items():
|
||||||
|
# Intersection of Our UIDs and Group UIDs
|
||||||
|
common = our_uids.intersection(uids)
|
||||||
|
if common:
|
||||||
|
if gid == winner_gid:
|
||||||
|
our_in_winner = True
|
||||||
|
else:
|
||||||
|
our_in_loser = True
|
||||||
|
|
||||||
|
if our_in_winner and not our_in_loser:
|
||||||
|
result_map[mid] = 'win'
|
||||||
|
elif our_in_loser and not our_in_winner:
|
||||||
|
result_map[mid] = 'loss'
|
||||||
|
elif our_in_winner and our_in_loser:
|
||||||
|
result_map[mid] = 'mixed'
|
||||||
|
else:
|
||||||
|
# Fallback: If UID matching failed (maybe missing UIDs), try old team_id method?
|
||||||
|
# Or just leave it as None (safe)
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Convert to dict to modify
|
||||||
|
matches = [dict(m) for m in matches]
|
||||||
|
for m in matches:
|
||||||
|
m['avg_elo'] = elo_map.get(m['match_id'], 0)
|
||||||
|
m['max_party'] = party_map.get(m['match_id'], 1)
|
||||||
|
m['our_result'] = result_map.get(m['match_id'])
|
||||||
|
|
||||||
|
# Convert to dict to modify
|
||||||
|
matches = [dict(m) for m in matches]
|
||||||
|
for m in matches:
|
||||||
|
m['avg_elo'] = elo_map.get(m['match_id'], 0)
|
||||||
|
m['max_party'] = party_map.get(m['match_id'], 1)
|
||||||
|
m['our_result'] = result_map.get(m['match_id'])
|
||||||
|
|
||||||
|
# Count total for pagination
|
||||||
|
count_sql = f"SELECT COUNT(*) as cnt FROM fact_matches WHERE {where_str}"
|
||||||
|
total = query_db('l2', count_sql, args[:-2], one=True)['cnt']
|
||||||
|
|
||||||
|
return matches, total
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_match_detail(match_id):
|
||||||
|
sql = "SELECT * FROM fact_matches WHERE match_id = ?"
|
||||||
|
return query_db('l2', sql, [match_id], one=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_match_players(match_id):
|
||||||
|
sql = """
|
||||||
|
SELECT mp.*, p.username, p.avatar_url
|
||||||
|
FROM fact_match_players mp
|
||||||
|
LEFT JOIN dim_players p ON mp.steam_id_64 = p.steam_id_64
|
||||||
|
WHERE mp.match_id = ?
|
||||||
|
ORDER BY mp.team_id, mp.rating DESC
|
||||||
|
"""
|
||||||
|
return query_db('l2', sql, [match_id])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_match_rounds(match_id):
|
||||||
|
sql = "SELECT * FROM fact_rounds WHERE match_id = ? ORDER BY round_num"
|
||||||
|
return query_db('l2', sql, [match_id])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_players(page=1, per_page=20, search=None, sort_by='rating_desc'):
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
args = []
|
||||||
|
where_clauses = ["1=1"]
|
||||||
|
|
||||||
|
if search:
|
||||||
|
# Force case-insensitive search
|
||||||
|
where_clauses.append("(LOWER(username) LIKE LOWER(?) OR steam_id_64 LIKE ?)")
|
||||||
|
args.append(f"%{search}%")
|
||||||
|
args.append(f"%{search}%")
|
||||||
|
|
||||||
|
where_str = " AND ".join(where_clauses)
|
||||||
|
|
||||||
|
# Sort mapping
|
||||||
|
order_clause = "rating DESC" # Default logic (this query needs refinement as L2 dim_players doesn't store avg rating)
|
||||||
|
# Wait, dim_players only has static info. We need aggregated stats.
|
||||||
|
# Ideally, we should fetch from L3 for player list stats.
|
||||||
|
# But StatsService is for L2.
|
||||||
|
# For the Player List, we usually want L3 data (Career stats).
|
||||||
|
# I will leave the detailed stats logic for FeatureService or do a join here if necessary.
|
||||||
|
# For now, just listing players from dim_players.
|
||||||
|
|
||||||
|
sql = f"""
|
||||||
|
SELECT * FROM dim_players
|
||||||
|
WHERE {where_str}
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
"""
|
||||||
|
args.extend([per_page, offset])
|
||||||
|
|
||||||
|
players = query_db('l2', sql, args)
|
||||||
|
total = query_db('l2', f"SELECT COUNT(*) as cnt FROM dim_players WHERE {where_str}", args[:-2], one=True)['cnt']
|
||||||
|
|
||||||
|
return players, total
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_player_info(steam_id):
|
||||||
|
sql = "SELECT * FROM dim_players WHERE steam_id_64 = ?"
|
||||||
|
return query_db('l2', sql, [steam_id], one=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_daily_match_counts(days=365):
|
||||||
|
# Return list of {date: 'YYYY-MM-DD', count: N}
|
||||||
|
sql = """
|
||||||
|
SELECT date(start_time, 'unixepoch') as day, COUNT(*) as count
|
||||||
|
FROM fact_matches
|
||||||
|
WHERE start_time > strftime('%s', 'now', ?)
|
||||||
|
GROUP BY day
|
||||||
|
ORDER BY day
|
||||||
|
"""
|
||||||
|
# sqlite modifier for 'now' needs format like '-365 days'
|
||||||
|
modifier = f'-{days} days'
|
||||||
|
rows = query_db('l2', sql, [modifier])
|
||||||
|
return rows
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_players_by_ids(steam_ids):
|
||||||
|
if not steam_ids:
|
||||||
|
return []
|
||||||
|
placeholders = ','.join('?' for _ in steam_ids)
|
||||||
|
sql = f"SELECT * FROM dim_players WHERE steam_id_64 IN ({placeholders})"
|
||||||
|
return query_db('l2', sql, steam_ids)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_player_basic_stats(steam_id):
|
||||||
|
# Calculate stats from fact_match_players
|
||||||
|
# Prefer calculating from sums (kills/deaths) for K/D accuracy
|
||||||
|
# AVG(adr) is used as damage_total might be missing in some sources
|
||||||
|
sql = """
|
||||||
|
SELECT
|
||||||
|
AVG(rating) as rating,
|
||||||
|
SUM(kills) as total_kills,
|
||||||
|
SUM(deaths) as total_deaths,
|
||||||
|
AVG(kd_ratio) as avg_kd,
|
||||||
|
AVG(kast) as kast,
|
||||||
|
AVG(adr) as adr,
|
||||||
|
COUNT(*) as matches_played
|
||||||
|
FROM fact_match_players
|
||||||
|
WHERE steam_id_64 = ?
|
||||||
|
"""
|
||||||
|
row = query_db('l2', sql, [steam_id], one=True)
|
||||||
|
|
||||||
|
if row and row['matches_played'] > 0:
|
||||||
|
res = dict(row)
|
||||||
|
|
||||||
|
# Calculate K/D: Sum Kills / Sum Deaths
|
||||||
|
kills = res.get('total_kills') or 0
|
||||||
|
deaths = res.get('total_deaths') or 0
|
||||||
|
|
||||||
|
if deaths > 0:
|
||||||
|
res['kd'] = kills / deaths
|
||||||
|
else:
|
||||||
|
res['kd'] = kills # If 0 deaths, K/D is kills (or infinity, but kills is safer for display)
|
||||||
|
|
||||||
|
# Fallback to avg_kd if calculation failed (e.g. both 0) but avg_kd exists
|
||||||
|
if res['kd'] == 0 and res['avg_kd'] and res['avg_kd'] > 0:
|
||||||
|
res['kd'] = res['avg_kd']
|
||||||
|
|
||||||
|
# ADR validation
|
||||||
|
if res['adr'] is None:
|
||||||
|
res['adr'] = 0.0
|
||||||
|
|
||||||
|
return res
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_shared_matches(steam_ids):
|
||||||
|
# Find matches where ALL steam_ids were present
|
||||||
|
if not steam_ids or len(steam_ids) < 1:
|
||||||
|
return []
|
||||||
|
|
||||||
|
placeholders = ','.join('?' for _ in steam_ids)
|
||||||
|
count = len(steam_ids)
|
||||||
|
|
||||||
|
# We need to know which team the players were on to determine win/loss
|
||||||
|
# Assuming they were on the SAME team for "shared experience"
|
||||||
|
# If count=1, it's just match history
|
||||||
|
|
||||||
|
# Query: Get matches where all steam_ids are present
|
||||||
|
# Also join to get team_id to check if they were on the same team (optional but better)
|
||||||
|
# For simplicity in v1: Just check presence in the match.
|
||||||
|
# AND check if the player won.
|
||||||
|
|
||||||
|
# We need to return: match_id, map_name, score, result (Win/Loss)
|
||||||
|
# "Result" is relative to the lineup.
|
||||||
|
# If they were on the winning team, it's a Win.
|
||||||
|
|
||||||
|
sql = f"""
|
||||||
|
SELECT m.match_id, m.start_time, m.map_name, m.score_team1, m.score_team2, m.winner_team,
|
||||||
|
MAX(mp.team_id) as player_team_id -- Just take one team_id (assuming same)
|
||||||
|
FROM fact_matches m
|
||||||
|
JOIN fact_match_players mp ON m.match_id = mp.match_id
|
||||||
|
WHERE mp.steam_id_64 IN ({placeholders})
|
||||||
|
GROUP BY m.match_id
|
||||||
|
HAVING COUNT(DISTINCT mp.steam_id_64) = ?
|
||||||
|
ORDER BY m.start_time DESC
|
||||||
|
"""
|
||||||
|
|
||||||
|
args = list(steam_ids)
|
||||||
|
args.append(count)
|
||||||
|
|
||||||
|
rows = query_db('l2', sql, args)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for r in rows:
|
||||||
|
# Determine if Win
|
||||||
|
# winner_team in DB is 'Team 1' or 'Team 2' usually, or the team name.
|
||||||
|
# fact_matches.winner_team stores the NAME of the winner? Or 'team1'/'team2'?
|
||||||
|
# Let's check how L2_Builder stores it. Usually it stores the name.
|
||||||
|
# But fact_match_players.team_id stores the name too.
|
||||||
|
|
||||||
|
# Logic: If m.winner_team == mp.team_id, then Win.
|
||||||
|
is_win = (r['winner_team'] == r['player_team_id'])
|
||||||
|
|
||||||
|
# If winner_team is NULL or empty, it's a draw?
|
||||||
|
if not r['winner_team']:
|
||||||
|
result_str = 'Draw'
|
||||||
|
elif is_win:
|
||||||
|
result_str = 'Win'
|
||||||
|
else:
|
||||||
|
result_str = 'Loss'
|
||||||
|
|
||||||
|
res = dict(r)
|
||||||
|
res['is_win'] = is_win # Boolean for styling
|
||||||
|
res['result_str'] = result_str # Text for display
|
||||||
|
results.append(res)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_player_trend(steam_id, limit=20):
|
||||||
|
# We need party_size: count of players with same match_team_id in the same match
|
||||||
|
# Using a correlated subquery for party_size
|
||||||
|
sql = """
|
||||||
|
SELECT * FROM (
|
||||||
|
SELECT
|
||||||
|
m.start_time,
|
||||||
|
mp.rating,
|
||||||
|
mp.kd_ratio,
|
||||||
|
mp.adr,
|
||||||
|
m.match_id,
|
||||||
|
m.map_name,
|
||||||
|
mp.is_win,
|
||||||
|
mp.match_team_id,
|
||||||
|
(SELECT COUNT(*)
|
||||||
|
FROM fact_match_players p2
|
||||||
|
WHERE p2.match_id = mp.match_id
|
||||||
|
AND p2.match_team_id = mp.match_team_id
|
||||||
|
AND p2.match_team_id > 0 -- Ensure we don't count 0 (solo default) as a massive party
|
||||||
|
) as party_size,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM fact_matches m2
|
||||||
|
WHERE m2.start_time <= m.start_time
|
||||||
|
) as match_index
|
||||||
|
FROM fact_match_players mp
|
||||||
|
JOIN fact_matches m ON mp.match_id = m.match_id
|
||||||
|
WHERE mp.steam_id_64 = ?
|
||||||
|
ORDER BY m.start_time DESC
|
||||||
|
LIMIT ?
|
||||||
|
) ORDER BY start_time ASC
|
||||||
|
"""
|
||||||
|
return query_db('l2', sql, [steam_id, limit])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_roster_stats_distribution(target_steam_id):
|
||||||
|
"""
|
||||||
|
Calculates rank and distribution of the target player within the active roster.
|
||||||
|
Now covers all L3 Basic Features for Detailed Panel.
|
||||||
|
"""
|
||||||
|
from web.services.web_service import WebService
|
||||||
|
from web.services.feature_service import FeatureService
|
||||||
|
import json
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# 1. Get Active Roster IDs
|
||||||
|
lineups = WebService.get_lineups()
|
||||||
|
active_roster_ids = []
|
||||||
|
if lineups:
|
||||||
|
try:
|
||||||
|
raw_ids = json.loads(lineups[0]['player_ids_json'])
|
||||||
|
active_roster_ids = [str(uid) for uid in raw_ids]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not active_roster_ids:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 2. Fetch L3 features for all roster members
|
||||||
|
# We need to use FeatureService to get the full L3 set (including detailed stats)
|
||||||
|
# Assuming L3 data is up to date.
|
||||||
|
|
||||||
|
placeholders = ','.join('?' for _ in active_roster_ids)
|
||||||
|
sql = f"SELECT * FROM dm_player_features WHERE steam_id_64 IN ({placeholders})"
|
||||||
|
rows = query_db('l3', sql, active_roster_ids)
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
|
||||||
|
stats_map = {row['steam_id_64']: dict(row) for row in rows}
|
||||||
|
target_steam_id = str(target_steam_id)
|
||||||
|
|
||||||
|
# If target not in map (e.g. no L3 data), try to add empty default
|
||||||
|
if target_steam_id not in stats_map:
|
||||||
|
stats_map[target_steam_id] = {}
|
||||||
|
|
||||||
|
# 3. Calculate Distribution for ALL metrics
|
||||||
|
# Define metrics list (must match Detailed Panel keys)
|
||||||
|
metrics = [
|
||||||
|
'basic_avg_rating', 'basic_avg_kd', 'basic_avg_kast', 'basic_avg_rws', 'basic_avg_adr',
|
||||||
|
'basic_avg_headshot_kills', 'basic_headshot_rate', 'basic_avg_assisted_kill', 'basic_avg_awp_kill', 'basic_avg_jump_count',
|
||||||
|
'basic_avg_mvps', 'basic_avg_plants', 'basic_avg_defuses', 'basic_avg_flash_assists',
|
||||||
|
'basic_avg_first_kill', 'basic_avg_first_death', 'basic_first_kill_rate', 'basic_first_death_rate',
|
||||||
|
'basic_avg_kill_2', 'basic_avg_kill_3', 'basic_avg_kill_4', 'basic_avg_kill_5',
|
||||||
|
'basic_avg_perfect_kill', 'basic_avg_revenge_kill',
|
||||||
|
# L3 Advanced Dimensions
|
||||||
|
'sta_last_30_rating', 'sta_win_rating', 'sta_loss_rating', 'sta_rating_volatility', 'sta_time_rating_corr',
|
||||||
|
'bat_kd_diff_high_elo', 'bat_avg_duel_win_rate', 'bat_win_rate_vs_all',
|
||||||
|
'hps_clutch_win_rate_1v1', 'hps_clutch_win_rate_1v3_plus', 'hps_match_point_win_rate', 'hps_pressure_entry_rate', 'hps_comeback_kd_diff', 'hps_losing_streak_kd_diff',
|
||||||
|
'ptl_pistol_kills', 'ptl_pistol_win_rate', 'ptl_pistol_kd', 'ptl_pistol_util_efficiency',
|
||||||
|
'side_rating_ct', 'side_rating_t', 'side_first_kill_rate_ct', 'side_first_kill_rate_t', 'side_kd_diff_ct_t', 'side_hold_success_rate_ct', 'side_entry_success_rate_t',
|
||||||
|
'side_win_rate_ct', 'side_win_rate_t', 'side_kd_ct', 'side_kd_t',
|
||||||
|
'side_kast_ct', 'side_kast_t', 'side_rws_ct', 'side_rws_t',
|
||||||
|
'side_first_death_rate_ct', 'side_first_death_rate_t',
|
||||||
|
'side_multikill_rate_ct', 'side_multikill_rate_t',
|
||||||
|
'side_headshot_rate_ct', 'side_headshot_rate_t',
|
||||||
|
'side_defuses_ct', 'side_plants_t',
|
||||||
|
'util_avg_nade_dmg', 'util_avg_flash_time', 'util_avg_flash_enemy', 'util_usage_rate'
|
||||||
|
]
|
||||||
|
|
||||||
|
# Mapping for L2 legacy calls (if any) - mainly map 'rating' to 'basic_avg_rating' etc if needed
|
||||||
|
# But here we just use L3 columns directly.
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
for m in metrics:
|
||||||
|
values = [p.get(m, 0) or 0 for p in stats_map.values()]
|
||||||
|
target_val = stats_map[target_steam_id].get(m, 0) or 0
|
||||||
|
|
||||||
|
if not values:
|
||||||
|
result[m] = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
values.sort(reverse=True)
|
||||||
|
|
||||||
|
# Rank
|
||||||
|
try:
|
||||||
|
rank = values.index(target_val) + 1
|
||||||
|
except ValueError:
|
||||||
|
rank = len(values)
|
||||||
|
|
||||||
|
result[m] = {
|
||||||
|
'val': target_val,
|
||||||
|
'rank': rank,
|
||||||
|
'total': len(values),
|
||||||
|
'min': min(values),
|
||||||
|
'max': max(values),
|
||||||
|
'avg': sum(values) / len(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Legacy mapping for top cards (rating, kd, adr, kast)
|
||||||
|
legacy_map = {
|
||||||
|
'basic_avg_rating': 'rating',
|
||||||
|
'basic_avg_kd': 'kd',
|
||||||
|
'basic_avg_adr': 'adr',
|
||||||
|
'basic_avg_kast': 'kast'
|
||||||
|
}
|
||||||
|
if m in legacy_map:
|
||||||
|
result[legacy_map[m]] = result[m]
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_live_matches():
|
||||||
|
# Query matches started in last 2 hours with no winner
|
||||||
|
# Assuming we have a way to ingest live matches.
|
||||||
|
# For now, this query is 'formal' but will likely return empty on static dataset.
|
||||||
|
sql = """
|
||||||
|
SELECT m.match_id, m.map_name, m.score_team1, m.score_team2, m.start_time
|
||||||
|
FROM fact_matches m
|
||||||
|
WHERE m.winner_team IS NULL
|
||||||
|
AND m.start_time > strftime('%s', 'now', '-2 hours')
|
||||||
|
"""
|
||||||
|
return query_db('l2', sql)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_head_to_head_stats(match_id):
|
||||||
|
"""
|
||||||
|
Returns a matrix of kills between players.
|
||||||
|
List of {attacker_steam_id, victim_steam_id, kills}
|
||||||
|
"""
|
||||||
|
sql = """
|
||||||
|
SELECT attacker_steam_id, victim_steam_id, COUNT(*) as kills
|
||||||
|
FROM fact_round_events
|
||||||
|
WHERE match_id = ? AND event_type = 'kill'
|
||||||
|
GROUP BY attacker_steam_id, victim_steam_id
|
||||||
|
"""
|
||||||
|
return query_db('l2', sql, [match_id])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_match_round_details(match_id):
|
||||||
|
"""
|
||||||
|
Returns a detailed dictionary of rounds, events, and economy.
|
||||||
|
{
|
||||||
|
round_num: {
|
||||||
|
info: {winner_side, win_reason_desc, end_time_stamp...},
|
||||||
|
events: [ {event_type, event_time, attacker..., weapon...}, ... ],
|
||||||
|
economy: { steam_id: {main_weapon, equipment_value...}, ... }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# 1. Base Round Info
|
||||||
|
rounds_sql = "SELECT * FROM fact_rounds WHERE match_id = ? ORDER BY round_num"
|
||||||
|
rounds_rows = query_db('l2', rounds_sql, [match_id])
|
||||||
|
|
||||||
|
if not rounds_rows:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# 2. Events
|
||||||
|
events_sql = """
|
||||||
|
SELECT * FROM fact_round_events
|
||||||
|
WHERE match_id = ?
|
||||||
|
ORDER BY round_num, event_time
|
||||||
|
"""
|
||||||
|
events_rows = query_db('l2', events_sql, [match_id])
|
||||||
|
|
||||||
|
# 3. Economy (if avail)
|
||||||
|
eco_sql = """
|
||||||
|
SELECT * FROM fact_round_player_economy
|
||||||
|
WHERE match_id = ?
|
||||||
|
"""
|
||||||
|
eco_rows = query_db('l2', eco_sql, [match_id])
|
||||||
|
|
||||||
|
# Structure Data
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
# Initialize rounds
|
||||||
|
for r in rounds_rows:
|
||||||
|
r_num = r['round_num']
|
||||||
|
result[r_num] = {
|
||||||
|
'info': dict(r),
|
||||||
|
'events': [],
|
||||||
|
'economy': {}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Group events
|
||||||
|
for e in events_rows:
|
||||||
|
r_num = e['round_num']
|
||||||
|
if r_num in result:
|
||||||
|
result[r_num]['events'].append(dict(e))
|
||||||
|
|
||||||
|
# Group economy
|
||||||
|
for eco in eco_rows:
|
||||||
|
r_num = eco['round_num']
|
||||||
|
sid = eco['steam_id_64']
|
||||||
|
if r_num in result:
|
||||||
|
result[r_num]['economy'][sid] = dict(eco)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
120
web/services/web_service.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
from web.database import query_db, execute_db
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class WebService:
|
||||||
|
# --- Comments ---
|
||||||
|
@staticmethod
|
||||||
|
def get_comments(target_type, target_id):
|
||||||
|
sql = "SELECT * FROM comments WHERE target_type = ? AND target_id = ? AND is_hidden = 0 ORDER BY created_at DESC"
|
||||||
|
return query_db('web', sql, [target_type, target_id])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def add_comment(user_id, username, target_type, target_id, content):
|
||||||
|
sql = """
|
||||||
|
INSERT INTO comments (user_id, username, target_type, target_id, content)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
"""
|
||||||
|
return execute_db('web', sql, [user_id, username, target_type, target_id, content])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def like_comment(comment_id):
|
||||||
|
sql = "UPDATE comments SET likes = likes + 1 WHERE id = ?"
|
||||||
|
return execute_db('web', sql, [comment_id])
|
||||||
|
|
||||||
|
# --- Wiki ---
|
||||||
|
@staticmethod
|
||||||
|
def get_wiki_page(path):
|
||||||
|
sql = "SELECT * FROM wiki_pages WHERE path = ?"
|
||||||
|
return query_db('web', sql, [path], one=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_all_wiki_pages():
|
||||||
|
sql = "SELECT path, title FROM wiki_pages ORDER BY path"
|
||||||
|
return query_db('web', sql)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def save_wiki_page(path, title, content, updated_by):
|
||||||
|
# Upsert logic
|
||||||
|
check = query_db('web', "SELECT id FROM wiki_pages WHERE path = ?", [path], one=True)
|
||||||
|
if check:
|
||||||
|
sql = "UPDATE wiki_pages SET title=?, content=?, updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE path=?"
|
||||||
|
execute_db('web', sql, [title, content, updated_by, path])
|
||||||
|
else:
|
||||||
|
sql = "INSERT INTO wiki_pages (path, title, content, updated_by) VALUES (?, ?, ?, ?)"
|
||||||
|
execute_db('web', sql, [path, title, content, updated_by])
|
||||||
|
|
||||||
|
# --- Team Lineups ---
|
||||||
|
@staticmethod
|
||||||
|
def save_lineup(name, description, player_ids, lineup_id=None):
|
||||||
|
# player_ids is a list
|
||||||
|
ids_json = json.dumps(player_ids)
|
||||||
|
if lineup_id:
|
||||||
|
sql = "UPDATE team_lineups SET name=?, description=?, player_ids_json=? WHERE id=?"
|
||||||
|
return execute_db('web', sql, [name, description, ids_json, lineup_id])
|
||||||
|
else:
|
||||||
|
sql = "INSERT INTO team_lineups (name, description, player_ids_json) VALUES (?, ?, ?)"
|
||||||
|
return execute_db('web', sql, [name, description, ids_json])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_lineups():
|
||||||
|
return query_db('web', "SELECT * FROM team_lineups ORDER BY created_at DESC")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_lineup(lineup_id):
|
||||||
|
return query_db('web', "SELECT * FROM team_lineups WHERE id = ?", [lineup_id], one=True)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Users / Auth ---
|
||||||
|
@staticmethod
|
||||||
|
def get_user_by_token(token):
|
||||||
|
sql = "SELECT * FROM users WHERE token = ?"
|
||||||
|
return query_db('web', sql, [token], one=True)
|
||||||
|
|
||||||
|
# --- Player Metadata ---
|
||||||
|
@staticmethod
|
||||||
|
def get_player_metadata(steam_id):
|
||||||
|
sql = "SELECT * FROM player_metadata WHERE steam_id_64 = ?"
|
||||||
|
row = query_db('web', sql, [steam_id], one=True)
|
||||||
|
if row:
|
||||||
|
res = dict(row)
|
||||||
|
try:
|
||||||
|
res['tags'] = json.loads(res['tags']) if res['tags'] else []
|
||||||
|
except:
|
||||||
|
res['tags'] = []
|
||||||
|
return res
|
||||||
|
return {'steam_id_64': steam_id, 'notes': '', 'tags': []}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_player_metadata(steam_id, notes=None, tags=None):
|
||||||
|
# Upsert
|
||||||
|
check = query_db('web', "SELECT steam_id_64 FROM player_metadata WHERE steam_id_64 = ?", [steam_id], one=True)
|
||||||
|
|
||||||
|
tags_json = json.dumps(tags) if tags is not None else None
|
||||||
|
|
||||||
|
if check:
|
||||||
|
# Update
|
||||||
|
clauses = []
|
||||||
|
args = []
|
||||||
|
if notes is not None:
|
||||||
|
clauses.append("notes = ?")
|
||||||
|
args.append(notes)
|
||||||
|
if tags is not None:
|
||||||
|
clauses.append("tags = ?")
|
||||||
|
args.append(tags_json)
|
||||||
|
|
||||||
|
if clauses:
|
||||||
|
clauses.append("updated_at = CURRENT_TIMESTAMP")
|
||||||
|
sql = f"UPDATE player_metadata SET {', '.join(clauses)} WHERE steam_id_64 = ?"
|
||||||
|
args.append(steam_id)
|
||||||
|
execute_db('web', sql, args)
|
||||||
|
else:
|
||||||
|
# Insert
|
||||||
|
sql = "INSERT INTO player_metadata (steam_id_64, notes, tags) VALUES (?, ?, ?)"
|
||||||
|
execute_db('web', sql, [steam_id, notes or '', tags_json or '[]'])
|
||||||
|
|
||||||
|
# --- Strategy Board ---
|
||||||
|
@staticmethod
|
||||||
|
def save_strategy_board(title, map_name, data_json, created_by):
|
||||||
|
sql = "INSERT INTO strategy_boards (title, map_name, data_json, created_by) VALUES (?, ?, ?, ?)"
|
||||||
|
return execute_db('web', sql, [title, map_name, data_json, created_by])
|
||||||
BIN
web/static/avatars/76561198330488905.jpg
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
web/static/avatars/76561198970034329.jpg
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
web/static/avatars/76561199026688017.jpg
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
web/static/avatars/76561199032002725.jpg
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
web/static/avatars/76561199076109761.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
web/static/avatars/76561199078250590.jpg
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
web/static/avatars/76561199106558767.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
web/static/avatars/76561199390145159.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
web/static/avatars/76561199417030350.jpg
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
web/static/avatars/76561199467422873.jpg
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
web/static/avatars/76561199526984477.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
54
web/templates/admin/dashboard.html
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">管理后台 (Admin Dashboard)</h2>
|
||||||
|
<a href="{{ url_for('admin.logout') }}" class="text-red-600 hover:text-red-800">Logout</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- ETL Controls -->
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-4">数据管线 (ETL)</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<button onclick="triggerEtl('L1A.py')" class="w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700">Trigger L1A (Ingest)</button>
|
||||||
|
<button onclick="triggerEtl('L2_Builder.py')" class="w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700">Trigger L2 Builder</button>
|
||||||
|
<button onclick="triggerEtl('L3_Builder.py')" class="w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700">Trigger L3 Builder</button>
|
||||||
|
</div>
|
||||||
|
<div id="etlResult" class="mt-4 text-sm text-gray-600 dark:text-gray-400"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tools -->
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-4">工具箱</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<a href="{{ url_for('admin.sql_runner') }}" class="block w-full text-center bg-gray-600 text-white py-2 px-4 rounded hover:bg-gray-700">SQL Runner</a>
|
||||||
|
<a href="{{ url_for('wiki.index') }}" class="block w-full text-center bg-gray-600 text-white py-2 px-4 rounded hover:bg-gray-700">Manage Wiki</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function triggerEtl(scriptName) {
|
||||||
|
const resultDiv = document.getElementById('etlResult');
|
||||||
|
resultDiv.innerText = "Triggering " + scriptName + "...";
|
||||||
|
|
||||||
|
fetch("{{ url_for('admin.trigger_etl') }}", {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: 'script=' + scriptName
|
||||||
|
})
|
||||||
|
.then(response => response.text())
|
||||||
|
.then(text => {
|
||||||
|
resultDiv.innerText = text;
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
resultDiv.innerText = "Error: " + err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
38
web/templates/admin/login.html
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="min-h-full flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="max-w-md w-full space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||||
|
Admin Login
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
|
||||||
|
<span class="block sm:inline">{{ message }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form class="mt-8 space-y-6" action="{{ url_for('admin.login') }}" method="POST">
|
||||||
|
<div class="rounded-md shadow-sm -space-y-px">
|
||||||
|
<div>
|
||||||
|
<label for="token" class="sr-only">Admin Token</label>
|
||||||
|
<input id="token" name="token" type="password" required class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md rounded-b-md focus:outline-none focus:ring-yrtv-500 focus:border-yrtv-500 focus:z-10 sm:text-sm" placeholder="Enter Admin Token">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button type="submit" class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-yrtv-600 hover:bg-yrtv-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yrtv-500">
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
52
web/templates/admin/sql.html
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">SQL Runner</h2>
|
||||||
|
|
||||||
|
<form action="{{ url_for('admin.sql_runner') }}" method="POST" class="mb-6">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Database</label>
|
||||||
|
<select name="db_name" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 dark:bg-slate-700 dark:text-white">
|
||||||
|
<option value="l2" {% if db_name == 'l2' %}selected{% endif %}>L2 (Facts)</option>
|
||||||
|
<option value="l3" {% if db_name == 'l3' %}selected{% endif %}>L3 (Features)</option>
|
||||||
|
<option value="web" {% if db_name == 'web' %}selected{% endif %}>Web (App Data)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Query</label>
|
||||||
|
<textarea name="query" rows="5" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 font-mono text-sm dark:bg-slate-700 dark:text-white" placeholder="SELECT * FROM table LIMIT 10">{{ query }}</textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="bg-yrtv-600 text-white py-2 px-4 rounded hover:bg-yrtv-700">Run Query</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if result %}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 border">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-700">
|
||||||
|
<tr>
|
||||||
|
{% for col in result.columns %}
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border-b">{{ col }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% for row in result.rows %}
|
||||||
|
<tr>
|
||||||
|
{% for col in result.columns %}
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 border-b">{{ row[col] }}</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
158
web/templates/base.html
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}YRTV - CS2 Data Platform{% endblock %}</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/hammer.js/2.0.8/hammer.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
yrtv: {
|
||||||
|
50: '#f5f3ff',
|
||||||
|
100: '#ede9fe',
|
||||||
|
500: '#8b5cf6',
|
||||||
|
600: '#7c3aed',
|
||||||
|
900: '#4c1d95',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Inter', sans-serif; }
|
||||||
|
</style>
|
||||||
|
{% block head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body class="bg-slate-50 text-slate-900 dark:bg-slate-900 dark:text-slate-100 flex flex-col min-h-screen">
|
||||||
|
|
||||||
|
<!-- Navbar -->
|
||||||
|
<nav class="bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700" x-data="{ mobileMenuOpen: false }">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex justify-between h-16">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0 flex items-center">
|
||||||
|
<a href="{{ url_for('main.index') }}" class="text-2xl font-bold text-yrtv-600">YRTV</a>
|
||||||
|
</div>
|
||||||
|
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
|
||||||
|
<a href="{{ url_for('main.index') }}" class="{% if request.endpoint == 'main.index' %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">首页</a>
|
||||||
|
<a href="{{ url_for('matches.index') }}" class="{% if request.endpoint and 'matches' in request.endpoint %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">比赛</a>
|
||||||
|
<a href="{{ url_for('players.index') }}" class="{% if request.endpoint and 'players' in request.endpoint %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">玩家</a>
|
||||||
|
<a href="{{ url_for('teams.index') }}" class="{% if request.endpoint and 'teams' in request.endpoint %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">战队</a>
|
||||||
|
<a href="{{ url_for('tactics.index') }}" class="{% if request.endpoint and 'tactics' in request.endpoint %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">战术</a>
|
||||||
|
<a href="{{ url_for('wiki.index') }}" class="{% if request.endpoint and 'wiki' in request.endpoint %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">Wiki</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<!-- Mobile menu button -->
|
||||||
|
<div class="flex items-center sm:hidden">
|
||||||
|
<button @click="mobileMenuOpen = !mobileMenuOpen" type="button" class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-yrtv-500" aria-controls="mobile-menu" aria-expanded="false">
|
||||||
|
<span class="sr-only">Open main menu</span>
|
||||||
|
<svg class="block h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dark Mode Toggle -->
|
||||||
|
<button id="theme-toggle" type="button" class="text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5">
|
||||||
|
<svg id="theme-toggle-dark-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path></svg>
|
||||||
|
<svg id="theme-toggle-light-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" fill-rule="evenodd" clip-rule="evenodd"></path></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{% if session.get('is_admin') %}
|
||||||
|
<a href="{{ url_for('admin.dashboard') }}" class="hidden sm:block text-sm font-medium text-gray-500 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white">Admin</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('admin.login') }}" class="hidden sm:block bg-yrtv-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-yrtv-500">登录</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile menu, show/hide based on menu state. -->
|
||||||
|
<div class="sm:hidden" id="mobile-menu" x-show="mobileMenuOpen" style="display: none;">
|
||||||
|
<div class="pt-2 pb-3 space-y-1">
|
||||||
|
<a href="{{ url_for('main.index') }}" class="{% if request.endpoint == 'main.index' %}bg-yrtv-50 border-yrtv-500 text-yrtv-700{% else %}border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700{% endif %} block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">首页</a>
|
||||||
|
<a href="{{ url_for('matches.index') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">比赛</a>
|
||||||
|
<a href="{{ url_for('players.index') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">玩家</a>
|
||||||
|
<a href="{{ url_for('teams.index') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">战队</a>
|
||||||
|
<a href="{{ url_for('tactics.index') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">战术</a>
|
||||||
|
<a href="{{ url_for('wiki.index') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">Wiki</a>
|
||||||
|
{% if session.get('is_admin') %}
|
||||||
|
<a href="{{ url_for('admin.dashboard') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">Admin</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('admin.login') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">登录</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="flex-grow max-w-7xl mx-auto py-6 sm:px-6 lg:px-8 w-full">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="bg-white dark:bg-slate-800 border-t border-slate-200 dark:border-slate-700 mt-auto">
|
||||||
|
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||||
|
<p class="text-center text-sm text-gray-500">© 2026 YRTV CS2 Data Platform. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Dark mode toggle logic
|
||||||
|
var themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
|
||||||
|
var themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
|
||||||
|
|
||||||
|
// Change the icons inside the button based on previous settings
|
||||||
|
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||||
|
themeToggleLightIcon.classList.remove('hidden');
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
themeToggleDarkIcon.classList.remove('hidden');
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
var themeToggleBtn = document.getElementById('theme-toggle');
|
||||||
|
|
||||||
|
themeToggleBtn.addEventListener('click', function() {
|
||||||
|
|
||||||
|
// toggle icons inside button
|
||||||
|
themeToggleDarkIcon.classList.toggle('hidden');
|
||||||
|
themeToggleLightIcon.classList.toggle('hidden');
|
||||||
|
|
||||||
|
// if set via local storage previously
|
||||||
|
if (localStorage.getItem('color-theme')) {
|
||||||
|
if (localStorage.getItem('color-theme') === 'light') {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
localStorage.setItem('color-theme', 'dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
localStorage.setItem('color-theme', 'light');
|
||||||
|
}
|
||||||
|
|
||||||
|
// if NOT set via local storage previously
|
||||||
|
} else {
|
||||||
|
if (document.documentElement.classList.contains('dark')) {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
localStorage.setItem('color-theme', 'light');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
localStorage.setItem('color-theme', 'dark');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
195
web/templates/home/index.html
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-8">
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<div class="bg-gradient-to-r from-yrtv-900 to-yrtv-600 rounded-2xl shadow-xl overflow-hidden">
|
||||||
|
<div class="px-6 py-12 sm:px-12 sm:py-16 lg:py-20 text-center">
|
||||||
|
<h1 class="text-4xl font-extrabold tracking-tight text-white sm:text-5xl lg:text-6xl">
|
||||||
|
JKTV CS2 队伍数据洞察平台
|
||||||
|
</h1>
|
||||||
|
<p class="mt-6 max-w-lg mx-auto text-xl text-yrtv-100 sm:max-w-3xl">
|
||||||
|
深度挖掘比赛数据,提供战术研判、阵容模拟与多维能力分析。
|
||||||
|
</p>
|
||||||
|
<div class="mt-10 max-w-sm mx-auto sm:max-w-none sm:flex sm:justify-center">
|
||||||
|
<a href="{{ url_for('matches.index') }}" class="flex items-center justify-center px-4 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-yrtv-700 bg-white hover:bg-yrtv-50 dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700 sm:px-8">
|
||||||
|
近期比赛
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('players.index') }}" class="mt-3 sm:mt-0 sm:ml-3 flex items-center justify-center px-4 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-yrtv-500 bg-opacity-60 hover:bg-opacity-70 dark:bg-yrtv-600 dark:hover:bg-yrtv-700 sm:px-8">
|
||||||
|
数据中心
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Match Parser Input -->
|
||||||
|
<div class="mt-10 max-w-lg mx-auto">
|
||||||
|
<form id="parserForm" class="sm:flex">
|
||||||
|
<label for="match-url" class="sr-only">Match URL</label>
|
||||||
|
<input id="match-url" name="url" type="text" placeholder="Paste 5E Match URL here..." required class="block w-full px-5 py-3 text-base text-gray-900 placeholder-gray-500 border border-transparent rounded-md shadow-sm focus:outline-none focus:border-transparent focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-yrtv-600">
|
||||||
|
<button type="submit" class="mt-3 w-full px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-yrtv-500 shadow-sm hover:bg-yrtv-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yrtv-600 sm:mt-0 sm:ml-3 sm:flex-shrink-0 sm:inline-flex sm:items-center sm:w-auto">
|
||||||
|
Parse
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p id="parserMsg" class="mt-3 text-sm text-yrtv-100"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live & Recent Status -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<!-- Activity Heatmap -->
|
||||||
|
<div class="lg:col-span-3 bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||||
|
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white mb-4">活跃度 (Activity)</h3>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<div id="calendar-heatmap" class="flex space-x-1 min-w-max pb-2">
|
||||||
|
<!-- JS will populate this -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex items-center justify-end text-xs text-gray-500 space-x-1">
|
||||||
|
<span>Less</span>
|
||||||
|
<span class="w-3 h-3 bg-gray-100 dark:bg-slate-700 rounded-sm"></span>
|
||||||
|
<span class="w-3 h-3 bg-green-200 rounded-sm"></span>
|
||||||
|
<span class="w-3 h-3 bg-green-400 rounded-sm"></span>
|
||||||
|
<span class="w-3 h-3 bg-green-600 rounded-sm"></span>
|
||||||
|
<span class="w-3 h-3 bg-green-800 rounded-sm"></span>
|
||||||
|
<span>More</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live Status -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white">正在进行 (Live)</h3>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
|
Online
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-center py-8 text-gray-500">
|
||||||
|
{% if live_matches %}
|
||||||
|
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% for m in live_matches %}
|
||||||
|
<li class="py-2">
|
||||||
|
<span class="font-bold">{{ m.map_name }}</span>: {{ m.score_team1 }} - {{ m.score_team2 }}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p>暂无正在进行的比赛</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Matches -->
|
||||||
|
<div class="lg:col-span-2 bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||||
|
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white mb-4">近期战况</h3>
|
||||||
|
<div class="flow-root">
|
||||||
|
<ul class="-my-5 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% for match in recent_matches %}
|
||||||
|
<li class="py-4">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||||
|
{{ match.map_name }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500 truncate">
|
||||||
|
{{ match.start_time | default('Unknown Date') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex items-center text-base font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ match.score_team1 }} : {{ match.score_team2 }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="{{ url_for('matches.detail', match_id=match.match_id) }}" class="text-sm text-yrtv-600 hover:text-yrtv-900">详情</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="py-4 text-center text-gray-500">暂无比赛数据</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// --- Match Parser ---
|
||||||
|
const parserForm = document.getElementById('parserForm');
|
||||||
|
const parserMsg = document.getElementById('parserMsg');
|
||||||
|
|
||||||
|
if (parserForm) {
|
||||||
|
parserForm.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const url = document.getElementById('match-url').value;
|
||||||
|
parserMsg.innerText = "Parsing...";
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('url', url);
|
||||||
|
|
||||||
|
fetch("{{ url_for('main.parse_match') }}", {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
parserMsg.innerText = data.message;
|
||||||
|
if(data.success) {
|
||||||
|
document.getElementById('match-url').value = '';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
parserMsg.innerText = "Error: " + err;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Heatmap ---
|
||||||
|
const heatmapData = {{ heatmap_data|tojson }};
|
||||||
|
const heatmapContainer = document.getElementById('calendar-heatmap');
|
||||||
|
|
||||||
|
if (heatmapContainer) {
|
||||||
|
// Generate last 365 days
|
||||||
|
const today = new Date();
|
||||||
|
const oneDay = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
let weeks = [];
|
||||||
|
let currentWeek = [];
|
||||||
|
const startDate = new Date(today.getTime() - (52 * 7 * oneDay));
|
||||||
|
|
||||||
|
for (let i = 0; i < 365; i++) {
|
||||||
|
const d = new Date(startDate.getTime() + (i * oneDay));
|
||||||
|
const dateStr = d.toISOString().split('T')[0];
|
||||||
|
const count = heatmapData[dateStr] || 0;
|
||||||
|
|
||||||
|
let colorClass = 'bg-gray-100 dark:bg-slate-700';
|
||||||
|
if (count > 0) colorClass = 'bg-green-200';
|
||||||
|
if (count > 2) colorClass = 'bg-green-400';
|
||||||
|
if (count > 5) colorClass = 'bg-green-600';
|
||||||
|
if (count > 8) colorClass = 'bg-green-800';
|
||||||
|
|
||||||
|
currentWeek.push({date: dateStr, count: count, color: colorClass});
|
||||||
|
|
||||||
|
if (currentWeek.length === 7) {
|
||||||
|
weeks.push(currentWeek);
|
||||||
|
currentWeek = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentWeek.length > 0) weeks.push(currentWeek);
|
||||||
|
|
||||||
|
weeks.forEach(week => {
|
||||||
|
const weekDiv = document.createElement('div');
|
||||||
|
weekDiv.className = 'flex flex-col space-y-1';
|
||||||
|
week.forEach(day => {
|
||||||
|
const dayDiv = document.createElement('div');
|
||||||
|
dayDiv.className = `w-3 h-3 rounded-sm ${day.color}`;
|
||||||
|
dayDiv.title = `${day.date}: ${day.count} matches`;
|
||||||
|
weekDiv.appendChild(dayDiv);
|
||||||
|
});
|
||||||
|
heatmapContainer.appendChild(weekDiv);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
406
web/templates/matches/detail.html
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6" x-data="{ tab: 'overview' }">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">{{ match.map_name }}</h1>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">Match ID: {{ match.match_id }} | {{ match.start_time }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-4xl font-black text-gray-900 dark:text-white">
|
||||||
|
<span class="{% if match.winner_team == 1 %}text-green-600{% endif %}">{{ match.score_team1 }}</span>
|
||||||
|
:
|
||||||
|
<span class="{% if match.winner_team == 2 %}text-green-600{% endif %}">{{ match.score_team2 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="{{ url_for('matches.raw_json', match_id=match.match_id) }}" target="_blank" class="text-sm text-yrtv-600 hover:underline">Download Raw JSON</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Navigation -->
|
||||||
|
<div class="mt-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<nav class="-mb-px flex space-x-8" aria-label="Tabs">
|
||||||
|
<button @click="tab = 'overview'"
|
||||||
|
:class="tab === 'overview' ? 'border-yrtv-500 text-yrtv-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||||
|
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
|
||||||
|
Overview
|
||||||
|
</button>
|
||||||
|
<button @click="tab = 'h2h'"
|
||||||
|
:class="tab === 'h2h' ? 'border-yrtv-500 text-yrtv-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||||
|
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
|
||||||
|
Head to Head
|
||||||
|
</button>
|
||||||
|
<button @click="tab = 'rounds'"
|
||||||
|
:class="tab === 'rounds' ? 'border-yrtv-500 text-yrtv-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||||
|
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
|
||||||
|
Round History
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Overview -->
|
||||||
|
<div x-show="tab === 'overview'" class="space-y-6">
|
||||||
|
<!-- Team 1 Stats -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow rounded-lg overflow-hidden">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-slate-700">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Team 1</h3>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-700">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Player</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">K</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">D</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">A</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">+/-</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">ADR</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">KAST</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Rating</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% for p in team1_players %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0 h-8 w-8">
|
||||||
|
{% if p.avatar_url %}
|
||||||
|
<img class="h-8 w-8 rounded-full" src="{{ p.avatar_url }}" alt="">
|
||||||
|
{% else %}
|
||||||
|
<div class="h-8 w-8 rounded-full bg-yrtv-100 flex items-center justify-center text-yrtv-600 font-bold text-xs border border-yrtv-200">
|
||||||
|
{{ (p.username or p.steam_id_64)[:2] | upper }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<a href="{{ url_for('players.detail', steam_id=p.steam_id_64) }}" class="text-sm font-medium text-gray-900 dark:text-white hover:text-yrtv-600">
|
||||||
|
{{ p.username or p.steam_id_64 }}
|
||||||
|
</a>
|
||||||
|
{% if p.party_size > 1 %}
|
||||||
|
{% set pc = p.party_size %}
|
||||||
|
{% set p_color = 'bg-blue-100 text-blue-800' %}
|
||||||
|
{% if pc == 2 %}{% set p_color = 'bg-indigo-100 text-indigo-800' %}
|
||||||
|
{% elif pc == 3 %}{% set p_color = 'bg-blue-100 text-blue-800' %}
|
||||||
|
{% elif pc == 4 %}{% set p_color = 'bg-purple-100 text-purple-800' %}
|
||||||
|
{% elif pc >= 5 %}{% set p_color = 'bg-orange-100 text-orange-800' %}
|
||||||
|
{% endif %}
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium {{ p_color }} dark:bg-opacity-20" title="Roster Party of {{ p.party_size }}">
|
||||||
|
<svg class="mr-1 h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z" />
|
||||||
|
</svg>
|
||||||
|
{{ p.party_size }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-4 whitespace-nowrap text-sm text-right text-gray-900 dark:text-white">{{ p.kills }}</td>
|
||||||
|
<td class="px-4 py-4 whitespace-nowrap text-sm text-right text-gray-500 dark:text-gray-400">{{ p.deaths }}</td>
|
||||||
|
<td class="px-4 py-4 whitespace-nowrap text-sm text-right text-gray-500 dark:text-gray-400">{{ p.assists }}</td>
|
||||||
|
<td class="px-4 py-4 whitespace-nowrap text-sm text-right font-medium {% if (p.kills - p.deaths) >= 0 %}text-green-600{% else %}text-red-600{% endif %}">
|
||||||
|
{{ p.kills - p.deaths }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-4 whitespace-nowrap text-sm text-right text-gray-500 dark:text-gray-400">{{ "%.1f"|format(p.adr or 0) }}</td>
|
||||||
|
<td class="px-4 py-4 whitespace-nowrap text-sm text-right text-gray-500 dark:text-gray-400">{{ "%.1f"|format(p.kast or 0) }}%</td>
|
||||||
|
<td class="px-4 py-4 whitespace-nowrap text-sm text-right font-bold text-gray-900 dark:text-white">{{ "%.2f"|format(p.rating or 0) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Team 2 Stats -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow rounded-lg overflow-hidden">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-slate-700">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Team 2</h3>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-700">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Player</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">K</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">D</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">A</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">+/-</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">ADR</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">KAST</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Rating</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% for p in team2_players %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0 h-8 w-8">
|
||||||
|
{% if p.avatar_url %}
|
||||||
|
<img class="h-8 w-8 rounded-full" src="{{ p.avatar_url }}" alt="">
|
||||||
|
{% else %}
|
||||||
|
<div class="h-8 w-8 rounded-full bg-yrtv-100 flex items-center justify-center text-yrtv-600 font-bold text-xs border border-yrtv-200">
|
||||||
|
{{ (p.username or p.steam_id_64)[:2] | upper }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<a href="{{ url_for('players.detail', steam_id=p.steam_id_64) }}" class="text-sm font-medium text-gray-900 dark:text-white hover:text-yrtv-600">
|
||||||
|
{{ p.username or p.steam_id_64 }}
|
||||||
|
</a>
|
||||||
|
{% if p.party_size > 1 %}
|
||||||
|
{% set pc = p.party_size %}
|
||||||
|
{% set p_color = 'bg-blue-100 text-blue-800' %}
|
||||||
|
{% if pc == 2 %}{% set p_color = 'bg-indigo-100 text-indigo-800' %}
|
||||||
|
{% elif pc == 3 %}{% set p_color = 'bg-blue-100 text-blue-800' %}
|
||||||
|
{% elif pc == 4 %}{% set p_color = 'bg-purple-100 text-purple-800' %}
|
||||||
|
{% elif pc >= 5 %}{% set p_color = 'bg-orange-100 text-orange-800' %}
|
||||||
|
{% endif %}
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium {{ p_color }} dark:bg-opacity-20" title="Roster Party of {{ p.party_size }}">
|
||||||
|
<svg class="mr-1 h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z" />
|
||||||
|
</svg>
|
||||||
|
{{ p.party_size }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-4 whitespace-nowrap text-sm text-right text-gray-900 dark:text-white">{{ p.kills }}</td>
|
||||||
|
<td class="px-4 py-4 whitespace-nowrap text-sm text-right text-gray-500 dark:text-gray-400">{{ p.deaths }}</td>
|
||||||
|
<td class="px-4 py-4 whitespace-nowrap text-sm text-right text-gray-500 dark:text-gray-400">{{ p.assists }}</td>
|
||||||
|
<td class="px-4 py-4 whitespace-nowrap text-sm text-right font-medium {% if (p.kills - p.deaths) >= 0 %}text-green-600{% else %}text-red-600{% endif %}">
|
||||||
|
{{ p.kills - p.deaths }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-4 whitespace-nowrap text-sm text-right text-gray-500 dark:text-gray-400">{{ "%.1f"|format(p.adr or 0) }}</td>
|
||||||
|
<td class="px-4 py-4 whitespace-nowrap text-sm text-right text-gray-500 dark:text-gray-400">{{ "%.1f"|format(p.kast or 0) }}%</td>
|
||||||
|
<td class="px-4 py-4 whitespace-nowrap text-sm text-right font-bold text-gray-900 dark:text-white">{{ "%.2f"|format(p.rating or 0) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Head to Head -->
|
||||||
|
<div x-show="tab === 'h2h'" class="bg-white dark:bg-slate-800 shadow rounded-lg overflow-hidden p-6" style="display: none;">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Head-to-Head Kills</h3>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-700">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Killer \ Victim</th>
|
||||||
|
{% for victim in team2_players %}
|
||||||
|
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 dark:text-gray-300 tracking-wider w-20" title="{{ victim.username }}">
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
{% if victim.avatar_url %}
|
||||||
|
<img class="h-6 w-6 rounded-full mb-1" src="{{ victim.avatar_url }}">
|
||||||
|
{% else %}
|
||||||
|
<div class="h-6 w-6 rounded-full bg-yrtv-100 flex items-center justify-center text-yrtv-600 font-bold text-xs border border-yrtv-200 mb-1">
|
||||||
|
{{ (victim.username or victim.steam_id_64)[:2] | upper }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<span class="truncate w-16 text-center">{{ victim.username or 'Player' }}</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% for killer in team1_players %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-3 py-2 whitespace-nowrap font-medium text-gray-900 dark:text-white flex items-center">
|
||||||
|
{% if killer.avatar_url %}
|
||||||
|
<img class="h-6 w-6 rounded-full mr-2" src="{{ killer.avatar_url }}">
|
||||||
|
{% else %}
|
||||||
|
<div class="h-6 w-6 rounded-full bg-yrtv-100 flex items-center justify-center text-yrtv-600 font-bold text-xs border border-yrtv-200 mr-2">
|
||||||
|
{{ (killer.username or killer.steam_id_64)[:2] | upper }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<span class="truncate w-24">{{ killer.username or 'Player' }}</span>
|
||||||
|
</td>
|
||||||
|
{% for victim in team2_players %}
|
||||||
|
{% set kills = h2h_matrix.get(killer.steam_id_64, {}).get(victim.steam_id_64, 0) %}
|
||||||
|
<td class="px-3 py-2 text-center text-sm border-l border-gray-100 dark:border-gray-700
|
||||||
|
{% if kills > 0 %}font-bold text-gray-900 dark:text-white{% else %}text-gray-300 dark:text-gray-600{% endif %}"
|
||||||
|
style="{% if kills > 0 %}background-color: rgba(239, 68, 68, {{ kills * 0.1 }}){% endif %}">
|
||||||
|
{{ kills if kills > 0 else '-' }}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="my-6 border-t border-gray-200 dark:border-gray-700"></div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-700">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Killer \ Victim</th>
|
||||||
|
{% for victim in team1_players %}
|
||||||
|
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 dark:text-gray-300 tracking-wider w-20" title="{{ victim.username }}">
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
{% if victim.avatar_url %}
|
||||||
|
<img class="h-6 w-6 rounded-full mb-1" src="{{ victim.avatar_url }}">
|
||||||
|
{% else %}
|
||||||
|
<div class="h-6 w-6 rounded-full bg-yrtv-100 flex items-center justify-center text-yrtv-600 font-bold text-xs border border-yrtv-200 mb-1">
|
||||||
|
{{ (victim.username or victim.steam_id_64)[:2] | upper }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<span class="truncate w-16 text-center">{{ victim.username or 'Player' }}</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% for killer in team2_players %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-3 py-2 whitespace-nowrap font-medium text-gray-900 dark:text-white flex items-center">
|
||||||
|
{% if killer.avatar_url %}
|
||||||
|
<img class="h-6 w-6 rounded-full mr-2" src="{{ killer.avatar_url }}">
|
||||||
|
{% else %}
|
||||||
|
<div class="h-6 w-6 rounded-full bg-yrtv-100 flex items-center justify-center text-yrtv-600 font-bold text-xs border border-yrtv-200 mr-2">
|
||||||
|
{{ (killer.username or killer.steam_id_64)[:2] | upper }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<span class="truncate w-24">{{ killer.username or 'Player' }}</span>
|
||||||
|
</td>
|
||||||
|
{% for victim in team1_players %}
|
||||||
|
{% set kills = h2h_matrix.get(killer.steam_id_64, {}).get(victim.steam_id_64, 0) %}
|
||||||
|
<td class="px-3 py-2 text-center text-sm border-l border-gray-100 dark:border-gray-700
|
||||||
|
{% if kills > 0 %}font-bold text-gray-900 dark:text-white{% else %}text-gray-300 dark:text-gray-600{% endif %}"
|
||||||
|
style="{% if kills > 0 %}background-color: rgba(59, 130, 246, {{ kills * 0.1 }}){% endif %}">
|
||||||
|
{{ kills if kills > 0 else '-' }}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Round History -->
|
||||||
|
<div x-show="tab === 'rounds'" class="bg-white dark:bg-slate-800 shadow rounded-lg p-6 space-y-4" style="display: none;">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Round by Round History</h3>
|
||||||
|
|
||||||
|
{% if not round_details %}
|
||||||
|
<p class="text-gray-500">No round detail data available for this match.</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{% for r_num, data in round_details.items() %}
|
||||||
|
<div x-data="{ expanded: false }" class="border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden">
|
||||||
|
<!-- Round Header -->
|
||||||
|
<div @click="expanded = !expanded"
|
||||||
|
class="flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-slate-700 cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-600 transition">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<span class="text-sm font-bold text-gray-500 dark:text-gray-400">Round {{ r_num }}</span>
|
||||||
|
|
||||||
|
<!-- Winner Icon -->
|
||||||
|
{% if data.info.winner_side == 'CT' %}
|
||||||
|
<span class="px-2 py-0.5 rounded text-xs font-bold bg-blue-100 text-blue-800 border border-blue-200">
|
||||||
|
CT Win
|
||||||
|
</span>
|
||||||
|
{% elif data.info.winner_side == 'T' %}
|
||||||
|
<span class="px-2 py-0.5 rounded text-xs font-bold bg-yellow-100 text-yellow-800 border border-yellow-200">
|
||||||
|
T Win
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 py-0.5 rounded text-xs font-bold bg-gray-100 text-gray-800">
|
||||||
|
{{ data.info.winner_side }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ data.info.win_reason_desc }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<span class="text-lg font-mono font-bold text-gray-900 dark:text-white">
|
||||||
|
{{ data.info.ct_score }} - {{ data.info.t_score }}
|
||||||
|
</span>
|
||||||
|
<svg :class="{'rotate-180': expanded}" class="h-5 w-5 text-gray-400 transform transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Round Details (Expanded) -->
|
||||||
|
<div x-show="expanded" class="p-4 bg-white dark:bg-slate-800 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
|
||||||
|
<!-- Economy Section (if available) -->
|
||||||
|
{% if data.economy %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<h4 class="text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Economy Snapshot</h4>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<!-- Left Team (usually CT start, but let's just list keys for now) -->
|
||||||
|
<!-- We can map steam_id to username via existing players list if passed, or just show summary -->
|
||||||
|
<!-- For simplicity v1: Just show count of weapons -->
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-400 italic">
|
||||||
|
(Detailed economy view coming soon)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Events Timeline -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
{% for event in data.events %}
|
||||||
|
<div class="flex items-center text-sm">
|
||||||
|
<span class="w-12 text-right text-gray-400 font-mono text-xs mr-4">{{ event.event_time }}s</span>
|
||||||
|
|
||||||
|
{% if event.event_type == 'kill' %}
|
||||||
|
<div class="flex items-center flex-1">
|
||||||
|
<span class="font-medium {% if event.is_headshot %}text-red-600{% else %}text-gray-900 dark:text-white{% endif %}">
|
||||||
|
{{ player_name_map.get(event.attacker_steam_id, event.attacker_steam_id) }}
|
||||||
|
</span>
|
||||||
|
<span class="mx-2 text-gray-400">
|
||||||
|
{% if event.is_headshot %}⌖{% else %}🔫{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-300">
|
||||||
|
{{ player_name_map.get(event.victim_steam_id, event.victim_steam_id) }}
|
||||||
|
</span>
|
||||||
|
<span class="ml-2 text-xs text-gray-400 bg-gray-100 dark:bg-slate-700 px-1 rounded">{{ event.weapon }}</span>
|
||||||
|
</div>
|
||||||
|
{% elif event.event_type == 'bomb_plant' %}
|
||||||
|
<div class="flex items-center text-yellow-600 font-medium">
|
||||||
|
<span>💣 Bomb Planted</span>
|
||||||
|
</div>
|
||||||
|
{% elif event.event_type == 'bomb_defuse' %}
|
||||||
|
<div class="flex items-center text-blue-600 font-medium">
|
||||||
|
<span>✂️ Bomb Defused</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Player Name Map for JS/Frontend Lookup if needed -->
|
||||||
|
<script>
|
||||||
|
// Optional: Pass player mapping to JS to replace IDs with Names in Timeline
|
||||||
|
// But Jinja is cleaner if we had the map.
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
214
web/templates/matches/list.html
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Team Stats Summary (Party >= 2) -->
|
||||||
|
{% if summary_stats %}
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
|
<!-- Left Block: Map Stats -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||||
|
<span class="mr-2">🗺️</span>
|
||||||
|
地图表现 (Party ≥ 2)
|
||||||
|
</h3>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-700">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Map</th>
|
||||||
|
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">Matches</th>
|
||||||
|
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">Win Rate</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% for stat in summary_stats.map_stats[:6] %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-2 text-sm font-medium dark:text-white">{{ stat.label }}</td>
|
||||||
|
<td class="px-4 py-2 text-sm text-right text-gray-500 dark:text-gray-400">{{ stat.count }}</td>
|
||||||
|
<td class="px-4 py-2 text-sm text-right">
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<span class="font-bold {% if stat.win_rate >= 50 %}text-green-600{% else %}text-red-500{% endif %}">
|
||||||
|
{{ "%.1f"|format(stat.win_rate) }}%
|
||||||
|
</span>
|
||||||
|
<div class="w-16 h-1.5 bg-gray-200 dark:bg-slate-600 rounded-full overflow-hidden">
|
||||||
|
<div class="h-full {% if stat.win_rate >= 50 %}bg-green-500{% else %}bg-red-500{% endif %}" style="width: {{ stat.win_rate }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Block: Context Stats -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||||
|
<span class="mr-2">📊</span>
|
||||||
|
环境胜率分析
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- ELO Stats -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-xs font-bold text-gray-500 uppercase mb-2">ELO 层级表现</h4>
|
||||||
|
<div class="grid grid-cols-7 gap-2">
|
||||||
|
{% for stat in summary_stats.elo_stats %}
|
||||||
|
<div class="bg-gray-50 dark:bg-slate-700 p-2 rounded text-center">
|
||||||
|
<div class="text-[9px] text-gray-400 truncate" title="{{ stat.label }}">{{ stat.label }}</div>
|
||||||
|
<div class="text-xs font-bold dark:text-white">{{ "%.0f"|format(stat.win_rate) }}%</div>
|
||||||
|
<div class="text-[9px] text-gray-400">({{ stat.count }})</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Duration Stats -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-xs font-bold text-gray-500 uppercase mb-2">时长表现</h4>
|
||||||
|
<div class="grid grid-cols-3 gap-2">
|
||||||
|
{% for stat in summary_stats.duration_stats %}
|
||||||
|
<div class="bg-gray-50 dark:bg-slate-700 p-2 rounded text-center">
|
||||||
|
<div class="text-[10px] text-gray-400">{{ stat.label }}</div>
|
||||||
|
<div class="text-sm font-bold dark:text-white">{{ "%.0f"|format(stat.win_rate) }}%</div>
|
||||||
|
<div class="text-[10px] text-gray-400">({{ stat.count }})</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Round Stats -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-xs font-bold text-gray-500 uppercase mb-2">局势表现 (总回合数)</h4>
|
||||||
|
<div class="grid grid-cols-4 gap-2">
|
||||||
|
{% for stat in summary_stats.round_stats %}
|
||||||
|
<div class="bg-gray-50 dark:bg-slate-700 p-2 rounded text-center border {% if 'Stomp' in stat.label %}border-green-200{% elif 'Close' in stat.label %}border-orange-200{% elif 'Choke' in stat.label %}border-red-200{% else %}border-gray-200{% endif %}">
|
||||||
|
<div class="text-[9px] text-gray-400 truncate" title="{{ stat.label }}">{{ stat.label }}</div>
|
||||||
|
<div class="text-sm font-bold dark:text-white">{{ "%.0f"|format(stat.win_rate) }}%</div>
|
||||||
|
<div class="text-[9px] text-gray-400">({{ stat.count }})</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">比赛列表</h2>
|
||||||
|
<!-- Filters (Simple placeholders) -->
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<!-- <input type="text" placeholder="Map..." class="border rounded px-2 py-1"> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-700">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">时间</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">地图</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">比分</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">ELO</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Party</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">时长</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% for match in matches %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<script>document.write(new Date({{ match.start_time }} * 1000).toLocaleString())</script>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white font-medium">
|
||||||
|
{{ match.map_name }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {% if match.winner_team == 1 %}bg-green-100 text-green-800 border border-green-200{% else %}bg-gray-100 text-gray-500{% endif %}">
|
||||||
|
{{ match.score_team1 }}
|
||||||
|
{% if match.winner_team == 1 %}
|
||||||
|
<svg class="ml-1 h-3 w-3" fill="currentColor" viewBox="0 0 20 20"><path d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" /></svg>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {% if match.winner_team == 2 %}bg-green-100 text-green-800 border border-green-200{% else %}bg-gray-100 text-gray-500{% endif %}">
|
||||||
|
{{ match.score_team2 }}
|
||||||
|
{% if match.winner_team == 2 %}
|
||||||
|
<svg class="ml-1 h-3 w-3" fill="currentColor" viewBox="0 0 20 20"><path d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" /></svg>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Our Team Result Badge -->
|
||||||
|
{% if match.our_result %}
|
||||||
|
{% if match.our_result == 'win' %}
|
||||||
|
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-green-500 text-white">
|
||||||
|
VICTORY
|
||||||
|
</span>
|
||||||
|
{% elif match.our_result == 'loss' %}
|
||||||
|
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-red-500 text-white">
|
||||||
|
DEFEAT
|
||||||
|
</span>
|
||||||
|
{% elif match.our_result == 'mixed' %}
|
||||||
|
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-yellow-500 text-white">
|
||||||
|
CIVIL WAR
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{% if match.avg_elo and match.avg_elo > 0 %}
|
||||||
|
<span class="font-mono">{{ "%.0f"|format(match.avg_elo) }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-xs text-gray-300">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{% if match.max_party and match.max_party > 1 %}
|
||||||
|
{% set p = match.max_party %}
|
||||||
|
{% set party_class = 'bg-gray-100 text-gray-800' %}
|
||||||
|
{% if p == 2 %} {% set party_class = 'bg-indigo-100 text-indigo-800 border border-indigo-200' %}
|
||||||
|
{% elif p == 3 %} {% set party_class = 'bg-blue-100 text-blue-800 border border-blue-200' %}
|
||||||
|
{% elif p == 4 %} {% set party_class = 'bg-purple-100 text-purple-800 border border-purple-200' %}
|
||||||
|
{% elif p >= 5 %} {% set party_class = 'bg-orange-100 text-orange-800 border border-orange-200' %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{ party_class }}">
|
||||||
|
👥 {{ match.max_party }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-xs text-gray-300">Solo</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ (match.duration / 60) | int }} min
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
|
<a href="{{ url_for('matches.detail', match_id=match.match_id) }}" class="text-yrtv-600 hover:text-yrtv-900">详情</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="mt-4 flex justify-between items-center">
|
||||||
|
<div class="text-sm text-gray-700 dark:text-gray-400">
|
||||||
|
Total {{ total }} matches
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
{% if page > 1 %}
|
||||||
|
<a href="{{ url_for('matches.index', page=page-1) }}" class="px-3 py-1 border rounded bg-white text-gray-700 hover:bg-gray-50 dark:bg-slate-700 dark:text-white dark:border-slate-600 dark:hover:bg-slate-600">Prev</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if page < total_pages %}
|
||||||
|
<a href="{{ url_for('matches.index', page=page+1) }}" class="px-3 py-1 border rounded bg-white text-gray-700 hover:bg-gray-50 dark:bg-slate-700 dark:text-white dark:border-slate-600 dark:hover:bg-slate-600">Next</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
78
web/templates/players/list.html
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">玩家列表</h2>
|
||||||
|
<div class="flex space-x-4">
|
||||||
|
<!-- Sort Dropdown -->
|
||||||
|
<div class="relative inline-block text-left">
|
||||||
|
<select onchange="location = this.value;" class="border rounded px-2 py-1 dark:bg-slate-700 dark:text-white dark:border-slate-600">
|
||||||
|
<option value="{{ url_for('players.index', search=request.args.get('search', ''), sort='rating') }}" {% if sort_by == 'rating' %}selected{% endif %}>Sort by Rating</option>
|
||||||
|
<option value="{{ url_for('players.index', search=request.args.get('search', ''), sort='kd') }}" {% if sort_by == 'kd' %}selected{% endif %}>Sort by K/D</option>
|
||||||
|
<option value="{{ url_for('players.index', search=request.args.get('search', ''), sort='kast') }}" {% if sort_by == 'kast' %}selected{% endif %}>Sort by KAST</option>
|
||||||
|
<option value="{{ url_for('players.index', search=request.args.get('search', ''), sort='matches') }}" {% if sort_by == 'matches' %}selected{% endif %}>Sort by Matches</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action="{{ url_for('players.index') }}" method="get" class="flex space-x-2">
|
||||||
|
<input type="hidden" name="sort" value="{{ sort_by }}">
|
||||||
|
<input type="text" name="search" placeholder="Search player..." class="border rounded px-2 py-1 dark:bg-slate-700 dark:text-white dark:border-slate-600" value="{{ request.args.get('search', '') }}">
|
||||||
|
<button type="submit" class="px-3 py-1 bg-yrtv-600 text-white rounded hover:bg-yrtv-500">Search</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
{% for player in players %}
|
||||||
|
<div class="bg-gray-50 dark:bg-slate-700 rounded-lg p-4 flex flex-col items-center hover:shadow-lg transition">
|
||||||
|
<!-- Avatar -->
|
||||||
|
{% if player.avatar_url %}
|
||||||
|
<img class="h-20 w-20 rounded-full mb-4 object-cover border-4 border-white shadow-sm" src="{{ player.avatar_url }}" alt="{{ player.username }}">
|
||||||
|
{% else %}
|
||||||
|
<div class="h-20 w-20 rounded-full mb-4 bg-yrtv-100 flex items-center justify-center text-yrtv-600 font-bold text-2xl border-4 border-white shadow-sm">
|
||||||
|
{{ player.username[:2] | upper if player.username else '??' }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white">{{ player.username }}</h3>
|
||||||
|
<p class="text-sm text-gray-500 mb-2">{{ player.steam_id_64 }}</p>
|
||||||
|
|
||||||
|
<!-- Mini Stats -->
|
||||||
|
<div class="grid grid-cols-3 gap-x-4 gap-y-2 text-xs text-gray-600 dark:text-gray-300 mb-4 w-full text-center">
|
||||||
|
<div>
|
||||||
|
<span class="block font-bold">{{ "%.2f"|format(player.basic_avg_rating|default(0)) }}</span>
|
||||||
|
<span class="text-gray-400">Rating</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="block font-bold">{{ "%.2f"|format(player.basic_avg_kd|default(0)) }}</span>
|
||||||
|
<span class="text-gray-400">K/D</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="block font-bold">{{ "%.1f"|format((player.basic_avg_kast|default(0)) * 100) }}%</span>
|
||||||
|
<span class="text-gray-400">KAST</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="{{ url_for('players.detail', steam_id=player.steam_id_64) }}" class="mt-auto px-4 py-2 border border-transparent text-sm font-medium rounded-md text-yrtv-700 bg-yrtv-100 hover:bg-yrtv-200 dark:bg-slate-800 dark:text-yrtv-300 dark:hover:bg-slate-600 dark:border-slate-600">
|
||||||
|
View Profile
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="mt-6 flex justify-between items-center">
|
||||||
|
<div class="text-sm text-gray-700 dark:text-gray-400">
|
||||||
|
Total {{ total }} players
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
{% if page > 1 %}
|
||||||
|
<a href="{{ url_for('players.index', page=page-1, search=request.args.get('search', '')) }}" class="px-3 py-1 border rounded bg-white text-gray-700 hover:bg-gray-50 dark:bg-slate-700 dark:text-white dark:border-slate-600 dark:hover:bg-slate-600">Prev</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if page < total_pages %}
|
||||||
|
<a href="{{ url_for('players.index', page=page+1, search=request.args.get('search', '')) }}" class="px-3 py-1 border rounded bg-white text-gray-700 hover:bg-gray-50 dark:bg-slate-700 dark:text-white dark:border-slate-600 dark:hover:bg-slate-600">Next</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
820
web/templates/players/profile.html
Normal file
@@ -0,0 +1,820 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-8" x-data="{ range: '20' }">
|
||||||
|
<!-- 1. Header & Data Dashboard (Top) -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow-xl rounded-2xl overflow-hidden border border-gray-100 dark:border-slate-700">
|
||||||
|
<div class="p-8">
|
||||||
|
<div class="lg:flex lg:items-start lg:space-x-8">
|
||||||
|
<!-- Avatar & Basic Info -->
|
||||||
|
<div class="flex-shrink-0 flex flex-col items-center lg:items-start space-y-4">
|
||||||
|
<div class="relative group">
|
||||||
|
{% if player.avatar_url %}
|
||||||
|
<img src="{{ player.avatar_url }}" class="h-32 w-32 rounded-2xl object-cover shadow-lg border-4 border-white dark:border-slate-700 transform group-hover:scale-105 transition duration-300">
|
||||||
|
{% else %}
|
||||||
|
<div class="h-32 w-32 rounded-2xl bg-gradient-to-br from-yrtv-100 to-yrtv-200 flex items-center justify-center text-yrtv-600 font-bold text-4xl shadow-lg border-4 border-white dark:border-slate-700">
|
||||||
|
{{ player.username[:2] | upper if player.username else '??' }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if session.get('is_admin') %}
|
||||||
|
<button onclick="document.getElementById('editProfileModal').classList.remove('hidden')" class="absolute -bottom-2 -right-2 bg-white dark:bg-slate-700 p-2 rounded-full shadow-md text-gray-500 hover:text-yrtv-600 transition">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path></svg>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center lg:text-left">
|
||||||
|
<h1 class="text-3xl font-black text-gray-900 dark:text-white tracking-tight">{{ player.username }}</h1>
|
||||||
|
<p class="text-sm font-mono text-gray-500 dark:text-gray-400 mt-1">{{ player.steam_id_64 }}</p>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<div class="mt-3 flex flex-wrap justify-center lg:justify-start gap-2">
|
||||||
|
{% for tag in metadata.tags %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-bold bg-gray-100 text-gray-700 dark:bg-slate-700 dark:text-gray-300 border border-gray-200 dark:border-slate-600">
|
||||||
|
{{ tag }}
|
||||||
|
{% if session.get('is_admin') %}
|
||||||
|
<form action="{{ url_for('players.detail', steam_id=player.steam_id_64) }}" method="POST" class="inline ml-1">
|
||||||
|
<input type="hidden" name="admin_action" value="remove_tag">
|
||||||
|
<input type="hidden" name="tag" value="{{ tag }}">
|
||||||
|
<button type="submit" class="text-gray-400 hover:text-red-500 focus:outline-none">×</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if session.get('is_admin') %}
|
||||||
|
<form action="{{ url_for('players.detail', steam_id=player.steam_id_64) }}" method="POST" class="inline-flex">
|
||||||
|
<input type="hidden" name="admin_action" value="add_tag">
|
||||||
|
<input type="text" name="tag" placeholder="+Tag" class="w-16 text-xs border border-gray-300 rounded px-1 py-0.5 focus:outline-none dark:bg-slate-700 dark:border-slate-600 dark:text-white">
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data Dashboard -->
|
||||||
|
<div class="flex-1 w-full mt-8 lg:mt-0">
|
||||||
|
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{% macro stat_card(label, metric_key, format_str, icon) %}
|
||||||
|
{% set dist = distribution[metric_key] if distribution else None %}
|
||||||
|
<div class="bg-gray-50 dark:bg-slate-700/50 rounded-xl p-5 border border-gray-100 dark:border-slate-600 relative overflow-hidden group hover:shadow-md transition-shadow">
|
||||||
|
<div class="flex justify-between items-start mb-2">
|
||||||
|
<div class="text-xs font-bold text-gray-400 uppercase tracking-wider flex items-center gap-1">
|
||||||
|
{{ icon }} {{ label }}
|
||||||
|
</div>
|
||||||
|
{% if dist %}
|
||||||
|
<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
|
||||||
|
{% 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 %}">
|
||||||
|
Rank #{{ dist.rank }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-3xl font-black text-gray-900 dark:text-white mb-3">
|
||||||
|
{{ format_str.format(dist.val if dist else 0) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Distribution Bar -->
|
||||||
|
{% if dist %}
|
||||||
|
<div class="w-full h-1.5 bg-gray-200 dark:bg-slate-600 rounded-full overflow-hidden relative">
|
||||||
|
<!-- Range: Min to Max -->
|
||||||
|
{% set range = dist.max - dist.min %}
|
||||||
|
{% set percent = ((dist.val - dist.min) / range * 100) if range > 0 else 100 %}
|
||||||
|
<div class="absolute h-full bg-yrtv-500 rounded-full transition-all duration-1000" style="width: {{ percent }}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-[10px] text-gray-400 mt-1 font-mono">
|
||||||
|
<span>{{ format_str.format(dist.min) }}</span>
|
||||||
|
<span>Avg: {{ format_str.format(dist.avg) }}</span>
|
||||||
|
<span>{{ format_str.format(dist.max) }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-xs text-gray-400">No team data</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{{ stat_card('Rating', 'rating', '{:.2f}', '⭐') }}
|
||||||
|
{{ stat_card('K/D Ratio', 'kd', '{:.2f}', '🔫') }}
|
||||||
|
{{ stat_card('ADR', 'adr', '{:.1f}', '🔥') }}
|
||||||
|
{{ stat_card('KAST', 'kast', '{:.1%}', '🛡️') }} <!-- Note: KAST is stored as 0-1, formatted as % -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. Charts Section (Middle) -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
<!-- Trend Chart -->
|
||||||
|
<div class="lg:col-span-2 bg-white dark:bg-slate-800 shadow-lg rounded-2xl p-6 border border-gray-100 dark:border-slate-700">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<span>📈</span> 近期表现走势 (Performance Trend)
|
||||||
|
</h3>
|
||||||
|
<!-- Simple Range Filter (Visual Only for now, could be wired to JS) -->
|
||||||
|
<div class="flex bg-gray-100 dark:bg-slate-700 rounded-lg p-1">
|
||||||
|
<button class="px-3 py-1 text-xs font-bold rounded-md bg-white dark:bg-slate-600 shadow-sm text-gray-800 dark:text-white">Recent 20</button>
|
||||||
|
<!-- <button class="px-3 py-1 text-xs font-medium rounded-md text-gray-500 hover:text-gray-900">All Time</button> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="relative h-80 w-full">
|
||||||
|
<canvas id="trendChart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex justify-center gap-6 text-xs text-gray-500">
|
||||||
|
<div class="flex items-center gap-1"><span class="w-3 h-3 rounded-full bg-green-500/20 border border-green-500"></span> Carry (>1.5)</div>
|
||||||
|
<div class="flex items-center gap-1"><span class="w-3 h-3 rounded-full bg-yellow-500/20 border border-yellow-500"></span> Normal (1.0-1.5)</div>
|
||||||
|
<div class="flex items-center gap-1"><span class="w-3 h-3 rounded-full bg-red-500/20 border border-red-500"></span> Poor (<0.6)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Radar Chart -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-2xl p-6 border border-gray-100 dark:border-slate-700 flex flex-col">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-6 flex items-center gap-2">
|
||||||
|
<span>🕸️</span> 能力六维图 (Capabilities)
|
||||||
|
</h3>
|
||||||
|
<div class="relative flex-1 min-h-[300px] flex items-center justify-center">
|
||||||
|
<canvas id="radarChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2.5 Detailed Stats Panel -->
|
||||||
|
<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">
|
||||||
|
<span>📊</span> 详细数据面板 (Detailed Stats)
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-y-6 gap-x-4">
|
||||||
|
{% macro detail_item(label, value, key, format_str='{:.2f}', sublabel=None) %}
|
||||||
|
{% set dist = distribution[key] if distribution else None %}
|
||||||
|
<div class="flex flex-col group relative">
|
||||||
|
<div class="flex justify-between items-center mb-1">
|
||||||
|
<span class="text-xs font-bold text-gray-400 uppercase tracking-wider">{{ label }}</span>
|
||||||
|
{% if dist %}
|
||||||
|
<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
|
||||||
|
{% elif dist.rank <= 3 %}bg-gray-50 text-gray-600 border border-gray-100
|
||||||
|
{% else %}text-gray-300{% endif %}">
|
||||||
|
#{{ dist.rank }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-baseline gap-1 mb-1">
|
||||||
|
<span class="text-xl font-black text-gray-900 dark:text-white font-mono">
|
||||||
|
{{ format_str.format(value if value is not none else 0) }}
|
||||||
|
</span>
|
||||||
|
{% if sublabel %}
|
||||||
|
<span class="text-[10px] text-gray-400">{{ sublabel }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Distribution Bar -->
|
||||||
|
{% if dist %}
|
||||||
|
<div class="w-full h-1 bg-gray-100 dark:bg-slate-700 rounded-full overflow-hidden relative mt-1">
|
||||||
|
{% set range = dist.max - dist.min %}
|
||||||
|
{% set percent = ((dist.val - dist.min) / range * 100) if range > 0 else 100 %}
|
||||||
|
<div class="absolute h-full bg-yrtv-400/60 rounded-full" style="width: {{ percent }}%"></div>
|
||||||
|
<!-- Avg Marker -->
|
||||||
|
{% set avg_pct = ((dist.avg - dist.min) / range * 100) if range > 0 else 50 %}
|
||||||
|
<div class="absolute h-full w-0.5 bg-gray-400 dark:bg-slate-400 top-0" style="left: {{ avg_pct }}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-[9px] text-gray-300 dark:text-gray-600 font-mono mt-0.5">
|
||||||
|
<span>L:{{ format_str.format(dist.min) }}</span>
|
||||||
|
<span>H:{{ format_str.format(dist.max) }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
<!-- Row 1: Core -->
|
||||||
|
{{ detail_item('Rating (评分)', features['basic_avg_rating'], 'basic_avg_rating') }}
|
||||||
|
{{ detail_item('KD Ratio (击杀比)', features['basic_avg_kd'], 'basic_avg_kd') }}
|
||||||
|
{{ detail_item('KAST (贡献率)', features['basic_avg_kast'], 'basic_avg_kast', '{:.1%}') }}
|
||||||
|
{{ detail_item('RWS (每局得分)', features['basic_avg_rws'], 'basic_avg_rws') }}
|
||||||
|
{{ detail_item('ADR (场均伤害)', features['basic_avg_adr'], 'basic_avg_adr', '{:.1f}') }}
|
||||||
|
|
||||||
|
<!-- Row 2: Combat -->
|
||||||
|
{{ detail_item('Avg HS (场均爆头)', features['basic_avg_headshot_kills'], 'basic_avg_headshot_kills') }}
|
||||||
|
{{ detail_item('HS Rate (爆头率)', features['basic_headshot_rate'], 'basic_headshot_rate', '{:.1%}') }}
|
||||||
|
{{ detail_item('Assists (场均助攻)', features['basic_avg_assisted_kill'], 'basic_avg_assisted_kill') }}
|
||||||
|
{{ detail_item('AWP Kills (狙击击杀)', features['basic_avg_awp_kill'], 'basic_avg_awp_kill') }}
|
||||||
|
{{ detail_item('Jumps (场均跳跃)', features['basic_avg_jump_count'], 'basic_avg_jump_count', '{:.1f}') }}
|
||||||
|
|
||||||
|
<!-- Row 3: Objective -->
|
||||||
|
{{ detail_item('MVP (最有价值)', features['basic_avg_mvps'], 'basic_avg_mvps') }}
|
||||||
|
{{ detail_item('Plants (下包)', features['basic_avg_plants'], 'basic_avg_plants') }}
|
||||||
|
{{ detail_item('Defuses (拆包)', features['basic_avg_defuses'], 'basic_avg_defuses') }}
|
||||||
|
{{ detail_item('Flash Assist (闪光助攻)', features['basic_avg_flash_assists'], 'basic_avg_flash_assists') }}
|
||||||
|
<div class="hidden lg:block"></div> <!-- Spacer -->
|
||||||
|
|
||||||
|
<!-- Row 4: Opening -->
|
||||||
|
{{ detail_item('First Kill (场均首杀)', features['basic_avg_first_kill'], 'basic_avg_first_kill') }}
|
||||||
|
{{ detail_item('First Death (场均首死)', features['basic_avg_first_death'], 'basic_avg_first_death') }}
|
||||||
|
{{ detail_item('FK Rate (首杀率)', features['basic_first_kill_rate'], 'basic_first_kill_rate', '{:.1%}') }}
|
||||||
|
{{ detail_item('FD Rate (首死率)', features['basic_first_death_rate'], 'basic_first_death_rate', '{:.1%}') }}
|
||||||
|
<div class="hidden lg:block"></div> <!-- Spacer -->
|
||||||
|
|
||||||
|
<!-- Row 5: Multi-Kills -->
|
||||||
|
{{ detail_item('2K Rounds (双杀)', features['basic_avg_kill_2'], 'basic_avg_kill_2') }}
|
||||||
|
{{ detail_item('3K Rounds (三杀)', features['basic_avg_kill_3'], 'basic_avg_kill_3') }}
|
||||||
|
{{ detail_item('4K Rounds (四杀)', features['basic_avg_kill_4'], 'basic_avg_kill_4') }}
|
||||||
|
{{ detail_item('5K Rounds (五杀)', features['basic_avg_kill_5'], 'basic_avg_kill_5') }}
|
||||||
|
{{ detail_item('Multi-Kill % (多杀率)', features['basic_multi_kill_rate'], 'basic_multi_kill_rate', '{:.1%}') }}
|
||||||
|
|
||||||
|
<!-- Row 6: Clutch -->
|
||||||
|
{{ detail_item('1v1 Wins (1v1胜)', features['basic_total_1v1'], 'basic_total_1v1', '{:.0f}') }}
|
||||||
|
{{ detail_item('1v2 Wins (1v2胜)', features['basic_total_1v2'], 'basic_total_1v2', '{:.0f}') }}
|
||||||
|
{{ detail_item('1v3 Wins (1v3胜)', features['basic_total_1v3'], 'basic_total_1v3', '{:.0f}') }}
|
||||||
|
{{ detail_item('1v4 Wins (1v4胜)', features['basic_total_1v4'], 'basic_total_1v4', '{:.0f}') }}
|
||||||
|
{{ detail_item('1v5 Wins (1v5胜)', features['basic_total_1v5'], 'basic_total_1v5', '{:.0f}') }}
|
||||||
|
|
||||||
|
<!-- Row 7: Special -->
|
||||||
|
{{ detail_item('Perfect Kills (无伤杀)', features['basic_avg_perfect_kill'], 'basic_avg_perfect_kill') }}
|
||||||
|
{{ detail_item('Revenge Kills (复仇杀)', features['basic_avg_revenge_kill'], 'basic_avg_revenge_kill') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-6 flex items-center gap-2">
|
||||||
|
<span>🔬</span> 深层能力维度 (Deep Capabilities Breakdown)
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Reusing detail_item macro, but with a different grid if needed -->
|
||||||
|
<!-- Grouped by Dimensions -->
|
||||||
|
<div class="space-y-8">
|
||||||
|
<!-- Group 1: STA & BAT -->
|
||||||
|
<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">
|
||||||
|
STA (Stability) & BAT (Aim/Battle)
|
||||||
|
</h4>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-y-6 gap-x-4">
|
||||||
|
{{ detail_item('Last 30 Rating (近30场)', features['sta_last_30_rating'], 'sta_last_30_rating') }}
|
||||||
|
{{ detail_item('Win Rating (胜局)', features['sta_win_rating'], 'sta_win_rating') }}
|
||||||
|
{{ detail_item('Loss Rating (败局)', features['sta_loss_rating'], 'sta_loss_rating') }}
|
||||||
|
{{ detail_item('Volatility (波动)', features['sta_rating_volatility'], 'sta_rating_volatility') }}
|
||||||
|
{{ detail_item('Time Corr (耐力)', features['sta_time_rating_corr'], 'sta_time_rating_corr') }}
|
||||||
|
|
||||||
|
{{ detail_item('High Elo KD Diff (高分抗压)', features['bat_kd_diff_high_elo'], 'bat_kd_diff_high_elo') }}
|
||||||
|
{{ detail_item('Duel Win% (对枪胜率)', features['bat_avg_duel_win_rate'], 'bat_avg_duel_win_rate', '{:.1%}') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Group 2: HPS & PTL -->
|
||||||
|
<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">
|
||||||
|
HPS (Clutch/Pressure) & PTL (Pistol)
|
||||||
|
</h4>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-y-6 gap-x-4">
|
||||||
|
{{ detail_item('1v1 Win% (1v1胜率)', features['hps_clutch_win_rate_1v1'], 'hps_clutch_win_rate_1v1', '{:.1%}') }}
|
||||||
|
{{ detail_item('1v3+ Win% (残局大神)', features['hps_clutch_win_rate_1v3_plus'], 'hps_clutch_win_rate_1v3_plus', '{:.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('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%}') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Group 3: UTIL (Utility) -->
|
||||||
|
<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">
|
||||||
|
UTIL (Utility Usage)
|
||||||
|
</h4>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-y-6 gap-x-4">
|
||||||
|
{{ 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') }}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- 3. Match History & Comments (Bottom) -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
<!-- Match History Table -->
|
||||||
|
<div class="lg:col-span-2 bg-white dark:bg-slate-800 shadow-lg rounded-2xl overflow-hidden border border-gray-100 dark:border-slate-700">
|
||||||
|
<div class="p-6 border-b border-gray-100 dark:border-slate-700 flex justify-between items-center">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white">比赛记录 (Match History)</h3>
|
||||||
|
<span class="px-2.5 py-0.5 rounded-full text-xs font-bold bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-gray-300">
|
||||||
|
{{ history|length }} Matches
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto max-h-[600px] overflow-y-auto custom-scroll">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-700/50 sticky top-0 backdrop-blur-sm z-10">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Date/Map</th>
|
||||||
|
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Result</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-bold text-gray-500 uppercase tracking-wider">Rating</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-bold text-gray-500 uppercase tracking-wider">K/D</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-bold text-gray-500 uppercase tracking-wider">ADR</th>
|
||||||
|
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Link</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 dark:divide-slate-700 bg-white dark:bg-slate-800">
|
||||||
|
{% for m in history | reverse %}
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors group">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<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">
|
||||||
|
<script>document.write(new Date({{ m.start_time }} * 1000).toLocaleDateString())</script>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
|
<div class="flex flex-col items-center gap-1">
|
||||||
|
<span class="px-2.5 py-0.5 rounded text-[10px] font-black uppercase tracking-wide
|
||||||
|
{% if m.is_win %}bg-green-100 text-green-700 border border-green-200
|
||||||
|
{% else %}bg-red-50 text-red-600 border border-red-100{% endif %}">
|
||||||
|
{{ 'WIN' if m.is_win else 'LOSS' }}
|
||||||
|
</span>
|
||||||
|
{% if m.party_size and m.party_size > 1 %}
|
||||||
|
<span class="text-[10px] text-gray-400 flex items-center gap-0.5" title="Party Size">
|
||||||
|
👥 {{ m.party_size }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||||
|
{% set r = m.rating or 0 %}
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<span class="text-sm font-bold font-mono {% if r >= 1.5 %}text-yrtv-600{% elif r >= 1.1 %}text-green-600{% elif r < 0.6 %}text-red-500{% else %}text-gray-700 dark:text-gray-300{% endif %}">
|
||||||
|
{{ "%.2f"|format(r) }}
|
||||||
|
</span>
|
||||||
|
<!-- Mini Bar -->
|
||||||
|
<div class="w-12 h-1 bg-gray-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||||
|
<div class="h-full {% if r >= 1.1 %}bg-green-500{% elif r < 0.9 %}bg-red-500{% else %}bg-gray-400{% endif %}" style="width: {{ (r / 2.0 * 100)|int }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm text-gray-600 dark:text-gray-400 font-mono">
|
||||||
|
{{ "%.2f"|format(m.kd_ratio or 0) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm text-gray-600 dark:text-gray-400 font-mono">
|
||||||
|
{{ "%.1f"|format(m.adr or 0) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
|
<a href="{{ url_for('matches.detail', match_id=m.match_id) }}" class="p-2 text-gray-400 hover:text-yrtv-600 transition-colors">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="px-6 py-12 text-center text-gray-400">
|
||||||
|
<div class="text-4xl mb-2">🏜️</div>
|
||||||
|
No matches recorded yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</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 -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<form action="{{ url_for('players.detail', steam_id=player.steam_id_64) }}" method="POST" class="mb-8 relative">
|
||||||
|
<input type="text" name="username" class="absolute top-2 left-2 text-xs border-none bg-transparent focus:ring-0 text-gray-500 w-full" placeholder="Name (Optional)">
|
||||||
|
<textarea name="content" rows="3" required class="block w-full pt-8 pb-2 px-3 border border-gray-200 dark:border-slate-600 rounded-xl bg-gray-50 dark:bg-slate-700/50 focus:ring-2 focus:ring-yrtv-500 focus:bg-white dark:focus:bg-slate-700 transition" placeholder="Write a comment..."></textarea>
|
||||||
|
<button type="submit" class="absolute bottom-2 right-2 px-3 py-1 bg-yrtv-600 text-white text-xs font-bold rounded-lg hover:bg-yrtv-700 transition shadow-sm">Post</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="space-y-4 max-h-[500px] overflow-y-auto custom-scroll pr-2">
|
||||||
|
{% for comment in comments %}
|
||||||
|
<div class="flex gap-3 group">
|
||||||
|
<div class="flex-shrink-0 mt-1">
|
||||||
|
<div class="h-8 w-8 rounded-full bg-gradient-to-br from-gray-100 to-gray-200 flex items-center justify-center text-gray-500 text-xs font-bold border border-white shadow-sm">
|
||||||
|
{{ comment.username[:1] | upper }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 bg-gray-50 dark:bg-slate-700/30 rounded-r-xl rounded-bl-xl p-3 text-sm hover:bg-gray-100 dark:hover:bg-slate-700/50 transition-colors">
|
||||||
|
<div class="flex justify-between items-baseline mb-1">
|
||||||
|
<span class="font-bold text-gray-900 dark:text-white">{{ comment.username }}</span>
|
||||||
|
<span class="text-xs text-gray-400">{{ comment.created_at }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600 dark:text-gray-300 leading-relaxed">{{ comment.content }}</p>
|
||||||
|
<div class="mt-2 flex justify-end">
|
||||||
|
<button onclick="likeComment({{ comment.id }}, this)" class="text-xs text-gray-400 hover:text-red-500 flex items-center gap-1 transition-colors">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/></svg>
|
||||||
|
<span class="like-count font-bold">{{ comment.likes }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-8 text-gray-400 text-sm">No comments yet.</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Modal (Hidden) -->
|
||||||
|
<div id="editProfileModal" class="hidden fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl w-full max-w-md p-6 m-4 animate-scale-in">
|
||||||
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Edit Profile</h3>
|
||||||
|
<form action="{{ url_for('players.detail', steam_id=player.steam_id_64) }}" method="POST" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" name="admin_action" value="update_profile">
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-1">Avatar</label>
|
||||||
|
<input type="file" name="avatar" accept="image/*" class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-bold file:bg-yrtv-50 file:text-yrtv-700 hover:file:bg-yrtv-100">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-1">Notes</label>
|
||||||
|
<textarea name="notes" rows="3" class="w-full border-gray-300 rounded-lg shadow-sm focus:border-yrtv-500 focus:ring-yrtv-500 dark:bg-slate-700 dark:border-slate-600 dark:text-white">{{ metadata.notes }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex gap-3">
|
||||||
|
<button type="button" onclick="document.getElementById('editProfileModal').classList.add('hidden')" class="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 font-bold transition">Cancel</button>
|
||||||
|
<button type="submit" class="flex-1 px-4 py-2 bg-yrtv-600 text-white rounded-lg hover:bg-yrtv-700 font-bold shadow-lg shadow-yrtv-500/30 transition">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
let trendChartInstance = null;
|
||||||
|
|
||||||
|
function resetZoom() {
|
||||||
|
if (trendChartInstance) {
|
||||||
|
trendChartInstance.resetZoom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function likeComment(commentId, btn) {
|
||||||
|
fetch(`/players/comment/${commentId}/like`, { method: 'POST' })
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
const countSpan = btn.querySelector('.like-count');
|
||||||
|
countSpan.innerText = parseInt(countSpan.innerText) + 1;
|
||||||
|
btn.classList.add('text-red-500');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const steamId = "{{ player.steam_id_64 }}";
|
||||||
|
|
||||||
|
fetch(`/players/${steamId}/charts_data`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
// Register Zoom Plugin Manually if needed (usually auto-registers in UMD)
|
||||||
|
if (window.ChartZoom) {
|
||||||
|
Chart.register(window.ChartZoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Radar Chart
|
||||||
|
const ctxRadar = document.getElementById('radarChart').getContext('2d');
|
||||||
|
|
||||||
|
// Prepare Distribution Data
|
||||||
|
const dist = data.radar_dist || {};
|
||||||
|
const getDist = (key) => dist[key] || { rank: '?', avg: 0 };
|
||||||
|
|
||||||
|
// Map friendly names to keys
|
||||||
|
const keys = ['score_bat', 'score_hps', 'score_ptl', 'score_tct', 'score_util', 'score_sta'];
|
||||||
|
// Corresponding Labels
|
||||||
|
const rawLabels = ['Aim (BAT)', 'Clutch (HPS)', 'Pistol (PTL)', 'Defense (SIDE)', 'Util (UTIL)', 'Stability (STA)'];
|
||||||
|
|
||||||
|
const labels = rawLabels.map((l, i) => {
|
||||||
|
const k = keys[i];
|
||||||
|
const d = getDist(k);
|
||||||
|
return `${l} #${d.rank}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamAvgs = keys.map(k => getDist(k).avg);
|
||||||
|
|
||||||
|
new Chart(ctxRadar, {
|
||||||
|
type: 'radar',
|
||||||
|
data: {
|
||||||
|
// Update labels to friendly names
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Player',
|
||||||
|
data: [
|
||||||
|
data.radar.BAT, data.radar.HPS,
|
||||||
|
data.radar.PTL, data.radar.SIDE, data.radar.UTIL,
|
||||||
|
data.radar.STA
|
||||||
|
],
|
||||||
|
backgroundColor: 'rgba(124, 58, 237, 0.2)',
|
||||||
|
borderColor: '#7c3aed',
|
||||||
|
borderWidth: 2,
|
||||||
|
pointBackgroundColor: '#7c3aed',
|
||||||
|
pointBorderColor: '#fff',
|
||||||
|
pointHoverBackgroundColor: '#fff',
|
||||||
|
pointHoverBorderColor: '#7c3aed'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Team Avg',
|
||||||
|
data: teamAvgs,
|
||||||
|
backgroundColor: 'rgba(148, 163, 184, 0.2)', // Slate-400
|
||||||
|
borderColor: '#94a3b8',
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0,
|
||||||
|
borderDash: [5, 5]
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: {
|
||||||
|
legend: { display: true, position: 'bottom' }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
r: {
|
||||||
|
beginAtZero: true,
|
||||||
|
suggestedMax: 100,
|
||||||
|
angleLines: {
|
||||||
|
color: 'rgba(156, 163, 175, 0.2)'
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(156, 163, 175, 0.2)'
|
||||||
|
},
|
||||||
|
pointLabels: {
|
||||||
|
font: {
|
||||||
|
size: 11,
|
||||||
|
weight: 'bold'
|
||||||
|
},
|
||||||
|
color: '#6b7280' // gray-500
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
display: false // Hide numbers on axis
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trend Chart
|
||||||
|
const ctxTrend = document.getElementById('trendChart').getContext('2d');
|
||||||
|
|
||||||
|
// Create Gradient
|
||||||
|
const gradient = ctxTrend.createLinearGradient(0, 0, 0, 400);
|
||||||
|
gradient.addColorStop(0, 'rgba(124, 58, 237, 0.5)'); // Purple
|
||||||
|
gradient.addColorStop(1, 'rgba(124, 58, 237, 0.0)');
|
||||||
|
|
||||||
|
trendChartInstance = new Chart(ctxTrend, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: data.trend.labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Rating',
|
||||||
|
data: data.trend.values,
|
||||||
|
borderColor: '#7c3aed', // YRTV Purple
|
||||||
|
backgroundColor: gradient,
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.4, // Smoother curve
|
||||||
|
pointRadius: 3,
|
||||||
|
pointBackgroundColor: '#fff',
|
||||||
|
pointBorderColor: '#7c3aed',
|
||||||
|
pointHoverRadius: 6,
|
||||||
|
pointHoverBackgroundColor: '#7c3aed',
|
||||||
|
pointHoverBorderColor: '#fff',
|
||||||
|
fill: true,
|
||||||
|
order: 1
|
||||||
|
},
|
||||||
|
// Baselines
|
||||||
|
{
|
||||||
|
label: 'Carry (1.5)',
|
||||||
|
data: Array(data.trend.labels.length).fill(1.5),
|
||||||
|
borderColor: 'rgba(34, 197, 94, 0.6)', // Green
|
||||||
|
borderWidth: 1,
|
||||||
|
borderDash: [5, 5],
|
||||||
|
pointRadius: 0,
|
||||||
|
fill: false,
|
||||||
|
order: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Normal (1.0)',
|
||||||
|
data: Array(data.trend.labels.length).fill(1.0),
|
||||||
|
borderColor: 'rgba(234, 179, 8, 0.6)', // Yellow
|
||||||
|
borderWidth: 1,
|
||||||
|
borderDash: [5, 5],
|
||||||
|
pointRadius: 0,
|
||||||
|
fill: false,
|
||||||
|
order: 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Poor (0.6)',
|
||||||
|
data: Array(data.trend.labels.length).fill(0.6),
|
||||||
|
borderColor: 'rgba(239, 68, 68, 0.6)', // Red
|
||||||
|
borderWidth: 1,
|
||||||
|
borderDash: [5, 5],
|
||||||
|
pointRadius: 0,
|
||||||
|
fill: false,
|
||||||
|
order: 4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
zoom: {
|
||||||
|
pan: {
|
||||||
|
enabled: true,
|
||||||
|
mode: 'x',
|
||||||
|
modifierKey: null, // Allow plain drag
|
||||||
|
},
|
||||||
|
zoom: {
|
||||||
|
wheel: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
pinch: {
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
mode: 'x',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'rgba(17, 24, 39, 0.9)',
|
||||||
|
titleFont: { size: 12 },
|
||||||
|
bodyFont: { size: 14, weight: 'bold' },
|
||||||
|
padding: 12,
|
||||||
|
cornerRadius: 8,
|
||||||
|
displayColors: false, // Cleaner look
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
if (context.datasetIndex > 0) return null; // Hide baseline tooltips
|
||||||
|
let val = context.parsed.y.toFixed(2);
|
||||||
|
let label = "Rating: " + val;
|
||||||
|
if (val >= 1.5) label += " 🔥";
|
||||||
|
else if (val < 0.6) label += " 💀";
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
suggestedMax: 2.0,
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(156, 163, 175, 0.1)',
|
||||||
|
borderDash: [2, 2]
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
font: { size: 10 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
grid: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
maxRotation: 0, // Keep labels horizontal
|
||||||
|
minRotation: 0,
|
||||||
|
autoSkip: true,
|
||||||
|
maxTicksLimit: 10, // Avoid crowding
|
||||||
|
font: { size: 10 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
25
web/templates/tactics/analysis.html
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{% extends "tactics/layout.html" %}
|
||||||
|
|
||||||
|
{% block title %}Deep Analysis - Tactics{% endblock %}
|
||||||
|
|
||||||
|
{% block tactics_content %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">Deep Analysis: Chemistry & Depth</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
<!-- Lineup Selector (Placeholder) -->
|
||||||
|
<div class="border-2 border-dashed border-gray-300 dark:border-slate-600 rounded-lg p-8 flex flex-col items-center justify-center text-center">
|
||||||
|
<svg class="w-12 h-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path></svg>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Lineup Builder</h3>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">Drag 5 players here to analyze chemistry.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Synergy Matrix (Placeholder) -->
|
||||||
|
<div class="border-2 border-dashed border-gray-300 dark:border-slate-600 rounded-lg p-8 flex flex-col items-center justify-center text-center">
|
||||||
|
<svg class="w-12 h-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"></path></svg>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Synergy Matrix</h3>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">Select lineup to view pair-wise win rates.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
396
web/templates/tactics/board.html
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Strategy Board - Tactics{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<!-- Leaflet CSS -->
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
|
||||||
|
<style>
|
||||||
|
.player-token {
|
||||||
|
cursor: grab;
|
||||||
|
transition: transform 0.1s;
|
||||||
|
}
|
||||||
|
.player-token:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
#map-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.leaflet-container {
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
.custom-scroll::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
.custom-scroll::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.custom-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(156, 163, 175, 0.5);
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex flex-col h-[calc(100vh-4rem)]">
|
||||||
|
|
||||||
|
<!-- Navigation (Compact) -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-slate-700 px-4 py-2 flex items-center justify-between shrink-0 z-30 shadow-sm">
|
||||||
|
<div class="flex space-x-6 text-sm font-medium">
|
||||||
|
<a href="{{ url_for('tactics.index') }}" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-white">← Dashboard</a>
|
||||||
|
<a href="{{ url_for('tactics.analysis') }}" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-white">Deep Analysis</a>
|
||||||
|
<a href="{{ url_for('tactics.data') }}" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-white">Data Center</a>
|
||||||
|
<span class="text-yrtv-600 dark:text-yrtv-400 border-b-2 border-yrtv-500">Strategy Board</span>
|
||||||
|
<a href="{{ url_for('tactics.economy') }}" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-white">Economy</a>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Real-time Sync: <span class="text-green-500">● Active</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Board Area -->
|
||||||
|
<div class="flex flex-1 overflow-hidden" x-data="tacticsBoard()">
|
||||||
|
|
||||||
|
<!-- Left Sidebar: Controls & Roster -->
|
||||||
|
<div class="w-72 flex flex-col bg-white dark:bg-slate-800 border-r border-gray-200 dark:border-slate-700 shadow-xl z-20">
|
||||||
|
|
||||||
|
<!-- Map Select -->
|
||||||
|
<div class="p-4 border-b border-gray-200 dark:border-slate-700">
|
||||||
|
<div class="flex space-x-2 mb-2">
|
||||||
|
<select x-model="currentMap" @change="changeMap()" class="flex-1 rounded border-gray-300 dark:bg-slate-700 dark:border-slate-600 dark:text-white text-sm">
|
||||||
|
<option value="de_mirage">Mirage</option>
|
||||||
|
<option value="de_inferno">Inferno</option>
|
||||||
|
<option value="de_dust2">Dust 2</option>
|
||||||
|
<option value="de_nuke">Nuke</option>
|
||||||
|
<option value="de_ancient">Ancient</option>
|
||||||
|
<option value="de_anubis">Anubis</option>
|
||||||
|
<option value="de_vertigo">Vertigo</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<button @click="saveBoard()" class="flex-1 px-3 py-1.5 bg-yrtv-600 text-white rounded hover:bg-yrtv-700 text-xs font-medium">Save Snapshot</button>
|
||||||
|
<button @click="clearBoard()" class="px-3 py-1.5 bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 rounded hover:bg-red-200 dark:hover:bg-red-900/50 text-xs font-medium">Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scrollable Content -->
|
||||||
|
<div class="flex-1 overflow-y-auto custom-scroll p-4 space-y-6">
|
||||||
|
|
||||||
|
<!-- Roster (Draggable) -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Roster</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<template x-for="player in roster" :key="player.steam_id_64">
|
||||||
|
<div class="player-token group flex items-center p-2 rounded-lg border border-transparent hover:bg-gray-50 dark:hover:bg-slate-700 hover:border-gray-200 dark:hover:border-slate-600 transition select-none cursor-grab active:cursor-grabbing"
|
||||||
|
:data-id="player.steam_id_64"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="dragStart($event, player)">
|
||||||
|
|
||||||
|
<img :src="player.avatar_url || 'https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg'"
|
||||||
|
class="w-8 h-8 rounded-full border border-gray-200 dark:border-slate-600 object-cover pointer-events-none">
|
||||||
|
|
||||||
|
<div class="ml-3 flex-1 min-w-0 pointer-events-none">
|
||||||
|
<div class="text-xs font-medium text-gray-900 dark:text-white truncate" x-text="player.username || player.name"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="roster.length === 0">
|
||||||
|
<div class="text-xs text-gray-500 text-center py-4 border-2 border-dashed border-gray-200 dark:border-slate-700 rounded-lg">
|
||||||
|
No players found.
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Players List -->
|
||||||
|
<div x-show="activePlayers.length > 0">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3 flex justify-between items-center">
|
||||||
|
<span>On Board</span>
|
||||||
|
<span class="text-xs bg-yrtv-100 text-yrtv-800 dark:bg-yrtv-900 dark:text-yrtv-300 px-2 py-0.5 rounded-full" x-text="activePlayers.length"></span>
|
||||||
|
</h3>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<template x-for="p in activePlayers" :key="p.id">
|
||||||
|
<li class="flex items-center justify-between p-2 rounded bg-gray-50 dark:bg-slate-700/50">
|
||||||
|
<span class="text-xs text-gray-700 dark:text-gray-300 truncate" x-text="p.username || p.name"></span>
|
||||||
|
<button @click="removeMarker(p.id)" class="text-gray-400 hover:text-red-500 transition">×</button>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Radar Chart -->
|
||||||
|
<div class="pt-4 border-t border-gray-200 dark:border-slate-700">
|
||||||
|
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Synergy</h3>
|
||||||
|
<div class="relative h-40 w-full">
|
||||||
|
<canvas id="tacticRadar"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Map Area -->
|
||||||
|
<div class="flex-1 relative bg-gray-900" id="map-dropzone" @dragover.prevent @drop="dropOnMap($event)">
|
||||||
|
<div id="map-container"></div>
|
||||||
|
|
||||||
|
<div class="absolute bottom-4 right-4 z-[400] bg-black/50 backdrop-blur text-white text-[10px] px-2 py-1 rounded pointer-events-none">
|
||||||
|
Drag players to map • Scroll to zoom
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scripts -->
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function tacticsBoard() {
|
||||||
|
return {
|
||||||
|
roster: [],
|
||||||
|
currentMap: 'de_mirage',
|
||||||
|
map: null,
|
||||||
|
markers: {}, // id -> marker
|
||||||
|
activePlayers: [], // list of {id, name, stats}
|
||||||
|
radarChart: null,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.fetchRoster();
|
||||||
|
this.initMap();
|
||||||
|
this.initRadar();
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (this.map) this.map.invalidateSize();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchRoster() {
|
||||||
|
fetch('/teams/api/roster')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
this.roster = data.roster || [];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
initMap() {
|
||||||
|
this.map = L.map('map-container', {
|
||||||
|
crs: L.CRS.Simple,
|
||||||
|
minZoom: -2,
|
||||||
|
maxZoom: 2,
|
||||||
|
zoomControl: true,
|
||||||
|
attributionControl: false
|
||||||
|
});
|
||||||
|
|
||||||
|
this.loadMapImage();
|
||||||
|
},
|
||||||
|
|
||||||
|
loadMapImage() {
|
||||||
|
const mapUrls = {
|
||||||
|
'de_mirage': 'https://static.wikia.nocookie.net/cswikia/images/e/e3/Mirage_CS2_Radar.png',
|
||||||
|
'de_inferno': 'https://static.wikia.nocookie.net/cswikia/images/7/77/Inferno_CS2_Radar.png',
|
||||||
|
'de_dust2': 'https://static.wikia.nocookie.net/cswikia/images/0/03/Dust2_CS2_Radar.png',
|
||||||
|
'de_nuke': 'https://static.wikia.nocookie.net/cswikia/images/1/14/Nuke_CS2_Radar.png',
|
||||||
|
'de_ancient': 'https://static.wikia.nocookie.net/cswikia/images/1/16/Ancient_CS2_Radar.png',
|
||||||
|
'de_anubis': 'https://static.wikia.nocookie.net/cswikia/images/2/22/Anubis_CS2_Radar.png',
|
||||||
|
'de_vertigo': 'https://static.wikia.nocookie.net/cswikia/images/2/23/Vertigo_CS2_Radar.png'
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = mapUrls[this.currentMap] || mapUrls['de_mirage'];
|
||||||
|
const bounds = [[0,0], [1024,1024]];
|
||||||
|
|
||||||
|
this.map.eachLayer((layer) => {
|
||||||
|
this.map.removeLayer(layer);
|
||||||
|
});
|
||||||
|
|
||||||
|
L.imageOverlay(url, bounds).addTo(this.map);
|
||||||
|
this.map.fitBounds(bounds);
|
||||||
|
},
|
||||||
|
|
||||||
|
changeMap() {
|
||||||
|
this.loadMapImage();
|
||||||
|
this.clearBoard();
|
||||||
|
},
|
||||||
|
|
||||||
|
dragStart(event, player) {
|
||||||
|
event.dataTransfer.setData('text/plain', JSON.stringify(player));
|
||||||
|
event.dataTransfer.effectAllowed = 'copy';
|
||||||
|
},
|
||||||
|
|
||||||
|
dropOnMap(event) {
|
||||||
|
const data = event.dataTransfer.getData('text/plain');
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const player = JSON.parse(data);
|
||||||
|
const container = document.getElementById('map-container');
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
|
||||||
|
const x = event.clientX - rect.left;
|
||||||
|
const y = event.clientY - rect.top;
|
||||||
|
|
||||||
|
const point = this.map.containerPointToLatLng([x, y]);
|
||||||
|
|
||||||
|
this.addMarker(player, point);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Drop failed:", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addMarker(player, latlng) {
|
||||||
|
if (this.markers[player.steam_id_64]) {
|
||||||
|
this.markers[player.steam_id_64].setLatLng(latlng);
|
||||||
|
} else {
|
||||||
|
const displayName = player.username || player.name || player.steam_id_64;
|
||||||
|
|
||||||
|
const iconHtml = `
|
||||||
|
<div class="flex flex-col items-center justify-center transform hover:scale-110 transition duration-200">
|
||||||
|
<img src="${player.avatar_url || 'https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg'}"
|
||||||
|
class="w-10 h-10 rounded-full border-2 border-white shadow-lg box-content">
|
||||||
|
<span class="mt-1 text-[10px] font-bold text-white bg-black/60 px-1.5 py-0.5 rounded backdrop-blur-sm whitespace-nowrap overflow-hidden max-w-[80px] text-ellipsis">
|
||||||
|
${displayName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const icon = L.divIcon({
|
||||||
|
className: 'bg-transparent',
|
||||||
|
html: iconHtml,
|
||||||
|
iconSize: [60, 60],
|
||||||
|
iconAnchor: [30, 30]
|
||||||
|
});
|
||||||
|
|
||||||
|
const marker = L.marker(latlng, { icon: icon, draggable: true }).addTo(this.map);
|
||||||
|
this.markers[player.steam_id_64] = marker;
|
||||||
|
|
||||||
|
this.activePlayers.push({
|
||||||
|
id: player.steam_id_64,
|
||||||
|
username: player.username,
|
||||||
|
name: player.name,
|
||||||
|
stats: player.stats
|
||||||
|
});
|
||||||
|
|
||||||
|
this.updateRadar();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeMarker(id) {
|
||||||
|
if (this.markers[id]) {
|
||||||
|
this.map.removeLayer(this.markers[id]);
|
||||||
|
delete this.markers[id];
|
||||||
|
this.activePlayers = this.activePlayers.filter(p => p.id !== id);
|
||||||
|
this.updateRadar();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearBoard() {
|
||||||
|
for (let id in this.markers) {
|
||||||
|
this.map.removeLayer(this.markers[id]);
|
||||||
|
}
|
||||||
|
this.markers = {};
|
||||||
|
this.activePlayers = [];
|
||||||
|
this.updateRadar();
|
||||||
|
},
|
||||||
|
|
||||||
|
saveBoard() {
|
||||||
|
const title = prompt("Enter a title for this strategy:", "New Strat " + new Date().toLocaleTimeString());
|
||||||
|
if (!title) return;
|
||||||
|
|
||||||
|
const markerData = [];
|
||||||
|
for (let id in this.markers) {
|
||||||
|
const m = this.markers[id];
|
||||||
|
markerData.push({
|
||||||
|
id: id,
|
||||||
|
lat: m.getLatLng().lat,
|
||||||
|
lng: m.getLatLng().lng
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch("{{ url_for('tactics.save_board') }}", {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: title,
|
||||||
|
map_name: this.currentMap,
|
||||||
|
markers: markerData
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if(data.success) alert("Saved!");
|
||||||
|
else alert("Error: " + data.message);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
initRadar() {
|
||||||
|
const ctx = document.getElementById('tacticRadar').getContext('2d');
|
||||||
|
Chart.defaults.color = '#9ca3af';
|
||||||
|
Chart.defaults.borderColor = '#374151';
|
||||||
|
|
||||||
|
this.radarChart = new Chart(ctx, {
|
||||||
|
type: 'radar',
|
||||||
|
data: {
|
||||||
|
labels: ['RTG', 'K/D', 'KST', 'ADR', 'IMP', 'UTL'],
|
||||||
|
datasets: [{
|
||||||
|
label: 'Avg',
|
||||||
|
data: [0, 0, 0, 0, 0, 0],
|
||||||
|
backgroundColor: 'rgba(139, 92, 246, 0.2)',
|
||||||
|
borderColor: 'rgba(139, 92, 246, 1)',
|
||||||
|
pointBackgroundColor: 'rgba(139, 92, 246, 1)',
|
||||||
|
borderWidth: 1,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
r: {
|
||||||
|
beginAtZero: true,
|
||||||
|
max: 1.5,
|
||||||
|
grid: { color: 'rgba(156, 163, 175, 0.1)' },
|
||||||
|
angleLines: { color: 'rgba(156, 163, 175, 0.1)' },
|
||||||
|
pointLabels: { font: { size: 9 } },
|
||||||
|
ticks: { display: false }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: { legend: { display: false } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updateRadar() {
|
||||||
|
if (this.activePlayers.length === 0) {
|
||||||
|
this.radarChart.data.datasets[0].data = [0, 0, 0, 0, 0, 0];
|
||||||
|
this.radarChart.update();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let totals = [0, 0, 0, 0, 0, 0];
|
||||||
|
this.activePlayers.forEach(p => {
|
||||||
|
const s = p.stats || {};
|
||||||
|
totals[0] += s.basic_avg_rating || 0;
|
||||||
|
totals[1] += s.basic_avg_kd || 0;
|
||||||
|
totals[2] += s.basic_avg_kast || 0;
|
||||||
|
totals[3] += (s.basic_avg_adr || 0) / 100;
|
||||||
|
totals[4] += s.bat_avg_impact || 1.0;
|
||||||
|
totals[5] += s.util_usage_rate || 0.5;
|
||||||
|
});
|
||||||
|
|
||||||
|
const count = this.activePlayers.length;
|
||||||
|
const avgs = totals.map(t => t / count);
|
||||||
|
|
||||||
|
this.radarChart.data.datasets[0].data = avgs;
|
||||||
|
this.radarChart.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
161
web/templates/tactics/compare.html
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">数据对比中心 (Data Center)</h2>
|
||||||
|
|
||||||
|
<!-- Search & Add -->
|
||||||
|
<div class="mb-6 relative">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">添加对比玩家</label>
|
||||||
|
<input type="text" id="playerSearch" placeholder="输入 ID 或昵称搜索..." class="w-full border border-gray-300 rounded-md py-2 px-4 dark:bg-slate-700 dark:text-white">
|
||||||
|
<div id="searchResults" class="absolute z-10 w-full bg-white dark:bg-slate-700 shadow-lg rounded-b-md hidden"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Selected Players Tags -->
|
||||||
|
<div id="selectedPlayers" class="flex flex-wrap gap-2 mb-6">
|
||||||
|
<!-- Tags will be injected here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chart -->
|
||||||
|
<div class="relative h-96">
|
||||||
|
<canvas id="compareChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const searchInput = document.getElementById('playerSearch');
|
||||||
|
const resultsDiv = document.getElementById('searchResults');
|
||||||
|
const selectedDiv = document.getElementById('selectedPlayers');
|
||||||
|
|
||||||
|
let selectedIds = [];
|
||||||
|
let chartInstance = null;
|
||||||
|
|
||||||
|
// Init Chart
|
||||||
|
const ctx = document.getElementById('compareChart').getContext('2d');
|
||||||
|
chartInstance = new Chart(ctx, {
|
||||||
|
type: 'radar',
|
||||||
|
data: {
|
||||||
|
labels: ['STA', 'BAT', 'HPS', 'PTL', 'SIDE', 'UTIL'],
|
||||||
|
datasets: []
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
r: {
|
||||||
|
beginAtZero: true,
|
||||||
|
suggestedMax: 2.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search
|
||||||
|
let debounceTimer;
|
||||||
|
searchInput.addEventListener('input', function() {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
const query = this.value;
|
||||||
|
if (query.length < 2) {
|
||||||
|
resultsDiv.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
fetch(`/players/api/search?q=${query}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
resultsDiv.innerHTML = '';
|
||||||
|
if (data.length > 0) {
|
||||||
|
resultsDiv.classList.remove('hidden');
|
||||||
|
data.forEach(p => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'p-2 hover:bg-gray-100 dark:hover:bg-slate-600 cursor-pointer text-gray-900 dark:text-white';
|
||||||
|
div.innerText = `${p.username} (${p.steam_id})`;
|
||||||
|
div.onclick = () => addPlayer(p);
|
||||||
|
resultsDiv.appendChild(div);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resultsDiv.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hide results on click outside
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (!searchInput.contains(e.target) && !resultsDiv.contains(e.target)) {
|
||||||
|
resultsDiv.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function addPlayer(player) {
|
||||||
|
if (selectedIds.includes(player.steam_id)) return;
|
||||||
|
selectedIds.push(player.steam_id);
|
||||||
|
|
||||||
|
// Add Tag
|
||||||
|
const tag = document.createElement('span');
|
||||||
|
tag.className = 'inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-yrtv-100 text-yrtv-800';
|
||||||
|
tag.innerHTML = `
|
||||||
|
${player.username}
|
||||||
|
<button type="button" class="flex-shrink-0 ml-1.5 h-4 w-4 rounded-full inline-flex items-center justify-center text-yrtv-400 hover:bg-yrtv-200 hover:text-yrtv-500 focus:outline-none" onclick="removePlayer('${player.steam_id}', this)">
|
||||||
|
<span class="sr-only">Remove</span>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
selectedDiv.appendChild(tag);
|
||||||
|
|
||||||
|
// Fetch Stats and Update Chart
|
||||||
|
updateChart();
|
||||||
|
|
||||||
|
searchInput.value = '';
|
||||||
|
resultsDiv.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.removePlayer = function(id, btn) {
|
||||||
|
selectedIds = selectedIds.filter(sid => sid !== id);
|
||||||
|
btn.parentElement.remove();
|
||||||
|
updateChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChart() {
|
||||||
|
if (selectedIds.length === 0) {
|
||||||
|
chartInstance.data.datasets = [];
|
||||||
|
chartInstance.update();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = selectedIds.join(',');
|
||||||
|
fetch(`/players/api/batch_stats?ids=${ids}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const datasets = data.map((p, index) => {
|
||||||
|
const colors = [
|
||||||
|
'rgba(124, 58, 237, 1)', 'rgba(16, 185, 129, 1)', 'rgba(239, 68, 68, 1)',
|
||||||
|
'rgba(59, 130, 246, 1)', 'rgba(245, 158, 11, 1)'
|
||||||
|
];
|
||||||
|
const color = colors[index % colors.length];
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: p.username,
|
||||||
|
data: [
|
||||||
|
p.radar.STA, p.radar.BAT, p.radar.HPS,
|
||||||
|
p.radar.PTL, p.radar.SIDE, p.radar.UTIL
|
||||||
|
],
|
||||||
|
backgroundColor: color.replace('1)', '0.2)'),
|
||||||
|
borderColor: color,
|
||||||
|
pointBackgroundColor: color
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
chartInstance.data.datasets = datasets;
|
||||||
|
chartInstance.update();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
355
web/templates/tactics/data.html
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
<!-- Data Center Tab Content -->
|
||||||
|
<div x-show="activeTab === 'data'" class="space-y-6 h-full flex flex-col">
|
||||||
|
<!-- Header / Controls -->
|
||||||
|
<div class="flex justify-between items-center bg-white dark:bg-slate-800 p-4 rounded-xl shadow-sm border border-gray-200 dark:border-slate-700">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<span>📊</span> 数据对比中心 (Data Comparison)
|
||||||
|
</h3>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">拖拽左侧队员至下方区域,或点击搜索添加</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<div class="relative">
|
||||||
|
<input type="text" x-model="searchQuery" @keydown.enter="searchPlayer()" placeholder="Search Player..." class="pl-3 pr-8 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm bg-gray-50 dark:bg-slate-900 dark:text-white focus:ring-2 focus:ring-yrtv-500">
|
||||||
|
<button @click="searchPlayer()" class="absolute right-2 top-2 text-gray-400 hover:text-yrtv-600">🔍</button>
|
||||||
|
</div>
|
||||||
|
<button @click="clearDataLineup()" class="px-4 py-2 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 text-sm font-bold transition">清空</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content Grid -->
|
||||||
|
<div class="flex-1 grid grid-cols-1 lg:grid-cols-4 gap-6 min-h-0">
|
||||||
|
|
||||||
|
<!-- Left: Selected Players (Drop Zone) -->
|
||||||
|
<div class="lg:col-span-1 bg-white dark:bg-slate-800 rounded-xl shadow-lg border border-gray-100 dark:border-slate-700 flex flex-col overflow-hidden transition-colors duration-200"
|
||||||
|
:class="{'border-yrtv-400 bg-yrtv-50 dark:bg-slate-700 ring-2 ring-yrtv-200': isDraggingOverData}"
|
||||||
|
@dragover.prevent="isDraggingOverData = true"
|
||||||
|
@dragleave="isDraggingOverData = false"
|
||||||
|
@drop="dropData($event)">
|
||||||
|
|
||||||
|
<div class="p-4 border-b border-gray-100 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||||
|
<h4 class="font-bold text-gray-700 dark:text-gray-200 flex justify-between">
|
||||||
|
<span>对比列表</span>
|
||||||
|
<span class="text-xs bg-yrtv-100 text-yrtv-700 px-2 py-0.5 rounded-full" x-text="dataLineup.length + '/5'">0/5</span>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 p-4 space-y-3 overflow-y-auto custom-scroll min-h-[100px]">
|
||||||
|
|
||||||
|
<template x-for="(p, idx) in dataLineup" :key="p.steam_id_64">
|
||||||
|
<div class="flex items-center p-3 bg-white dark:bg-slate-700 border border-gray-200 dark:border-slate-600 rounded-xl shadow-sm group hover:border-yrtv-300 transition relative">
|
||||||
|
<!-- Color Indicator -->
|
||||||
|
<div class="w-1.5 h-full absolute left-0 top-0 rounded-l-xl" :style="'background-color: ' + getPlayerColor(idx)"></div>
|
||||||
|
|
||||||
|
<div class="ml-3 flex-shrink-0">
|
||||||
|
<template x-if="p.avatar_url">
|
||||||
|
<img :src="p.avatar_url" class="w-10 h-10 rounded-full object-cover border border-gray-200 dark:border-slate-500">
|
||||||
|
</template>
|
||||||
|
<template x-if="!p.avatar_url">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-500 font-bold text-xs">
|
||||||
|
<span x-text="(p.username || p.name).substring(0,2).toUpperCase()"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-bold text-gray-900 dark:text-white truncate" x-text="p.username || p.name"></div>
|
||||||
|
<div class="text-xs text-gray-500 font-mono truncate" x-text="p.steam_id_64"></div>
|
||||||
|
</div>
|
||||||
|
<button @click="removeFromDataLineup(idx)" class="text-gray-400 hover:text-red-500 p-1 opacity-0 group-hover:opacity-100 transition">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="dataLineup.length < 5">
|
||||||
|
<div class="h-24 border-2 border-dashed border-gray-200 dark:border-slate-600 rounded-xl flex flex-col items-center justify-center text-gray-400 text-sm hover:bg-gray-50 dark:hover:bg-slate-800 transition cursor-default"
|
||||||
|
:class="{'border-yrtv-400 text-yrtv-600 bg-white': isDraggingOverData}">
|
||||||
|
<span>+ 拖拽或搜索添加</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Visualization (Scrollable) -->
|
||||||
|
<div class="lg:col-span-3 space-y-6 overflow-y-auto custom-scroll pr-2">
|
||||||
|
|
||||||
|
<!-- 1. Radar & Key Stats -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<!-- Radar Chart -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 p-6 rounded-xl shadow-lg border border-gray-100 dark:border-slate-700 min-h-[400px] flex flex-col">
|
||||||
|
<h4 class="font-bold text-gray-800 dark:text-gray-200 mb-4">能力模型对比 (Capability Radar)</h4>
|
||||||
|
<div class="flex-1 relative">
|
||||||
|
<canvas id="dataRadarChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Basic Stats Table -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 p-6 rounded-xl shadow-lg border border-gray-100 dark:border-slate-700 flex flex-col">
|
||||||
|
<h4 class="font-bold text-gray-800 dark:text-gray-200 mb-4">基础数据 (Basic Stats)</h4>
|
||||||
|
<div class="flex-1 overflow-x-auto">
|
||||||
|
<table class="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-gray-500 border-b border-gray-100 dark:border-slate-700">
|
||||||
|
<th class="py-2 text-left">Player</th>
|
||||||
|
<th class="py-2 text-right">Rating</th>
|
||||||
|
<th class="py-2 text-right">K/D</th>
|
||||||
|
<th class="py-2 text-right">ADR</th>
|
||||||
|
<th class="py-2 text-right">KAST</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 dark:divide-slate-700">
|
||||||
|
<template x-for="(stat, idx) in dataResult" :key="stat.steam_id">
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50">
|
||||||
|
<td class="py-3 flex items-center gap-2">
|
||||||
|
<div class="w-3 h-3 rounded-full" :style="'background-color: ' + getPlayerColor(idx)"></div>
|
||||||
|
<span class="font-bold dark:text-white truncate max-w-[100px]" x-text="stat.username"></span>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 text-right font-mono font-bold" :class="getRatingColor(stat.basic.rating)" x-text="stat.basic.rating.toFixed(2)"></td>
|
||||||
|
<td class="py-3 text-right font-mono" x-text="stat.basic.kd.toFixed(2)"></td>
|
||||||
|
<td class="py-3 text-right font-mono" x-text="stat.basic.adr.toFixed(1)"></td>
|
||||||
|
<td class="py-3 text-right font-mono" x-text="(stat.basic.kast * 100).toFixed(1) + '%'"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<template x-if="!dataResult || dataResult.length === 0">
|
||||||
|
<tr><td colspan="5" class="py-8 text-center text-gray-400">请选择选手进行对比</td></tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. Detailed Breakdown (New) -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 p-6 rounded-xl shadow-lg border border-gray-100 dark:border-slate-700">
|
||||||
|
<h4 class="font-bold text-gray-800 dark:text-gray-200 mb-6">详细数据对比 (Detailed Stats)</h4>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-gray-50 dark:bg-slate-700/50 text-gray-500">
|
||||||
|
<th class="px-4 py-3 text-left rounded-l-lg">Metric</th>
|
||||||
|
<template x-for="(stat, idx) in dataResult" :key="'dh-'+stat.steam_id">
|
||||||
|
<th class="px-4 py-3 text-center" :class="{'rounded-r-lg': idx === dataResult.length-1}">
|
||||||
|
<span class="border-b-2 px-1 font-bold dark:text-gray-300" :style="'border-color: ' + getPlayerColor(idx)" x-text="stat.username"></span>
|
||||||
|
</th>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 dark:divide-slate-700">
|
||||||
|
<!-- Row 1 -->
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Rating (Rating/KD)</td>
|
||||||
|
<template x-for="stat in dataResult">
|
||||||
|
<td class="px-4 py-2 text-center font-mono text-xs">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto">
|
||||||
|
<span class="text-amber-600 dark:text-amber-400 font-bold" x-text="stat.detailed.rating_t.toFixed(2)"></span>
|
||||||
|
<span class="text-blue-600 dark:text-blue-400 font-bold" x-text="stat.detailed.rating_ct.toFixed(2)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto text-[10px] text-gray-400">
|
||||||
|
<span>T-Side</span><span>CT-Side</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">KD Ratio</td>
|
||||||
|
<template x-for="stat in dataResult">
|
||||||
|
<td class="px-4 py-2 text-center font-mono text-xs">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto">
|
||||||
|
<span class="text-amber-600 dark:text-amber-400" x-text="stat.detailed.kd_t.toFixed(2)"></span>
|
||||||
|
<span class="text-blue-600 dark:text-blue-400" x-text="stat.detailed.kd_ct.toFixed(2)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto text-[10px] text-gray-400">
|
||||||
|
<span>T-Side</span><span>CT-Side</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Row 2 -->
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Win Rate (胜率)</td>
|
||||||
|
<template x-for="stat in dataResult">
|
||||||
|
<td class="px-4 py-2 text-center font-mono text-xs">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto">
|
||||||
|
<span class="text-amber-600 dark:text-amber-400" x-text="(stat.detailed.win_rate_t * 100).toFixed(1) + '%'"></span>
|
||||||
|
<span class="text-blue-600 dark:text-blue-400" x-text="(stat.detailed.win_rate_ct * 100).toFixed(1) + '%'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto text-[10px] text-gray-400">
|
||||||
|
<span>T-Side</span><span>CT-Side</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">First Kill Rate (首杀率)</td>
|
||||||
|
<template x-for="stat in dataResult">
|
||||||
|
<td class="px-4 py-2 text-center font-mono text-xs">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto">
|
||||||
|
<span class="text-amber-600 dark:text-amber-400" x-text="(stat.detailed.first_kill_t * 100).toFixed(1) + '%'"></span>
|
||||||
|
<span class="text-blue-600 dark:text-blue-400" x-text="(stat.detailed.first_kill_ct * 100).toFixed(1) + '%'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto text-[10px] text-gray-400">
|
||||||
|
<span>T-Side</span><span>CT-Side</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Row 3 -->
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">First Death Rate (首死率)</td>
|
||||||
|
<template x-for="stat in dataResult">
|
||||||
|
<td class="px-4 py-2 text-center font-mono text-xs">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto">
|
||||||
|
<span class="text-amber-600 dark:text-amber-400" x-text="(stat.detailed.first_death_t * 100).toFixed(1) + '%'"></span>
|
||||||
|
<span class="text-blue-600 dark:text-blue-400" x-text="(stat.detailed.first_death_ct * 100).toFixed(1) + '%'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto text-[10px] text-gray-400">
|
||||||
|
<span>T-Side</span><span>CT-Side</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">KAST (贡献率)</td>
|
||||||
|
<template x-for="stat in dataResult">
|
||||||
|
<td class="px-4 py-2 text-center font-mono text-xs">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto">
|
||||||
|
<span class="text-amber-600 dark:text-amber-400" x-text="(stat.detailed.kast_t * 100).toFixed(1) + '%'"></span>
|
||||||
|
<span class="text-blue-600 dark:text-blue-400" x-text="(stat.detailed.kast_ct * 100).toFixed(1) + '%'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto text-[10px] text-gray-400">
|
||||||
|
<span>T-Side</span><span>CT-Side</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Row 4 -->
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">RWS (Round Win Share)</td>
|
||||||
|
<template x-for="stat in dataResult">
|
||||||
|
<td class="px-4 py-2 text-center font-mono text-xs">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto">
|
||||||
|
<span class="text-amber-600 dark:text-amber-400" x-text="stat.detailed.rws_t.toFixed(2)"></span>
|
||||||
|
<span class="text-blue-600 dark:text-blue-400" x-text="stat.detailed.rws_ct.toFixed(2)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto text-[10px] text-gray-400">
|
||||||
|
<span>T-Side</span><span>CT-Side</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Multi-Kill Rate (多杀率)</td>
|
||||||
|
<template x-for="stat in dataResult">
|
||||||
|
<td class="px-4 py-2 text-center font-mono text-xs">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto">
|
||||||
|
<span class="text-amber-600 dark:text-amber-400" x-text="(stat.detailed.multikill_t * 100).toFixed(1) + '%'"></span>
|
||||||
|
<span class="text-blue-600 dark:text-blue-400" x-text="(stat.detailed.multikill_ct * 100).toFixed(1) + '%'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto text-[10px] text-gray-400">
|
||||||
|
<span>T-Side</span><span>CT-Side</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Row 5 -->
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Headshot Rate (爆头率)</td>
|
||||||
|
<template x-for="stat in dataResult">
|
||||||
|
<td class="px-4 py-2 text-center font-mono text-xs">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto">
|
||||||
|
<span class="text-amber-600 dark:text-amber-400" x-text="(stat.detailed.hs_t * 100).toFixed(1) + '%'"></span>
|
||||||
|
<span class="text-blue-600 dark:text-blue-400" x-text="(stat.detailed.hs_ct * 100).toFixed(1) + '%'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto text-[10px] text-gray-400">
|
||||||
|
<span>T-Side</span><span>CT-Side</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Obj (下包 vs 拆包)</td>
|
||||||
|
<template x-for="stat in dataResult">
|
||||||
|
<td class="px-4 py-2 text-center font-mono text-xs">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto">
|
||||||
|
<span class="text-amber-600 dark:text-amber-400" x-text="stat.detailed.obj_t.toFixed(2)"></span>
|
||||||
|
<span class="text-blue-600 dark:text-blue-400" x-text="stat.detailed.obj_ct.toFixed(2)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto text-[10px] text-gray-400">
|
||||||
|
<span>T-Side</span><span>CT-Side</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. Map Performance -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 p-6 rounded-xl shadow-lg border border-gray-100 dark:border-slate-700">
|
||||||
|
<h4 class="font-bold text-gray-800 dark:text-gray-200 mb-6">地图表现 (Map Performance)</h4>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full text-sm">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-700/50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 text-left rounded-l-lg">Map</th>
|
||||||
|
<template x-for="(stat, idx) in dataResult" :key="'h-'+stat.steam_id">
|
||||||
|
<th class="px-4 py-2 text-center" :class="{'rounded-r-lg': idx === dataResult.length-1}">
|
||||||
|
<span class="border-b-2 px-1" :style="'border-color: ' + getPlayerColor(idx)" x-text="stat.username"></span>
|
||||||
|
</th>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 dark:divide-slate-700">
|
||||||
|
<!-- We need to iterate maps. Assuming mapMap is computed in JS -->
|
||||||
|
<template x-for="mapName in allMaps" :key="mapName">
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||||
|
<td class="px-4 py-3 font-bold text-gray-600 dark:text-gray-300" x-text="mapName"></td>
|
||||||
|
<template x-for="stat in dataResult" :key="'d-'+stat.steam_id+mapName">
|
||||||
|
<td class="px-4 py-3 text-center">
|
||||||
|
<template x-if="getMapStat(stat.steam_id, mapName)">
|
||||||
|
<div>
|
||||||
|
<div class="font-bold font-mono" :class="getRatingColor(getMapStat(stat.steam_id, mapName).rating)" x-text="getMapStat(stat.steam_id, mapName).rating.toFixed(2)"></div>
|
||||||
|
<div class="text-[10px] text-gray-400" x-text="(getMapStat(stat.steam_id, mapName).win_rate * 100).toFixed(0) + '% (' + getMapStat(stat.steam_id, mapName).matches + ')'"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="!getMapStat(stat.steam_id, mapName)">
|
||||||
|
<span class="text-gray-300">-</span>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
65
web/templates/tactics/economy.html
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
{% extends "tactics/layout.html" %}
|
||||||
|
|
||||||
|
{% block title %}Economy Calculator - Tactics{% endblock %}
|
||||||
|
|
||||||
|
{% block tactics_content %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">Economy Calculator</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
<!-- Input Form -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Current Round State</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Round Result</label>
|
||||||
|
<select class="mt-1 block w-full rounded-md border-gray-300 dark:bg-slate-700 dark:text-white">
|
||||||
|
<option>Won (Elimination/Time)</option>
|
||||||
|
<option>Won (Bomb Defused)</option>
|
||||||
|
<option>Lost (Elimination)</option>
|
||||||
|
<option>Lost (Bomb Planted)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Surviving Players</label>
|
||||||
|
<input type="number" min="0" max="5" value="0" class="mt-1 block w-full rounded-md border-gray-300 dark:bg-slate-700 dark:text-white">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Current Loss Bonus</label>
|
||||||
|
<select class="mt-1 block w-full rounded-md border-gray-300 dark:bg-slate-700 dark:text-white">
|
||||||
|
<option>$1400 (0)</option>
|
||||||
|
<option>$1900 (1)</option>
|
||||||
|
<option>$2400 (2)</option>
|
||||||
|
<option>$2900 (3)</option>
|
||||||
|
<option>$3400 (4+)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="w-full px-4 py-2 bg-yrtv-600 text-white rounded-md">Calculate Next Round</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Output -->
|
||||||
|
<div class="bg-gray-50 dark:bg-slate-700 p-6 rounded-lg">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Prediction</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600 dark:text-gray-300">Team Money (Min)</span>
|
||||||
|
<span class="font-bold text-gray-900 dark:text-white">$12,400</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600 dark:text-gray-300">Team Money (Max)</span>
|
||||||
|
<span class="font-bold text-gray-900 dark:text-white">$18,500</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-200 dark:border-slate-600 pt-4">
|
||||||
|
<span class="block text-sm text-gray-500 dark:text-gray-400">Recommendation</span>
|
||||||
|
<span class="block text-xl font-bold text-green-600 dark:text-green-400">Full Buy</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
780
web/templates/tactics/index.html
Normal file
@@ -0,0 +1,780 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Tactics Center{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<!-- Leaflet CSS -->
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
|
||||||
|
<style>
|
||||||
|
.player-token { cursor: grab; transition: transform 0.1s; }
|
||||||
|
.player-token:active { cursor: grabbing; transform: scale(1.05); }
|
||||||
|
#map-container { background-color: #1a1a1a; z-index: 1; }
|
||||||
|
.leaflet-container { background: #1a1a1a; }
|
||||||
|
.custom-scroll::-webkit-scrollbar { width: 6px; }
|
||||||
|
.custom-scroll::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.custom-scroll::-webkit-scrollbar-thumb { background-color: rgba(156, 163, 175, 0.5); border-radius: 20px; }
|
||||||
|
[x-cloak] { display: none !important; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex h-[calc(100vh-4rem)] overflow-hidden" x-data="tacticsApp()" x-cloak>
|
||||||
|
|
||||||
|
<!-- Left Sidebar: Roster (Permanent) -->
|
||||||
|
<div class="w-72 flex flex-col bg-white dark:bg-slate-800 border-r border-gray-200 dark:border-slate-700 shadow-xl z-20 shrink-0">
|
||||||
|
<div class="p-4 border-b border-gray-200 dark:border-slate-700">
|
||||||
|
<h2 class="text-lg font-bold text-gray-900 dark:text-white">队员列表 (Roster)</h2>
|
||||||
|
<p class="text-xs text-gray-500">拖拽队员至右侧功能区</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto custom-scroll p-4 space-y-2">
|
||||||
|
<template x-for="player in roster" :key="player.steam_id_64">
|
||||||
|
<div class="player-token group flex items-center p-2 rounded-lg border border-transparent hover:bg-gray-50 dark:hover:bg-slate-700 hover:border-gray-200 dark:hover:border-slate-600 transition select-none cursor-grab active:cursor-grabbing"
|
||||||
|
:data-id="player.steam_id_64"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="dragStart($event, player)">
|
||||||
|
|
||||||
|
<template x-if="player.avatar_url">
|
||||||
|
<img :src="player.avatar_url" class="w-10 h-10 rounded-full border border-gray-200 dark:border-slate-600 object-cover pointer-events-none">
|
||||||
|
</template>
|
||||||
|
<template x-if="!player.avatar_url">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-yrtv-100 flex items-center justify-center border border-gray-200 dark:border-slate-600 text-yrtv-600 font-bold text-xs pointer-events-none">
|
||||||
|
<span x-text="(player.username || player.name || player.steam_id_64).substring(0, 2).toUpperCase()"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="ml-3 flex-1 min-w-0 pointer-events-none">
|
||||||
|
<div class="text-sm font-medium text-gray-900 dark:text-white truncate" x-text="player.username || player.name || player.steam_id_64"></div>
|
||||||
|
<!-- Tag Display -->
|
||||||
|
<div class="flex flex-wrap gap-1 mt-0.5">
|
||||||
|
<template x-for="tag in player.tags">
|
||||||
|
<span class="text-[10px] bg-gray-100 dark:bg-slate-600 text-gray-600 dark:text-gray-300 px-1 rounded" x-text="tag"></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="roster.length === 0">
|
||||||
|
<div class="text-sm text-gray-500 text-center py-8">
|
||||||
|
暂无队员,请去 <a href="/teams" class="text-yrtv-600 hover:underline">Team</a> 页面添加。
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Content Area -->
|
||||||
|
<div class="flex-1 flex flex-col min-w-0 bg-gray-50 dark:bg-gray-900">
|
||||||
|
|
||||||
|
<!-- Top Navigation Tabs -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-slate-700 px-4">
|
||||||
|
<nav class="-mb-px flex space-x-8">
|
||||||
|
<button @click="switchTab('analysis')" :class="{'border-yrtv-500 text-yrtv-600 dark:text-yrtv-400': activeTab === 'analysis', 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400': activeTab !== 'analysis'}" class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition">
|
||||||
|
深度分析 (Deep Analysis)
|
||||||
|
</button>
|
||||||
|
<button @click="switchTab('data')" :class="{'border-yrtv-500 text-yrtv-600 dark:text-yrtv-400': activeTab === 'data', 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400': activeTab !== 'data'}" class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition">
|
||||||
|
数据中心 (Data Center)
|
||||||
|
</button>
|
||||||
|
<button @click="switchTab('board')" :class="{'border-yrtv-500 text-yrtv-600 dark:text-yrtv-400': activeTab === 'board', 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400': activeTab !== 'board'}" class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition">
|
||||||
|
战术白板 (Strategy Board)
|
||||||
|
</button>
|
||||||
|
<button @click="switchTab('economy')" :class="{'border-yrtv-500 text-yrtv-600 dark:text-yrtv-400': activeTab === 'economy', 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400': activeTab !== 'economy'}" class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition">
|
||||||
|
经济计算 (Economy)
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Contents -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-6 relative">
|
||||||
|
|
||||||
|
<!-- 1. Deep Analysis -->
|
||||||
|
<div x-show="activeTab === 'analysis'" class="space-y-6">
|
||||||
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white">阵容化学反应分析</h3>
|
||||||
|
|
||||||
|
<div class="flex flex-col space-y-8">
|
||||||
|
<!-- Drop Zone -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 p-8 rounded-xl shadow-lg min-h-[320px] border border-gray-100 dark:border-slate-700"
|
||||||
|
@dragover.prevent @drop="dropAnalysis($event)">
|
||||||
|
<h4 class="text-lg font-bold text-gray-800 dark:text-gray-200 mb-6 flex justify-between items-center">
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<span class="bg-yrtv-100 text-yrtv-700 p-1 rounded">🏗️</span>
|
||||||
|
<span x-text="'阵容构建 (' + analysisLineup.length + '/5)'">阵容构建 (0/5)</span>
|
||||||
|
</span>
|
||||||
|
<button @click="clearAnalysis()" class="px-3 py-1.5 bg-red-50 text-red-600 rounded-md hover:bg-red-100 text-sm font-medium transition">清空全部</button>
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-5 gap-6">
|
||||||
|
<template x-for="(p, idx) in analysisLineup" :key="p.steam_id_64">
|
||||||
|
<div class="relative group bg-gradient-to-b from-gray-50 to-gray-100 dark:from-slate-700 dark:to-slate-800 p-4 rounded-xl border-2 border-yrtv-200 dark:border-slate-600 flex flex-col items-center justify-center h-48 shadow-sm transition-all duration-200 hover:-translate-y-1 hover:shadow-md">
|
||||||
|
<button @click="removeFromAnalysis(idx)" class="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition shadow-sm">×</button>
|
||||||
|
|
||||||
|
<!-- Avatar -->
|
||||||
|
<template x-if="p.avatar_url">
|
||||||
|
<img :src="p.avatar_url" class="w-20 h-20 rounded-full mb-3 object-cover border-4 border-white dark:border-slate-600 shadow-md">
|
||||||
|
</template>
|
||||||
|
<template x-if="!p.avatar_url">
|
||||||
|
<div class="w-20 h-20 rounded-full mb-3 bg-white flex items-center justify-center text-yrtv-600 font-bold text-2xl border-4 border-gray-100 dark:border-slate-600 shadow-md">
|
||||||
|
<span x-text="(p.username || p.name || p.steam_id_64).substring(0, 2).toUpperCase()"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<span class="text-sm font-bold truncate w-full text-center dark:text-white mb-1" x-text="p.username || p.name"></span>
|
||||||
|
<div class="px-2.5 py-1 bg-white dark:bg-slate-900 rounded-full text-xs text-gray-500 dark:text-gray-400 shadow-inner border border-gray-100 dark:border-slate-700">
|
||||||
|
Rating: <span class="font-bold text-yrtv-600" x-text="(p.stats?.basic_avg_rating || 0).toFixed(2)"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Empty Slots -->
|
||||||
|
<template x-for="i in (5 - analysisLineup.length)">
|
||||||
|
<div class="border-2 border-dashed border-gray-300 dark:border-slate-600 rounded-xl flex flex-col items-center justify-center h-48 text-gray-400 text-sm bg-gray-50/30 dark:bg-slate-800/30 hover:bg-gray-50 dark:hover:bg-slate-800 transition cursor-default">
|
||||||
|
<div class="text-4xl mb-2 opacity-30 text-gray-300">+</div>
|
||||||
|
<span class="opacity-70">拖拽队员</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results Area -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 p-8 rounded-xl shadow-lg min-h-[240px] border border-gray-100 dark:border-slate-700">
|
||||||
|
<template x-if="!analysisResult">
|
||||||
|
<div class="h-48 flex flex-col items-center justify-center text-gray-400">
|
||||||
|
<div class="text-5xl mb-4 opacity-20 grayscale">📊</div>
|
||||||
|
<div class="text-lg font-medium text-gray-500">请先构建阵容,系统将自动分析</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="analysisResult">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex justify-between items-end border-b border-gray-100 dark:border-slate-700 pb-4">
|
||||||
|
<h4 class="font-bold text-xl text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<span>📈</span> 综合评分
|
||||||
|
</h4>
|
||||||
|
<div class="flex items-baseline gap-2">
|
||||||
|
<span class="text-sm text-gray-500">Team Rating</span>
|
||||||
|
<span class="text-4xl font-black text-yrtv-600 tracking-tight" x-text="analysisResult.avg_stats.rating.toFixed(2)"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-3 gap-6 text-center">
|
||||||
|
<div class="bg-gray-50 dark:bg-slate-700 p-4 rounded-xl border border-gray-100 dark:border-slate-600">
|
||||||
|
<div class="text-gray-500 text-xs uppercase tracking-wider mb-1">Avg K/D</div>
|
||||||
|
<div class="text-2xl font-bold dark:text-white" x-text="analysisResult.avg_stats.kd.toFixed(2)"></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 dark:bg-slate-700 p-4 rounded-xl border border-gray-100 dark:border-slate-600">
|
||||||
|
<div class="text-gray-500 text-xs uppercase tracking-wider mb-1">Avg ADR</div>
|
||||||
|
<div class="text-2xl font-bold dark:text-white" x-text="analysisResult.avg_stats.adr.toFixed(1)"></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 dark:bg-slate-700 p-4 rounded-xl border border-gray-100 dark:border-slate-600">
|
||||||
|
<div class="text-gray-500 text-xs uppercase tracking-wider mb-1">Shared Matches</div>
|
||||||
|
<div class="text-2xl font-bold dark:text-white" x-text="analysisResult.total_shared_matches"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h5 class="text-sm font-bold text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
|
||||||
|
<span>🗓️</span> 共同比赛记录 (Shared Matches History)
|
||||||
|
</h5>
|
||||||
|
<div class="max-h-60 overflow-y-auto custom-scroll border border-gray-200 dark:border-slate-700 rounded-lg mb-6">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-800 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Map</th>
|
||||||
|
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Score</th>
|
||||||
|
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Result</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-slate-700">
|
||||||
|
<template x-for="m in analysisResult.shared_matches" :key="m.match_id">
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||||
|
<td class="px-4 py-3 text-sm font-medium dark:text-gray-300" x-text="m.map_name"></td>
|
||||||
|
<td class="px-4 py-3 text-sm text-right dark:text-gray-400 font-mono" x-text="m.score_team1 + ':' + m.score_team2"></td>
|
||||||
|
<td class="px-4 py-3 text-sm text-right font-bold">
|
||||||
|
<span :class="m.is_win ? 'bg-green-100 text-green-800 px-2 py-0.5 rounded dark:bg-green-900 dark:text-green-200' : 'bg-red-100 text-red-800 px-2 py-0.5 rounded dark:bg-red-900 dark:text-red-200'"
|
||||||
|
x-text="m.result_str"></span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<template x-if="analysisResult.shared_matches.length === 0">
|
||||||
|
<div class="p-8 text-center text-gray-400 bg-gray-50 dark:bg-slate-800">
|
||||||
|
无共同比赛记录
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Map Stats -->
|
||||||
|
<h5 class="text-sm font-bold text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
|
||||||
|
<span>🗺️</span> 地图表现统计 (Map Performance)
|
||||||
|
</h5>
|
||||||
|
<div class="border border-gray-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-800">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Map</th>
|
||||||
|
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Matches</th>
|
||||||
|
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Wins</th>
|
||||||
|
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Win Rate</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-slate-700">
|
||||||
|
<template x-for="stat in analysisResult.map_stats" :key="stat.map_name">
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||||
|
<td class="px-4 py-2 text-sm font-medium dark:text-gray-300" x-text="stat.map_name"></td>
|
||||||
|
<td class="px-4 py-2 text-sm text-right dark:text-gray-400" x-text="stat.count"></td>
|
||||||
|
<td class="px-4 py-2 text-sm text-right text-green-600 font-bold" x-text="stat.wins"></td>
|
||||||
|
<td class="px-4 py-2 text-sm text-right font-bold dark:text-white">
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<span x-text="stat.win_rate.toFixed(1) + '%'"></span>
|
||||||
|
<div class="w-16 h-1.5 bg-gray-200 dark:bg-slate-600 rounded-full overflow-hidden">
|
||||||
|
<div class="h-full bg-yrtv-500 rounded-full" :style="'width: ' + stat.win_rate + '%'"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<template x-if="!analysisResult.map_stats || analysisResult.map_stats.length === 0">
|
||||||
|
<div class="p-4 text-center text-gray-400 bg-gray-50 dark:bg-slate-800 text-sm">
|
||||||
|
暂无地图数据
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. Data Center -->
|
||||||
|
{% include 'tactics/data.html' %}
|
||||||
|
|
||||||
|
<!-- 3. Strategy Board -->
|
||||||
|
<div x-show="activeTab === 'board'" class="h-full flex flex-col">
|
||||||
|
<!-- Map Controls -->
|
||||||
|
<div class="mb-4 flex justify-between items-center bg-white dark:bg-slate-800 p-3 rounded shadow">
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<select x-model="currentMap" @change="changeMap()" class="rounded border-gray-300 dark:bg-slate-700 dark:border-slate-600 dark:text-white text-sm">
|
||||||
|
<option value="de_mirage">Mirage</option>
|
||||||
|
<option value="de_inferno">Inferno</option>
|
||||||
|
<option value="de_dust2">Dust 2</option>
|
||||||
|
<option value="de_nuke">Nuke</option>
|
||||||
|
<option value="de_ancient">Ancient</option>
|
||||||
|
<option value="de_anubis">Anubis</option>
|
||||||
|
<option value="de_vertigo">Vertigo</option>
|
||||||
|
</select>
|
||||||
|
<button @click="clearBoard()" class="px-3 py-1 bg-red-100 text-red-700 rounded hover:bg-red-200 text-sm">清空 (Clear)</button>
|
||||||
|
<button @click="saveBoard()" class="px-3 py-1 bg-green-100 text-green-700 rounded hover:bg-green-200 text-sm">保存快照 (Save)</button>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
在场人数: <span x-text="boardPlayers.length" class="font-bold text-yrtv-600"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Map Area -->
|
||||||
|
<div class="flex-1 relative bg-gray-900 rounded-lg overflow-hidden border border-gray-700"
|
||||||
|
id="board-dropzone"
|
||||||
|
@dragover.prevent
|
||||||
|
@drop="dropBoard($event)">
|
||||||
|
<div id="map-container" class="w-full h-full"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 4. Economy -->
|
||||||
|
<div x-show="activeTab === 'economy'" class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">经济计算器 (Economy Calculator)</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">本回合结果</label>
|
||||||
|
<select x-model="econ.result" class="mt-1 block w-full rounded-md border-gray-300 dark:bg-slate-700 dark:text-white">
|
||||||
|
<option value="win">胜利 (Won)</option>
|
||||||
|
<option value="loss">失败 (Lost)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">连败加成等级 (Loss Bonus)</label>
|
||||||
|
<select x-model="econ.lossBonus" class="mt-1 block w-full rounded-md border-gray-300 dark:bg-slate-700 dark:text-white">
|
||||||
|
<option value="0">$1400 (0)</option>
|
||||||
|
<option value="1">$1900 (1)</option>
|
||||||
|
<option value="2">$2400 (2)</option>
|
||||||
|
<option value="3">$2900 (3)</option>
|
||||||
|
<option value="4">$3400 (4+)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">存活人数</label>
|
||||||
|
<input type="number" x-model="econ.surviving" min="0" max="5" class="mt-1 block w-full rounded-md border-gray-300 dark:bg-slate-700 dark:text-white">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-4">
|
||||||
|
<div class="p-4 bg-gray-100 dark:bg-slate-700 rounded-lg">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">下回合收入预测</div>
|
||||||
|
<div class="text-3xl font-bold text-green-600 dark:text-green-400" x-text="'$' + calculateIncome()"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- External Libs -->
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function tacticsApp() {
|
||||||
|
return {
|
||||||
|
activeTab: 'analysis',
|
||||||
|
roster: [],
|
||||||
|
|
||||||
|
// Analysis State
|
||||||
|
analysisLineup: [],
|
||||||
|
analysisResult: null,
|
||||||
|
debounceTimer: null,
|
||||||
|
|
||||||
|
// Data Center State
|
||||||
|
dataLineup: [],
|
||||||
|
dataResult: [],
|
||||||
|
searchQuery: '',
|
||||||
|
radarChart: null,
|
||||||
|
allMaps: ['de_mirage', 'de_inferno', 'de_dust2', 'de_nuke', 'de_ancient', 'de_anubis', 'de_vertigo'],
|
||||||
|
mapStatsCache: {},
|
||||||
|
isDraggingOverData: false,
|
||||||
|
|
||||||
|
// Board State
|
||||||
|
currentMap: 'de_mirage',
|
||||||
|
map: null,
|
||||||
|
markers: {},
|
||||||
|
boardPlayers: [],
|
||||||
|
|
||||||
|
// Economy State
|
||||||
|
econ: {
|
||||||
|
result: 'loss',
|
||||||
|
lossBonus: '0',
|
||||||
|
surviving: 0
|
||||||
|
},
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.fetchRoster();
|
||||||
|
|
||||||
|
// Auto-analyze when lineup changes
|
||||||
|
this.$watch('analysisLineup', () => {
|
||||||
|
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
||||||
|
this.debounceTimer = setTimeout(() => {
|
||||||
|
if (this.analysisLineup.length > 0) {
|
||||||
|
this.analyzeLineup();
|
||||||
|
} else {
|
||||||
|
this.analysisResult = null;
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watch Data Lineup
|
||||||
|
this.$watch('dataLineup', () => {
|
||||||
|
this.comparePlayers();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Init map on first board view, or delay
|
||||||
|
this.$watch('activeTab', value => {
|
||||||
|
if (value === 'board') {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
if (!this.map) this.initMap();
|
||||||
|
else this.map.invalidateSize();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchRoster() {
|
||||||
|
fetch('/teams/api/roster')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
this.roster = data.roster || [];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
switchTab(tab) {
|
||||||
|
this.activeTab = tab;
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Drag & Drop Generic ---
|
||||||
|
dragStart(event, player) {
|
||||||
|
// Only send essential data to avoid circular references with Alpine proxies
|
||||||
|
const payload = {
|
||||||
|
steam_id_64: player.steam_id_64,
|
||||||
|
username: player.username || player.name,
|
||||||
|
name: player.name || player.username,
|
||||||
|
avatar_url: player.avatar_url
|
||||||
|
};
|
||||||
|
event.dataTransfer.setData('text/plain', JSON.stringify(payload));
|
||||||
|
event.dataTransfer.effectAllowed = 'copy';
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Data Center Logic ---
|
||||||
|
searchPlayer() {
|
||||||
|
if (!this.searchQuery) return;
|
||||||
|
const q = this.searchQuery.toLowerCase();
|
||||||
|
const found = this.roster.find(p =>
|
||||||
|
(p.username && p.username.toLowerCase().includes(q)) ||
|
||||||
|
(p.steam_id_64 && p.steam_id_64.includes(q))
|
||||||
|
);
|
||||||
|
if (found) {
|
||||||
|
this.addToDataLineup(found);
|
||||||
|
this.searchQuery = '';
|
||||||
|
} else {
|
||||||
|
alert('未找到玩家 (Locally)');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addToDataLineup(player) {
|
||||||
|
if (this.dataLineup.some(p => p.steam_id_64 === player.steam_id_64)) {
|
||||||
|
alert('该选手已在对比列表中');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.dataLineup.length >= 5) {
|
||||||
|
alert('对比列表已满 (最多5人)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.dataLineup.push(player);
|
||||||
|
},
|
||||||
|
|
||||||
|
removeFromDataLineup(index) {
|
||||||
|
this.dataLineup.splice(index, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearDataLineup() {
|
||||||
|
this.dataLineup = [];
|
||||||
|
},
|
||||||
|
|
||||||
|
dropData(event) {
|
||||||
|
this.isDraggingOverData = false;
|
||||||
|
const data = event.dataTransfer.getData('text/plain');
|
||||||
|
if (!data) return;
|
||||||
|
try {
|
||||||
|
const player = JSON.parse(data);
|
||||||
|
this.addToDataLineup(player);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Drop Error:", e);
|
||||||
|
alert("无法解析拖拽数据");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
comparePlayers() {
|
||||||
|
if (this.dataLineup.length === 0) {
|
||||||
|
this.dataResult = [];
|
||||||
|
if (this.radarChart) {
|
||||||
|
this.radarChart.data.datasets = [];
|
||||||
|
this.radarChart.update();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = this.dataLineup.map(p => p.steam_id_64).join(',');
|
||||||
|
|
||||||
|
// 1. Fetch Basic & Radar Stats
|
||||||
|
fetch('/players/api/batch_stats?ids=' + ids)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
this.dataResult = data;
|
||||||
|
// Use $nextTick to ensure DOM update if needed, but for Chart.js usually direct call is fine.
|
||||||
|
// However, dataResult is reactive. Let's call update explicitly.
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.updateRadarChart();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Fetch Map Stats
|
||||||
|
fetch('/players/api/batch_map_stats?ids=' + ids)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(mapData => {
|
||||||
|
this.mapStatsCache = mapData;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getMapStat(sid, mapName) {
|
||||||
|
if (!this.mapStatsCache[sid]) return null;
|
||||||
|
return this.mapStatsCache[sid].find(m => m.map_name === mapName);
|
||||||
|
},
|
||||||
|
|
||||||
|
getPlayerColor(idx) {
|
||||||
|
const colors = ['#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6'];
|
||||||
|
return colors[idx % colors.length];
|
||||||
|
},
|
||||||
|
|
||||||
|
getRatingColor(rating) {
|
||||||
|
if (rating >= 1.2) return 'text-red-500';
|
||||||
|
if (rating >= 1.05) return 'text-green-600';
|
||||||
|
return 'text-gray-500';
|
||||||
|
},
|
||||||
|
|
||||||
|
updateRadarChart() {
|
||||||
|
// Force destroy to avoid state issues (fullSize error)
|
||||||
|
if (this.radarChart) {
|
||||||
|
this.radarChart.destroy();
|
||||||
|
this.radarChart = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.getElementById('dataRadarChart');
|
||||||
|
if (!canvas) return; // Tab might not be visible yet
|
||||||
|
|
||||||
|
// Unwrap proxy if needed
|
||||||
|
const rawData = JSON.parse(JSON.stringify(this.dataResult));
|
||||||
|
|
||||||
|
const datasets = rawData.map((p, idx) => {
|
||||||
|
const color = this.getPlayerColor(idx);
|
||||||
|
const d = [
|
||||||
|
p.radar.BAT || 0, p.radar.PTL || 0, p.radar.HPS || 0,
|
||||||
|
p.radar.SIDE || 0, p.radar.UTIL || 0, p.radar.STA || 0
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: p.username,
|
||||||
|
data: d,
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: color + '20',
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 3
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recreate Chart with Profile-aligned config
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
this.radarChart = new Chart(ctx, {
|
||||||
|
type: 'radar',
|
||||||
|
data: {
|
||||||
|
labels: ['BAT (火力)', 'PTL (手枪)', 'HPS (抗压)', 'SIDE (阵营)', 'UTIL (道具)', 'STA (稳定)'],
|
||||||
|
datasets: datasets
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
r: {
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
ticks: {
|
||||||
|
display: false, // Cleaner look like profile
|
||||||
|
stepSize: 20
|
||||||
|
},
|
||||||
|
pointLabels: {
|
||||||
|
font: { size: 12, weight: 'bold' },
|
||||||
|
color: (ctx) => document.documentElement.classList.contains('dark') ? '#cbd5e1' : '#374151'
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: (ctx) => document.documentElement.classList.contains('dark') ? 'rgba(51, 65, 85, 0.5)' : 'rgba(229, 231, 235, 0.8)'
|
||||||
|
},
|
||||||
|
angleLines: {
|
||||||
|
color: (ctx) => document.documentElement.classList.contains('dark') ? 'rgba(51, 65, 85, 0.5)' : 'rgba(229, 231, 235, 0.8)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
labels: {
|
||||||
|
color: (ctx) => document.documentElement.classList.contains('dark') ? '#fff' : '#000',
|
||||||
|
usePointStyle: true,
|
||||||
|
padding: 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
initRadarChart() {
|
||||||
|
const canvas = document.getElementById('dataRadarChart');
|
||||||
|
if (!canvas) return; // Tab might not be visible yet
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
this.radarChart = new Chart(ctx, {
|
||||||
|
type: 'radar',
|
||||||
|
data: {
|
||||||
|
labels: ['BAT (火力)', 'PTL (手枪)', 'HPS (抗压)', 'SIDE (阵营)', 'UTIL (道具)', 'STA (稳定)'],
|
||||||
|
datasets: []
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
scales: {
|
||||||
|
r: {
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
ticks: { display: false, stepSize: 20 },
|
||||||
|
pointLabels: {
|
||||||
|
font: { size: 12, weight: 'bold' },
|
||||||
|
color: (ctx) => document.documentElement.classList.contains('dark') ? '#cbd5e1' : '#374151'
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: (ctx) => document.documentElement.classList.contains('dark') ? '#334155' : '#e5e7eb'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
labels: {
|
||||||
|
color: (ctx) => document.documentElement.classList.contains('dark') ? '#fff' : '#000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
maintainAspectRatio: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Analysis Logic ---
|
||||||
|
dropAnalysis(event) {
|
||||||
|
const data = event.dataTransfer.getData('text/plain');
|
||||||
|
if (!data) return;
|
||||||
|
const player = JSON.parse(data);
|
||||||
|
|
||||||
|
// Check duplicates
|
||||||
|
if (this.analysisLineup.some(p => p.steam_id_64 === player.steam_id_64)) return;
|
||||||
|
|
||||||
|
// Limit 5
|
||||||
|
if (this.analysisLineup.length >= 5) return;
|
||||||
|
|
||||||
|
this.analysisLineup.push(player);
|
||||||
|
},
|
||||||
|
|
||||||
|
removeFromAnalysis(index) {
|
||||||
|
this.analysisLineup.splice(index, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearAnalysis() {
|
||||||
|
this.analysisLineup = [];
|
||||||
|
this.analysisResult = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
analyzeLineup() {
|
||||||
|
const ids = this.analysisLineup.map(p => p.steam_id_64);
|
||||||
|
fetch('/tactics/api/analyze', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({steam_ids: ids})
|
||||||
|
})
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
this.analysisResult = data;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Board Logic ---
|
||||||
|
initMap() {
|
||||||
|
this.map = L.map('map-container', {
|
||||||
|
crs: L.CRS.Simple,
|
||||||
|
minZoom: -2,
|
||||||
|
maxZoom: 2,
|
||||||
|
zoomControl: true,
|
||||||
|
attributionControl: false
|
||||||
|
});
|
||||||
|
this.loadMapImage();
|
||||||
|
},
|
||||||
|
|
||||||
|
loadMapImage() {
|
||||||
|
const mapUrls = {
|
||||||
|
'de_mirage': 'https://static.wikia.nocookie.net/cswikia/images/e/e3/Mirage_CS2_Radar.png',
|
||||||
|
'de_inferno': 'https://static.wikia.nocookie.net/cswikia/images/7/77/Inferno_CS2_Radar.png',
|
||||||
|
'de_dust2': 'https://static.wikia.nocookie.net/cswikia/images/0/03/Dust2_CS2_Radar.png',
|
||||||
|
'de_nuke': 'https://static.wikia.nocookie.net/cswikia/images/1/14/Nuke_CS2_Radar.png',
|
||||||
|
'de_ancient': 'https://static.wikia.nocookie.net/cswikia/images/1/16/Ancient_CS2_Radar.png',
|
||||||
|
'de_anubis': 'https://static.wikia.nocookie.net/cswikia/images/2/22/Anubis_CS2_Radar.png',
|
||||||
|
'de_vertigo': 'https://static.wikia.nocookie.net/cswikia/images/2/23/Vertigo_CS2_Radar.png'
|
||||||
|
};
|
||||||
|
const url = mapUrls[this.currentMap] || mapUrls['de_mirage'];
|
||||||
|
const bounds = [[0,0], [1024,1024]];
|
||||||
|
|
||||||
|
this.map.eachLayer((layer) => { this.map.removeLayer(layer); });
|
||||||
|
L.imageOverlay(url, bounds).addTo(this.map);
|
||||||
|
this.map.fitBounds(bounds);
|
||||||
|
},
|
||||||
|
|
||||||
|
changeMap() {
|
||||||
|
this.loadMapImage();
|
||||||
|
this.clearBoard();
|
||||||
|
},
|
||||||
|
|
||||||
|
dropBoard(event) {
|
||||||
|
const data = event.dataTransfer.getData('text/plain');
|
||||||
|
if (!data) return;
|
||||||
|
const player = JSON.parse(data);
|
||||||
|
|
||||||
|
const container = document.getElementById('map-container');
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const x = event.clientX - rect.left;
|
||||||
|
const y = event.clientY - rect.top;
|
||||||
|
const point = this.map.containerPointToLatLng([x, y]);
|
||||||
|
|
||||||
|
this.addMarker(player, point);
|
||||||
|
},
|
||||||
|
|
||||||
|
addMarker(player, latlng) {
|
||||||
|
if (this.markers[player.steam_id_64]) {
|
||||||
|
this.markers[player.steam_id_64].setLatLng(latlng);
|
||||||
|
} else {
|
||||||
|
const displayName = player.username || player.name || player.steam_id_64;
|
||||||
|
const iconHtml = `
|
||||||
|
<div class="flex flex-col items-center justify-center transform hover:scale-110 transition duration-200">
|
||||||
|
${player.avatar_url ?
|
||||||
|
`<img src="${player.avatar_url}" class="w-8 h-8 rounded-full border-2 border-white shadow-lg box-content object-cover">` :
|
||||||
|
`<div class="w-8 h-8 rounded-full bg-yrtv-100 border-2 border-white shadow-lg box-content flex items-center justify-center text-yrtv-600 font-bold text-[10px]">${(player.username || player.name).substring(0, 2).toUpperCase()}</div>`
|
||||||
|
}
|
||||||
|
<span class="mt-1 text-[10px] font-bold text-white bg-black/60 px-1.5 py-0.5 rounded backdrop-blur-sm whitespace-nowrap overflow-hidden max-w-[80px] text-ellipsis">
|
||||||
|
${displayName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const icon = L.divIcon({ className: 'bg-transparent', html: iconHtml, iconSize: [60, 60], iconAnchor: [30, 30] });
|
||||||
|
|
||||||
|
const marker = L.marker(latlng, { icon: icon, draggable: true }).addTo(this.map);
|
||||||
|
this.markers[player.steam_id_64] = marker;
|
||||||
|
this.boardPlayers.push(player);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearBoard() {
|
||||||
|
for (let id in this.markers) { this.map.removeLayer(this.markers[id]); }
|
||||||
|
this.markers = {};
|
||||||
|
this.boardPlayers = [];
|
||||||
|
},
|
||||||
|
|
||||||
|
saveBoard() {
|
||||||
|
const title = prompt("请输入战术标题:", "New Strat " + new Date().toLocaleTimeString());
|
||||||
|
if (!title) return;
|
||||||
|
|
||||||
|
const markerData = [];
|
||||||
|
for (let id in this.markers) {
|
||||||
|
const m = this.markers[id];
|
||||||
|
markerData.push({ id: id, lat: m.getLatLng().lat, lng: m.getLatLng().lng });
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/tactics/save_board', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ title: title, map_name: this.currentMap, markers: markerData })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => alert(data.success ? "保存成功" : "保存失败"));
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Economy Logic ---
|
||||||
|
calculateIncome() {
|
||||||
|
let base = 0;
|
||||||
|
const lbLevel = parseInt(this.econ.lossBonus);
|
||||||
|
|
||||||
|
if (this.econ.result === 'win') {
|
||||||
|
base = 3250 + (300 * this.econ.surviving); // Simplified estimate
|
||||||
|
} else {
|
||||||
|
// Loss base
|
||||||
|
const lossAmounts = [1400, 1900, 2400, 2900, 3400];
|
||||||
|
base = lossAmounts[Math.min(lbLevel, 4)];
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
28
web/templates/tactics/layout.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<!-- Navigation Tabs -->
|
||||||
|
<div class="border-b border-gray-200 dark:border-slate-700 mb-6">
|
||||||
|
<nav class="-mb-px flex space-x-8">
|
||||||
|
<a href="{{ url_for('tactics.index') }}" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
|
||||||
|
← Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('tactics.analysis') }}" class="{{ 'border-yrtv-500 text-yrtv-600 dark:text-yrtv-400' if request.endpoint == 'tactics.analysis' else 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-200' }} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
|
||||||
|
Deep Analysis
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('tactics.data') }}" class="{{ 'border-yrtv-500 text-yrtv-600 dark:text-yrtv-400' if request.endpoint == 'tactics.data' else 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-200' }} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
|
||||||
|
Data Center
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('tactics.board') }}" class="{{ 'border-yrtv-500 text-yrtv-600 dark:text-yrtv-400' if request.endpoint == 'tactics.board' else 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-200' }} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
|
||||||
|
Strategy Board
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('tactics.economy') }}" class="{{ 'border-yrtv-500 text-yrtv-600 dark:text-yrtv-400' if request.endpoint == 'tactics.economy' else 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-200' }} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
|
||||||
|
Economy
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% block tactics_content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
27
web/templates/tactics/maps.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">地图情报</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{% for map in maps %}
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden hover:shadow-lg transition cursor-pointer">
|
||||||
|
<div class="h-40 bg-gray-300 flex items-center justify-center overflow-hidden">
|
||||||
|
<!-- Use actual map images or fallback -->
|
||||||
|
<img src="{{ url_for('static', filename='images/maps/' + map.name + '.jpg') }}"
|
||||||
|
onerror="this.src='https://developer.valvesoftware.com/w/images/thumb/3/3d/De_mirage_radar_spectator.png/800px-De_mirage_radar_spectator.png'; this.style.objectFit='cover'; this.style.height='100%'; this.style.width='100%';"
|
||||||
|
alt="{{ map.title }}" class="w-full h-full object-cover">
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white">{{ map.title }}</h3>
|
||||||
|
<div class="mt-4 flex space-x-2">
|
||||||
|
<button class="px-3 py-1 bg-yrtv-100 text-yrtv-700 rounded text-sm hover:bg-yrtv-200">道具点位</button>
|
||||||
|
<button class="px-3 py-1 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200">战术板</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
278
web/templates/teams/clubhouse.html
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}My Team - Clubhouse{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8" x-data="clubhouse()">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="md:flex md:items-center md:justify-between mb-8">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h2 class="text-2xl font-bold leading-7 text-gray-900 dark:text-white sm:text-3xl sm:truncate">
|
||||||
|
<span x-text="team.name || 'My Team'"></span>
|
||||||
|
<span class="ml-2 text-sm font-normal text-gray-500" x-text="team.description"></span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex md:mt-0 md:ml-4">
|
||||||
|
{% if session.get('is_admin') %}
|
||||||
|
<button @click="showScoutModal = true" type="button" class="ml-3 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-yrtv-600 hover:bg-yrtv-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yrtv-500">
|
||||||
|
<span class="mr-2">🔍</span> Scout Player
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sorting Controls -->
|
||||||
|
<div class="flex justify-end mb-4">
|
||||||
|
<div class="inline-flex shadow-sm rounded-md" role="group">
|
||||||
|
<button type="button" @click="sortBy('rating')" :class="{'bg-yrtv-600 text-white': currentSort === 'rating', 'bg-white text-gray-700 hover:bg-gray-50': currentSort !== 'rating'}" class="px-4 py-2 text-sm font-medium border border-gray-200 rounded-l-lg dark:bg-slate-700 dark:border-slate-600 dark:text-white dark:hover:bg-slate-600">
|
||||||
|
Rating
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="sortBy('kd')" :class="{'bg-yrtv-600 text-white': currentSort === 'kd', 'bg-white text-gray-700 hover:bg-gray-50': currentSort !== 'kd'}" class="px-4 py-2 text-sm font-medium border-t border-b border-gray-200 dark:bg-slate-700 dark:border-slate-600 dark:text-white dark:hover:bg-slate-600">
|
||||||
|
K/D
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="sortBy('matches')" :class="{'bg-yrtv-600 text-white': currentSort === 'matches', 'bg-white text-gray-700 hover:bg-gray-50': currentSort !== 'matches'}" class="px-4 py-2 text-sm font-medium border border-gray-200 rounded-r-lg dark:bg-slate-700 dark:border-slate-600 dark:text-white dark:hover:bg-slate-600">
|
||||||
|
Matches
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Roster (Grid) -->
|
||||||
|
<div class="mb-10">
|
||||||
|
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white mb-4">Active Roster</h3>
|
||||||
|
<!-- Dynamic Grid based on roster size, default to 5 slots + 1 add button -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-6">
|
||||||
|
<!-- Render Actual Roster -->
|
||||||
|
<template x-for="(player, index) in roster" :key="player.steam_id_64">
|
||||||
|
<div class="relative bg-white dark:bg-slate-800 rounded-lg shadow-md border border-gray-200 dark:border-slate-600 h-80 flex flex-col items-center justify-center p-4 transition hover:border-yrtv-400">
|
||||||
|
|
||||||
|
<div class="w-full h-full flex flex-col items-center">
|
||||||
|
<div class="relative w-32 h-32 mb-4">
|
||||||
|
<!-- Avatar Logic: Image or Initials -->
|
||||||
|
<template x-if="player.avatar_url">
|
||||||
|
<img :src="player.avatar_url" class="w-32 h-32 rounded-full object-cover border-4 border-yrtv-500 shadow-lg">
|
||||||
|
</template>
|
||||||
|
<template x-if="!player.avatar_url">
|
||||||
|
<div class="w-32 h-32 rounded-full bg-yrtv-100 flex items-center justify-center border-4 border-yrtv-500 shadow-lg text-yrtv-600 font-bold text-4xl">
|
||||||
|
<span x-text="(player.username || player.name || player.steam_id_64).substring(0, 2).toUpperCase()"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="text-lg font-bold text-gray-900 dark:text-white truncate w-full text-center" x-text="player.username || player.name || player.steam_id_64"></h4>
|
||||||
|
<div class="flex flex-wrap justify-center gap-1 mb-4 min-h-[1.5rem]">
|
||||||
|
<template x-for="tag in (player.tags || [])">
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300" x-text="tag"></span>
|
||||||
|
</template>
|
||||||
|
<template x-if="!player.tags || player.tags.length === 0">
|
||||||
|
<span class="text-xs text-gray-400 italic">No tags</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Grid -->
|
||||||
|
<div class="grid grid-cols-2 gap-2 w-full text-center mb-auto">
|
||||||
|
<div class="bg-gray-50 dark:bg-slate-700 rounded p-1">
|
||||||
|
<div class="text-xs text-gray-400">Rating</div>
|
||||||
|
<div class="font-bold text-yrtv-600 dark:text-yrtv-400" x-text="(player.stats?.basic_avg_rating || 0).toFixed(2)"></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 dark:bg-slate-700 rounded p-1">
|
||||||
|
<div class="text-xs text-gray-400">K/D</div>
|
||||||
|
<div class="font-bold" x-text="(player.stats?.basic_avg_kd || 0).toFixed(2)"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex space-x-2 mt-2">
|
||||||
|
<a :href="'/players/' + player.steam_id_64" class="text-yrtv-600 hover:text-yrtv-800 text-sm font-medium">Profile</a>
|
||||||
|
{% if session.get('is_admin') %}
|
||||||
|
<button @click="removePlayer(player.steam_id_64)" class="text-red-500 hover:text-red-700 text-sm font-medium">Release</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Add Player Slot (Only for Admin) -->
|
||||||
|
{% if session.get('is_admin') %}
|
||||||
|
<div class="relative bg-gray-50 dark:bg-slate-800/50 rounded-lg shadow-sm border-2 border-dashed border-gray-300 dark:border-slate-600 h-80 flex flex-col items-center justify-center p-4 hover:border-yrtv-400 transition cursor-pointer" @click="showScoutModal = true">
|
||||||
|
<div class="w-16 h-16 rounded-full bg-white dark:bg-slate-700 flex items-center justify-center mb-3 group-hover:bg-yrtv-100 dark:group-hover:bg-slate-600 transition">
|
||||||
|
<svg class="w-8 h-8 text-gray-400 group-hover:text-yrtv-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path></svg>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-gray-500 dark:text-gray-400 group-hover:text-yrtv-600">Add Player</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bench / Extended Roster (Hidden as logic is merged into main grid) -->
|
||||||
|
<!-- The grid above now handles unlimited players, so we remove the separate Bench section to avoid duplication -->
|
||||||
|
|
||||||
|
<!-- Scout Modal -->
|
||||||
|
<div x-show="showScoutModal" class="fixed inset-0 z-10 overflow-y-auto" style="display: none;">
|
||||||
|
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
|
<div class="fixed inset-0 transition-opacity" aria-hidden="true" @click="showScoutModal = false">
|
||||||
|
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||||
|
|
||||||
|
<div class="inline-block align-bottom bg-white dark:bg-slate-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg w-full">
|
||||||
|
<div class="bg-white dark:bg-slate-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||||
|
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white mb-4">Scout New Player</h3>
|
||||||
|
|
||||||
|
<!-- Search Input -->
|
||||||
|
<div class="mt-2 relative rounded-md shadow-sm">
|
||||||
|
<input type="text" x-model="searchQuery" @input.debounce.300ms="searchPlayers()" placeholder="Search by name..." class="focus:ring-yrtv-500 focus:border-yrtv-500 block w-full pl-4 pr-12 sm:text-sm border-gray-300 dark:bg-slate-700 dark:border-slate-600 dark:text-white rounded-md h-12">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results List -->
|
||||||
|
<div class="mt-4 max-h-60 overflow-y-auto">
|
||||||
|
<template x-if="searchResults.length === 0 && searchQuery.length > 1">
|
||||||
|
<p class="text-sm text-gray-500 text-center py-4">No players found.</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ul class="divide-y divide-gray-200 dark:divide-slate-700">
|
||||||
|
<template x-for="player in searchResults" :key="player.steam_id">
|
||||||
|
<li class="py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-slate-700 px-2 rounded cursor-pointer">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<img :src="player.avatar" class="h-10 w-10 rounded-full">
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white" x-text="player.name"></p>
|
||||||
|
<p class="text-xs text-gray-500" x-text="player.matches + ' matches'"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button @click="signPlayer(player.steam_id)" class="inline-flex items-center px-3 py-1 border border-transparent text-xs font-medium rounded text-yrtv-700 bg-yrtv-100 hover:bg-yrtv-200 dark:bg-yrtv-700 dark:text-white dark:hover:bg-yrtv-600">
|
||||||
|
Sign
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 dark:bg-slate-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||||
|
<button type="button" @click="showScoutModal = false" class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm dark:bg-slate-600 dark:text-white dark:border-slate-500">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function clubhouse() {
|
||||||
|
return {
|
||||||
|
team: {},
|
||||||
|
roster: [],
|
||||||
|
currentSort: 'rating', // Default sort
|
||||||
|
showScoutModal: false,
|
||||||
|
searchQuery: '',
|
||||||
|
searchResults: [],
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.fetchRoster();
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchRoster() {
|
||||||
|
fetch('/teams/api/roster')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
this.team = data.team;
|
||||||
|
this.roster = data.roster;
|
||||||
|
this.sortRoster(); // Apply default sort
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
sortBy(key) {
|
||||||
|
this.currentSort = key;
|
||||||
|
this.sortRoster();
|
||||||
|
},
|
||||||
|
|
||||||
|
sortRoster() {
|
||||||
|
if (!this.roster || this.roster.length === 0) return;
|
||||||
|
|
||||||
|
this.roster.sort((a, b) => {
|
||||||
|
let valA = 0, valB = 0;
|
||||||
|
|
||||||
|
if (this.currentSort === 'rating') {
|
||||||
|
valA = a.stats?.basic_avg_rating || 0;
|
||||||
|
valB = b.stats?.basic_avg_rating || 0;
|
||||||
|
} else if (this.currentSort === 'kd') {
|
||||||
|
valA = a.stats?.basic_avg_kd || 0;
|
||||||
|
valB = b.stats?.basic_avg_kd || 0;
|
||||||
|
} else if (this.currentSort === 'matches') {
|
||||||
|
// matches_played is usually on the player object now? or stats?
|
||||||
|
// Check API: it's not explicitly in 'stats', but search added it.
|
||||||
|
// Roster API usually doesn't attach matches_played unless we ask.
|
||||||
|
// Let's assume stats.total_matches or check object root.
|
||||||
|
// Looking at roster API: we attach match counts? No, only search.
|
||||||
|
// But we can use total_matches from stats.
|
||||||
|
valA = a.stats?.total_matches || 0;
|
||||||
|
valB = b.stats?.total_matches || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return valB - valA; // Descending
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
searchPlayers() {
|
||||||
|
if (this.searchQuery.length < 2) {
|
||||||
|
this.searchResults = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Use encodeURIComponent for safety
|
||||||
|
const q = encodeURIComponent(this.searchQuery);
|
||||||
|
console.log(`Searching for: ${q}`); // Debug Log
|
||||||
|
|
||||||
|
fetch(`/teams/api/search?q=${q}&sort=matches`)
|
||||||
|
.then(res => {
|
||||||
|
console.log('Response status:', res.status);
|
||||||
|
const contentType = res.headers.get("content-type");
|
||||||
|
if (contentType && contentType.indexOf("application/json") !== -1) {
|
||||||
|
return res.json();
|
||||||
|
} else {
|
||||||
|
// Not JSON, probably HTML error page
|
||||||
|
return res.text().then(text => {
|
||||||
|
console.error("Non-JSON response:", text.substring(0, 500));
|
||||||
|
throw new Error("Server returned non-JSON response");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
console.log('Search results:', data); // Debug Log
|
||||||
|
this.searchResults = data;
|
||||||
|
})
|
||||||
|
.catch(err => console.error('Search error:', err));
|
||||||
|
},
|
||||||
|
|
||||||
|
signPlayer(steamId) {
|
||||||
|
fetch('/teams/api/roster', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'add', steam_id: steamId })
|
||||||
|
})
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
this.showScoutModal = false;
|
||||||
|
this.searchQuery = '';
|
||||||
|
this.searchResults = [];
|
||||||
|
this.fetchRoster(); // Refresh
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
removePlayer(steamId) {
|
||||||
|
if(!confirm('Are you sure you want to release this player?')) return;
|
||||||
|
|
||||||
|
fetch('/teams/api/roster', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'remove', steam_id: steamId })
|
||||||
|
})
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
this.fetchRoster();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
71
web/templates/teams/create.html
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-2xl mx-auto bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">新建战队阵容</h2>
|
||||||
|
|
||||||
|
<form action="{{ url_for('teams.create') }}" method="POST" class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">阵容名称</label>
|
||||||
|
<input type="text" name="name" required class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-yrtv-500 focus:border-yrtv-500 dark:bg-slate-700 dark:border-slate-600 dark:text-white">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">描述</label>
|
||||||
|
<textarea name="description" rows="3" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-yrtv-500 focus:border-yrtv-500 dark:bg-slate-700 dark:border-slate-600 dark:text-white"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4" id="players-container">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">选择队员 (不限人数)</label>
|
||||||
|
<button type="button" onclick="addPlayerSelect()" class="text-sm text-yrtv-600 hover:text-yrtv-800 font-medium">+ 添加队员</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Template for JS -->
|
||||||
|
<div id="player-select-template" class="hidden">
|
||||||
|
<div class="flex gap-2 mb-2 player-row">
|
||||||
|
<select name="player_ids" class="block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-yrtv-500 focus:border-yrtv-500 dark:bg-slate-700 dark:border-slate-600 dark:text-white">
|
||||||
|
<option value="">选择队员</option>
|
||||||
|
{% for p in players %}
|
||||||
|
<option value="{{ p.steam_id_64 }}">{{ p.username }} ({{ (p.rating or 0)|round(2) }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="button" onclick="this.parentElement.remove()" class="text-red-500 hover:text-red-700 px-2">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Initial Selects -->
|
||||||
|
<div id="active-players">
|
||||||
|
{% for i in range(1, 6) %}
|
||||||
|
<div class="flex gap-2 mb-2 player-row">
|
||||||
|
<select name="player_ids" class="block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-yrtv-500 focus:border-yrtv-500 dark:bg-slate-700 dark:border-slate-600 dark:text-white">
|
||||||
|
<option value="">(空缺) 队员 {{ i }}</option>
|
||||||
|
{% for p in players %}
|
||||||
|
<option value="{{ p.steam_id_64 }}">{{ p.username }} ({{ (p.rating or 0)|round(2) }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="button" onclick="this.parentElement.remove()" class="text-red-500 hover:text-red-700 px-2">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function addPlayerSelect() {
|
||||||
|
const template = document.getElementById('player-select-template').firstElementChild.cloneNode(true);
|
||||||
|
document.getElementById('active-players').appendChild(template);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="pt-4">
|
||||||
|
<button type="submit" class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-yrtv-600 hover:bg-yrtv-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yrtv-500">
|
||||||
|
创建阵容
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
116
web/templates/teams/detail.html
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">{{ lineup.name }}</h1>
|
||||||
|
<p class="text-gray-500 mt-2">{{ lineup.description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Players Grid -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||||
|
{% for p in players %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-4 flex flex-col items-center">
|
||||||
|
<img class="h-16 w-16 rounded-full mb-2" src="{{ p.avatar_url or 'https://via.placeholder.com/64' }}" alt="">
|
||||||
|
<a href="{{ url_for('players.detail', steam_id=p.steam_id_64) }}" class="text-sm font-medium text-gray-900 dark:text-white hover:text-yrtv-600 truncate w-full text-center">
|
||||||
|
{{ p.username }}
|
||||||
|
</a>
|
||||||
|
<span class="text-xs text-gray-500">Rating: {{ "%.2f"|format(p.rating if p.rating else 0) }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Aggregate Stats -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">阵容综合能力</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
<div>
|
||||||
|
<dl class="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||||
|
<div class="px-4 py-5 bg-gray-50 dark:bg-slate-700 shadow rounded-lg overflow-hidden sm:p-6">
|
||||||
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">平均 Rating</dt>
|
||||||
|
<dd class="mt-1 text-3xl font-semibold text-gray-900 dark:text-white">{{ "%.2f"|format(agg_stats.avg_rating or 0) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-5 bg-gray-50 dark:bg-slate-700 shadow rounded-lg overflow-hidden sm:p-6">
|
||||||
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">平均 K/D</dt>
|
||||||
|
<dd class="mt-1 text-3xl font-semibold text-gray-900 dark:text-white">{{ "%.2f"|format(agg_stats.avg_kd or 0) }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Radar Chart -->
|
||||||
|
<div class="relative h-64">
|
||||||
|
<canvas id="teamRadarChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Shared History -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">共同经历 (Shared Matches)</h3>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-700">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Date</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Map</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Score</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Link</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% for m in shared_matches %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{{ m.start_time }}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">{{ m.map_name }}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">{{ m.score_team1 }} : {{ m.score_team2 }}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
|
<a href="{{ url_for('matches.detail', match_id=m.match_id) }}" class="text-yrtv-600 hover:text-yrtv-900">View</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="px-6 py-4 text-center text-gray-500">No shared matches found for this lineup.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const radarData = {{ radar_data|tojson }};
|
||||||
|
const ctx = document.getElementById('teamRadarChart').getContext('2d');
|
||||||
|
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: 'radar',
|
||||||
|
data: {
|
||||||
|
labels: ['STA', 'BAT', 'HPS', 'PTL', 'SIDE', 'UTIL'],
|
||||||
|
datasets: [{
|
||||||
|
label: 'Team Average',
|
||||||
|
data: [
|
||||||
|
radarData.STA, radarData.BAT, radarData.HPS,
|
||||||
|
radarData.PTL, radarData.SIDE, radarData.UTIL
|
||||||
|
],
|
||||||
|
backgroundColor: 'rgba(124, 58, 237, 0.2)',
|
||||||
|
borderColor: 'rgba(124, 58, 237, 1)',
|
||||||
|
pointBackgroundColor: 'rgba(124, 58, 237, 1)',
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
r: {
|
||||||
|
beginAtZero: true,
|
||||||
|
suggestedMax: 2.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
34
web/templates/teams/list.html
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">战队阵容库</h2>
|
||||||
|
<a href="{{ url_for('teams.create') }}" class="px-4 py-2 bg-yrtv-600 text-white rounded hover:bg-yrtv-500">
|
||||||
|
新建阵容
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{% for lineup in lineups %}
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white">{{ lineup.name }}</h3>
|
||||||
|
<p class="text-sm text-gray-500 mb-4">{{ lineup.description }}</p>
|
||||||
|
|
||||||
|
<div class="flex -space-x-2 overflow-hidden mb-4">
|
||||||
|
{% for p in lineup.players %}
|
||||||
|
<img class="inline-block h-8 w-8 rounded-full ring-2 ring-white dark:ring-slate-800"
|
||||||
|
src="{{ p.avatar_url or 'https://via.placeholder.com/32' }}"
|
||||||
|
alt="{{ p.username }}"
|
||||||
|
title="{{ p.username }}">
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="{{ url_for('teams.detail', lineup_id=lineup.id) }}" class="text-sm text-yrtv-600 hover:text-yrtv-800 font-medium">
|
||||||
|
查看分析 →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
30
web/templates/wiki/edit.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Edit Wiki Page</h2>
|
||||||
|
|
||||||
|
<form method="POST" class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Page Path (Unique ID)</label>
|
||||||
|
<input type="text" disabled value="{{ page_path }}" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 bg-gray-100 dark:bg-slate-600 dark:text-white">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Path cannot be changed after creation (unless new).</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Title</label>
|
||||||
|
<input type="text" name="title" value="{{ page.title if page else '' }}" required class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 dark:bg-slate-700 dark:text-white">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Content (Markdown)</label>
|
||||||
|
<textarea name="content" rows="15" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 font-mono text-sm dark:bg-slate-700 dark:text-white">{{ page.content if page else '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-4">
|
||||||
|
<a href="{{ url_for('wiki.index') }}" class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50">Cancel</a>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-yrtv-600 text-white rounded-md hover:bg-yrtv-700">Save Page</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
25
web/templates/wiki/index.html
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">知识库 (Wiki)</h2>
|
||||||
|
{% if session.get('is_admin') %}
|
||||||
|
<a href="{{ url_for('wiki.edit', page_path='new') }}" class="px-4 py-2 bg-yrtv-600 text-white rounded hover:bg-yrtv-500">New Page</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
{% for page in pages %}
|
||||||
|
<a href="{{ url_for('wiki.view', page_path=page.path) }}" class="block p-4 border border-gray-200 dark:border-gray-700 rounded hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-lg font-medium text-yrtv-600">{{ page.title }}</span>
|
||||||
|
<span class="text-sm text-gray-500">{{ page.path }}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-gray-500">暂无文档。</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
33
web/templates/wiki/view.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||||
|
<div class="flex justify-between items-center mb-6 border-b pb-4 border-gray-200 dark:border-gray-700">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">{{ page.title }}</h1>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">Path: {{ page.path }} | Updated: {{ page.updated_at }}</p>
|
||||||
|
</div>
|
||||||
|
{% if session.get('is_admin') %}
|
||||||
|
<a href="{{ url_for('wiki.edit', page_path=page.path) }}" class="px-4 py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300">Edit</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="wiki-content" class="prose dark:prose-invert max-w-none">
|
||||||
|
<!-- Content will be rendered here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden source for JS -->
|
||||||
|
<div id="raw-content" class="hidden">{{ page.content }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const rawContent = document.getElementById('raw-content').textContent;
|
||||||
|
document.getElementById('wiki-content').innerHTML = marked.parse(rawContent);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||