feat: Add recent performance stability stats (matches/days) to player profile

This commit is contained in:
2026-01-28 14:04:32 +08:00
commit 48f1f71d3a
104 changed files with 17572 additions and 0 deletions

72
.gitignore vendored Normal file
View File

@@ -0,0 +1,72 @@
__pycache__/
*.py[cod]
*$py.class
*.so
*.dylib
*.dll
.trae/
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
*.mo
*.pot
*.log
local_settings.py
db.sqlite3
instance/
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
.spyderproject
.spyproject
.idea/
.vscode/
output/
output_arena/
arena/
scripts/
experiment
yrtv.zip

102
ETL/L1A.py Normal file
View File

@@ -0,0 +1,102 @@
"""
L1A Data Ingestion Script
This script reads raw JSON files from the 'output_arena' directory and ingests them into the SQLite database.
It supports incremental updates by default, skipping files that have already been processed.
Usage:
python ETL/L1A.py # Standard incremental run
python ETL/L1A.py --force # Force re-process all files (overwrite existing data)
"""
import os
import json
import sqlite3
import glob
import argparse # Added
# Paths
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUTPUT_ARENA_DIR = os.path.join(BASE_DIR, 'output_arena')
DB_DIR = os.path.join(BASE_DIR, 'database', 'L1A')
DB_PATH = os.path.join(DB_DIR, 'L1A.sqlite')
def init_db():
if not os.path.exists(DB_DIR):
os.makedirs(DB_DIR)
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS raw_iframe_network (
match_id TEXT PRIMARY KEY,
content TEXT,
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
conn.commit()
return conn
def process_files():
parser = argparse.ArgumentParser()
parser.add_argument('--force', action='store_true', help='Force reprocessing of all files')
args = parser.parse_args()
conn = init_db()
cursor = conn.cursor()
# Get existing match_ids to skip
existing_ids = set()
if not args.force:
try:
cursor.execute("SELECT match_id FROM raw_iframe_network")
existing_ids = set(row[0] for row in cursor.fetchall())
print(f"Found {len(existing_ids)} existing matches in DB. Incremental mode active.")
except Exception as e:
print(f"Error checking existing data: {e}")
# Pattern to match all iframe_network.json files
# output_arena/*/iframe_network.json
pattern = os.path.join(OUTPUT_ARENA_DIR, '*', 'iframe_network.json')
files = glob.glob(pattern)
print(f"Found {len(files)} files in directory.")
count = 0
skipped = 0
for file_path in files:
try:
# Extract match_id from directory name
# file_path is like .../output_arena/g161-xxx/iframe_network.json
parent_dir = os.path.dirname(file_path)
match_id = os.path.basename(parent_dir)
if match_id in existing_ids:
skipped += 1
continue
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# Upsert data
cursor.execute('''
INSERT OR REPLACE INTO raw_iframe_network (match_id, content)
VALUES (?, ?)
''', (match_id, content))
count += 1
if count % 100 == 0:
print(f"Processed {count} files...")
conn.commit()
except Exception as e:
print(f"Error processing {file_path}: {e}")
conn.commit()
conn.close()
print(f"Finished. Processed: {count}, Skipped: {skipped}.")
if __name__ == '__main__':
process_files()

1469
ETL/L2_Builder.py Normal file

File diff suppressed because it is too large Load Diff

108
ETL/L3_Builder.py Normal file
View File

@@ -0,0 +1,108 @@
import logging
import os
import sys
# 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
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
L3_DB_PATH = Config.DB_L3_PATH
SCHEMA_PATH = os.path.join(Config.BASE_DIR, 'database', 'L3', 'schema.sql')
def _get_existing_columns(conn, table_name):
cur = conn.execute(f"PRAGMA table_info({table_name})")
return {row[1] for row in cur.fetchall()}
def _ensure_columns(conn, table_name, columns):
existing = _get_existing_columns(conn, table_name)
for col, col_type in columns.items():
if col in existing:
continue
conn.execute(f"ALTER TABLE {table_name} ADD COLUMN {col} {col_type}")
def init_db():
l3_dir = os.path.dirname(L3_DB_PATH)
if not os.path.exists(l3_dir):
os.makedirs(l3_dir)
conn = sqlite3.connect(L3_DB_PATH)
with open(SCHEMA_PATH, 'r', encoding='utf-8') as f:
conn.executescript(f.read())
_ensure_columns(
conn,
"dm_player_features",
{
"rd_phase_kill_early_share": "REAL",
"rd_phase_kill_mid_share": "REAL",
"rd_phase_kill_late_share": "REAL",
"rd_phase_death_early_share": "REAL",
"rd_phase_death_mid_share": "REAL",
"rd_phase_death_late_share": "REAL",
"rd_phase_kill_early_share_t": "REAL",
"rd_phase_kill_mid_share_t": "REAL",
"rd_phase_kill_late_share_t": "REAL",
"rd_phase_kill_early_share_ct": "REAL",
"rd_phase_kill_mid_share_ct": "REAL",
"rd_phase_kill_late_share_ct": "REAL",
"rd_phase_death_early_share_t": "REAL",
"rd_phase_death_mid_share_t": "REAL",
"rd_phase_death_late_share_t": "REAL",
"rd_phase_death_early_share_ct": "REAL",
"rd_phase_death_mid_share_ct": "REAL",
"rd_phase_death_late_share_ct": "REAL",
"rd_firstdeath_team_first_death_rounds": "INTEGER",
"rd_firstdeath_team_first_death_win_rate": "REAL",
"rd_invalid_death_rounds": "INTEGER",
"rd_invalid_death_rate": "REAL",
"rd_pressure_kpr_ratio": "REAL",
"rd_pressure_perf_ratio": "REAL",
"rd_pressure_rounds_down3": "INTEGER",
"rd_pressure_rounds_normal": "INTEGER",
"rd_matchpoint_kpr_ratio": "REAL",
"rd_matchpoint_perf_ratio": "REAL",
"rd_matchpoint_rounds": "INTEGER",
"rd_comeback_kill_share": "REAL",
"rd_comeback_rounds": "INTEGER",
"rd_trade_response_10s_rate": "REAL",
"rd_weapon_top_json": "TEXT",
"rd_roundtype_split_json": "TEXT",
"map_stability_coef": "REAL",
"basic_avg_knife_kill": "REAL",
"basic_avg_zeus_kill": "REAL",
"basic_zeus_pick_rate": "REAL",
},
)
conn.commit()
conn.close()
logger.info("L3 DB Initialized/Updated with Schema.")
def main():
logger.info("Starting L3 Builder (Delegating to FeatureService)...")
# 1. Ensure Schema is up to date
init_db()
# 2. Rebuild Features using the centralized logic
try:
app = create_app()
with app.app_context():
count = FeatureService.rebuild_all_features()
logger.info(f"Successfully rebuilt features for {count} players.")
except Exception as e:
logger.error(f"Error rebuilding features: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
main()

23
ETL/README.md Normal file
View File

@@ -0,0 +1,23 @@
# ETL Pipeline Documentation
## 1. L1A (Raw Data Ingestion)
**Status**: ✅ Supports Incremental Update
This script ingests raw JSON files from `output_arena/` into `database/L1A/L1A.sqlite`.
### Usage
```bash
# Standard Run (Incremental)
# Only processes new files that are not yet in the database.
python ETL/L1A.py
# Force Refresh
# Reprocesses ALL files, overwriting existing records.
python ETL/L1A.py --force
```
L1B demoparser2 -> L1B.sqlite
L2 L1A.sqlite (+L1b.sqlite) -> L2.sqlite
L3 Deep Dive

48
ETL/refresh.py Normal file
View 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()

View File

@@ -0,0 +1,39 @@
import sqlite3
import os
# 路径指向正式数据库
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
DB_PATH = os.path.join(BASE_DIR, 'database', 'L1A', 'L1A.sqlite')
def clean_db():
if not os.path.exists(DB_PATH):
print(f"Database not found at {DB_PATH}")
return
print(f"Connecting to production DB: {DB_PATH}")
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# 查找脏数据 (假设模拟数据的 match_id 是 match_001, match_002, match_003)
dirty_ids = ['match_001', 'match_002', 'match_003']
# 也可以用 LIKE 'match_%' 如果您想删得更彻底,但要小心误删
# 这里我们精准删除
deleted_count = 0
for mid in dirty_ids:
cursor.execute("DELETE FROM raw_iframe_network WHERE match_id = ?", (mid,))
if cursor.rowcount > 0:
print(f"Deleted dirty record: {mid}")
deleted_count += 1
conn.commit()
conn.close()
if deleted_count > 0:
print(f"Cleanup complete. Removed {deleted_count} dirty records.")
else:
print("Cleanup complete. No dirty records found.")
if __name__ == "__main__":
clean_db()

View File

@@ -0,0 +1,35 @@
import os
import json
# 定义路径
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.dirname(os.path.dirname(CURRENT_DIR))
OUTPUT_ARENA_DIR = os.path.join(PROJECT_ROOT, 'output_arena')
def create_mock_data():
if not os.path.exists(OUTPUT_ARENA_DIR):
os.makedirs(OUTPUT_ARENA_DIR)
print(f"Created directory: {OUTPUT_ARENA_DIR}")
# 创建 3 个模拟比赛数据
mock_matches = ['match_001', 'match_002', 'match_003']
for match_id in mock_matches:
match_dir = os.path.join(OUTPUT_ARENA_DIR, match_id)
if not os.path.exists(match_dir):
os.makedirs(match_dir)
file_path = os.path.join(match_dir, 'iframe_network.json')
if not os.path.exists(file_path):
mock_content = {
"match_id": match_id,
"data": "This is mock data for testing."
}
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(mock_content, f)
print(f"Created mock file: {file_path}")
else:
print(f"File already exists: {file_path}")
if __name__ == "__main__":
create_mock_data()

View File

@@ -0,0 +1,76 @@
import os
import sqlite3
import subprocess
import glob
# 配置路径
# 当前脚本位于 ETL/verify/ 目录下,需要向上两级找到项目根目录
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.dirname(os.path.dirname(CURRENT_DIR))
L1_SCRIPT = os.path.join(PROJECT_ROOT, 'ETL', 'L1A.py')
DB_PATH = os.path.join(PROJECT_ROOT, 'database', 'L1A', 'L1A.sqlite')
OUTPUT_ARENA_DIR = os.path.join(PROJECT_ROOT, 'output_arena')
def get_db_count():
"""获取数据库中的记录数"""
if not os.path.exists(DB_PATH):
return 0
try:
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM raw_iframe_network")
count = cursor.fetchone()[0]
conn.close()
return count
except Exception:
return 0
def get_file_count():
"""获取源文件总数"""
pattern = os.path.join(OUTPUT_ARENA_DIR, '*', 'iframe_network.json')
files = glob.glob(pattern)
return len(files)
def run_l1_script():
"""运行 L1 脚本并返回输出"""
# 必须在项目根目录下运行,或者正确处理 Python 路径
# 这里我们使用绝对路径调用脚本
result = subprocess.run(['python', L1_SCRIPT], capture_output=True, text=True)
return result.stdout
def main():
print("=== 开始 L1 增量逻辑测试 ===")
print(f"项目根目录: {PROJECT_ROOT}")
# 1. 检查环境
total_files = get_file_count()
initial_db_count = get_db_count()
print(f"[环境] 源文件总数: {total_files}")
print(f"[环境] 数据库当前记录数: {initial_db_count}")
# 2. 运行脚本 (第一次)
print("\n--- 运行 L1A.py (Run 1) ---")
output1 = run_l1_script()
print(output1.strip())
mid_db_count = get_db_count()
print(f"[状态] 运行后数据库记录数: {mid_db_count}")
if mid_db_count < total_files:
print("警告: 数据库记录数少于文件数,可能部分文件处理失败或尚未完成。")
# 3. 运行脚本 (第二次 - 验证增量)
print("\n--- 再次运行 L1A.py (Run 2 - 验证增量) ---")
output2 = run_l1_script()
print(output2.strip())
# 4. 验证结果
expected_msg = f"Skipped: {total_files}"
if expected_msg in output2:
print("\n✅ 测试通过! 第二次运行跳过了所有文件,增量逻辑生效。")
else:
print(f"\n❌ 测试未通过。预期输出应包含 '{expected_msg}'")
if __name__ == "__main__":
main()

Binary file not shown.

504
ETL/verify/verify_L2.py Normal file
View File

@@ -0,0 +1,504 @@
import sqlite3
import pandas as pd
import csv
import os
import sys
import time
pd.set_option('display.max_columns', None)
pd.set_option('display.width', 1000)
db_path = 'database/L2/L2_Main.sqlite'
schema_path = 'database/original_json_schema/schema_flat.csv'
covered_main_fields = {
"match_code", "map", "start_time", "end_time", "match_winner",
"group1_all_score", "group1_change_elo", "group1_fh_role", "group1_fh_score",
"group1_origin_elo", "group1_sh_role", "group1_sh_score", "group1_tid", "group1_uids",
"group2_all_score", "group2_change_elo", "group2_fh_role", "group2_fh_score",
"group2_origin_elo", "group2_sh_role", "group2_sh_score", "group2_tid", "group2_uids",
"server_ip", "server_port", "location", "location_full", "map_desc",
"demo_url", "game_mode", "game_name", "match_mode", "match_status", "match_flag",
"status", "waiver", "year", "season", "round_total", "cs_type", "priority_show_type",
"pug10m_show_type", "credit_match_status", "knife_winner", "knife_winner_role",
"most_1v2_uid", "most_assist_uid", "most_awp_uid", "most_end_uid",
"most_first_kill_uid", "most_headshot_uid", "most_jump_uid", "mvp_uid", "id"
}
covered_user_fields = {
"data.group_N[].user_info."
}
covered_round_fields = [
"data.round_list[].current_score.ct",
"data.round_list[].current_score.t",
"data.round_list[].current_score.final_round_time",
"data.round_list[].all_kill[].pasttime",
"data.round_list[].all_kill[].weapon",
"data.round_list[].all_kill[].headshot",
"data.round_list[].all_kill[].penetrated",
"data.round_list[].all_kill[].attackerblind",
"data.round_list[].all_kill[].throughsmoke",
"data.round_list[].all_kill[].noscope",
"data.round_list[].all_kill[].attacker.steamid_64",
"data.round_list[].all_kill[].victim.steamid_64",
"data.round_list[].all_kill[].attacker.pos.x",
"data.round_list[].all_kill[].attacker.pos.y",
"data.round_list[].all_kill[].attacker.pos.z",
"data.round_list[].all_kill[].victim.pos.x",
"data.round_list[].all_kill[].victim.pos.y",
"data.round_list[].all_kill[].victim.pos.z"
]
covered_leetify_fields = [
"data.leetify_data.round_stat[].round",
"data.leetify_data.round_stat[].win_reason",
"data.leetify_data.round_stat[].end_ts",
"data.leetify_data.round_stat[].sfui_event.score_ct",
"data.leetify_data.round_stat[].sfui_event.score_t",
"data.leetify_data.round_stat[].ct_money_group",
"data.leetify_data.round_stat[].t_money_group",
"data.leetify_data.round_stat[].show_event[].ts",
"data.leetify_data.round_stat[].show_event[].kill_event.Ts",
"data.leetify_data.round_stat[].show_event[].kill_event.Killer",
"data.leetify_data.round_stat[].show_event[].kill_event.Victim",
"data.leetify_data.round_stat[].show_event[].kill_event.WeaponName",
"data.leetify_data.round_stat[].show_event[].kill_event.Headshot",
"data.leetify_data.round_stat[].show_event[].kill_event.Penetrated",
"data.leetify_data.round_stat[].show_event[].kill_event.AttackerBlind",
"data.leetify_data.round_stat[].show_event[].kill_event.ThroughSmoke",
"data.leetify_data.round_stat[].show_event[].kill_event.NoScope",
"data.leetify_data.round_stat[].show_event[].trade_score_change.",
"data.leetify_data.round_stat[].show_event[].flash_assist_killer_score_change.",
"data.leetify_data.round_stat[].show_event[].killer_score_change.",
"data.leetify_data.round_stat[].show_event[].victim_score_change.",
"data.leetify_data.round_stat[].bron_equipment.",
"data.leetify_data.round_stat[].player_t_score.",
"data.leetify_data.round_stat[].player_ct_score.",
"data.leetify_data.round_stat[].player_bron_crash."
]
covered_vip_fields = {
"awp_kill",
"awp_kill_ct",
"awp_kill_t",
"damage_receive",
"damage_stats",
"fd_ct",
"fd_t",
"kast"
}
def load_schema_paths(schema_path_value):
paths = []
with open(schema_path_value, 'r', encoding='utf-8') as f:
reader = csv.reader(f)
_ = next(reader, None)
for row in reader:
if len(row) >= 2:
paths.append(row[1])
return paths
def is_covered(path):
if path in ["data", "code", "message", "status", "timestamp", "timeStamp", "traceId", "success", "errcode"]:
return True
if path.startswith("data.<steamid>."):
key = path.split("data.<steamid>.")[1].split(".")[0]
if key in covered_vip_fields:
return True
if "data.group_N[].fight_any." in path:
return True
if "data.group_N[].fight_t." in path or "data.group_N[].fight_ct." in path:
return True
if "data.group_N[].sts." in path:
return True
if "data.group_N[].level_info." in path:
return True
if "data.treat_info." in path:
return True
if "data.has_side_data_and_rating2" in path:
return True
if "data.main." in path:
key = path.split("data.main.")[1].split(".")[0]
if key in covered_main_fields:
return True
if any(k in path for k in covered_user_fields):
return True
if "data.round_list" in path:
return True
if any(k in path for k in covered_round_fields):
return True
if "data.leetify_data." in path:
return True
if any(k in path for k in covered_leetify_fields):
return True
return False
def group_key(p):
if "data.group_N[].user_info." in p:
return "data.group_N[].user_info.*"
if "data.group_N[].fight_any." in p:
return "data.group_N[].fight_any.*"
if "data.group_N[].fight_t." in p:
return "data.group_N[].fight_t.*"
if "data.group_N[].fight_ct." in p:
return "data.group_N[].fight_ct.*"
if "data.main." in p:
return "data.main.*"
if "data.round_list[]" in p or "data.round_list[]." in p:
return "data.round_list.*"
if "data.leetify_data.round_stat[]" in p or "data.leetify_data.round_stat[]." in p:
return "data.leetify_data.round_stat.*"
if "data.leetify_data." in p:
return "data.leetify_data.*"
if "data.treat_info." in p:
return "data.treat_info.*"
if "data." in p:
return "data.*"
return "other"
def dump_uncovered(output_path):
paths = load_schema_paths(schema_path)
uncovered = [p for p in paths if not is_covered(p)]
df_unc = pd.DataFrame({"path": uncovered})
if len(df_unc) == 0:
print("no uncovered paths")
return
df_unc["group"] = df_unc["path"].apply(group_key)
df_unc = df_unc.sort_values(["group", "path"])
df_unc.to_csv(output_path, index=False, encoding='utf-8-sig')
print(f"uncovered total: {len(df_unc)}")
print("\n-- uncovered groups (count) --")
print(df_unc.groupby("group").size().sort_values(ascending=False))
print(f"\noutput: {output_path}")
def print_schema(conn):
tables = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").fetchall()
for (name,) in tables:
print(f"\n[{name}]")
cols = conn.execute(f"PRAGMA table_info({name})").fetchall()
rows = [["column", "type", "pk"]]
for _, col_name, col_type, _, _, pk in cols:
rows.append([col_name, col_type or "", str(pk)])
widths = [max(len(r[i]) for r in rows) for i in range(3)]
for idx, r in enumerate(rows):
line = " | ".join([r[i].ljust(widths[i]) for i in range(3)])
print(line)
if idx == 0:
print("-" * len(line))
def refresh_schema_sql(conn, output_path):
rows = conn.execute("""
SELECT type, name, sql
FROM sqlite_master
WHERE sql IS NOT NULL AND type IN ('table', 'index') AND name NOT LIKE 'sqlite_%'
ORDER BY CASE WHEN type='table' THEN 0 ELSE 1 END, name
""").fetchall()
lines = ["PRAGMA foreign_keys = ON;", ""]
for _, _, sql in rows:
lines.append(sql.strip() + ";")
lines.append("")
with open(output_path, 'w', encoding='utf-8') as f:
f.write("\n".join(lines).strip() + "\n")
def verify():
conn = sqlite3.connect(db_path)
print("--- Counts ---")
tables = [
'dim_players',
'dim_maps',
'fact_matches',
'fact_match_teams',
'fact_match_players',
'fact_match_players_t',
'fact_match_players_ct',
'fact_rounds',
'fact_round_events',
'fact_round_player_economy'
]
for t in tables:
count = conn.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0]
print(f"{t}: {count}")
print("\n--- Data Source Distribution ---")
dist = pd.read_sql("SELECT data_source_type, COUNT(*) as cnt FROM fact_matches GROUP BY data_source_type", conn)
print(dist)
print("\n--- Sample Round Events (Leetify vs Classic) ---")
# Fetch one event from a leetify match
leetify_match = conn.execute("SELECT match_id FROM fact_matches WHERE data_source_type='leetify' LIMIT 1").fetchone()
if leetify_match:
mid = leetify_match[0]
print(f"Leetify Match: {mid}")
df = pd.read_sql(f"SELECT * FROM fact_round_events WHERE match_id='{mid}' AND event_type='kill' LIMIT 1", conn)
print(df[['event_type', 'attacker_steam_id', 'trade_killer_steam_id', 'attacker_pos_x', 'score_change_attacker']])
# Fetch one event from a classic match
classic_match = conn.execute("SELECT match_id FROM fact_matches WHERE data_source_type='classic' LIMIT 1").fetchone()
if classic_match:
mid = classic_match[0]
print(f"Classic Match: {mid}")
df = pd.read_sql(f"SELECT * FROM fact_round_events WHERE match_id='{mid}' AND event_type='kill' LIMIT 1", conn)
print(df[['event_type', 'attacker_steam_id', 'trade_killer_steam_id', 'attacker_pos_x', 'score_change_attacker']])
print("\n--- Sample Player Stats (New Fields) ---")
df_players = pd.read_sql("SELECT steam_id_64, rating, rating3, elo_change, rank_score, flash_duration, jump_count FROM fact_match_players LIMIT 5", conn)
print(df_players)
print("\n--- Insert Field Checks ---")
meta_counts = conn.execute("""
SELECT
SUM(CASE WHEN response_code IS NOT NULL THEN 1 ELSE 0 END) AS response_code_cnt,
SUM(CASE WHEN response_trace_id IS NOT NULL AND response_trace_id != '' THEN 1 ELSE 0 END) AS response_trace_id_cnt,
SUM(CASE WHEN response_success IS NOT NULL THEN 1 ELSE 0 END) AS response_success_cnt,
SUM(CASE WHEN response_errcode IS NOT NULL THEN 1 ELSE 0 END) AS response_errcode_cnt,
SUM(CASE WHEN treat_info_raw IS NOT NULL AND treat_info_raw != '' THEN 1 ELSE 0 END) AS treat_info_raw_cnt,
SUM(CASE WHEN round_list_raw IS NOT NULL AND round_list_raw != '' THEN 1 ELSE 0 END) AS round_list_raw_cnt,
SUM(CASE WHEN leetify_data_raw IS NOT NULL AND leetify_data_raw != '' THEN 1 ELSE 0 END) AS leetify_data_raw_cnt
FROM fact_matches
""").fetchone()
print(f"response_code non-null: {meta_counts[0]}")
print(f"response_trace_id non-empty: {meta_counts[1]}")
print(f"response_success non-null: {meta_counts[2]}")
print(f"response_errcode non-null: {meta_counts[3]}")
print(f"treat_info_raw non-empty: {meta_counts[4]}")
print(f"round_list_raw non-empty: {meta_counts[5]}")
print(f"leetify_data_raw non-empty: {meta_counts[6]}")
print("\n--- Integrity Checks ---")
missing_players = conn.execute("""
SELECT COUNT(*) FROM fact_match_players f
LEFT JOIN dim_players d ON f.steam_id_64 = d.steam_id_64
WHERE d.steam_id_64 IS NULL
""").fetchone()[0]
print(f"fact_match_players missing dim_players: {missing_players}")
missing_round_matches = conn.execute("""
SELECT COUNT(*) FROM fact_rounds r
LEFT JOIN fact_matches m ON r.match_id = m.match_id
WHERE m.match_id IS NULL
""").fetchone()[0]
print(f"fact_rounds missing fact_matches: {missing_round_matches}")
missing_event_rounds = conn.execute("""
SELECT COUNT(*) FROM fact_round_events e
LEFT JOIN fact_rounds r ON e.match_id = r.match_id AND e.round_num = r.round_num
WHERE r.match_id IS NULL
""").fetchone()[0]
print(f"fact_round_events missing fact_rounds: {missing_event_rounds}")
side_zero_t = conn.execute("""
SELECT COUNT(*) FROM fact_match_players_t
WHERE COALESCE(kills,0)=0 AND COALESCE(deaths,0)=0 AND COALESCE(assists,0)=0
""").fetchone()[0]
side_zero_ct = conn.execute("""
SELECT COUNT(*) FROM fact_match_players_ct
WHERE COALESCE(kills,0)=0 AND COALESCE(deaths,0)=0 AND COALESCE(assists,0)=0
""").fetchone()[0]
print(f"fact_match_players_t zero K/D/A: {side_zero_t}")
print(f"fact_match_players_ct zero K/D/A: {side_zero_ct}")
print("\n--- Full vs T/CT Comparison ---")
cols = [
'kills', 'deaths', 'assists', 'headshot_count', 'adr', 'rating', 'rating2',
'rating3', 'rws', 'mvp_count', 'flash_duration', 'jump_count', 'is_win'
]
df_full = pd.read_sql(
"SELECT match_id, steam_id_64, " + ",".join(cols) + " FROM fact_match_players",
conn
)
df_t = pd.read_sql(
"SELECT match_id, steam_id_64, " + ",".join(cols) + " FROM fact_match_players_t",
conn
).rename(columns={c: f"{c}_t" for c in cols})
df_ct = pd.read_sql(
"SELECT match_id, steam_id_64, " + ",".join(cols) + " FROM fact_match_players_ct",
conn
).rename(columns={c: f"{c}_ct" for c in cols})
df = df_full.merge(df_t, on=['match_id', 'steam_id_64'], how='left')
df = df.merge(df_ct, on=['match_id', 'steam_id_64'], how='left')
def is_empty(s):
return s.isna() | (s == 0)
for c in cols:
empty_count = is_empty(df[c]).sum()
print(f"{c} empty: {empty_count}")
additive = ['kills', 'deaths', 'assists', 'headshot_count', 'mvp_count', 'flash_duration', 'jump_count']
for c in additive:
t_sum = df[f"{c}_t"].fillna(0) + df[f"{c}_ct"].fillna(0)
tol = 0.01 if c == 'flash_duration' else 0
diff = (df[c].fillna(0) - t_sum).abs() > tol
print(f"{c} full != t+ct: {diff.sum()}")
non_additive = ['adr', 'rating', 'rating2', 'rating3', 'rws', 'is_win']
for c in non_additive:
side_nonempty = (~is_empty(df[f"{c}_t"])) | (~is_empty(df[f"{c}_ct"]))
full_empty_side_nonempty = is_empty(df[c]) & side_nonempty
full_nonempty_side_empty = (~is_empty(df[c])) & (~side_nonempty)
print(f"{c} full empty but side has: {full_empty_side_nonempty.sum()}")
print(f"{c} full has but side empty: {full_nonempty_side_empty.sum()}")
print("\n--- Rating Detail ---")
rating_cols = ['rating', 'rating2', 'rating3']
for c in rating_cols:
full_null = df[c].isna().sum()
full_zero = (df[c] == 0).sum()
full_nonzero = ((~df[c].isna()) & (df[c] != 0)).sum()
side_t_nonzero = ((~df[f"{c}_t"].isna()) & (df[f"{c}_t"] != 0)).sum()
side_ct_nonzero = ((~df[f"{c}_ct"].isna()) & (df[f"{c}_ct"] != 0)).sum()
side_any_nonzero = ((~df[f"{c}_t"].isna()) & (df[f"{c}_t"] != 0)) | ((~df[f"{c}_ct"].isna()) & (df[f"{c}_ct"] != 0))
full_nonzero_side_zero = ((~df[c].isna()) & (df[c] != 0) & (~side_any_nonzero)).sum()
full_zero_side_nonzero = (((df[c].isna()) | (df[c] == 0)) & side_any_nonzero).sum()
print(f"{c} full null: {full_null} full zero: {full_zero} full nonzero: {full_nonzero}")
print(f"{c} side t nonzero: {side_t_nonzero} side ct nonzero: {side_ct_nonzero}")
print(f"{c} full nonzero but side all zero: {full_nonzero_side_zero}")
print(f"{c} full zero but side has: {full_zero_side_nonzero}")
df_rating_src = pd.read_sql(
"SELECT f.rating, f.rating2, f.rating3, m.data_source_type FROM fact_match_players f JOIN fact_matches m ON f.match_id = m.match_id",
conn
)
for c in rating_cols:
grp = df_rating_src.groupby('data_source_type')[c].apply(lambda s: (s != 0).sum()).reset_index(name='nonzero')
print(f"{c} nonzero by source")
print(grp)
print("\n--- Schema Coverage (fight_any) ---")
paths = load_schema_paths(schema_path)
fight_keys = set()
for p in paths:
if 'data.group_N[].fight_any.' in p:
key = p.split('fight_any.')[1].split('.')[0]
fight_keys.add(key)
l2_cols = set(pd.read_sql("PRAGMA table_info(fact_match_players)", conn)['name'].tolist())
alias = {
'kills': 'kill',
'deaths': 'death',
'assists': 'assist',
'headshot_count': 'headshot',
'mvp_count': 'is_mvp',
'flash_duration': 'flash_enemy_time',
'jump_count': 'jump_total',
'awp_kills': 'awp_kill'
}
covered = set()
for c in l2_cols:
if c in fight_keys:
covered.add(c)
elif c in alias and alias[c] in fight_keys:
covered.add(alias[c])
missing_keys = sorted(list(fight_keys - covered))
print(f"fight_any keys: {len(fight_keys)}")
print(f"covered by L2 columns: {len(covered)}")
print(f"uncovered fight_any keys: {len(missing_keys)}")
if missing_keys:
print(missing_keys)
print("\n--- Coverage Zero Rate (fight_any -> fact_match_players) ---")
fight_cols = [k for k in fight_keys if k in l2_cols or k in alias.values()]
col_map = {}
for k in fight_cols:
if k in l2_cols:
col_map[k] = k
else:
for l2k, src in alias.items():
if src == k:
col_map[k] = l2k
break
select_cols = ["steam_id_64"] + list(set(col_map.values()))
df_fight = pd.read_sql(
"SELECT " + ",".join(select_cols) + " FROM fact_match_players",
conn
)
total_rows = len(df_fight)
stats = []
for fight_key, col in sorted(col_map.items()):
s = df_fight[col]
zeros = (s == 0).sum()
nulls = s.isna().sum()
nonzero = total_rows - zeros - nulls
stats.append({
"fight_key": fight_key,
"column": col,
"nonzero": nonzero,
"zero": zeros,
"null": nulls,
"zero_rate": 0 if total_rows == 0 else round(zeros / total_rows, 4)
})
df_stats = pd.DataFrame(stats).sort_values(["zero_rate", "nonzero"], ascending=[False, True])
print(df_stats.head(30))
print("\n-- zero_rate top (most zeros) --")
print(df_stats.head(10))
print("\n-- zero_rate bottom (most nonzero) --")
print(df_stats.tail(10))
print("\n--- Schema Coverage (leetify economy) ---")
econ_keys = [
'data.leetify_data.round_stat[].bron_equipment.',
'data.leetify_data.round_stat[].player_t_score.',
'data.leetify_data.round_stat[].player_ct_score.',
'data.leetify_data.round_stat[].player_bron_crash.'
]
for k in econ_keys:
count = sum(1 for p in paths if k in p)
print(f"{k} paths: {count}")
print("\n--- Schema Summary Coverage (by path groups) ---")
uncovered = [p for p in paths if not is_covered(p)]
print(f"total paths: {len(paths)}")
print(f"covered paths: {len(paths) - len(uncovered)}")
print(f"uncovered paths: {len(uncovered)}")
df_unc = pd.DataFrame({"path": uncovered})
if len(df_unc) > 0:
df_unc["group"] = df_unc["path"].apply(group_key)
print("\n-- Uncovered groups (count) --")
print(df_unc.groupby("group").size().sort_values(ascending=False))
print("\n-- Uncovered examples (top 50) --")
print(df_unc["path"].head(50).to_list())
conn.close()
def watch_schema(schema_path, interval=1.0):
last_db_mtime = 0
last_schema_mtime = 0
first = True
while True:
if not os.path.exists(db_path):
print(f"db not found: {db_path}")
time.sleep(interval)
continue
db_mtime = os.path.getmtime(db_path)
schema_mtime = os.path.getmtime(schema_path) if os.path.exists(schema_path) else 0
if first or db_mtime > last_db_mtime or schema_mtime > last_schema_mtime:
conn = sqlite3.connect(db_path)
refresh_schema_sql(conn, schema_path)
print(f"\n[{time.strftime('%Y-%m-%d %H:%M:%S')}] schema.sql refreshed")
print_schema(conn)
conn.close()
last_db_mtime = db_mtime
last_schema_mtime = os.path.getmtime(schema_path) if os.path.exists(schema_path) else 0
first = False
time.sleep(interval)
if __name__ == "__main__":
args = [a.lower() for a in sys.argv[1:]]
if "dump_uncovered" in args or "uncovered" in args:
dump_uncovered('database/original_json_schema/uncovered_features.csv')
elif "watch_schema" in args or "watch" in args:
try:
watch_schema('database/L2/schema.sql')
except KeyboardInterrupt:
pass
elif "schema" in args or "refresh_schema" in args:
if not os.path.exists(db_path):
print(f"db not found: {db_path}")
else:
conn = sqlite3.connect(db_path)
if "refresh_schema" in args:
refresh_schema_sql(conn, 'database/L2/schema.sql')
print("schema.sql refreshed")
print_schema(conn)
conn.close()
else:
verify()

29
ETL/verify/verify_L3.py Normal file
View File

@@ -0,0 +1,29 @@
import sqlite3
import pandas as pd
L3_DB_PATH = 'database/L3/L3_Features.sqlite'
def verify():
conn = sqlite3.connect(L3_DB_PATH)
# 1. Row count
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM dm_player_features")
count = cursor.fetchone()[0]
print(f"Total Players in L3: {count}")
# 2. Sample Data
df = pd.read_sql_query("SELECT * FROM dm_player_features LIMIT 5", conn)
print("\nSample Data (First 5 rows):")
print(df[['steam_id_64', 'total_matches', 'basic_avg_rating', 'sta_last_30_rating', 'bat_kd_diff_high_elo', 'hps_clutch_win_rate_1v1']].to_string())
# 3. Stats Summary
print("\nStats Summary:")
full_df = pd.read_sql_query("SELECT basic_avg_rating, sta_last_30_rating, bat_win_rate_vs_all FROM dm_player_features", conn)
print(full_df.describe())
conn.close()
if __name__ == "__main__":
verify()

82
ETL/verify/verify_deep.py Normal file
View File

@@ -0,0 +1,82 @@
import sqlite3
import pandas as pd
import numpy as np
import sys
# 设置pandas显示选项确保不省略任何行和列
pd.set_option('display.max_rows', None)
pd.set_option('display.max_columns', None)
pd.set_option('display.width', 2000)
pd.set_option('display.float_format', '{:.2f}'.format)
pd.set_option('display.max_colwidth', None)
db_path = 'database/L2/L2_Main.sqlite'
def check_all_tables():
conn = sqlite3.connect(db_path)
# 获取所有表名
tables = pd.read_sql("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'", conn)['name'].tolist()
for table in tables:
print(f"\n{'='*20} Table: {table} {'='*20}")
# 获取表的所有列
cols_info = pd.read_sql(f"PRAGMA table_info({table})", conn)
cols = cols_info['name'].tolist()
# 读取全表数据
df = pd.read_sql(f"SELECT * FROM {table}", conn)
total = len(df)
if total == 0:
print(f"Table is empty (0 rows)")
continue
print(f"Total Rows: {total}")
print("-" * 60)
stats = []
for col in cols:
# 1. Null Check
nulls = df[col].isnull().sum()
# 2. Zero Check (仅对数值型或可转换为数值的列)
zeros = 0
try:
# 尝试转为数值无法转换的变为NaN
numeric_series = pd.to_numeric(df[col], errors='coerce')
# 统计0值 (排除原本就是NaN的)
zeros = (numeric_series == 0).sum()
except:
zeros = 0
# 3. Unique Count (基数)
unique_count = df[col].nunique()
# 4. Example Value (取第一个非空值)
example = df[col].dropna().iloc[0] if df[col].count() > 0 else 'ALL NULL'
stats.append({
'Field': col,
'Nulls': nulls,
'Null%': (nulls/total)*100,
'Zeros': zeros,
'Zero%': (zeros/total)*100,
'Unique': unique_count,
'Example': str(example)[:50] # 截断过长示例
})
# 输出完整统计表
df_stats = pd.DataFrame(stats)
# 按 Zero% 降序排列,但保证 Null% 高的也显眼,这里默认不排序直接按字段序,或者按关注度排序
# 用户要求全面探查按字段原序输出可能更符合直觉或者按Zero%排序
# 这里为了排查问题,按 Zero% 降序输出
df_stats = df_stats.sort_values('Zero%', ascending=False)
print(df_stats.to_string(index=False))
print("\n")
conn.close()
if __name__ == "__main__":
check_all_tables()

144
README.md Normal file
View File

@@ -0,0 +1,144 @@
# YRTV 项目说明 till 1.0.2hotfix
## 项目概览
YRTV 是一个基于 CS2 比赛数据的综合分析与战队管理平台。它集成了数据采集、ETL 清洗建模、特征挖掘以及现代化的 Web 交互界面。
核心目标是为战队提供数据驱动的决策支持包括战术分析、队员表现评估、阵容管理Clubhouse以及实时战术板功能。
---
您可以使用以下命令快速配置环境:
pip install -r requirements.txt
数据来源与处理核心包括:
- 比赛页面的 iframe JSON 数据(`iframe_network.json`
- 可选的 demo 文件(`.zip/.dem`
- 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. **下载与落盘**
通过 `downloader/downloader.py` 抓取比赛页面数据,生成 `output_arena/<match_id>/iframe_network.json`,并可同时下载 demo 文件。
2. **L1A 入库(原始 JSON**
`ETL/L1A.py``output_arena/*/iframe_network.json` 批量写入 `database/L1A/L1A.sqlite`
3. **L2 入库(结构化事实表/维度表)**
`ETL/L2_Builder.py` 读取 L1A 数据,按 `database/L2/schema.sql` 构建维度表与事实表,生成 `database/L2/L2_Main.sqlite`
4. **L3 入库(特征集市)**
`ETL/L3_Builder.py` 读取 L2 数据,计算 Basic 及 6 大挖掘能力维度特征,生成 `database/L3/L3_Features.sqlite`
5. **质量校验与覆盖分析**
`ETL/verify/verify_L2.py``ETL/verify/verify_deep.py` 用于 L2 字段覆盖与逻辑检查。
## 目录结构
```
yrtv/
├── downloader/ # 下载器(抓取 iframe JSON 与 demo
├── ETL/ # ETL 脚本
│ ├── L1A.py
│ ├── L2_Builder.py
│ ├── L3_Builder.py
│ ├── refresh.py # [NEW] 一键刷新脚本
│ └── verify/
├── database/ # SQLite 数据库存储
│ ├── L1A/
│ ├── L2/
│ ├── L3/
│ └── original_json_schema/
├── web/ # [NEW] Web 应用程序
│ ├── app.py # 应用入口
│ ├── routes/ # 路由 (matches, players, teams, tactics)
│ ├── services/ # 业务逻辑 (stats, web)
│ ├── templates/ # Jinja2 模板 (TailwindCSS + Alpine.js)
│ └── static/ # 静态资源 (CSS, JS, Uploads)
└── utils/
└── json_extractor/ # JSON Schema 抽取工具
```
## 环境要求
- Python 3.11.4+
- Flask, Jinja2
- Playwright下载器依赖
- pandas, numpy数据处理依赖
## 数据库层级说明
### L1A
- **用途**:保存原始 iframe JSON
- **输入**`output_arena/*/iframe_network.json`
- **输出**`database/L1A/L1A.sqlite`
- **脚本**`ETL/L1A.py`
### L1B
- **用途**:保存 demo 解析后的原始数据(由 demoparser2 产出)
- **输出**`database/L1B/L1B.sqlite`
- 当前仓库提供目录与说明,解析流程需结合外部工具执行
### L2
结构化事实表/维度表数据库,覆盖比赛、玩家、回合与经济等数据:
- **Schema**`database/L2/schema.sql`
- **输出**`database/L2/L2_Main.sqlite`
- **核心表**
- `dim_players``dim_maps`
- `fact_matches``fact_match_teams`
- `fact_match_players``fact_match_players_t``fact_match_players_ct`
- `fact_rounds``fact_round_events``fact_round_player_economy`
### L3
玩家特征集市 (Player Features Data Mart),聚合 Basic 及 6 大挖掘能力维度 (STA, BAT, HPS, PTL, T/CT, UTIL)。
- **Schema**`database/L3/schema.sql`
- **输出**`database/L3/L3_Features.sqlite`
- **脚本**`ETL/L3_Builder.py`
- **核心表**`dm_player_features` (玩家聚合画像)
## JSON Schema 抽取工具
用于分析大量 `iframe_network.json` 的字段结构与覆盖情况,支持动态 Key 归并与多格式输出。
输出内容通常位于 `output_reports/``database/original_json_schema/`,包括:
- `schema_summary.md`:结构概览
- `schema_flat.csv`:扁平字段列表
- `uncovered_features.csv`:未覆盖字段清单
## 数据源互斥说明
L2 中 `fact_matches.data_source_type` 用于区分数据来源与字段覆盖范围:
- `classic`:含 round_list 详细回合与坐标信息
- `leetify`:含 leetify 评分与经济信息
- `unknown`:无法识别来源
入库逻辑保持互斥:同一场比赛只会按其来源覆盖相应字段,避免重复或冲突。

BIN
database/L1A/L1A.sqlite Normal file

Binary file not shown.

16
database/L1A/README.md Normal file
View File

@@ -0,0 +1,16 @@
L1A 5eplay平台网页爬虫原始数据。
## ETL Step 1:
从原始json数据库提取到L1A级数据库中。
`output_arena/*/iframe_network.json` -> `database/L1A/L1A.sqlite`
### 脚本说明
- **脚本位置**: `ETL/L1A.py`
- **功能**: 自动遍历 `output_arena` 目录下所有的 `iframe_network.json` 文件,提取原始内容并以 `match_id` (文件夹名) 为主键存入 `L1A.sqlite` 数据库的 `raw_iframe_network` 表中。
### 运行方式
使用项目指定的 Python 环境运行脚本:
```bash
C:/ProgramData/anaconda3/python.exe ETL/L1A.py
```

4
database/L1B/README.md Normal file
View File

@@ -0,0 +1,4 @@
L1B demo原始数据。
ETL Step 2:
从demoparser2提取demo原始数据到L1B级数据库中。
output_arena/*/iframe_network.json -> database/L1B/L1B.sqlite

BIN
database/L2/L2_Main.sqlite Normal file

Binary file not shown.

583
database/L2/schema.sql Normal file
View File

@@ -0,0 +1,583 @@
-- Enable Foreign Keys
PRAGMA foreign_keys = ON;
-- 1. Dimension: Players
-- Stores persistent player information.
-- Conflict resolution: UPSERT on steam_id_64.
CREATE TABLE IF NOT EXISTS dim_players (
steam_id_64 TEXT PRIMARY KEY,
uid INTEGER, -- 5E Platform ID
username TEXT,
avatar_url TEXT,
domain TEXT,
created_at INTEGER, -- Timestamp
updated_at INTEGER, -- Timestamp
last_seen_match_id TEXT,
uuid TEXT,
email TEXT,
area TEXT,
mobile TEXT,
user_domain TEXT,
username_audit_status INTEGER,
accid TEXT,
team_id INTEGER,
trumpet_count INTEGER,
profile_nickname TEXT,
profile_avatar_audit_status INTEGER,
profile_rgb_avatar_url TEXT,
profile_photo_url TEXT,
profile_gender INTEGER,
profile_birthday INTEGER,
profile_country_id TEXT,
profile_region_id TEXT,
profile_city_id TEXT,
profile_language TEXT,
profile_recommend_url TEXT,
profile_group_id INTEGER,
profile_reg_source INTEGER,
status_status INTEGER,
status_expire INTEGER,
status_cancellation_status INTEGER,
status_new_user INTEGER,
status_login_banned_time INTEGER,
status_anticheat_type INTEGER,
status_flag_status1 TEXT,
status_anticheat_status TEXT,
status_flag_honor TEXT,
status_privacy_policy_status INTEGER,
status_csgo_frozen_exptime INTEGER,
platformexp_level INTEGER,
platformexp_exp INTEGER,
steam_account TEXT,
steam_trade_url TEXT,
steam_rent_id TEXT,
trusted_credit INTEGER,
trusted_credit_level INTEGER,
trusted_score INTEGER,
trusted_status INTEGER,
trusted_credit_status INTEGER,
certify_id_type INTEGER,
certify_status INTEGER,
certify_age INTEGER,
certify_real_name TEXT,
certify_uid_list TEXT,
certify_audit_status INTEGER,
certify_gender INTEGER,
identity_type INTEGER,
identity_extras TEXT,
identity_status INTEGER,
identity_slogan TEXT,
identity_list TEXT,
identity_slogan_ext TEXT,
identity_live_url TEXT,
identity_live_type INTEGER,
plus_is_plus INTEGER,
user_info_raw TEXT
);
CREATE INDEX IF NOT EXISTS idx_dim_players_uid ON dim_players(uid);
-- 2. Dimension: Maps
CREATE TABLE IF NOT EXISTS dim_maps (
map_id INTEGER PRIMARY KEY AUTOINCREMENT,
map_name TEXT UNIQUE NOT NULL,
map_desc TEXT
);
-- 3. Fact: Matches
CREATE TABLE IF NOT EXISTS fact_matches (
match_id TEXT PRIMARY KEY,
match_code TEXT,
map_name TEXT,
start_time INTEGER,
end_time INTEGER,
duration INTEGER,
winner_team INTEGER, -- 1 or 2
score_team1 INTEGER,
score_team2 INTEGER,
server_ip TEXT,
server_port INTEGER,
location TEXT,
has_side_data_and_rating2 INTEGER,
match_main_id INTEGER,
demo_url TEXT,
game_mode INTEGER,
game_name TEXT,
map_desc TEXT,
location_full TEXT,
match_mode INTEGER,
match_status INTEGER,
match_flag INTEGER,
status INTEGER,
waiver INTEGER,
year INTEGER,
season TEXT,
round_total INTEGER,
cs_type INTEGER,
priority_show_type INTEGER,
pug10m_show_type INTEGER,
credit_match_status INTEGER,
knife_winner INTEGER,
knife_winner_role INTEGER,
most_1v2_uid INTEGER,
most_assist_uid INTEGER,
most_awp_uid INTEGER,
most_end_uid INTEGER,
most_first_kill_uid INTEGER,
most_headshot_uid INTEGER,
most_jump_uid INTEGER,
mvp_uid INTEGER,
response_code INTEGER,
response_message TEXT,
response_status INTEGER,
response_timestamp INTEGER,
response_trace_id TEXT,
response_success INTEGER,
response_errcode INTEGER,
treat_info_raw TEXT,
round_list_raw TEXT,
leetify_data_raw TEXT,
data_source_type TEXT CHECK(data_source_type IN ('leetify', 'classic', 'unknown')), -- 'leetify' has economy data, 'classic' has detailed xyz
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_fact_matches_time ON fact_matches(start_time);
CREATE TABLE IF NOT EXISTS fact_match_teams (
match_id TEXT,
group_id INTEGER,
group_all_score INTEGER,
group_change_elo REAL,
group_fh_role INTEGER,
group_fh_score INTEGER,
group_origin_elo REAL,
group_sh_role INTEGER,
group_sh_score INTEGER,
group_tid INTEGER,
group_uids TEXT,
PRIMARY KEY (match_id, group_id),
FOREIGN KEY (match_id) REFERENCES fact_matches(match_id) ON DELETE CASCADE
);
-- 4. Fact: Match Player Stats (Wide Table)
-- Aggregated stats for a player in a specific match
CREATE TABLE IF NOT EXISTS fact_match_players (
match_id TEXT,
steam_id_64 TEXT,
team_id INTEGER, -- 1 or 2
-- Basic Stats
kills INTEGER DEFAULT 0,
deaths INTEGER DEFAULT 0,
assists INTEGER DEFAULT 0,
headshot_count INTEGER DEFAULT 0,
kd_ratio REAL,
adr REAL,
rating REAL, -- 5E Rating
rating2 REAL,
rating3 REAL,
rws REAL,
mvp_count INTEGER DEFAULT 0,
elo_change REAL,
rank_score INTEGER,
is_win BOOLEAN,
-- Advanced Stats (VIP/Plus)
kast REAL,
entry_kills INTEGER,
entry_deaths INTEGER,
awp_kills INTEGER,
clutch_1v1 INTEGER,
clutch_1v2 INTEGER,
clutch_1v3 INTEGER,
clutch_1v4 INTEGER,
clutch_1v5 INTEGER,
flash_assists INTEGER,
flash_duration REAL,
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_received INTEGER,
damage_receive INTEGER,
damage_stats INTEGER,
assisted_kill INTEGER,
awp_kill INTEGER,
awp_kill_ct INTEGER,
awp_kill_t INTEGER,
benefit_kill INTEGER,
day TEXT,
defused_bomb INTEGER,
end_1v1 INTEGER,
end_1v2 INTEGER,
end_1v3 INTEGER,
end_1v4 INTEGER,
end_1v5 INTEGER,
explode_bomb INTEGER,
first_death INTEGER,
fd_ct INTEGER,
fd_t INTEGER,
first_kill INTEGER,
flash_enemy INTEGER,
flash_team INTEGER,
flash_team_time REAL,
flash_time REAL,
game_mode TEXT,
group_id INTEGER,
hold_total INTEGER,
id INTEGER,
is_highlight INTEGER,
is_most_1v2 INTEGER,
is_most_assist INTEGER,
is_most_awp INTEGER,
is_most_end INTEGER,
is_most_first_kill INTEGER,
is_most_headshot INTEGER,
is_most_jump INTEGER,
is_svp INTEGER,
is_tie INTEGER,
kill_1 INTEGER,
kill_2 INTEGER,
kill_3 INTEGER,
kill_4 INTEGER,
kill_5 INTEGER,
many_assists_cnt1 INTEGER,
many_assists_cnt2 INTEGER,
many_assists_cnt3 INTEGER,
many_assists_cnt4 INTEGER,
many_assists_cnt5 INTEGER,
map TEXT,
match_code TEXT,
match_mode TEXT,
match_team_id INTEGER,
match_time INTEGER,
per_headshot REAL,
perfect_kill INTEGER,
planted_bomb INTEGER,
revenge_kill INTEGER,
round_total INTEGER,
season TEXT,
team_kill INTEGER,
throw_harm INTEGER,
throw_harm_enemy INTEGER,
uid INTEGER,
year TEXT,
sts_raw TEXT,
level_info_raw TEXT,
PRIMARY KEY (match_id, steam_id_64),
FOREIGN KEY (match_id) REFERENCES fact_matches(match_id) ON DELETE CASCADE
-- Intentionally not enforcing FK on steam_id_64 strictly to allow stats even if player dim missing, but ideally it should match.
);
CREATE TABLE IF NOT EXISTS fact_match_players_t (
match_id TEXT,
steam_id_64 TEXT,
team_id INTEGER,
kills INTEGER DEFAULT 0,
deaths INTEGER DEFAULT 0,
assists INTEGER DEFAULT 0,
headshot_count INTEGER DEFAULT 0,
kd_ratio REAL,
adr REAL,
rating REAL,
rating2 REAL,
rating3 REAL,
rws REAL,
mvp_count INTEGER DEFAULT 0,
elo_change REAL,
rank_score INTEGER,
is_win BOOLEAN,
kast REAL,
entry_kills INTEGER,
entry_deaths INTEGER,
awp_kills INTEGER,
clutch_1v1 INTEGER,
clutch_1v2 INTEGER,
clutch_1v3 INTEGER,
clutch_1v4 INTEGER,
clutch_1v5 INTEGER,
flash_assists INTEGER,
flash_duration REAL,
jump_count INTEGER,
damage_total INTEGER,
damage_received INTEGER,
damage_receive INTEGER,
damage_stats INTEGER,
assisted_kill INTEGER,
awp_kill INTEGER,
awp_kill_ct INTEGER,
awp_kill_t INTEGER,
benefit_kill INTEGER,
day TEXT,
defused_bomb INTEGER,
end_1v1 INTEGER,
end_1v2 INTEGER,
end_1v3 INTEGER,
end_1v4 INTEGER,
end_1v5 INTEGER,
explode_bomb INTEGER,
first_death INTEGER,
fd_ct INTEGER,
fd_t INTEGER,
first_kill INTEGER,
flash_enemy INTEGER,
flash_team INTEGER,
flash_team_time REAL,
flash_time REAL,
game_mode TEXT,
group_id INTEGER,
hold_total INTEGER,
id INTEGER,
is_highlight INTEGER,
is_most_1v2 INTEGER,
is_most_assist INTEGER,
is_most_awp INTEGER,
is_most_end INTEGER,
is_most_first_kill INTEGER,
is_most_headshot INTEGER,
is_most_jump INTEGER,
is_svp INTEGER,
is_tie INTEGER,
kill_1 INTEGER,
kill_2 INTEGER,
kill_3 INTEGER,
kill_4 INTEGER,
kill_5 INTEGER,
many_assists_cnt1 INTEGER,
many_assists_cnt2 INTEGER,
many_assists_cnt3 INTEGER,
many_assists_cnt4 INTEGER,
many_assists_cnt5 INTEGER,
map TEXT,
match_code TEXT,
match_mode TEXT,
match_team_id INTEGER,
match_time INTEGER,
per_headshot REAL,
perfect_kill INTEGER,
planted_bomb INTEGER,
revenge_kill INTEGER,
round_total INTEGER,
season TEXT,
team_kill INTEGER,
throw_harm INTEGER,
throw_harm_enemy INTEGER,
uid INTEGER,
year TEXT,
sts_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),
FOREIGN KEY (match_id) REFERENCES fact_matches(match_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS fact_match_players_ct (
match_id TEXT,
steam_id_64 TEXT,
team_id INTEGER,
kills INTEGER DEFAULT 0,
deaths INTEGER DEFAULT 0,
assists INTEGER DEFAULT 0,
headshot_count INTEGER DEFAULT 0,
kd_ratio REAL,
adr REAL,
rating REAL,
rating2 REAL,
rating3 REAL,
rws REAL,
mvp_count INTEGER DEFAULT 0,
elo_change REAL,
rank_score INTEGER,
is_win BOOLEAN,
kast REAL,
entry_kills INTEGER,
entry_deaths INTEGER,
awp_kills INTEGER,
clutch_1v1 INTEGER,
clutch_1v2 INTEGER,
clutch_1v3 INTEGER,
clutch_1v4 INTEGER,
clutch_1v5 INTEGER,
flash_assists INTEGER,
flash_duration REAL,
jump_count INTEGER,
damage_total INTEGER,
damage_received INTEGER,
damage_receive INTEGER,
damage_stats INTEGER,
assisted_kill INTEGER,
awp_kill INTEGER,
awp_kill_ct INTEGER,
awp_kill_t INTEGER,
benefit_kill INTEGER,
day TEXT,
defused_bomb INTEGER,
end_1v1 INTEGER,
end_1v2 INTEGER,
end_1v3 INTEGER,
end_1v4 INTEGER,
end_1v5 INTEGER,
explode_bomb INTEGER,
first_death INTEGER,
fd_ct INTEGER,
fd_t INTEGER,
first_kill INTEGER,
flash_enemy INTEGER,
flash_team INTEGER,
flash_team_time REAL,
flash_time REAL,
game_mode TEXT,
group_id INTEGER,
hold_total INTEGER,
id INTEGER,
is_highlight INTEGER,
is_most_1v2 INTEGER,
is_most_assist INTEGER,
is_most_awp INTEGER,
is_most_end INTEGER,
is_most_first_kill INTEGER,
is_most_headshot INTEGER,
is_most_jump INTEGER,
is_svp INTEGER,
is_tie INTEGER,
kill_1 INTEGER,
kill_2 INTEGER,
kill_3 INTEGER,
kill_4 INTEGER,
kill_5 INTEGER,
many_assists_cnt1 INTEGER,
many_assists_cnt2 INTEGER,
many_assists_cnt3 INTEGER,
many_assists_cnt4 INTEGER,
many_assists_cnt5 INTEGER,
map TEXT,
match_code TEXT,
match_mode TEXT,
match_team_id INTEGER,
match_time INTEGER,
per_headshot REAL,
perfect_kill INTEGER,
planted_bomb INTEGER,
revenge_kill INTEGER,
round_total INTEGER,
season TEXT,
team_kill INTEGER,
throw_harm INTEGER,
throw_harm_enemy INTEGER,
uid INTEGER,
year TEXT,
sts_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),
FOREIGN KEY (match_id) REFERENCES fact_matches(match_id) ON DELETE CASCADE
);
-- 5. Fact: Rounds
CREATE TABLE IF NOT EXISTS fact_rounds (
match_id TEXT,
round_num INTEGER,
winner_side TEXT CHECK(winner_side IN ('CT', 'T', 'None')),
win_reason INTEGER, -- Raw integer from source
win_reason_desc TEXT, -- Mapped description (e.g. 'TargetBombed')
duration REAL,
end_time_stamp TEXT,
ct_score INTEGER,
t_score INTEGER,
-- Leetify Specific
ct_money_start INTEGER,
t_money_start INTEGER,
PRIMARY KEY (match_id, round_num),
FOREIGN KEY (match_id) REFERENCES fact_matches(match_id) ON DELETE CASCADE
);
-- 6. Fact: Round Events (The largest table)
-- Unifies Kills, Bomb Events, etc.
CREATE TABLE IF NOT EXISTS fact_round_events (
event_id TEXT PRIMARY KEY, -- UUID
match_id TEXT,
round_num INTEGER,
event_type TEXT CHECK(event_type IN ('kill', 'bomb_plant', 'bomb_defuse', 'suicide', 'unknown')),
event_time INTEGER, -- Seconds from round start
-- Participants
attacker_steam_id TEXT,
victim_steam_id TEXT,
assister_steam_id TEXT,
flash_assist_steam_id TEXT,
trade_killer_steam_id TEXT,
-- Weapon & Context
weapon TEXT,
is_headshot BOOLEAN DEFAULT 0,
is_wallbang BOOLEAN DEFAULT 0,
is_blind BOOLEAN DEFAULT 0,
is_through_smoke BOOLEAN DEFAULT 0,
is_noscope BOOLEAN DEFAULT 0,
-- Spatial Data (From RoundList)
attacker_pos_x INTEGER,
attacker_pos_y INTEGER,
attacker_pos_z INTEGER,
victim_pos_x INTEGER,
victim_pos_y INTEGER,
victim_pos_z INTEGER,
-- Economy/Score Impact (From Leetify)
score_change_attacker REAL,
score_change_victim REAL,
FOREIGN KEY (match_id, round_num) REFERENCES fact_rounds(match_id, round_num) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_round_events_match ON fact_round_events(match_id);
CREATE INDEX IF NOT EXISTS idx_round_events_attacker ON fact_round_events(attacker_steam_id);
-- 7. Fact: Round Player Economy/Status
-- Snapshots of player state at round start/end
CREATE TABLE IF NOT EXISTS fact_round_player_economy (
match_id TEXT,
round_num INTEGER,
steam_id_64 TEXT,
side TEXT CHECK(side IN ('CT', 'T')),
start_money INTEGER,
equipment_value INTEGER,
-- Inventory Summary
main_weapon TEXT,
has_helmet BOOLEAN,
has_defuser BOOLEAN,
has_zeus BOOLEAN,
-- Round Performance Summary (Leetify)
round_performance_score REAL,
PRIMARY KEY (match_id, round_num, steam_id_64),
FOREIGN KEY (match_id, round_num) REFERENCES fact_rounds(match_id, round_num) ON DELETE CASCADE
);

Binary file not shown.

75
database/L3/README.md Normal file
View File

@@ -0,0 +1,75 @@
## basic、个人基础数据特征
1. 平均Rating每局
2. 平均KD值每局
3. 平均KAST每局
4. 平均RWS每局
5. 每局爆头击杀数
6. 爆头率(爆头击杀/总击杀)
7. 每局首杀次数
8. 每局首死次数
9. 首杀率(首杀次数/首遇交火次数)
10. 首死率(首死次数/首遇交火次数)
11. 每局2+杀/3+杀/4+杀/5杀次数多杀
12. 连续击杀累计次数(连杀)
15. **(New) 助攻次数 (assisted_kill)**
16. **(New) 无伤击杀 (perfect_kill)**
17. **(New) 复仇击杀 (revenge_kill)**
18. **(New) AWP击杀数 (awp_kill)**
19. **(New) 总跳跃次数 (jump_count)**
---
## 挖掘能力维度:
### 1、时间稳定序列特征 STA
1. 近30局平均Rating长期Rating
2. 胜局平均Rating
3. 败局平均Rating
4. Rating波动系数近10局Rating计算
5. 同一天内比赛时长与Rating相关性每2小时Rating变化率
6. 连续比赛局数与表现衰减率如第5局后vs前4局的KD变化
### 2、局内对抗能力特征 BAT
1. 对位最高Rating对手的KD差自身击杀-被该对手击杀)
2. 对位最低Rating对手的KD差自身击杀-被该对手击杀)
3. 对位所有对手的胜率(自身击杀>被击杀的对手占比)
4. 平均对枪成功率(对所有对手的对枪成功率求平均)
* ~~A. 对枪反应时间(遇敌到开火平均时长,需录像解析)~~ (Phase 5)
* B. 近/中/远距对枪占比及各自胜率 (仅 Classic 可行)
### 3、高压场景表现特征 HPS (High Pressure Scenario)
1. 1v1/1v2/1v3+残局胜率
2. 赛点12-12、12-11等残局胜率
3. 人数劣势时的平均存活时间/击杀数(少打多能力)
4. 队伍连续丢3+局后自身首杀率(压力下突破能力)
5. 队伍连续赢3+局后自身2+杀率(顺境多杀能力)
6. 受挫后状态下滑率(被刀/被虐泉后3回合内Rating下降值
7. 起势后状态提升率(关键残局/多杀后3回合内Rating上升值
8. 翻盘阶段KD提升值同上场景下自身KD与平均差值
9. 连续丢分抗压性连续丢4+局时自身KD与平均差值
### 4、手枪局专项特征 PTL (Pistol Round)
1. 手枪局首杀次数
2. 手枪局2+杀次数(多杀)
3. 手枪局连杀次数
4. 参与的手枪局胜率(round1 round13)
5. 手枪类武器KD
6. 手枪局道具使用效率(烟雾/闪光帮助队友击杀数/投掷次数)
### 5、阵营倾向T/CT特征 T/CT
1. CT方平均Rating
2. T方平均Rating
3. CT方首杀率
4. T方首杀率
5. CT方守点成功率负责区域未被突破的回合占比
6. T方突破成功率成功突破敌方首道防线的回合占比
7. CT/T方KD差值CT KD - T KD
8. **(New) 下包次数 (planted_bomb)**
9. **(New) 拆包次数 (defused_bomb)**
### 6、道具特征 UTIL
1. 手雷伤害 (`throw_harm`)
2. 闪光致盲时间 (`flash_time`, `flash_enemy_time`, `flash_team_time`)
3. 闪光致盲人数 (`flash_enemy`, `flash_team`)
4. 每局平均道具数量与使用率(烟雾、闪光、燃烧弹、手雷)

251
database/L3/schema.sql Normal file
View File

@@ -0,0 +1,251 @@
-- L3 Schema: Player Features Data Mart
-- Based on FeatureRDD.md
-- Granularity: One row per player (Aggregated Profile)
-- Note: Some features requiring complex Demo parsing (Phase 5) are omitted or reserved.
CREATE TABLE IF NOT EXISTS dm_player_features (
steam_id_64 TEXT PRIMARY KEY,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
total_matches INTEGER DEFAULT 0,
-- ==========================================
-- 0. Basic Features (Avg per match)
-- ==========================================
basic_avg_rating REAL,
basic_avg_kd REAL,
basic_avg_adr REAL,
basic_avg_kast REAL,
basic_avg_rws REAL,
basic_avg_headshot_kills REAL,
basic_headshot_rate REAL, -- Headshot kills / Total kills
basic_avg_first_kill REAL,
basic_avg_first_death REAL,
basic_first_kill_rate REAL, -- FK / (FK + FD) or FK / Opening Duels
basic_first_death_rate REAL,
basic_avg_kill_2 REAL,
basic_avg_kill_3 REAL,
basic_avg_kill_4 REAL,
basic_avg_kill_5 REAL,
basic_avg_assisted_kill REAL,
basic_avg_perfect_kill REAL,
basic_avg_revenge_kill REAL,
basic_avg_awp_kill REAL,
basic_avg_jump_count REAL,
basic_avg_knife_kill REAL,
basic_avg_zeus_kill REAL,
basic_zeus_pick_rate REAL,
basic_avg_mvps REAL,
basic_avg_plants REAL,
basic_avg_defuses REAL,
basic_avg_flash_assists REAL,
-- ==========================================
-- 1. STA: Stability & Time Series
-- ==========================================
sta_last_30_rating REAL,
sta_win_rating REAL,
sta_loss_rating REAL,
sta_rating_volatility REAL, -- StdDev of last 10 ratings
sta_time_rating_corr REAL, -- Correlation between match duration/time and rating
sta_fatigue_decay REAL, -- Perf drop in later matches of same day
-- ==========================================
-- 2. BAT: Battle / Duel Capabilities
-- ==========================================
bat_kd_diff_high_elo REAL,
bat_kd_diff_low_elo REAL,
-- bat_win_rate_vs_all REAL, -- Removed
bat_avg_duel_win_rate REAL,
bat_avg_duel_freq REAL,
-- Distance based stats (Placeholder for Classic data)
bat_win_rate_close REAL,
bat_win_rate_mid REAL,
bat_win_rate_far REAL,
-- ==========================================
-- 3. HPS: High Pressure Scenarios
-- ==========================================
hps_clutch_win_rate_1v1 REAL,
hps_clutch_win_rate_1v2 REAL,
hps_clutch_win_rate_1v3_plus REAL,
hps_match_point_win_rate REAL,
hps_undermanned_survival_time REAL,
hps_pressure_entry_rate REAL, -- FK rate when team losing streak
hps_momentum_multikill_rate REAL, -- Multi-kill rate when team winning streak
hps_tilt_rating_drop REAL, -- Rating drop after getting knifed/BM'd
hps_clutch_rating_rise REAL, -- Rating rise after clutch
hps_comeback_kd_diff REAL,
hps_losing_streak_kd_diff REAL,
-- ==========================================
-- 4. PTL: Pistol Round Specialist
-- ==========================================
ptl_pistol_kills REAL, -- Avg per pistol round? Or Total? Usually Avg per match or Rate
ptl_pistol_multikills REAL,
ptl_pistol_win_rate REAL, -- Personal win rate in pistol rounds
ptl_pistol_kd REAL,
ptl_pistol_util_efficiency REAL,
-- ==========================================
-- 5. T/CT: Side Preference
-- ==========================================
side_rating_ct REAL, -- Currently calculated as K/D
side_rating_t REAL,
side_kd_ct REAL, -- Explicit K/D
side_kd_t REAL,
side_win_rate_ct REAL, -- Round Win %
side_win_rate_t REAL,
side_first_kill_rate_ct REAL,
side_first_kill_rate_t REAL,
side_kd_diff_ct_t REAL, -- CT KD - T KD
-- New Side Comparisons
side_rating_diff_ct_t REAL,
-- ==========================================
-- 6. Party Size Performance
-- ==========================================
party_1_win_rate REAL,
party_1_rating REAL,
party_1_adr REAL,
party_2_win_rate REAL,
party_2_rating REAL,
party_2_adr REAL,
party_3_win_rate REAL,
party_3_rating REAL,
party_3_adr REAL,
party_4_win_rate REAL,
party_4_rating REAL,
party_4_adr REAL,
party_5_win_rate REAL,
party_5_rating REAL,
party_5_adr REAL,
-- ==========================================
-- 7. Rating Distribution (Performance Tiers)
-- ==========================================
rating_dist_carry_rate REAL, -- > 1.5
rating_dist_normal_rate REAL, -- 1.0 - 1.5
rating_dist_sacrifice_rate REAL, -- 0.6 - 1.0
rating_dist_sleeping_rate REAL, -- < 0.6
-- ==========================================
-- 8. ELO Stratification (Performance vs ELO)
-- ==========================================
elo_lt1200_rating REAL,
elo_1200_1400_rating REAL,
elo_1400_1600_rating REAL,
elo_1600_1800_rating REAL,
elo_1800_2000_rating REAL,
elo_gt2000_rating REAL,
-- ==========================================
-- 9. More Side Stats (Restored)
-- ==========================================
side_kast_ct REAL,
side_kast_t REAL,
side_rws_ct REAL,
side_rws_t REAL,
side_first_death_rate_ct REAL,
side_first_death_rate_t REAL,
side_multikill_rate_ct REAL,
side_multikill_rate_t REAL,
side_headshot_rate_ct REAL,
side_headshot_rate_t REAL,
side_defuses_ct REAL,
side_plants_t REAL,
side_planted_bomb_count INTEGER,
side_defused_bomb_count INTEGER,
-- ==========================================
-- 6. UTIL: Utility Usage
-- ==========================================
util_avg_nade_dmg REAL,
util_avg_flash_time REAL,
util_avg_flash_enemy REAL,
util_avg_flash_team 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,
score_eco REAL,
score_pace REAL,
-- ==========================================
-- 8. ECO: Economy Efficiency
-- ==========================================
eco_avg_damage_per_1k REAL,
eco_rating_eco_rounds REAL,
eco_kd_ratio REAL,
eco_avg_rounds REAL,
-- ==========================================
-- 9. PACE: Aggression & Trade
-- ==========================================
pace_avg_time_to_first_contact REAL,
pace_trade_kill_rate REAL,
pace_opening_kill_time REAL,
pace_avg_life_time REAL,
rd_phase_kill_early_share REAL,
rd_phase_kill_mid_share REAL,
rd_phase_kill_late_share REAL,
rd_phase_death_early_share REAL,
rd_phase_death_mid_share REAL,
rd_phase_death_late_share REAL,
rd_phase_kill_early_share_t REAL,
rd_phase_kill_mid_share_t REAL,
rd_phase_kill_late_share_t REAL,
rd_phase_kill_early_share_ct REAL,
rd_phase_kill_mid_share_ct REAL,
rd_phase_kill_late_share_ct REAL,
rd_phase_death_early_share_t REAL,
rd_phase_death_mid_share_t REAL,
rd_phase_death_late_share_t REAL,
rd_phase_death_early_share_ct REAL,
rd_phase_death_mid_share_ct REAL,
rd_phase_death_late_share_ct REAL,
rd_firstdeath_team_first_death_rounds INTEGER,
rd_firstdeath_team_first_death_win_rate REAL,
rd_invalid_death_rounds INTEGER,
rd_invalid_death_rate REAL,
rd_pressure_kpr_ratio REAL,
rd_pressure_perf_ratio REAL,
rd_pressure_rounds_down3 INTEGER,
rd_pressure_rounds_normal INTEGER,
rd_matchpoint_kpr_ratio REAL,
rd_matchpoint_perf_ratio REAL,
rd_matchpoint_rounds INTEGER,
rd_comeback_kill_share REAL,
rd_comeback_rounds INTEGER,
rd_trade_response_10s_rate REAL,
rd_weapon_top_json TEXT,
rd_roundtype_split_json TEXT,
map_stability_coef REAL
);
-- Optional: Detailed per-match feature table for time-series analysis
CREATE TABLE IF NOT EXISTS fact_match_features (
match_id TEXT,
steam_id_64 TEXT,
-- Snapshots of the 6 dimensions for this specific match
basic_rating REAL,
sta_trend_pre_match REAL, -- Rating trend entering this match
bat_duel_win_rate REAL,
hps_clutch_success INTEGER,
ptl_performance_score REAL,
PRIMARY KEY (match_id, steam_id_64)
);

BIN
database/Web/Web_App.sqlite Normal file

Binary file not shown.

View File

@@ -0,0 +1,564 @@
Category,Path,Types,Examples
ats/api/v1/activityInterface/fallActivityInfo,code,int,401
ats/api/v1/activityInterface/fallActivityInfo,message,string,User auth failed
ats/api/v1/activityInterface/fallActivityInfo,data,null,None
ats/api/v1/activityInterface/fallActivityInfo,timeStamp,int,1768931732; 1768931718; 1768931709
ats/api/v1/activityInterface/fallActivityInfo,status,bool,False
ats/api/v1/activityInterface/fallActivityInfo,traceId,string,c3d47b6d9a6bf7099b45af1b3f516370; 96e6a86453435f463f2ff8e0b0d7611b; 2e40738b400d90ea6ece7be0abe2de3c
ats/api/v1/activityInterface/fallActivityInfo,success,bool,False
ats/api/v1/activityInterface/fallActivityInfo,errcode,int,401
crane/http/api/data/match/{match_id},data.has_side_data_and_rating2,bool,True
crane/http/api/data/match/{match_id},data.main.demo_url,string,; https://hz-demo.5eplaycdn.com/pug/20260118/g161-20260118202243599083093_de_dust2.zip; https://hz-demo.5eplaycdn.com/pug/20260118/g161-20260118215640650728700_de_nuke.zip
crane/http/api/data/match/{match_id},data.main.end_time,int,1739528619; 1739526455; 1739625426
crane/http/api/data/match/{match_id},data.main.game_mode,int,6; 24; 103
crane/http/api/data/match/{match_id},data.main.game_name,string,; nspug_c; npug_c
crane/http/api/data/match/{match_id},data.main.group1_all_score,int,10; 9; 4
crane/http/api/data/match/{match_id},data.main.group1_change_elo,int,0
crane/http/api/data/match/{match_id},data.main.group1_fh_role,int,1
crane/http/api/data/match/{match_id},data.main.group1_fh_score,int,6; 2; 7
crane/http/api/data/match/{match_id},data.main.group1_origin_elo,"float, int",1628.1; 1616.55; 1573.79
crane/http/api/data/match/{match_id},data.main.group1_sh_role,int,0
crane/http/api/data/match/{match_id},data.main.group1_sh_score,int,6; 5; 4
crane/http/api/data/match/{match_id},data.main.group1_tid,int,0
crane/http/api/data/match/{match_id},data.main.group1_uids,string,"14869472,14888575,1326932,14869396,14889445; 14869472,14889445,14869396,18337753,1326932; 18337753,14869472,14869396,13889539,1326932"
crane/http/api/data/match/{match_id},data.main.group2_all_score,int,6; 5; 11
crane/http/api/data/match/{match_id},data.main.group2_change_elo,int,0
crane/http/api/data/match/{match_id},data.main.group2_fh_role,int,0
crane/http/api/data/match/{match_id},data.main.group2_fh_score,int,6; 10; 7
crane/http/api/data/match/{match_id},data.main.group2_origin_elo,"float, int",1617.02; 1594.69; 1610.97
crane/http/api/data/match/{match_id},data.main.group2_sh_role,int,1
crane/http/api/data/match/{match_id},data.main.group2_sh_score,int,6; 5; 4
crane/http/api/data/match/{match_id},data.main.group2_tid,int,0
crane/http/api/data/match/{match_id},data.main.group2_uids,string,"7866482,7976557,13918176,7998628,18857497; 12501578,20691317,17181895,19535157,13074509; 14889445,14869472,14888575,1326932,14869396"
crane/http/api/data/match/{match_id},data.main.id,int,232025624; 232016531; 232248045
crane/http/api/data/match/{match_id},data.main.knife_winner,int,0
crane/http/api/data/match/{match_id},data.main.knife_winner_role,int,0
crane/http/api/data/match/{match_id},data.main.location,string,hz; sz; cd
crane/http/api/data/match/{match_id},data.main.location_full,string,sh_pug-low; sz_pug-high; bj_pug-low_volc
crane/http/api/data/match/{match_id},data.main.map,string,de_nuke; de_ancient; de_dust2
crane/http/api/data/match/{match_id},data.main.map_desc,string,阿努比斯; 远古遗迹; 炙热沙城2
crane/http/api/data/match/{match_id},data.main.match_code,string,g161-20250215211846894242128; g161-20250214164955786323546; g161-20250214172202090993964
crane/http/api/data/match/{match_id},data.main.match_mode,int,9
crane/http/api/data/match/{match_id},data.main.match_winner,int,1; 2
crane/http/api/data/match/{match_id},data.main.most_1v2_uid,"<5eid>, int",14869396; 18337753; 16009709
crane/http/api/data/match/{match_id},data.main.most_assist_uid,"<5eid>, int",14869396; 13918176; 15820822
crane/http/api/data/match/{match_id},data.main.most_awp_uid,"<5eid>, int",12501578; 21610332; 18337753
crane/http/api/data/match/{match_id},data.main.most_end_uid,"<5eid>, int",12501578; 14889445; 14565365
crane/http/api/data/match/{match_id},data.main.most_first_kill_uid,"<5eid>, int",18337753; 19535157; 14888575
crane/http/api/data/match/{match_id},data.main.most_headshot_uid,"<5eid>, int",17181895; 1326932; 16009709
crane/http/api/data/match/{match_id},data.main.most_jump_uid,"<5eid>, int",12501578; 17746844; 17783270
crane/http/api/data/match/{match_id},data.main.mvp_uid,"<5eid>, int",19535157; 14888575; 14869472
crane/http/api/data/match/{match_id},data.main.round_total,int,24; 22; 17
crane/http/api/data/match/{match_id},data.main.season,string,2025s2; 2025s3; 2025s4
crane/http/api/data/match/{match_id},data.main.server_ip,string,
crane/http/api/data/match/{match_id},data.main.server_port,string,27015
crane/http/api/data/match/{match_id},data.main.start_time,int,1739523090; 1739625610; 1739623308
crane/http/api/data/match/{match_id},data.main.status,int,1
crane/http/api/data/match/{match_id},data.main.waiver,int,0
crane/http/api/data/match/{match_id},data.main.year,int,2026; 2025
crane/http/api/data/match/{match_id},data.main.cs_type,int,0
crane/http/api/data/match/{match_id},data.main.priority_show_type,int,3; 1; 2
crane/http/api/data/match/{match_id},data.main.pug10m_show_type,int,1; 0
crane/http/api/data/match/{match_id},data.main.credit_match_status,int,1; 0
crane/http/api/data/match/{match_id},data.group_N[].fight_any.adr,string,106.58; 100.22; 62.39
crane/http/api/data/match/{match_id},data.group_N[].fight_any.assist,string,2; 4; 3
crane/http/api/data/match/{match_id},data.group_N[].fight_any.awp_kill,string,2; 5; 4
crane/http/api/data/match/{match_id},data.group_N[].fight_any.benefit_kill,string,6; 5; 3
crane/http/api/data/match/{match_id},data.group_N[].fight_any.day,string,20250218; 20250217; 20250214
crane/http/api/data/match/{match_id},data.group_N[].fight_any.death,string,5; 16; 4
crane/http/api/data/match/{match_id},data.group_N[].fight_any.defused_bomb,string,2; 4; 3
crane/http/api/data/match/{match_id},data.group_N[].fight_any.end_1v1,string,2; 4; 3
crane/http/api/data/match/{match_id},data.group_N[].fight_any.end_1v2,string,2; 1; 0
crane/http/api/data/match/{match_id},data.group_N[].fight_any.end_1v3,string,2; 1; 0
crane/http/api/data/match/{match_id},data.group_N[].fight_any.end_1v4,string,1; 0
crane/http/api/data/match/{match_id},data.group_N[].fight_any.end_1v5,string,1; 0
crane/http/api/data/match/{match_id},data.group_N[].fight_any.explode_bomb,string,2; 5; 3
crane/http/api/data/match/{match_id},data.group_N[].fight_any.first_death,string,5; 4; 3
crane/http/api/data/match/{match_id},data.group_N[].fight_any.first_kill,string,2; 7; 4
crane/http/api/data/match/{match_id},data.group_N[].fight_any.flash_enemy,string,43; 7; 4
crane/http/api/data/match/{match_id},data.group_N[].fight_any.flash_enemy_time,string,7; 4; 15
crane/http/api/data/match/{match_id},data.group_N[].fight_any.flash_team,string,5; 4; 3
crane/http/api/data/match/{match_id},data.group_N[].fight_any.flash_team_time,string,21; 16; 4
crane/http/api/data/match/{match_id},data.group_N[].fight_any.flash_time,string,6; 21; 7
crane/http/api/data/match/{match_id},data.group_N[].fight_any.game_mode,string,6; 24; 103
crane/http/api/data/match/{match_id},data.group_N[].fight_any.group_id,string,1; 2
crane/http/api/data/match/{match_id},data.group_N[].fight_any.headshot,string,2; 4; 3
crane/http/api/data/match/{match_id},data.group_N[].fight_any.hold_total,string,0
crane/http/api/data/match/{match_id},data.group_N[].fight_any.id,string,1937230471; 168065372; 168065362
crane/http/api/data/match/{match_id},data.group_N[].fight_any.is_highlight,string,1; 0
crane/http/api/data/match/{match_id},data.group_N[].fight_any.is_most_1v2,string,1; 0
crane/http/api/data/match/{match_id},data.group_N[].fight_any.is_most_assist,string,1; 0
crane/http/api/data/match/{match_id},data.group_N[].fight_any.is_most_awp,string,1; 0
crane/http/api/data/match/{match_id},data.group_N[].fight_any.is_most_end,string,1; 0
crane/http/api/data/match/{match_id},data.group_N[].fight_any.is_most_first_kill,string,1; 0
crane/http/api/data/match/{match_id},data.group_N[].fight_any.is_most_headshot,string,1; 0
crane/http/api/data/match/{match_id},data.group_N[].fight_any.is_most_jump,string,1; 0
crane/http/api/data/match/{match_id},data.group_N[].fight_any.is_mvp,string,1; 0
crane/http/api/data/match/{match_id},data.group_N[].fight_any.is_svp,string,; 1
crane/http/api/data/match/{match_id},data.group_N[].fight_any.is_tie,string,1; 0
crane/http/api/data/match/{match_id},data.group_N[].fight_any.is_win,string,1; 0
crane/http/api/data/match/{match_id},data.group_N[].fight_any.jump_total,string,64; 33; 17
crane/http/api/data/match/{match_id},data.group_N[].fight_any.kast,string,0.82; 0.7; 0.74
crane/http/api/data/match/{match_id},data.group_N[].fight_any.kill,string,14; 21; 7
crane/http/api/data/match/{match_id},data.group_N[].fight_any.kill_1,string,5; 4; 3
crane/http/api/data/match/{match_id},data.group_N[].fight_any.kill_2,string,2; 5; 3
crane/http/api/data/match/{match_id},data.group_N[].fight_any.kill_3,string,2; 5; 3
crane/http/api/data/match/{match_id},data.group_N[].fight_any.kill_4,string,3; 2; 1
crane/http/api/data/match/{match_id},data.group_N[].fight_any.kill_5,string,2; 1; 0
crane/http/api/data/match/{match_id},data.group_N[].fight_any.map,string,de_nuke; de_ancient; de_dust2
crane/http/api/data/match/{match_id},data.group_N[].fight_any.match_code,string,g161-20250215211846894242128; g161-20250214164955786323546; g161-20250214172202090993964
crane/http/api/data/match/{match_id},data.group_N[].fight_any.match_mode,string,9
crane/http/api/data/match/{match_id},data.group_N[].fight_any.match_team_id,string,2; 4; 3
crane/http/api/data/match/{match_id},data.group_N[].fight_any.match_time,string,1739625526; 1739623222; 1739522995
crane/http/api/data/match/{match_id},data.group_N[].fight_any.per_headshot,string,0.44; 0.29; 0.21
crane/http/api/data/match/{match_id},data.group_N[].fight_any.planted_bomb,string,2; 5; 3
crane/http/api/data/match/{match_id},data.group_N[].fight_any.rating,string,0.89; 0.87; 1.21
crane/http/api/data/match/{match_id},data.group_N[].fight_any.many_assists_cnt1,string,6; 4; 3
crane/http/api/data/match/{match_id},data.group_N[].fight_any.many_assists_cnt2,string,2; 4; 3
crane/http/api/data/match/{match_id},data.group_N[].fight_any.many_assists_cnt3,string,1; 0
crane/http/api/data/match/{match_id},data.group_N[].fight_any.many_assists_cnt4,string,1; 0
crane/http/api/data/match/{match_id},data.group_N[].fight_any.many_assists_cnt5,string,0
crane/http/api/data/match/{match_id},data.group_N[].fight_any.perfect_kill,string,10; 17; 7
crane/http/api/data/match/{match_id},data.group_N[].fight_any.assisted_kill,string,5; 4; 3
crane/http/api/data/match/{match_id},data.group_N[].fight_any.rating2,string,1.24; 1.63; 0.87
crane/http/api/data/match/{match_id},data.group_N[].fight_any.rating3,string,2.15; -0.53; 0.00
crane/http/api/data/match/{match_id},data.group_N[].fight_any.revenge_kill,string,2; 7; 3
crane/http/api/data/match/{match_id},data.group_N[].fight_any.round_total,string,17; 5; 23
crane/http/api/data/match/{match_id},data.group_N[].fight_any.rws,string,8.41; 8.86; 6.02
crane/http/api/data/match/{match_id},data.group_N[].fight_any.season,string,2025s2; 2025s3; 2025s4
crane/http/api/data/match/{match_id},data.group_N[].fight_any.team_kill,string,1; 0
crane/http/api/data/match/{match_id},data.group_N[].fight_any.throw_harm,string,120; 119; 70
crane/http/api/data/match/{match_id},data.group_N[].fight_any.throw_harm_enemy,string,10; 147; 3
crane/http/api/data/match/{match_id},data.group_N[].fight_any.uid,"<5eid>, string",14026928; 15478597; 21610332
crane/http/api/data/match/{match_id},data.group_N[].fight_any.year,string,2026; 2025
crane/http/api/data/match/{match_id},data.group_N[].sts.data_tips_detail,int,-7; 0
crane/http/api/data/match/{match_id},data.group_N[].sts.challenge_status,int,1; 0
crane/http/api/data/match/{match_id},data.group_N[].sts.map_reward_status,int,1; 0
crane/http/api/data/match/{match_id},data.group_N[].sts.change_rank,int,-423964; -51338; -9561
crane/http/api/data/match/{match_id},data.group_N[].sts.origin_level_id,int,103; 108; 105
crane/http/api/data/match/{match_id},data.group_N[].sts.rank_change_type,int,5; 1; 0
crane/http/api/data/match/{match_id},data.group_N[].sts.star_num,int,0
crane/http/api/data/match/{match_id},data.group_N[].sts.origin_star_num,int,0
crane/http/api/data/match/{match_id},data.group_N[].sts.change_elo,string,-22.97; -36.73; -20.39
crane/http/api/data/match/{match_id},data.group_N[].sts.id,string,1930709265; 1930709271; 1930709266
crane/http/api/data/match/{match_id},data.group_N[].sts.level_id,string,103; 108; 104
crane/http/api/data/match/{match_id},data.group_N[].sts.match_code,string,g161-20250215211846894242128; g161-20250214164955786323546; g161-20250214172202090993964
crane/http/api/data/match/{match_id},data.group_N[].sts.match_flag,string,32; 2; 3
crane/http/api/data/match/{match_id},data.group_N[].sts.match_mode,string,9
crane/http/api/data/match/{match_id},data.group_N[].sts.match_status,string,3; 2; 0
crane/http/api/data/match/{match_id},data.group_N[].sts.origin_elo,string,1214.69; 1490.09; 1777.88
crane/http/api/data/match/{match_id},data.group_N[].sts.origin_match_total,string,269; 145; 63
crane/http/api/data/match/{match_id},data.group_N[].sts.placement,string,1; 0
crane/http/api/data/match/{match_id},data.group_N[].sts.punishment,string,1; 0
crane/http/api/data/match/{match_id},data.group_N[].sts.rank,string,3251068; 1410250; 2717215
crane/http/api/data/match/{match_id},data.group_N[].sts.origin_rank,string,2293251; 3241507; 1358912
crane/http/api/data/match/{match_id},data.group_N[].sts.season,string,2025s2; 2025s3; 2025s4
crane/http/api/data/match/{match_id},data.group_N[].sts.special_data,string,"; {""match_data"":[{""is_win"":-1,""match_id"":""g161-20250214164503716847890"",""match_status"":0,""change_elo"":-100.14724769911413},{""is_win"":1,""match_id"":""g161-20250214172202090993964"",""match_status"":0,""change_elo"":160.71161885810778},{""is_win"":0,""match_id"":"""",""match_status"":0,""change_elo"":0},{""is_win"":0,""match_id"":"""",""match_status"":0,""change_elo"":0},{""is_win"":0,""match_id"":"""",""match_status"":0,""change_elo"":0}]}; {""match_data"":[{""is_win"":-1,""match_id"":""g161-20250214164503716847890"",""match_status"":0,""change_elo"":-56.99773123078694},{""is_win"":1,""match_id"":""g161-20250214172202090993964"",""match_status"":0,""change_elo"":120.48283784034022},{""is_win"":0,""match_id"":"""",""match_status"":0,""change_elo"":0},{""is_win"":0,""match_id"":"""",""match_status"":0,""change_elo"":0},{""is_win"":0,""match_id"":"""",""match_status"":0,""change_elo"":0}]}"
crane/http/api/data/match/{match_id},data.group_N[].sts.uid,"<5eid>, string",14026928; 15478597; 21610332
crane/http/api/data/match/{match_id},data.group_N[].level_info.level_id,int,103; 108; 104
crane/http/api/data/match/{match_id},data.group_N[].level_info.level_name,string,C; E-; B-
crane/http/api/data/match/{match_id},data.group_N[].level_info.level_type,int,2; 1; 0
crane/http/api/data/match/{match_id},data.group_N[].level_info.star_num,int,0
crane/http/api/data/match/{match_id},data.group_N[].level_info.origin_star_num,int,0
crane/http/api/data/match/{match_id},data.group_N[].level_info.dragon_flag,int,0
crane/http/api/data/match/{match_id},data.group_N[].level_info.deduct_data.all_deduct_elo,int,0
crane/http/api/data/match/{match_id},data.group_N[].level_info.deduct_data.deduct_remain_elo,int,0
crane/http/api/data/match/{match_id},data.group_N[].level_info.deduct_data.deduct_elo,int,0
crane/http/api/data/match/{match_id},data.group_N[].level_info.special_data[].is_win,int,1; 0; -1
crane/http/api/data/match/{match_id},data.group_N[].level_info.special_data[].match_id,string,; g161-n-20250103203331443454143; g161-20250214164503716847890
crane/http/api/data/match/{match_id},data.group_N[].level_info.special_data[].match_status,int,2; 0
crane/http/api/data/match/{match_id},data.group_N[].level_info.special_data[].change_elo,"float, int",-100.14724769911413; 120.48283784034022; 160.71161885810778
crane/http/api/data/match/{match_id},data.group_N[].level_info.match_status,string,3; 2; 0
crane/http/api/data/match/{match_id},data.group_N[].level_info.match_flag,string,32; 2; 3
crane/http/api/data/match/{match_id},data.group_N[].level_info.change_elo,string,-22.97; -36.73; -20.39
crane/http/api/data/match/{match_id},data.group_N[].level_info.origin_elo,string,1214.69; 1490.09; 1777.88
crane/http/api/data/match/{match_id},data.group_N[].level_info.rank,string,3251068; 1410250; 2717215
crane/http/api/data/match/{match_id},data.group_N[].level_info.origin_rank,string,; 1444425; 1444424
crane/http/api/data/match/{match_id},data.group_N[].level_info.trigger_promotion,int,0
crane/http/api/data/match/{match_id},data.group_N[].level_info.special_bo,int,0
crane/http/api/data/match/{match_id},data.group_N[].level_info.rise_type,int,0
crane/http/api/data/match/{match_id},data.group_N[].level_info.tie_status,int,1; 0
crane/http/api/data/match/{match_id},data.group_N[].level_info.level_elo,int,800; 1700; 1400
crane/http/api/data/match/{match_id},data.group_N[].level_info.max_level,int,19; 30; 0
crane/http/api/data/match/{match_id},data.group_N[].level_info.origin_level_id,int,103; 108; 105
crane/http/api/data/match/{match_id},data.group_N[].level_info.origin_match_total,int,269; 145; 63
crane/http/api/data/match/{match_id},data.group_N[].level_info.star_info.change_small_star_num,int,0
crane/http/api/data/match/{match_id},data.group_N[].level_info.star_info.origin_small_star_num,int,0
crane/http/api/data/match/{match_id},data.group_N[].level_info.star_info.change_type,int,0
crane/http/api/data/match/{match_id},data.group_N[].level_info.star_info.now_small_star_num,int,0
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.uid,"<5eid>, int",14026928; 15478597; 21610332
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.username,"<5eid>, string",Sonka; 午夜伤心忧郁玫瑰; _陆小果
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.uuid,string,e6f87d93-ea92-11ee-9ce2-ec0d9a495494; 857f1c11-49c8-11ef-ac9f-ec0d9a7185e0; 4d9e3561-c373-11ef-848e-506b4bfa3106
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.email,string,
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.area,string,; 86; 852
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.mobile,string,
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.createdAt,int,1711362715; 1688270111; 1676517088
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.updatedAt,int,1767921452; 1768905111; 1767770760
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.profile.uid,"<5eid>, int",14026928; 15478597; 21610332
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.profile.domain,"<5eid>, string",123442; 1226wi4xw0ya; 15478597ldiutg
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.profile.nickname,string,
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.profile.avatarUrl,string,disguise/images/cf/b2/cfb285c3d8d1c905b648954e42dc8cb0.jpg; disguise/images/9d/94/9d94029776f802318860f1bbd19c3bca.jpg; prop/images/6f/c0/6fc0c147e94ea8b1432ed072c19b0991.png
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.profile.avatarAuditStatus,int,1; 0
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.profile.rgbAvatarUrl,string,; rgb_avatar/20230503/1fc76fccd31807fcb709d5d119522d32.rgb; rgb_avatar/20230803/d8b7ba92df98837791082ea3bcf6292b.rgb
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.profile.photoUrl,string,
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.profile.gender,int,1; 0
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.profile.birthday,int,1141315200; 904233600; 1077638400
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.profile.countryId,string,; kr; bm
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.profile.regionId,string,; 620000; 450000
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.profile.cityId,string,; 360400; 451100
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.profile.language,string,simplified-chinese;
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.profile.recommendUrl,string,
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.profile.groupId,int,0
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.profile.regSource,int,5; 4; 3
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.status.uid,"<5eid>, int",14026928; 15478597; 21610332
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.status.status,int,-4; -6; 0
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.status.expire,int,0
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.status.cancellationStatus,int,2; 0
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.status.newUser,int,0
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.status.loginBannedTime,int,1687524902; 1733207455; 0
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.status.anticheatType,int,0
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.status.flagStatus1,string,32; 4224; 24704
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.status.anticheatStatus,string,0
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.status.FlagHonor,string,65548; 93196; 2162700
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.status.PrivacyPolicyStatus,int,3; 4
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.status.csgoFrozenExptime,int,1766231693; 1767001958; 1760438129
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.platformExp.uid,"<5eid>, int",14026928; 15478597; 21610332
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.platformExp.level,int,22; 30; 25
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.platformExp.exp,int,12641; 32004; 13776
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.steam.uid,"<5eid>, int",14026928; 15478597; 21610332
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.steam.steamId,<steamid>,76561198812383596; 76561199812085195; 76561199187871084
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.steam.steamAccount,string,
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.steam.tradeUrl,string,
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.steam.rentSteamId,string,
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.trusted.uid,"<5eid>, int",14026928; 15478597; 21610332
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.trusted.credit,int,2550; 2990; 2033
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.trusted.creditLevel,int,3; 1; 4
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.trusted.score,int,100000; 97059; 96082
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.trusted.status,int,1; 0
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.trusted.creditStatus,int,1; 2
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.certify.uid,"<5eid>, int",14026928; 15478597; 21610332
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.certify.idType,int,0
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.certify.status,int,1; 0
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.certify.age,int,20; 22; 25
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.certify.realName,string,
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.certify.auditStatus,int,1
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.certify.gender,int,1; 0
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.identity.uid,"<5eid>, int",14026928; 15478597; 21610332
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.identity.type,int,0
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.identity.extras,string,
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.identity.status,int,0
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.identity.slogan,string,
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.identity.slogan_ext,string,
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.identity.live_url,string,
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.identity.live_type,int,0
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.usernameAuditStatus,int,1
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.Accid,string,263d37a4e1f87bce763e0d1b8ec03982; 07809f60e739d9c47648f4acda66667d; 879462b5de38dce892033adc138dec22
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.teamID,int,99868; 132671; 117796
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.domain,"<5eid>, string",123442; 1226wi4xw0ya; 15478597ldiutg
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_data.trumpetCount,int,2; 23; 1
crane/http/api/data/match/{match_id},data.group_N[].user_info.plus_info.is_plus,int,1; 0
crane/http/api/data/match/{match_id},data.group_N[].user_info.plus_info.plus_icon,string,images/act/e9cf57699303d9f6b18e465156fc6291.png; images/act/dae5c4cb98ceb6eeb1700f63c9ed14b7.png; images/act/09bbeb0f83a2f13419a0d75ac93e8a0c.png
crane/http/api/data/match/{match_id},data.group_N[].user_info.plus_info.plus_icon_short,string,images/act/d53f3bd55c836e057af230e2a138e94a.png; images/act/b7e90458420245283d9878a1e92b3a74.png; images/act/49b525ee6f74f423f3c2f0f913289824.png
crane/http/api/data/match/{match_id},data.group_N[].user_info.plus_info.vip_level,int,6; 5; 0
crane/http/api/data/match/{match_id},data.group_N[].user_info.plus_info.plus_grade,int,6; 2; 4
crane/http/api/data/match/{match_id},data.group_N[].user_info.plus_info.growth_score,int,540; 8196; 5458
crane/http/api/data/match/{match_id},data.group_N[].user_info.user_avatar_frame,null,None
crane/http/api/data/match/{match_id},data.group_N[].friend_relation,int,0
crane/http/api/data/match/{match_id},data.level_list[].elo,int,1000; 800; 900
crane/http/api/data/match/{match_id},data.level_list[].remark,string,800-899; 700-799; 900-999
crane/http/api/data/match/{match_id},data.level_list[].level_id,int,2; 5; 4
crane/http/api/data/match/{match_id},data.level_list[].level_name,string,E-; E+; N
crane/http/api/data/match/{match_id},data.level_list[].elo_type,int,9
crane/http/api/data/match/{match_id},data.level_list[].group_id,int,2; 5; 4
crane/http/api/data/match/{match_id},data.level_list[].level_image,string,
crane/http/api/data/match/{match_id},data.level_list[].rise_type,int,0
crane/http/api/data/match/{match_id},data.level_list[].shelves_status,int,1
crane/http/api/data/match/{match_id},data.room_card.id,string,310; 1326; 1309
crane/http/api/data/match/{match_id},data.room_card.category,string,48; 0
crane/http/api/data/match/{match_id},data.room_card.describe,string,; PLUS1专属房间卡片; 灵动小5房间卡片
crane/http/api/data/match/{match_id},data.room_card.name,string,; PLUS1专属房间卡片; 赛博少女
crane/http/api/data/match/{match_id},data.room_card.propTemplateId,string,133841; 134304; 1001
crane/http/api/data/match/{match_id},data.room_card.getWay,string,
crane/http/api/data/match/{match_id},data.room_card.onShelf,int,0
crane/http/api/data/match/{match_id},data.room_card.shelfAt,string,
crane/http/api/data/match/{match_id},data.room_card.getButton,int,0
crane/http/api/data/match/{match_id},data.room_card.getUrl,string,
crane/http/api/data/match/{match_id},data.room_card.attrs.flagAnimation,string,; https://oss-arena.5eplay.com/prop/videos/ba/23/ba2356a47ba93454a2de62c6fb817f82.avif; https://oss-arena.5eplay.com/prop/videos/59/79/59795c76433dfcadad8e6c02627e7d0f.avif
crane/http/api/data/match/{match_id},data.room_card.attrs.flagAnimationTime,string,; 2
crane/http/api/data/match/{match_id},data.room_card.attrs.flagViewUrl,string,https://oss-arena.5eplay.com/prop/images/49/36/49365bf9f2b7fe3ac6a7ded3656e092a.png; https://oss-arena.5eplay.com/prop/images/77/8c/778c698eb83d864e49e8a90bc8837a50.png; https://oss-arena.5eplay.com/prop/images/09/a9/09a93ce3f1476005f926298491188b21.png
crane/http/api/data/match/{match_id},data.room_card.attrs.flagViewVideo,string,; https://oss-arena.5eplay.com/prop/videos/6a/ae/6aaee03bbd40a093e5c00d6babe8e276.avif; https://oss-arena.5eplay.com/prop/videos/11/e8/11e8446dcd0202316605b08ab0b35466.avif
crane/http/api/data/match/{match_id},data.room_card.attrs.flagViewVideoTime,string,; 5; 2
crane/http/api/data/match/{match_id},data.room_card.attrs.getWay,string,升级至PLUS1级获取; 购买DANK1NG联名装扮获得; CS全新版本上线活动获得
crane/http/api/data/match/{match_id},data.room_card.attrs.mallJumpLink,string,
crane/http/api/data/match/{match_id},data.room_card.attrs.matchViewUrlLeft,string,https://oss-arena.5eplay.com/prop/images/13/fd/13fdb6d3b8dfaca3e8cd4987acc45606.png; https://oss-arena.5eplay.com/prop/images/1a/3a/1a3a7725e7bcb19f5a42858160e78bf8.png; https://oss-arena.5eplay.com/prop/images/f9/36/f9366f00cf41b3609a5b52194bf3b309.png
crane/http/api/data/match/{match_id},data.room_card.attrs.matchViewUrlRight,string,https://oss-arena.5eplay.com/prop/images/a9/da/a9da623d19cff27141cf6335507071ff.png; https://oss-arena.5eplay.com/prop/images/fa/45/fa45de3775d1bb75a6456c75ea454147.png; https://oss-arena.5eplay.com/prop/images/0c/f6/0cf657f3461dbd312a1083f546db9e54.png
crane/http/api/data/match/{match_id},data.room_card.attrs.mvpSettleAnimation,string,https://oss-arena.5eplay.com/dress/room_card/9e2ab6983d4ed9a6d23637abd9cd2152.mp4; https://oss-arena.5eplay.com/prop/videos/38/3e/383ec8198005d46da7194252353e7cf4.mp4; https://oss-arena.5eplay.com/prop/videos/14/05/14055e4e7cb184edb5f9849031e97231.mp4
crane/http/api/data/match/{match_id},data.room_card.attrs.mvpSettleColor,string,#9f1dea; #1ab5c6; #c89c68
crane/http/api/data/match/{match_id},data.room_card.attrs.mvpSettleViewAnimation,string,https://oss-arena.5eplay.com/dress/room_card/9e2ab6983d4ed9a6d23637abd9cd2152.mp4; https://oss-arena.5eplay.com/prop/videos/82/52/82526d004e9d0f41f3a3e7367b253003.mp4; https://oss-arena.5eplay.com/prop/videos/d2/bc/d2bc06fcc9e997c1d826537c145ea38e.mp4
crane/http/api/data/match/{match_id},data.room_card.attrs.pcImg,string,https://oss-arena.5eplay.com/prop/images/1a/47/1a47dda552d9501004d9043f637406d5.png; https://oss-arena.5eplay.com/prop/images/a1/e6/a1e6656596228734258d74b727a1aa48.png; https://oss-arena.5eplay.com/prop/images/d5/45/d545c6caf716a99a6725d24e37098078.png
crane/http/api/data/match/{match_id},data.room_card.attrs.sort,int,1; 2
crane/http/api/data/match/{match_id},data.room_card.attrs.templateId,int,2029; 1663; 2050
crane/http/api/data/match/{match_id},data.room_card.attrs.rarityLevel,int,3; 4; 2
crane/http/api/data/match/{match_id},data.room_card.attrs.sourceId,int,3; 11; 4
crane/http/api/data/match/{match_id},data.room_card.displayStatus,int,0
crane/http/api/data/match/{match_id},data.room_card.sysType,int,0
crane/http/api/data/match/{match_id},data.room_card.createdAt,string,
crane/http/api/data/match/{match_id},data.room_card.updatedAt,string,
crane/http/api/data/match/{match_id},data.round_sfui_type[],string,2; 5; 4
crane/http/api/data/match/{match_id},data.user_stats.map_level.map_exp,int,0
crane/http/api/data/match/{match_id},data.user_stats.map_level.add_exp,int,0
crane/http/api/data/match/{match_id},data.user_stats.plat_level.plat_level_exp,int,0
crane/http/api/data/match/{match_id},data.user_stats.plat_level.add_exp,int,0
crane/http/api/data/match/{match_id},data.group_1_team_info.team_id,string,
crane/http/api/data/match/{match_id},data.group_1_team_info.team_name,string,
crane/http/api/data/match/{match_id},data.group_1_team_info.logo_url,string,
crane/http/api/data/match/{match_id},data.group_1_team_info.team_domain,string,
crane/http/api/data/match/{match_id},data.group_1_team_info.team_tag,string,
crane/http/api/data/match/{match_id},data.group_2_team_info.team_id,string,
crane/http/api/data/match/{match_id},data.group_2_team_info.team_name,string,
crane/http/api/data/match/{match_id},data.group_2_team_info.logo_url,string,
crane/http/api/data/match/{match_id},data.group_2_team_info.team_domain,string,
crane/http/api/data/match/{match_id},data.group_2_team_info.team_tag,string,
crane/http/api/data/match/{match_id},data.treat_info.user_id,"<5eid>, int",13048069; 21150835
crane/http/api/data/match/{match_id},data.treat_info.user_data.uid,"<5eid>, int",13048069; 21150835
crane/http/api/data/match/{match_id},data.treat_info.user_data.username,string,熊出没之深情熊二; Royc灬Kerat
crane/http/api/data/match/{match_id},data.treat_info.user_data.uuid,string,c9caad5c-a9b3-11ef-848e-506b4bfa3106; 83376211-5c36-11ed-9ce2-ec0d9a495494
crane/http/api/data/match/{match_id},data.treat_info.user_data.email,string,
crane/http/api/data/match/{match_id},data.treat_info.user_data.area,string,86
crane/http/api/data/match/{match_id},data.treat_info.user_data.mobile,string,
crane/http/api/data/match/{match_id},data.treat_info.user_data.createdAt,int,1667562471; 1732377512
crane/http/api/data/match/{match_id},data.treat_info.user_data.updatedAt,int,1768911939; 1768904695
crane/http/api/data/match/{match_id},data.treat_info.user_data.profile.uid,"<5eid>, int",13048069; 21150835
crane/http/api/data/match/{match_id},data.treat_info.user_data.profile.domain,string,13048069yf1jto; 1123rqi1bfha
crane/http/api/data/match/{match_id},data.treat_info.user_data.profile.nickname,string,
crane/http/api/data/match/{match_id},data.treat_info.user_data.profile.avatarUrl,string,prop/images/3d/c4/3dc4259c07c31adb2439f7acbf1e565f.png; disguise/images/0e/84/0e84fdbb1da54953f1985bfb206604a5.png
crane/http/api/data/match/{match_id},data.treat_info.user_data.profile.avatarAuditStatus,int,0
crane/http/api/data/match/{match_id},data.treat_info.user_data.profile.rgbAvatarUrl,string,; rgb_avatar/20221129/f1ba34afe43c4fa38fd7dd129b0dc303.rgb
crane/http/api/data/match/{match_id},data.treat_info.user_data.profile.photoUrl,string,
crane/http/api/data/match/{match_id},data.treat_info.user_data.profile.gender,int,1; 0
crane/http/api/data/match/{match_id},data.treat_info.user_data.profile.birthday,int,0
crane/http/api/data/match/{match_id},data.treat_info.user_data.profile.countryId,string,; cn
crane/http/api/data/match/{match_id},data.treat_info.user_data.profile.regionId,string,
crane/http/api/data/match/{match_id},data.treat_info.user_data.profile.cityId,string,
crane/http/api/data/match/{match_id},data.treat_info.user_data.profile.language,string,simplified-chinese;
crane/http/api/data/match/{match_id},data.treat_info.user_data.profile.recommendUrl,string,
crane/http/api/data/match/{match_id},data.treat_info.user_data.profile.groupId,int,0
crane/http/api/data/match/{match_id},data.treat_info.user_data.profile.regSource,int,4; 0
crane/http/api/data/match/{match_id},data.treat_info.user_data.status.uid,"<5eid>, int",13048069; 21150835
crane/http/api/data/match/{match_id},data.treat_info.user_data.status.status,int,0
crane/http/api/data/match/{match_id},data.treat_info.user_data.status.expire,int,0
crane/http/api/data/match/{match_id},data.treat_info.user_data.status.cancellationStatus,int,0
crane/http/api/data/match/{match_id},data.treat_info.user_data.status.newUser,int,0
crane/http/api/data/match/{match_id},data.treat_info.user_data.status.loginBannedTime,int,0
crane/http/api/data/match/{match_id},data.treat_info.user_data.status.anticheatType,int,0
crane/http/api/data/match/{match_id},data.treat_info.user_data.status.flagStatus1,string,128
crane/http/api/data/match/{match_id},data.treat_info.user_data.status.anticheatStatus,string,0
crane/http/api/data/match/{match_id},data.treat_info.user_data.status.FlagHonor,string,1178636; 65548
crane/http/api/data/match/{match_id},data.treat_info.user_data.status.PrivacyPolicyStatus,int,4
crane/http/api/data/match/{match_id},data.treat_info.user_data.status.csgoFrozenExptime,int,1767707372; 1765545847
crane/http/api/data/match/{match_id},data.treat_info.user_data.platformExp.uid,"<5eid>, int",13048069; 21150835
crane/http/api/data/match/{match_id},data.treat_info.user_data.platformExp.level,int,29
crane/http/api/data/match/{match_id},data.treat_info.user_data.platformExp.exp,int,26803; 26522
crane/http/api/data/match/{match_id},data.treat_info.user_data.steam.uid,"<5eid>, int",13048069; 21150835
crane/http/api/data/match/{match_id},data.treat_info.user_data.steam.steamId,<steamid>,76561199192775594; 76561198290113126
crane/http/api/data/match/{match_id},data.treat_info.user_data.steam.steamAccount,string,
crane/http/api/data/match/{match_id},data.treat_info.user_data.steam.tradeUrl,string,
crane/http/api/data/match/{match_id},data.treat_info.user_data.steam.rentSteamId,string,
crane/http/api/data/match/{match_id},data.treat_info.user_data.trusted.uid,"<5eid>, int",13048069; 21150835
crane/http/api/data/match/{match_id},data.treat_info.user_data.trusted.credit,int,2200; 5919
crane/http/api/data/match/{match_id},data.treat_info.user_data.trusted.creditLevel,int,4
crane/http/api/data/match/{match_id},data.treat_info.user_data.trusted.score,int,100000
crane/http/api/data/match/{match_id},data.treat_info.user_data.trusted.status,int,1
crane/http/api/data/match/{match_id},data.treat_info.user_data.trusted.creditStatus,int,1
crane/http/api/data/match/{match_id},data.treat_info.user_data.certify.uid,"<5eid>, int",13048069; 21150835
crane/http/api/data/match/{match_id},data.treat_info.user_data.certify.idType,int,0
crane/http/api/data/match/{match_id},data.treat_info.user_data.certify.status,int,1
crane/http/api/data/match/{match_id},data.treat_info.user_data.certify.age,int,23; 42
crane/http/api/data/match/{match_id},data.treat_info.user_data.certify.realName,string,
crane/http/api/data/match/{match_id},data.treat_info.user_data.certify.auditStatus,int,1
crane/http/api/data/match/{match_id},data.treat_info.user_data.certify.gender,int,1
crane/http/api/data/match/{match_id},data.treat_info.user_data.identity.uid,"<5eid>, int",13048069; 21150835
crane/http/api/data/match/{match_id},data.treat_info.user_data.identity.type,int,0
crane/http/api/data/match/{match_id},data.treat_info.user_data.identity.extras,string,
crane/http/api/data/match/{match_id},data.treat_info.user_data.identity.status,int,0
crane/http/api/data/match/{match_id},data.treat_info.user_data.identity.slogan,string,
crane/http/api/data/match/{match_id},data.treat_info.user_data.identity.slogan_ext,string,
crane/http/api/data/match/{match_id},data.treat_info.user_data.identity.live_url,string,
crane/http/api/data/match/{match_id},data.treat_info.user_data.identity.live_type,int,0
crane/http/api/data/match/{match_id},data.treat_info.user_data.usernameAuditStatus,int,1
crane/http/api/data/match/{match_id},data.treat_info.user_data.Accid,string,57cd6b98be64949589a6cecf7d258cd1; d0d986c392c55c5d422fd2c46e4d6318
crane/http/api/data/match/{match_id},data.treat_info.user_data.teamID,int,0
crane/http/api/data/match/{match_id},data.treat_info.user_data.domain,string,13048069yf1jto; 1123rqi1bfha
crane/http/api/data/match/{match_id},data.treat_info.user_data.trumpetCount,int,3; 2442
crane/http/api/data/match/{match_id},data.season_type,int,0
crane/http/api/data/match/{match_id},code,int,0
crane/http/api/data/match/{match_id},message,string,操作成功
crane/http/api/data/match/{match_id},status,bool,True
crane/http/api/data/match/{match_id},timestamp,int,1768931731; 1768931718; 1768931708
crane/http/api/data/match/{match_id},trace_id,string,8ae4feeb19cc4ed3a24a8a00f056d023; 19582ac94190e3baff795cff50c7a6f3; 87794472a94e5e40be8e12bd116dad55
crane/http/api/data/match/{match_id},success,bool,True
crane/http/api/data/match/{match_id},errcode,int,0
crane/http/api/data/vip_plus_match_data/{match_id},data.<steamid>.fd_ct,int,2; 4; 3
crane/http/api/data/vip_plus_match_data/{match_id},data.<steamid>.fd_t,int,2; 4; 3
crane/http/api/data/vip_plus_match_data/{match_id},data.<steamid>.kast,"float, int",0.7; 0.65; 0.48
crane/http/api/data/vip_plus_match_data/{match_id},data.<steamid>.awp_kill,int,2; 5; 4
crane/http/api/data/vip_plus_match_data/{match_id},data.<steamid>.awp_kill_ct,int,5; 4; 3
crane/http/api/data/vip_plus_match_data/{match_id},data.<steamid>.awp_kill_t,int,2; 5; 4
crane/http/api/data/vip_plus_match_data/{match_id},data.<steamid>.damage_stats,int,3; 5; 50
crane/http/api/data/vip_plus_match_data/{match_id},data.<steamid>.damage_receive,int,0
crane/http/api/data/vip_plus_match_data/{match_id},code,int,0
crane/http/api/data/vip_plus_match_data/{match_id},message,string,操作成功
crane/http/api/data/vip_plus_match_data/{match_id},status,bool,True
crane/http/api/data/vip_plus_match_data/{match_id},timestamp,int,1768931714; 1768931732; 1768931710
crane/http/api/data/vip_plus_match_data/{match_id},trace_id,string,cff29d5dcdd6285b80d11bbb4a8a7da0; 6e7c0c0590b0e561c6c4c8d935ebb02c; 97c1377302559a8f5e01aedfeb208751
crane/http/api/data/vip_plus_match_data/{match_id},success,bool,True
crane/http/api/data/vip_plus_match_data/{match_id},errcode,int,0
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].round,int,2; 5; 4
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].t_money_group,int,3; 1; 4
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].ct_money_group,int,3; 1; 4
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].win_reason,int,2; 5; 4
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].bron_equipment.<steamid>[].Money,int,400; 200; 2900
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].bron_equipment.<steamid>[].WeaponName,string,weapon_flashbang; weapon_tec9; weapon_hegrenade
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].bron_equipment.<steamid>[].Weapon,int,22; 33; 37
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].player_t_score.<steamid>,"float, int",-21.459999999999997; -16.640000000000004; 4
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].player_ct_score.<steamid>,"float, int",17.099999999999994; 15.120000000000001; 27.507999999999996
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].player_bron_crash.<steamid>,int,4200; 3900; 800
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].begin_ts,string,2026-01-18T19:57:29+08:00; 2026-01-18T19:59:18+08:00; 2026-01-18T19:55:55+08:00
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].sfui_event.sfui_type,int,2; 5; 4
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].sfui_event.score_ct,int,2; 5; 4
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].sfui_event.score_t,int,2; 10; 3
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].end_ts,string,2026-01-18T19:54:37+08:00; 2026-01-18T19:57:22+08:00; 2026-01-18T19:59:11+08:00
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].ts_real,string,0001-01-01T00:00:00Z; 2026-01-18T19:54:06+08:00; 2026-01-18T19:54:04+08:00
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].ts,int,45; 48; 46
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].t_num,int,2; 5; 4
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].ct_num,int,2; 5; 4
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].event_type,int,3; 1; 4
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].kill_event.Ts,string,2026-01-18T19:54:06+08:00; 2026-01-18T19:54:04+08:00; 2026-01-18T19:53:57+08:00
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].kill_event.Killer,<steamid>,76561199787406643; 76561199032002725; 76561199078250590
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].kill_event.Victim,<steamid>,76561199388433802; 76561199032002725; 76561199250737526
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].kill_event.Weapon,int,6; 7; 5
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].kill_event.KillerTeam,int,1; 2
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].kill_event.KillerBot,bool,False
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].kill_event.VictimBot,bool,False
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].kill_event.WeaponName,string,usp_silencer; deagle; famas
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].kill_event.Headshot,bool,False; True
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].kill_event.Penetrated,bool,False; True
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].kill_event.ThroughSmoke,bool,False; True
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].kill_event.NoScope,bool,False; True
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].kill_event.AttackerBlind,bool,False; True
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].kill_event.Attackerinair,bool,False
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].twin,"float, int",0.143; 0.341; 0.557
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].c_twin,"float, int",0.44299999999999995; 0.471; 0.659
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].twin_change,"float, int",-0.21600000000000003; -0.19800000000000004; 0.19899999999999995
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].c_twin_change,"float, int",0.21600000000000003; 0.19800000000000004; 0.17099999999999993
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].killer_score_change.<steamid>.score,"float, int",17.099999999999994; 19.899999999999995; 19.800000000000004
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].victim_score_change.<steamid>.score,"float, int",-15.8; -19.899999999999995; -21.6
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].assist_killer_score_change.<steamid>.score,float,2.592; 6.63; 6.45
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].trade_score_change.<steamid>.score,float,2.2100000000000004; 3.16; 3.66
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].flash_assist_killer_score_change.<steamid>.score,float,1.1520000000000001; 2.9850000000000003; 1.5299999999999996
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].protect_gun_player_score_change.<steamid>.score,float,5.8999999999999995; 7.1000000000000005
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].protect_gun_enemy_score_change.<steamid>.score,float,-1.18; -1.4200000000000002
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].disconnect_player_score_change,null,None
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].disconnect_comp_score_change,null,None
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].round_end_fixed_score_change.<steamid>.score,"float, int",20; -0.6000000000000005; -100
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].win_reason,int,2; 5; 4
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].side_info.ct[],<steamid>,76561199032002725; 76561199078250590; 76561199076109761
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].side_info.t[],<steamid>,76561199787406643; 76561199388433802; 76561199250737526
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.player_scores.<steamid>,float,12.491187500000002; 1.5764999999999993; 2.073937500000001
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.player_t_scores.<steamid>,float,19.06; 6.3349999999999955; -8.872500000000002
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.player_ct_scores.<steamid>,float,-0.009666666666665455; 10.301583333333335; -2.9330833333333324
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_total,int,18; 30; 21
crane/http/api/match/leetify_rating/{match_id},data.leetify_data.player_round_scores.<steamid>.<round_n>,"float, int",32.347; -1.100000000000001; 20.040000000000006
crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict.<steamid>.uid,"<5eid>, int",14889445; 14869396; 14888575
crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict.<steamid>.uuid,string,13f7dc52-ea7c-11ed-9ce2-ec0d9a495494; e74f23a3-e8ae-11ed-9ce2-ec0d9a495494; 7ced32f8-ea70-11ed-9ce2-ec0d9a495494
crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict.<steamid>.username,string,刚拉; R1nging; RRRTINA
crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict.<steamid>.nickname,string,
crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict.<steamid>.reg_date,int,1683007881; 1683007342; 1683200437
crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict.<steamid>.username_spam_status,int,1
crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict.<steamid>.steamid_64,<steamid>,76561199032002725; 76561199078250590; 76561199076109761
crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict.<steamid>.avatar_url,string,disguise/images/6f/89/6f89b22633cb95df1754fd30573c5ad6.png; disguise/images/09/96/09961ea8fc45bed1c60157055a4c05c5.jpg; disguise/images/5d/41/5d4182b66a5004a974aee7501873164b.jpg
crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict.<steamid>.gender,int,1; 0
crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict.<steamid>.country_id,string,; cn
crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict.<steamid>.language,string,; simplified-chinese
crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict.<steamid>.domain,string,rrrtina; 14869396o9jm5g; dxw123452
crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict.<steamid>.credit,int,0
crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict.<steamid>.trusted_score,int,0
crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict.<steamid>.trusted_status,int,0
crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict.<steamid>.plus_info,null,None
crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict.<steamid>.region,int,0
crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict.<steamid>.province,int,0
crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict.<steamid>.province_name,string,
crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict.<steamid>.region_name,string,
crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict.<steamid>.college_id,int,0
crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict.<steamid>.status,int,0
crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict.<steamid>.identity,null,None
crane/http/api/match/leetify_rating/{match_id},code,int,0
crane/http/api/match/leetify_rating/{match_id},message,string,操作成功
crane/http/api/match/leetify_rating/{match_id},status,bool,True
crane/http/api/match/leetify_rating/{match_id},timestamp,int,1768833830; 1768833808; 1768833806
crane/http/api/match/leetify_rating/{match_id},trace_id,string,376e200283d19770bdef6dacf260f40f; a7dd6602d3aedb3017bb37727b5be75a; dab4013545b5581fbb089fb5c273d0a9
crane/http/api/match/leetify_rating/{match_id},success,bool,True
crane/http/api/match/leetify_rating/{match_id},errcode,int,0
crane/http/api/match/round/{match_id},data.round_list[].all_kill[].attacker.name,string,5E-Player 我有必胜卡组; 5E-Player 青青C原懒大王w; 5E-Player xiezhongxie1
crane/http/api/match/round/{match_id},data.round_list[].all_kill[].attacker.pos.x,int,734; 999; 1170
crane/http/api/match/round/{match_id},data.round_list[].all_kill[].attacker.pos.y,int,125; -77; -772
crane/http/api/match/round/{match_id},data.round_list[].all_kill[].attacker.pos.z,int,0
crane/http/api/match/round/{match_id},data.round_list[].all_kill[].attacker.steamid_64,<steamid>,76561198330488905; 76561199032002725; 76561199076109761
crane/http/api/match/round/{match_id},data.round_list[].all_kill[].attacker.team,int,1; 2
crane/http/api/match/round/{match_id},data.round_list[].all_kill[].attackerblind,bool,False; True
crane/http/api/match/round/{match_id},data.round_list[].all_kill[].headshot,bool,False; True
crane/http/api/match/round/{match_id},data.round_list[].all_kill[].noscope,bool,False; True
crane/http/api/match/round/{match_id},data.round_list[].all_kill[].pasttime,int,45; 20; 24
crane/http/api/match/round/{match_id},data.round_list[].all_kill[].penetrated,bool,False; True
crane/http/api/match/round/{match_id},data.round_list[].all_kill[].throughsmoke,bool,False; True
crane/http/api/match/round/{match_id},data.round_list[].all_kill[].victim.name,"<5eid>, string",5E-Player 青青C原懒大王w; 5E-Player 午夜伤心忧郁玫瑰; 5E-Player RRRTINA
crane/http/api/match/round/{match_id},data.round_list[].all_kill[].victim.pos.x,int,1218; 706; 1298
crane/http/api/match/round/{match_id},data.round_list[].all_kill[].victim.pos.y,int,627; 587; 219
crane/http/api/match/round/{match_id},data.round_list[].all_kill[].victim.pos.z,int,0
crane/http/api/match/round/{match_id},data.round_list[].all_kill[].victim.steamid_64,"<steamid>, string",76561199482118960; 76561199812085195; 76561199207654712
crane/http/api/match/round/{match_id},data.round_list[].all_kill[].victim.team,int,1; 2
crane/http/api/match/round/{match_id},data.round_list[].all_kill[].weapon,string,usp_silencer; mag7; famas
crane/http/api/match/round/{match_id},data.round_list[].kill.<steamid>[].attacker.name,string,5E-Player 我有必胜卡组; 5E-Player 青青C原懒大王w; 5E-Player xiezhongxie1
crane/http/api/match/round/{match_id},data.round_list[].kill.<steamid>[].attacker.pos.x,int,734; 999; 397
crane/http/api/match/round/{match_id},data.round_list[].kill.<steamid>[].attacker.pos.y,int,149; 125; -77
crane/http/api/match/round/{match_id},data.round_list[].kill.<steamid>[].attacker.pos.z,int,0
crane/http/api/match/round/{match_id},data.round_list[].kill.<steamid>[].attacker.steamid_64,<steamid>,76561198330488905; 76561199032002725; 76561199076109761
crane/http/api/match/round/{match_id},data.round_list[].kill.<steamid>[].attacker.team,int,1; 2
crane/http/api/match/round/{match_id},data.round_list[].kill.<steamid>[].attackerblind,bool,False; True
crane/http/api/match/round/{match_id},data.round_list[].kill.<steamid>[].headshot,bool,False; True
crane/http/api/match/round/{match_id},data.round_list[].kill.<steamid>[].noscope,bool,False; True
crane/http/api/match/round/{match_id},data.round_list[].kill.<steamid>[].pasttime,int,24; 57; 20
crane/http/api/match/round/{match_id},data.round_list[].kill.<steamid>[].penetrated,bool,False; True
crane/http/api/match/round/{match_id},data.round_list[].kill.<steamid>[].throughsmoke,bool,False; True
crane/http/api/match/round/{match_id},data.round_list[].kill.<steamid>[].victim.name,"<5eid>, string",5E-Player 青青C原懒大王w; 5E-Player 午夜伤心忧郁玫瑰; 5E-Player _陆小果
crane/http/api/match/round/{match_id},data.round_list[].kill.<steamid>[].victim.pos.x,int,1218; 706; 1298
crane/http/api/match/round/{match_id},data.round_list[].kill.<steamid>[].victim.pos.y,int,627; 587; 219
crane/http/api/match/round/{match_id},data.round_list[].kill.<steamid>[].victim.pos.z,int,0
crane/http/api/match/round/{match_id},data.round_list[].kill.<steamid>[].victim.steamid_64,"<steamid>, string",76561198812383596; 76561199812085195; 76561199187871084
crane/http/api/match/round/{match_id},data.round_list[].kill.<steamid>[].victim.team,int,1; 2
crane/http/api/match/round/{match_id},data.round_list[].kill.<steamid>[].weapon,string,usp_silencer; mag7; famas
crane/http/api/match/round/{match_id},data.round_list[].c4_event[].event_name,string,planted_c4
crane/http/api/match/round/{match_id},data.round_list[].c4_event[].location,string,
crane/http/api/match/round/{match_id},data.round_list[].c4_event[].name,string,5E-Player 我有必胜卡组; 5E-Player RRRTINA; 5E-Player 俺有鱼鱼蒸
crane/http/api/match/round/{match_id},data.round_list[].c4_event[].pasttime,int,45; 30; 31
crane/http/api/match/round/{match_id},data.round_list[].c4_event[].steamid_64,<steamid>,76561198330488905; 76561199812085195; 76561199207654712
crane/http/api/match/round/{match_id},data.round_list[].current_score.ct,int,2; 10; 1
crane/http/api/match/round/{match_id},data.round_list[].current_score.final_round_time,int,68; 79; 63
crane/http/api/match/round/{match_id},data.round_list[].current_score.pasttime,int,57; 47; 62
crane/http/api/match/round/{match_id},data.round_list[].current_score.t,int,2; 5; 4
crane/http/api/match/round/{match_id},data.round_list[].current_score.type,int,2; 5; 4
crane/http/api/match/round/{match_id},data.round_list[].death_list[],"<steamid>, string",76561198812383596; 76561199812085195; 76561199187871084
crane/http/api/match/round/{match_id},data.round_list[].equiped.<steamid>[],string,usp_silencer; kevlar(100); smokegrenade
crane/http/api/match/round/{match_id},data.round_list[].equiped.[],string,
crane/http/api/match/round/{match_id},data.weapon_list.defuser[],string,defuser
crane/http/api/match/round/{match_id},data.weapon_list.item[],string,incgrenade; flashbang; molotov
crane/http/api/match/round/{match_id},data.weapon_list.main_weapon[],string,sg556; awp; ssg08
crane/http/api/match/round/{match_id},data.weapon_list.other_item[],string,kevlar; helmet
crane/http/api/match/round/{match_id},data.weapon_list.secondary_weapon[],string,usp_silencer; deagle; glock
crane/http/api/match/round/{match_id},code,int,0
crane/http/api/match/round/{match_id},message,string,操作成功
crane/http/api/match/round/{match_id},status,bool,True
crane/http/api/match/round/{match_id},timestamp,int,1768931714; 1768931731; 1768931710
crane/http/api/match/round/{match_id},trace_id,string,c2ee4f45abd89f1c90dc1cc390d21d33; f85069de4d785710dd55301334ff03c0; 98335f4087c76de69e8aeda3ca767d6f
crane/http/api/match/round/{match_id},success,bool,True
crane/http/api/match/round/{match_id},errcode,int,0
1 Category Path Types Examples
2 ats/api/v1/activityInterface/fallActivityInfo code int 401
3 ats/api/v1/activityInterface/fallActivityInfo message string User auth failed
4 ats/api/v1/activityInterface/fallActivityInfo data null None
5 ats/api/v1/activityInterface/fallActivityInfo timeStamp int 1768931732; 1768931718; 1768931709
6 ats/api/v1/activityInterface/fallActivityInfo status bool False
7 ats/api/v1/activityInterface/fallActivityInfo traceId string c3d47b6d9a6bf7099b45af1b3f516370; 96e6a86453435f463f2ff8e0b0d7611b; 2e40738b400d90ea6ece7be0abe2de3c
8 ats/api/v1/activityInterface/fallActivityInfo success bool False
9 ats/api/v1/activityInterface/fallActivityInfo errcode int 401
10 crane/http/api/data/match/{match_id} data.has_side_data_and_rating2 bool True
11 crane/http/api/data/match/{match_id} data.main.demo_url string ; https://hz-demo.5eplaycdn.com/pug/20260118/g161-20260118202243599083093_de_dust2.zip; https://hz-demo.5eplaycdn.com/pug/20260118/g161-20260118215640650728700_de_nuke.zip
12 crane/http/api/data/match/{match_id} data.main.end_time int 1739528619; 1739526455; 1739625426
13 crane/http/api/data/match/{match_id} data.main.game_mode int 6; 24; 103
14 crane/http/api/data/match/{match_id} data.main.game_name string ; nspug_c; npug_c
15 crane/http/api/data/match/{match_id} data.main.group1_all_score int 10; 9; 4
16 crane/http/api/data/match/{match_id} data.main.group1_change_elo int 0
17 crane/http/api/data/match/{match_id} data.main.group1_fh_role int 1
18 crane/http/api/data/match/{match_id} data.main.group1_fh_score int 6; 2; 7
19 crane/http/api/data/match/{match_id} data.main.group1_origin_elo float, int 1628.1; 1616.55; 1573.79
20 crane/http/api/data/match/{match_id} data.main.group1_sh_role int 0
21 crane/http/api/data/match/{match_id} data.main.group1_sh_score int 6; 5; 4
22 crane/http/api/data/match/{match_id} data.main.group1_tid int 0
23 crane/http/api/data/match/{match_id} data.main.group1_uids string 14869472,14888575,1326932,14869396,14889445; 14869472,14889445,14869396,18337753,1326932; 18337753,14869472,14869396,13889539,1326932
24 crane/http/api/data/match/{match_id} data.main.group2_all_score int 6; 5; 11
25 crane/http/api/data/match/{match_id} data.main.group2_change_elo int 0
26 crane/http/api/data/match/{match_id} data.main.group2_fh_role int 0
27 crane/http/api/data/match/{match_id} data.main.group2_fh_score int 6; 10; 7
28 crane/http/api/data/match/{match_id} data.main.group2_origin_elo float, int 1617.02; 1594.69; 1610.97
29 crane/http/api/data/match/{match_id} data.main.group2_sh_role int 1
30 crane/http/api/data/match/{match_id} data.main.group2_sh_score int 6; 5; 4
31 crane/http/api/data/match/{match_id} data.main.group2_tid int 0
32 crane/http/api/data/match/{match_id} data.main.group2_uids string 7866482,7976557,13918176,7998628,18857497; 12501578,20691317,17181895,19535157,13074509; 14889445,14869472,14888575,1326932,14869396
33 crane/http/api/data/match/{match_id} data.main.id int 232025624; 232016531; 232248045
34 crane/http/api/data/match/{match_id} data.main.knife_winner int 0
35 crane/http/api/data/match/{match_id} data.main.knife_winner_role int 0
36 crane/http/api/data/match/{match_id} data.main.location string hz; sz; cd
37 crane/http/api/data/match/{match_id} data.main.location_full string sh_pug-low; sz_pug-high; bj_pug-low_volc
38 crane/http/api/data/match/{match_id} data.main.map string de_nuke; de_ancient; de_dust2
39 crane/http/api/data/match/{match_id} data.main.map_desc string 阿努比斯; 远古遗迹; 炙热沙城2
40 crane/http/api/data/match/{match_id} data.main.match_code string g161-20250215211846894242128; g161-20250214164955786323546; g161-20250214172202090993964
41 crane/http/api/data/match/{match_id} data.main.match_mode int 9
42 crane/http/api/data/match/{match_id} data.main.match_winner int 1; 2
43 crane/http/api/data/match/{match_id} data.main.most_1v2_uid <5eid>, int 14869396; 18337753; 16009709
44 crane/http/api/data/match/{match_id} data.main.most_assist_uid <5eid>, int 14869396; 13918176; 15820822
45 crane/http/api/data/match/{match_id} data.main.most_awp_uid <5eid>, int 12501578; 21610332; 18337753
46 crane/http/api/data/match/{match_id} data.main.most_end_uid <5eid>, int 12501578; 14889445; 14565365
47 crane/http/api/data/match/{match_id} data.main.most_first_kill_uid <5eid>, int 18337753; 19535157; 14888575
48 crane/http/api/data/match/{match_id} data.main.most_headshot_uid <5eid>, int 17181895; 1326932; 16009709
49 crane/http/api/data/match/{match_id} data.main.most_jump_uid <5eid>, int 12501578; 17746844; 17783270
50 crane/http/api/data/match/{match_id} data.main.mvp_uid <5eid>, int 19535157; 14888575; 14869472
51 crane/http/api/data/match/{match_id} data.main.round_total int 24; 22; 17
52 crane/http/api/data/match/{match_id} data.main.season string 2025s2; 2025s3; 2025s4
53 crane/http/api/data/match/{match_id} data.main.server_ip string
54 crane/http/api/data/match/{match_id} data.main.server_port string 27015
55 crane/http/api/data/match/{match_id} data.main.start_time int 1739523090; 1739625610; 1739623308
56 crane/http/api/data/match/{match_id} data.main.status int 1
57 crane/http/api/data/match/{match_id} data.main.waiver int 0
58 crane/http/api/data/match/{match_id} data.main.year int 2026; 2025
59 crane/http/api/data/match/{match_id} data.main.cs_type int 0
60 crane/http/api/data/match/{match_id} data.main.priority_show_type int 3; 1; 2
61 crane/http/api/data/match/{match_id} data.main.pug10m_show_type int 1; 0
62 crane/http/api/data/match/{match_id} data.main.credit_match_status int 1; 0
63 crane/http/api/data/match/{match_id} data.group_N[].fight_any.adr string 106.58; 100.22; 62.39
64 crane/http/api/data/match/{match_id} data.group_N[].fight_any.assist string 2; 4; 3
65 crane/http/api/data/match/{match_id} data.group_N[].fight_any.awp_kill string 2; 5; 4
66 crane/http/api/data/match/{match_id} data.group_N[].fight_any.benefit_kill string 6; 5; 3
67 crane/http/api/data/match/{match_id} data.group_N[].fight_any.day string 20250218; 20250217; 20250214
68 crane/http/api/data/match/{match_id} data.group_N[].fight_any.death string 5; 16; 4
69 crane/http/api/data/match/{match_id} data.group_N[].fight_any.defused_bomb string 2; 4; 3
70 crane/http/api/data/match/{match_id} data.group_N[].fight_any.end_1v1 string 2; 4; 3
71 crane/http/api/data/match/{match_id} data.group_N[].fight_any.end_1v2 string 2; 1; 0
72 crane/http/api/data/match/{match_id} data.group_N[].fight_any.end_1v3 string 2; 1; 0
73 crane/http/api/data/match/{match_id} data.group_N[].fight_any.end_1v4 string 1; 0
74 crane/http/api/data/match/{match_id} data.group_N[].fight_any.end_1v5 string 1; 0
75 crane/http/api/data/match/{match_id} data.group_N[].fight_any.explode_bomb string 2; 5; 3
76 crane/http/api/data/match/{match_id} data.group_N[].fight_any.first_death string 5; 4; 3
77 crane/http/api/data/match/{match_id} data.group_N[].fight_any.first_kill string 2; 7; 4
78 crane/http/api/data/match/{match_id} data.group_N[].fight_any.flash_enemy string 43; 7; 4
79 crane/http/api/data/match/{match_id} data.group_N[].fight_any.flash_enemy_time string 7; 4; 15
80 crane/http/api/data/match/{match_id} data.group_N[].fight_any.flash_team string 5; 4; 3
81 crane/http/api/data/match/{match_id} data.group_N[].fight_any.flash_team_time string 21; 16; 4
82 crane/http/api/data/match/{match_id} data.group_N[].fight_any.flash_time string 6; 21; 7
83 crane/http/api/data/match/{match_id} data.group_N[].fight_any.game_mode string 6; 24; 103
84 crane/http/api/data/match/{match_id} data.group_N[].fight_any.group_id string 1; 2
85 crane/http/api/data/match/{match_id} data.group_N[].fight_any.headshot string 2; 4; 3
86 crane/http/api/data/match/{match_id} data.group_N[].fight_any.hold_total string 0
87 crane/http/api/data/match/{match_id} data.group_N[].fight_any.id string 1937230471; 168065372; 168065362
88 crane/http/api/data/match/{match_id} data.group_N[].fight_any.is_highlight string 1; 0
89 crane/http/api/data/match/{match_id} data.group_N[].fight_any.is_most_1v2 string 1; 0
90 crane/http/api/data/match/{match_id} data.group_N[].fight_any.is_most_assist string 1; 0
91 crane/http/api/data/match/{match_id} data.group_N[].fight_any.is_most_awp string 1; 0
92 crane/http/api/data/match/{match_id} data.group_N[].fight_any.is_most_end string 1; 0
93 crane/http/api/data/match/{match_id} data.group_N[].fight_any.is_most_first_kill string 1; 0
94 crane/http/api/data/match/{match_id} data.group_N[].fight_any.is_most_headshot string 1; 0
95 crane/http/api/data/match/{match_id} data.group_N[].fight_any.is_most_jump string 1; 0
96 crane/http/api/data/match/{match_id} data.group_N[].fight_any.is_mvp string 1; 0
97 crane/http/api/data/match/{match_id} data.group_N[].fight_any.is_svp string ; 1
98 crane/http/api/data/match/{match_id} data.group_N[].fight_any.is_tie string 1; 0
99 crane/http/api/data/match/{match_id} data.group_N[].fight_any.is_win string 1; 0
100 crane/http/api/data/match/{match_id} data.group_N[].fight_any.jump_total string 64; 33; 17
101 crane/http/api/data/match/{match_id} data.group_N[].fight_any.kast string 0.82; 0.7; 0.74
102 crane/http/api/data/match/{match_id} data.group_N[].fight_any.kill string 14; 21; 7
103 crane/http/api/data/match/{match_id} data.group_N[].fight_any.kill_1 string 5; 4; 3
104 crane/http/api/data/match/{match_id} data.group_N[].fight_any.kill_2 string 2; 5; 3
105 crane/http/api/data/match/{match_id} data.group_N[].fight_any.kill_3 string 2; 5; 3
106 crane/http/api/data/match/{match_id} data.group_N[].fight_any.kill_4 string 3; 2; 1
107 crane/http/api/data/match/{match_id} data.group_N[].fight_any.kill_5 string 2; 1; 0
108 crane/http/api/data/match/{match_id} data.group_N[].fight_any.map string de_nuke; de_ancient; de_dust2
109 crane/http/api/data/match/{match_id} data.group_N[].fight_any.match_code string g161-20250215211846894242128; g161-20250214164955786323546; g161-20250214172202090993964
110 crane/http/api/data/match/{match_id} data.group_N[].fight_any.match_mode string 9
111 crane/http/api/data/match/{match_id} data.group_N[].fight_any.match_team_id string 2; 4; 3
112 crane/http/api/data/match/{match_id} data.group_N[].fight_any.match_time string 1739625526; 1739623222; 1739522995
113 crane/http/api/data/match/{match_id} data.group_N[].fight_any.per_headshot string 0.44; 0.29; 0.21
114 crane/http/api/data/match/{match_id} data.group_N[].fight_any.planted_bomb string 2; 5; 3
115 crane/http/api/data/match/{match_id} data.group_N[].fight_any.rating string 0.89; 0.87; 1.21
116 crane/http/api/data/match/{match_id} data.group_N[].fight_any.many_assists_cnt1 string 6; 4; 3
117 crane/http/api/data/match/{match_id} data.group_N[].fight_any.many_assists_cnt2 string 2; 4; 3
118 crane/http/api/data/match/{match_id} data.group_N[].fight_any.many_assists_cnt3 string 1; 0
119 crane/http/api/data/match/{match_id} data.group_N[].fight_any.many_assists_cnt4 string 1; 0
120 crane/http/api/data/match/{match_id} data.group_N[].fight_any.many_assists_cnt5 string 0
121 crane/http/api/data/match/{match_id} data.group_N[].fight_any.perfect_kill string 10; 17; 7
122 crane/http/api/data/match/{match_id} data.group_N[].fight_any.assisted_kill string 5; 4; 3
123 crane/http/api/data/match/{match_id} data.group_N[].fight_any.rating2 string 1.24; 1.63; 0.87
124 crane/http/api/data/match/{match_id} data.group_N[].fight_any.rating3 string 2.15; -0.53; 0.00
125 crane/http/api/data/match/{match_id} data.group_N[].fight_any.revenge_kill string 2; 7; 3
126 crane/http/api/data/match/{match_id} data.group_N[].fight_any.round_total string 17; 5; 23
127 crane/http/api/data/match/{match_id} data.group_N[].fight_any.rws string 8.41; 8.86; 6.02
128 crane/http/api/data/match/{match_id} data.group_N[].fight_any.season string 2025s2; 2025s3; 2025s4
129 crane/http/api/data/match/{match_id} data.group_N[].fight_any.team_kill string 1; 0
130 crane/http/api/data/match/{match_id} data.group_N[].fight_any.throw_harm string 120; 119; 70
131 crane/http/api/data/match/{match_id} data.group_N[].fight_any.throw_harm_enemy string 10; 147; 3
132 crane/http/api/data/match/{match_id} data.group_N[].fight_any.uid <5eid>, string 14026928; 15478597; 21610332
133 crane/http/api/data/match/{match_id} data.group_N[].fight_any.year string 2026; 2025
134 crane/http/api/data/match/{match_id} data.group_N[].sts.data_tips_detail int -7; 0
135 crane/http/api/data/match/{match_id} data.group_N[].sts.challenge_status int 1; 0
136 crane/http/api/data/match/{match_id} data.group_N[].sts.map_reward_status int 1; 0
137 crane/http/api/data/match/{match_id} data.group_N[].sts.change_rank int -423964; -51338; -9561
138 crane/http/api/data/match/{match_id} data.group_N[].sts.origin_level_id int 103; 108; 105
139 crane/http/api/data/match/{match_id} data.group_N[].sts.rank_change_type int 5; 1; 0
140 crane/http/api/data/match/{match_id} data.group_N[].sts.star_num int 0
141 crane/http/api/data/match/{match_id} data.group_N[].sts.origin_star_num int 0
142 crane/http/api/data/match/{match_id} data.group_N[].sts.change_elo string -22.97; -36.73; -20.39
143 crane/http/api/data/match/{match_id} data.group_N[].sts.id string 1930709265; 1930709271; 1930709266
144 crane/http/api/data/match/{match_id} data.group_N[].sts.level_id string 103; 108; 104
145 crane/http/api/data/match/{match_id} data.group_N[].sts.match_code string g161-20250215211846894242128; g161-20250214164955786323546; g161-20250214172202090993964
146 crane/http/api/data/match/{match_id} data.group_N[].sts.match_flag string 32; 2; 3
147 crane/http/api/data/match/{match_id} data.group_N[].sts.match_mode string 9
148 crane/http/api/data/match/{match_id} data.group_N[].sts.match_status string 3; 2; 0
149 crane/http/api/data/match/{match_id} data.group_N[].sts.origin_elo string 1214.69; 1490.09; 1777.88
150 crane/http/api/data/match/{match_id} data.group_N[].sts.origin_match_total string 269; 145; 63
151 crane/http/api/data/match/{match_id} data.group_N[].sts.placement string 1; 0
152 crane/http/api/data/match/{match_id} data.group_N[].sts.punishment string 1; 0
153 crane/http/api/data/match/{match_id} data.group_N[].sts.rank string 3251068; 1410250; 2717215
154 crane/http/api/data/match/{match_id} data.group_N[].sts.origin_rank string 2293251; 3241507; 1358912
155 crane/http/api/data/match/{match_id} data.group_N[].sts.season string 2025s2; 2025s3; 2025s4
156 crane/http/api/data/match/{match_id} data.group_N[].sts.special_data string ; {"match_data":[{"is_win":-1,"match_id":"g161-20250214164503716847890","match_status":0,"change_elo":-100.14724769911413},{"is_win":1,"match_id":"g161-20250214172202090993964","match_status":0,"change_elo":160.71161885810778},{"is_win":0,"match_id":"","match_status":0,"change_elo":0},{"is_win":0,"match_id":"","match_status":0,"change_elo":0},{"is_win":0,"match_id":"","match_status":0,"change_elo":0}]}; {"match_data":[{"is_win":-1,"match_id":"g161-20250214164503716847890","match_status":0,"change_elo":-56.99773123078694},{"is_win":1,"match_id":"g161-20250214172202090993964","match_status":0,"change_elo":120.48283784034022},{"is_win":0,"match_id":"","match_status":0,"change_elo":0},{"is_win":0,"match_id":"","match_status":0,"change_elo":0},{"is_win":0,"match_id":"","match_status":0,"change_elo":0}]}
157 crane/http/api/data/match/{match_id} data.group_N[].sts.uid <5eid>, string 14026928; 15478597; 21610332
158 crane/http/api/data/match/{match_id} data.group_N[].level_info.level_id int 103; 108; 104
159 crane/http/api/data/match/{match_id} data.group_N[].level_info.level_name string C; E-; B-
160 crane/http/api/data/match/{match_id} data.group_N[].level_info.level_type int 2; 1; 0
161 crane/http/api/data/match/{match_id} data.group_N[].level_info.star_num int 0
162 crane/http/api/data/match/{match_id} data.group_N[].level_info.origin_star_num int 0
163 crane/http/api/data/match/{match_id} data.group_N[].level_info.dragon_flag int 0
164 crane/http/api/data/match/{match_id} data.group_N[].level_info.deduct_data.all_deduct_elo int 0
165 crane/http/api/data/match/{match_id} data.group_N[].level_info.deduct_data.deduct_remain_elo int 0
166 crane/http/api/data/match/{match_id} data.group_N[].level_info.deduct_data.deduct_elo int 0
167 crane/http/api/data/match/{match_id} data.group_N[].level_info.special_data[].is_win int 1; 0; -1
168 crane/http/api/data/match/{match_id} data.group_N[].level_info.special_data[].match_id string ; g161-n-20250103203331443454143; g161-20250214164503716847890
169 crane/http/api/data/match/{match_id} data.group_N[].level_info.special_data[].match_status int 2; 0
170 crane/http/api/data/match/{match_id} data.group_N[].level_info.special_data[].change_elo float, int -100.14724769911413; 120.48283784034022; 160.71161885810778
171 crane/http/api/data/match/{match_id} data.group_N[].level_info.match_status string 3; 2; 0
172 crane/http/api/data/match/{match_id} data.group_N[].level_info.match_flag string 32; 2; 3
173 crane/http/api/data/match/{match_id} data.group_N[].level_info.change_elo string -22.97; -36.73; -20.39
174 crane/http/api/data/match/{match_id} data.group_N[].level_info.origin_elo string 1214.69; 1490.09; 1777.88
175 crane/http/api/data/match/{match_id} data.group_N[].level_info.rank string 3251068; 1410250; 2717215
176 crane/http/api/data/match/{match_id} data.group_N[].level_info.origin_rank string ; 1444425; 1444424
177 crane/http/api/data/match/{match_id} data.group_N[].level_info.trigger_promotion int 0
178 crane/http/api/data/match/{match_id} data.group_N[].level_info.special_bo int 0
179 crane/http/api/data/match/{match_id} data.group_N[].level_info.rise_type int 0
180 crane/http/api/data/match/{match_id} data.group_N[].level_info.tie_status int 1; 0
181 crane/http/api/data/match/{match_id} data.group_N[].level_info.level_elo int 800; 1700; 1400
182 crane/http/api/data/match/{match_id} data.group_N[].level_info.max_level int 19; 30; 0
183 crane/http/api/data/match/{match_id} data.group_N[].level_info.origin_level_id int 103; 108; 105
184 crane/http/api/data/match/{match_id} data.group_N[].level_info.origin_match_total int 269; 145; 63
185 crane/http/api/data/match/{match_id} data.group_N[].level_info.star_info.change_small_star_num int 0
186 crane/http/api/data/match/{match_id} data.group_N[].level_info.star_info.origin_small_star_num int 0
187 crane/http/api/data/match/{match_id} data.group_N[].level_info.star_info.change_type int 0
188 crane/http/api/data/match/{match_id} data.group_N[].level_info.star_info.now_small_star_num int 0
189 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.uid <5eid>, int 14026928; 15478597; 21610332
190 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.username <5eid>, string Sonka; 午夜伤心忧郁玫瑰; _陆小果
191 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.uuid string e6f87d93-ea92-11ee-9ce2-ec0d9a495494; 857f1c11-49c8-11ef-ac9f-ec0d9a7185e0; 4d9e3561-c373-11ef-848e-506b4bfa3106
192 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.email string
193 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.area string ; 86; 852
194 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.mobile string
195 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.createdAt int 1711362715; 1688270111; 1676517088
196 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.updatedAt int 1767921452; 1768905111; 1767770760
197 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.profile.uid <5eid>, int 14026928; 15478597; 21610332
198 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.profile.domain <5eid>, string 123442; 1226wi4xw0ya; 15478597ldiutg
199 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.profile.nickname string
200 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.profile.avatarUrl string disguise/images/cf/b2/cfb285c3d8d1c905b648954e42dc8cb0.jpg; disguise/images/9d/94/9d94029776f802318860f1bbd19c3bca.jpg; prop/images/6f/c0/6fc0c147e94ea8b1432ed072c19b0991.png
201 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.profile.avatarAuditStatus int 1; 0
202 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.profile.rgbAvatarUrl string ; rgb_avatar/20230503/1fc76fccd31807fcb709d5d119522d32.rgb; rgb_avatar/20230803/d8b7ba92df98837791082ea3bcf6292b.rgb
203 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.profile.photoUrl string
204 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.profile.gender int 1; 0
205 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.profile.birthday int 1141315200; 904233600; 1077638400
206 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.profile.countryId string ; kr; bm
207 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.profile.regionId string ; 620000; 450000
208 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.profile.cityId string ; 360400; 451100
209 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.profile.language string simplified-chinese;
210 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.profile.recommendUrl string
211 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.profile.groupId int 0
212 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.profile.regSource int 5; 4; 3
213 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.status.uid <5eid>, int 14026928; 15478597; 21610332
214 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.status.status int -4; -6; 0
215 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.status.expire int 0
216 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.status.cancellationStatus int 2; 0
217 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.status.newUser int 0
218 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.status.loginBannedTime int 1687524902; 1733207455; 0
219 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.status.anticheatType int 0
220 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.status.flagStatus1 string 32; 4224; 24704
221 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.status.anticheatStatus string 0
222 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.status.FlagHonor string 65548; 93196; 2162700
223 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.status.PrivacyPolicyStatus int 3; 4
224 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.status.csgoFrozenExptime int 1766231693; 1767001958; 1760438129
225 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.platformExp.uid <5eid>, int 14026928; 15478597; 21610332
226 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.platformExp.level int 22; 30; 25
227 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.platformExp.exp int 12641; 32004; 13776
228 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.steam.uid <5eid>, int 14026928; 15478597; 21610332
229 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.steam.steamId <steamid> 76561198812383596; 76561199812085195; 76561199187871084
230 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.steam.steamAccount string
231 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.steam.tradeUrl string
232 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.steam.rentSteamId string
233 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.trusted.uid <5eid>, int 14026928; 15478597; 21610332
234 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.trusted.credit int 2550; 2990; 2033
235 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.trusted.creditLevel int 3; 1; 4
236 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.trusted.score int 100000; 97059; 96082
237 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.trusted.status int 1; 0
238 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.trusted.creditStatus int 1; 2
239 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.certify.uid <5eid>, int 14026928; 15478597; 21610332
240 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.certify.idType int 0
241 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.certify.status int 1; 0
242 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.certify.age int 20; 22; 25
243 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.certify.realName string
244 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.certify.auditStatus int 1
245 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.certify.gender int 1; 0
246 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.identity.uid <5eid>, int 14026928; 15478597; 21610332
247 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.identity.type int 0
248 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.identity.extras string
249 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.identity.status int 0
250 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.identity.slogan string
251 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.identity.slogan_ext string
252 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.identity.live_url string
253 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.identity.live_type int 0
254 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.usernameAuditStatus int 1
255 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.Accid string 263d37a4e1f87bce763e0d1b8ec03982; 07809f60e739d9c47648f4acda66667d; 879462b5de38dce892033adc138dec22
256 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.teamID int 99868; 132671; 117796
257 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.domain <5eid>, string 123442; 1226wi4xw0ya; 15478597ldiutg
258 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_data.trumpetCount int 2; 23; 1
259 crane/http/api/data/match/{match_id} data.group_N[].user_info.plus_info.is_plus int 1; 0
260 crane/http/api/data/match/{match_id} data.group_N[].user_info.plus_info.plus_icon string images/act/e9cf57699303d9f6b18e465156fc6291.png; images/act/dae5c4cb98ceb6eeb1700f63c9ed14b7.png; images/act/09bbeb0f83a2f13419a0d75ac93e8a0c.png
261 crane/http/api/data/match/{match_id} data.group_N[].user_info.plus_info.plus_icon_short string images/act/d53f3bd55c836e057af230e2a138e94a.png; images/act/b7e90458420245283d9878a1e92b3a74.png; images/act/49b525ee6f74f423f3c2f0f913289824.png
262 crane/http/api/data/match/{match_id} data.group_N[].user_info.plus_info.vip_level int 6; 5; 0
263 crane/http/api/data/match/{match_id} data.group_N[].user_info.plus_info.plus_grade int 6; 2; 4
264 crane/http/api/data/match/{match_id} data.group_N[].user_info.plus_info.growth_score int 540; 8196; 5458
265 crane/http/api/data/match/{match_id} data.group_N[].user_info.user_avatar_frame null None
266 crane/http/api/data/match/{match_id} data.group_N[].friend_relation int 0
267 crane/http/api/data/match/{match_id} data.level_list[].elo int 1000; 800; 900
268 crane/http/api/data/match/{match_id} data.level_list[].remark string 800-899; 700-799; 900-999
269 crane/http/api/data/match/{match_id} data.level_list[].level_id int 2; 5; 4
270 crane/http/api/data/match/{match_id} data.level_list[].level_name string E-; E+; N
271 crane/http/api/data/match/{match_id} data.level_list[].elo_type int 9
272 crane/http/api/data/match/{match_id} data.level_list[].group_id int 2; 5; 4
273 crane/http/api/data/match/{match_id} data.level_list[].level_image string
274 crane/http/api/data/match/{match_id} data.level_list[].rise_type int 0
275 crane/http/api/data/match/{match_id} data.level_list[].shelves_status int 1
276 crane/http/api/data/match/{match_id} data.room_card.id string 310; 1326; 1309
277 crane/http/api/data/match/{match_id} data.room_card.category string 48; 0
278 crane/http/api/data/match/{match_id} data.room_card.describe string ; PLUS1专属房间卡片; 灵动小5房间卡片
279 crane/http/api/data/match/{match_id} data.room_card.name string ; PLUS1专属房间卡片; 赛博少女
280 crane/http/api/data/match/{match_id} data.room_card.propTemplateId string 133841; 134304; 1001
281 crane/http/api/data/match/{match_id} data.room_card.getWay string
282 crane/http/api/data/match/{match_id} data.room_card.onShelf int 0
283 crane/http/api/data/match/{match_id} data.room_card.shelfAt string
284 crane/http/api/data/match/{match_id} data.room_card.getButton int 0
285 crane/http/api/data/match/{match_id} data.room_card.getUrl string
286 crane/http/api/data/match/{match_id} data.room_card.attrs.flagAnimation string ; https://oss-arena.5eplay.com/prop/videos/ba/23/ba2356a47ba93454a2de62c6fb817f82.avif; https://oss-arena.5eplay.com/prop/videos/59/79/59795c76433dfcadad8e6c02627e7d0f.avif
287 crane/http/api/data/match/{match_id} data.room_card.attrs.flagAnimationTime string ; 2
288 crane/http/api/data/match/{match_id} data.room_card.attrs.flagViewUrl string https://oss-arena.5eplay.com/prop/images/49/36/49365bf9f2b7fe3ac6a7ded3656e092a.png; https://oss-arena.5eplay.com/prop/images/77/8c/778c698eb83d864e49e8a90bc8837a50.png; https://oss-arena.5eplay.com/prop/images/09/a9/09a93ce3f1476005f926298491188b21.png
289 crane/http/api/data/match/{match_id} data.room_card.attrs.flagViewVideo string ; https://oss-arena.5eplay.com/prop/videos/6a/ae/6aaee03bbd40a093e5c00d6babe8e276.avif; https://oss-arena.5eplay.com/prop/videos/11/e8/11e8446dcd0202316605b08ab0b35466.avif
290 crane/http/api/data/match/{match_id} data.room_card.attrs.flagViewVideoTime string ; 5; 2
291 crane/http/api/data/match/{match_id} data.room_card.attrs.getWay string 升级至PLUS1级获取; 购买DANK1NG联名装扮获得; CS全新版本上线活动获得
292 crane/http/api/data/match/{match_id} data.room_card.attrs.mallJumpLink string
293 crane/http/api/data/match/{match_id} data.room_card.attrs.matchViewUrlLeft string https://oss-arena.5eplay.com/prop/images/13/fd/13fdb6d3b8dfaca3e8cd4987acc45606.png; https://oss-arena.5eplay.com/prop/images/1a/3a/1a3a7725e7bcb19f5a42858160e78bf8.png; https://oss-arena.5eplay.com/prop/images/f9/36/f9366f00cf41b3609a5b52194bf3b309.png
294 crane/http/api/data/match/{match_id} data.room_card.attrs.matchViewUrlRight string https://oss-arena.5eplay.com/prop/images/a9/da/a9da623d19cff27141cf6335507071ff.png; https://oss-arena.5eplay.com/prop/images/fa/45/fa45de3775d1bb75a6456c75ea454147.png; https://oss-arena.5eplay.com/prop/images/0c/f6/0cf657f3461dbd312a1083f546db9e54.png
295 crane/http/api/data/match/{match_id} data.room_card.attrs.mvpSettleAnimation string https://oss-arena.5eplay.com/dress/room_card/9e2ab6983d4ed9a6d23637abd9cd2152.mp4; https://oss-arena.5eplay.com/prop/videos/38/3e/383ec8198005d46da7194252353e7cf4.mp4; https://oss-arena.5eplay.com/prop/videos/14/05/14055e4e7cb184edb5f9849031e97231.mp4
296 crane/http/api/data/match/{match_id} data.room_card.attrs.mvpSettleColor string #9f1dea; #1ab5c6; #c89c68
297 crane/http/api/data/match/{match_id} data.room_card.attrs.mvpSettleViewAnimation string https://oss-arena.5eplay.com/dress/room_card/9e2ab6983d4ed9a6d23637abd9cd2152.mp4; https://oss-arena.5eplay.com/prop/videos/82/52/82526d004e9d0f41f3a3e7367b253003.mp4; https://oss-arena.5eplay.com/prop/videos/d2/bc/d2bc06fcc9e997c1d826537c145ea38e.mp4
298 crane/http/api/data/match/{match_id} data.room_card.attrs.pcImg string https://oss-arena.5eplay.com/prop/images/1a/47/1a47dda552d9501004d9043f637406d5.png; https://oss-arena.5eplay.com/prop/images/a1/e6/a1e6656596228734258d74b727a1aa48.png; https://oss-arena.5eplay.com/prop/images/d5/45/d545c6caf716a99a6725d24e37098078.png
299 crane/http/api/data/match/{match_id} data.room_card.attrs.sort int 1; 2
300 crane/http/api/data/match/{match_id} data.room_card.attrs.templateId int 2029; 1663; 2050
301 crane/http/api/data/match/{match_id} data.room_card.attrs.rarityLevel int 3; 4; 2
302 crane/http/api/data/match/{match_id} data.room_card.attrs.sourceId int 3; 11; 4
303 crane/http/api/data/match/{match_id} data.room_card.displayStatus int 0
304 crane/http/api/data/match/{match_id} data.room_card.sysType int 0
305 crane/http/api/data/match/{match_id} data.room_card.createdAt string
306 crane/http/api/data/match/{match_id} data.room_card.updatedAt string
307 crane/http/api/data/match/{match_id} data.round_sfui_type[] string 2; 5; 4
308 crane/http/api/data/match/{match_id} data.user_stats.map_level.map_exp int 0
309 crane/http/api/data/match/{match_id} data.user_stats.map_level.add_exp int 0
310 crane/http/api/data/match/{match_id} data.user_stats.plat_level.plat_level_exp int 0
311 crane/http/api/data/match/{match_id} data.user_stats.plat_level.add_exp int 0
312 crane/http/api/data/match/{match_id} data.group_1_team_info.team_id string
313 crane/http/api/data/match/{match_id} data.group_1_team_info.team_name string
314 crane/http/api/data/match/{match_id} data.group_1_team_info.logo_url string
315 crane/http/api/data/match/{match_id} data.group_1_team_info.team_domain string
316 crane/http/api/data/match/{match_id} data.group_1_team_info.team_tag string
317 crane/http/api/data/match/{match_id} data.group_2_team_info.team_id string
318 crane/http/api/data/match/{match_id} data.group_2_team_info.team_name string
319 crane/http/api/data/match/{match_id} data.group_2_team_info.logo_url string
320 crane/http/api/data/match/{match_id} data.group_2_team_info.team_domain string
321 crane/http/api/data/match/{match_id} data.group_2_team_info.team_tag string
322 crane/http/api/data/match/{match_id} data.treat_info.user_id <5eid>, int 13048069; 21150835
323 crane/http/api/data/match/{match_id} data.treat_info.user_data.uid <5eid>, int 13048069; 21150835
324 crane/http/api/data/match/{match_id} data.treat_info.user_data.username string 熊出没之深情熊二; Royc灬Kerat丶
325 crane/http/api/data/match/{match_id} data.treat_info.user_data.uuid string c9caad5c-a9b3-11ef-848e-506b4bfa3106; 83376211-5c36-11ed-9ce2-ec0d9a495494
326 crane/http/api/data/match/{match_id} data.treat_info.user_data.email string
327 crane/http/api/data/match/{match_id} data.treat_info.user_data.area string 86
328 crane/http/api/data/match/{match_id} data.treat_info.user_data.mobile string
329 crane/http/api/data/match/{match_id} data.treat_info.user_data.createdAt int 1667562471; 1732377512
330 crane/http/api/data/match/{match_id} data.treat_info.user_data.updatedAt int 1768911939; 1768904695
331 crane/http/api/data/match/{match_id} data.treat_info.user_data.profile.uid <5eid>, int 13048069; 21150835
332 crane/http/api/data/match/{match_id} data.treat_info.user_data.profile.domain string 13048069yf1jto; 1123rqi1bfha
333 crane/http/api/data/match/{match_id} data.treat_info.user_data.profile.nickname string
334 crane/http/api/data/match/{match_id} data.treat_info.user_data.profile.avatarUrl string prop/images/3d/c4/3dc4259c07c31adb2439f7acbf1e565f.png; disguise/images/0e/84/0e84fdbb1da54953f1985bfb206604a5.png
335 crane/http/api/data/match/{match_id} data.treat_info.user_data.profile.avatarAuditStatus int 0
336 crane/http/api/data/match/{match_id} data.treat_info.user_data.profile.rgbAvatarUrl string ; rgb_avatar/20221129/f1ba34afe43c4fa38fd7dd129b0dc303.rgb
337 crane/http/api/data/match/{match_id} data.treat_info.user_data.profile.photoUrl string
338 crane/http/api/data/match/{match_id} data.treat_info.user_data.profile.gender int 1; 0
339 crane/http/api/data/match/{match_id} data.treat_info.user_data.profile.birthday int 0
340 crane/http/api/data/match/{match_id} data.treat_info.user_data.profile.countryId string ; cn
341 crane/http/api/data/match/{match_id} data.treat_info.user_data.profile.regionId string
342 crane/http/api/data/match/{match_id} data.treat_info.user_data.profile.cityId string
343 crane/http/api/data/match/{match_id} data.treat_info.user_data.profile.language string simplified-chinese;
344 crane/http/api/data/match/{match_id} data.treat_info.user_data.profile.recommendUrl string
345 crane/http/api/data/match/{match_id} data.treat_info.user_data.profile.groupId int 0
346 crane/http/api/data/match/{match_id} data.treat_info.user_data.profile.regSource int 4; 0
347 crane/http/api/data/match/{match_id} data.treat_info.user_data.status.uid <5eid>, int 13048069; 21150835
348 crane/http/api/data/match/{match_id} data.treat_info.user_data.status.status int 0
349 crane/http/api/data/match/{match_id} data.treat_info.user_data.status.expire int 0
350 crane/http/api/data/match/{match_id} data.treat_info.user_data.status.cancellationStatus int 0
351 crane/http/api/data/match/{match_id} data.treat_info.user_data.status.newUser int 0
352 crane/http/api/data/match/{match_id} data.treat_info.user_data.status.loginBannedTime int 0
353 crane/http/api/data/match/{match_id} data.treat_info.user_data.status.anticheatType int 0
354 crane/http/api/data/match/{match_id} data.treat_info.user_data.status.flagStatus1 string 128
355 crane/http/api/data/match/{match_id} data.treat_info.user_data.status.anticheatStatus string 0
356 crane/http/api/data/match/{match_id} data.treat_info.user_data.status.FlagHonor string 1178636; 65548
357 crane/http/api/data/match/{match_id} data.treat_info.user_data.status.PrivacyPolicyStatus int 4
358 crane/http/api/data/match/{match_id} data.treat_info.user_data.status.csgoFrozenExptime int 1767707372; 1765545847
359 crane/http/api/data/match/{match_id} data.treat_info.user_data.platformExp.uid <5eid>, int 13048069; 21150835
360 crane/http/api/data/match/{match_id} data.treat_info.user_data.platformExp.level int 29
361 crane/http/api/data/match/{match_id} data.treat_info.user_data.platformExp.exp int 26803; 26522
362 crane/http/api/data/match/{match_id} data.treat_info.user_data.steam.uid <5eid>, int 13048069; 21150835
363 crane/http/api/data/match/{match_id} data.treat_info.user_data.steam.steamId <steamid> 76561199192775594; 76561198290113126
364 crane/http/api/data/match/{match_id} data.treat_info.user_data.steam.steamAccount string
365 crane/http/api/data/match/{match_id} data.treat_info.user_data.steam.tradeUrl string
366 crane/http/api/data/match/{match_id} data.treat_info.user_data.steam.rentSteamId string
367 crane/http/api/data/match/{match_id} data.treat_info.user_data.trusted.uid <5eid>, int 13048069; 21150835
368 crane/http/api/data/match/{match_id} data.treat_info.user_data.trusted.credit int 2200; 5919
369 crane/http/api/data/match/{match_id} data.treat_info.user_data.trusted.creditLevel int 4
370 crane/http/api/data/match/{match_id} data.treat_info.user_data.trusted.score int 100000
371 crane/http/api/data/match/{match_id} data.treat_info.user_data.trusted.status int 1
372 crane/http/api/data/match/{match_id} data.treat_info.user_data.trusted.creditStatus int 1
373 crane/http/api/data/match/{match_id} data.treat_info.user_data.certify.uid <5eid>, int 13048069; 21150835
374 crane/http/api/data/match/{match_id} data.treat_info.user_data.certify.idType int 0
375 crane/http/api/data/match/{match_id} data.treat_info.user_data.certify.status int 1
376 crane/http/api/data/match/{match_id} data.treat_info.user_data.certify.age int 23; 42
377 crane/http/api/data/match/{match_id} data.treat_info.user_data.certify.realName string
378 crane/http/api/data/match/{match_id} data.treat_info.user_data.certify.auditStatus int 1
379 crane/http/api/data/match/{match_id} data.treat_info.user_data.certify.gender int 1
380 crane/http/api/data/match/{match_id} data.treat_info.user_data.identity.uid <5eid>, int 13048069; 21150835
381 crane/http/api/data/match/{match_id} data.treat_info.user_data.identity.type int 0
382 crane/http/api/data/match/{match_id} data.treat_info.user_data.identity.extras string
383 crane/http/api/data/match/{match_id} data.treat_info.user_data.identity.status int 0
384 crane/http/api/data/match/{match_id} data.treat_info.user_data.identity.slogan string
385 crane/http/api/data/match/{match_id} data.treat_info.user_data.identity.slogan_ext string
386 crane/http/api/data/match/{match_id} data.treat_info.user_data.identity.live_url string
387 crane/http/api/data/match/{match_id} data.treat_info.user_data.identity.live_type int 0
388 crane/http/api/data/match/{match_id} data.treat_info.user_data.usernameAuditStatus int 1
389 crane/http/api/data/match/{match_id} data.treat_info.user_data.Accid string 57cd6b98be64949589a6cecf7d258cd1; d0d986c392c55c5d422fd2c46e4d6318
390 crane/http/api/data/match/{match_id} data.treat_info.user_data.teamID int 0
391 crane/http/api/data/match/{match_id} data.treat_info.user_data.domain string 13048069yf1jto; 1123rqi1bfha
392 crane/http/api/data/match/{match_id} data.treat_info.user_data.trumpetCount int 3; 2442
393 crane/http/api/data/match/{match_id} data.season_type int 0
394 crane/http/api/data/match/{match_id} code int 0
395 crane/http/api/data/match/{match_id} message string 操作成功
396 crane/http/api/data/match/{match_id} status bool True
397 crane/http/api/data/match/{match_id} timestamp int 1768931731; 1768931718; 1768931708
398 crane/http/api/data/match/{match_id} trace_id string 8ae4feeb19cc4ed3a24a8a00f056d023; 19582ac94190e3baff795cff50c7a6f3; 87794472a94e5e40be8e12bd116dad55
399 crane/http/api/data/match/{match_id} success bool True
400 crane/http/api/data/match/{match_id} errcode int 0
401 crane/http/api/data/vip_plus_match_data/{match_id} data.<steamid>.fd_ct int 2; 4; 3
402 crane/http/api/data/vip_plus_match_data/{match_id} data.<steamid>.fd_t int 2; 4; 3
403 crane/http/api/data/vip_plus_match_data/{match_id} data.<steamid>.kast float, int 0.7; 0.65; 0.48
404 crane/http/api/data/vip_plus_match_data/{match_id} data.<steamid>.awp_kill int 2; 5; 4
405 crane/http/api/data/vip_plus_match_data/{match_id} data.<steamid>.awp_kill_ct int 5; 4; 3
406 crane/http/api/data/vip_plus_match_data/{match_id} data.<steamid>.awp_kill_t int 2; 5; 4
407 crane/http/api/data/vip_plus_match_data/{match_id} data.<steamid>.damage_stats int 3; 5; 50
408 crane/http/api/data/vip_plus_match_data/{match_id} data.<steamid>.damage_receive int 0
409 crane/http/api/data/vip_plus_match_data/{match_id} code int 0
410 crane/http/api/data/vip_plus_match_data/{match_id} message string 操作成功
411 crane/http/api/data/vip_plus_match_data/{match_id} status bool True
412 crane/http/api/data/vip_plus_match_data/{match_id} timestamp int 1768931714; 1768931732; 1768931710
413 crane/http/api/data/vip_plus_match_data/{match_id} trace_id string cff29d5dcdd6285b80d11bbb4a8a7da0; 6e7c0c0590b0e561c6c4c8d935ebb02c; 97c1377302559a8f5e01aedfeb208751
414 crane/http/api/data/vip_plus_match_data/{match_id} success bool True
415 crane/http/api/data/vip_plus_match_data/{match_id} errcode int 0
416 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].round int 2; 5; 4
417 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].t_money_group int 3; 1; 4
418 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].ct_money_group int 3; 1; 4
419 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].win_reason int 2; 5; 4
420 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].bron_equipment.<steamid>[].Money int 400; 200; 2900
421 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].bron_equipment.<steamid>[].WeaponName string weapon_flashbang; weapon_tec9; weapon_hegrenade
422 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].bron_equipment.<steamid>[].Weapon int 22; 33; 37
423 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].player_t_score.<steamid> float, int -21.459999999999997; -16.640000000000004; 4
424 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].player_ct_score.<steamid> float, int 17.099999999999994; 15.120000000000001; 27.507999999999996
425 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].player_bron_crash.<steamid> int 4200; 3900; 800
426 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].begin_ts string 2026-01-18T19:57:29+08:00; 2026-01-18T19:59:18+08:00; 2026-01-18T19:55:55+08:00
427 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].sfui_event.sfui_type int 2; 5; 4
428 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].sfui_event.score_ct int 2; 5; 4
429 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].sfui_event.score_t int 2; 10; 3
430 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].end_ts string 2026-01-18T19:54:37+08:00; 2026-01-18T19:57:22+08:00; 2026-01-18T19:59:11+08:00
431 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].ts_real string 0001-01-01T00:00:00Z; 2026-01-18T19:54:06+08:00; 2026-01-18T19:54:04+08:00
432 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].ts int 45; 48; 46
433 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].t_num int 2; 5; 4
434 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].ct_num int 2; 5; 4
435 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].event_type int 3; 1; 4
436 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].kill_event.Ts string 2026-01-18T19:54:06+08:00; 2026-01-18T19:54:04+08:00; 2026-01-18T19:53:57+08:00
437 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].kill_event.Killer <steamid> 76561199787406643; 76561199032002725; 76561199078250590
438 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].kill_event.Victim <steamid> 76561199388433802; 76561199032002725; 76561199250737526
439 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].kill_event.Weapon int 6; 7; 5
440 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].kill_event.KillerTeam int 1; 2
441 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].kill_event.KillerBot bool False
442 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].kill_event.VictimBot bool False
443 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].kill_event.WeaponName string usp_silencer; deagle; famas
444 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].kill_event.Headshot bool False; True
445 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].kill_event.Penetrated bool False; True
446 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].kill_event.ThroughSmoke bool False; True
447 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].kill_event.NoScope bool False; True
448 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].kill_event.AttackerBlind bool False; True
449 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].kill_event.Attackerinair bool False
450 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].twin float, int 0.143; 0.341; 0.557
451 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].c_twin float, int 0.44299999999999995; 0.471; 0.659
452 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].twin_change float, int -0.21600000000000003; -0.19800000000000004; 0.19899999999999995
453 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].c_twin_change float, int 0.21600000000000003; 0.19800000000000004; 0.17099999999999993
454 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].killer_score_change.<steamid>.score float, int 17.099999999999994; 19.899999999999995; 19.800000000000004
455 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].victim_score_change.<steamid>.score float, int -15.8; -19.899999999999995; -21.6
456 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].assist_killer_score_change.<steamid>.score float 2.592; 6.63; 6.45
457 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].trade_score_change.<steamid>.score float 2.2100000000000004; 3.16; 3.66
458 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].flash_assist_killer_score_change.<steamid>.score float 1.1520000000000001; 2.9850000000000003; 1.5299999999999996
459 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].protect_gun_player_score_change.<steamid>.score float 5.8999999999999995; 7.1000000000000005
460 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].protect_gun_enemy_score_change.<steamid>.score float -1.18; -1.4200000000000002
461 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].disconnect_player_score_change null None
462 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].disconnect_comp_score_change null None
463 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].round_end_fixed_score_change.<steamid>.score float, int 20; -0.6000000000000005; -100
464 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].show_event[].win_reason int 2; 5; 4
465 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].side_info.ct[] <steamid> 76561199032002725; 76561199078250590; 76561199076109761
466 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_stat[].side_info.t[] <steamid> 76561199787406643; 76561199388433802; 76561199250737526
467 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.player_scores.<steamid> float 12.491187500000002; 1.5764999999999993; 2.073937500000001
468 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.player_t_scores.<steamid> float 19.06; 6.3349999999999955; -8.872500000000002
469 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.player_ct_scores.<steamid> float -0.009666666666665455; 10.301583333333335; -2.9330833333333324
470 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.round_total int 18; 30; 21
471 crane/http/api/match/leetify_rating/{match_id} data.leetify_data.player_round_scores.<steamid>.<round_n> float, int 32.347; -1.100000000000001; 20.040000000000006
472 crane/http/api/match/leetify_rating/{match_id} data.uinfo_dict.<steamid>.uid <5eid>, int 14889445; 14869396; 14888575
473 crane/http/api/match/leetify_rating/{match_id} data.uinfo_dict.<steamid>.uuid string 13f7dc52-ea7c-11ed-9ce2-ec0d9a495494; e74f23a3-e8ae-11ed-9ce2-ec0d9a495494; 7ced32f8-ea70-11ed-9ce2-ec0d9a495494
474 crane/http/api/match/leetify_rating/{match_id} data.uinfo_dict.<steamid>.username string 刚拉; R1nging; RRRTINA
475 crane/http/api/match/leetify_rating/{match_id} data.uinfo_dict.<steamid>.nickname string
476 crane/http/api/match/leetify_rating/{match_id} data.uinfo_dict.<steamid>.reg_date int 1683007881; 1683007342; 1683200437
477 crane/http/api/match/leetify_rating/{match_id} data.uinfo_dict.<steamid>.username_spam_status int 1
478 crane/http/api/match/leetify_rating/{match_id} data.uinfo_dict.<steamid>.steamid_64 <steamid> 76561199032002725; 76561199078250590; 76561199076109761
479 crane/http/api/match/leetify_rating/{match_id} data.uinfo_dict.<steamid>.avatar_url string disguise/images/6f/89/6f89b22633cb95df1754fd30573c5ad6.png; disguise/images/09/96/09961ea8fc45bed1c60157055a4c05c5.jpg; disguise/images/5d/41/5d4182b66a5004a974aee7501873164b.jpg
480 crane/http/api/match/leetify_rating/{match_id} data.uinfo_dict.<steamid>.gender int 1; 0
481 crane/http/api/match/leetify_rating/{match_id} data.uinfo_dict.<steamid>.country_id string ; cn
482 crane/http/api/match/leetify_rating/{match_id} data.uinfo_dict.<steamid>.language string ; simplified-chinese
483 crane/http/api/match/leetify_rating/{match_id} data.uinfo_dict.<steamid>.domain string rrrtina; 14869396o9jm5g; dxw123452
484 crane/http/api/match/leetify_rating/{match_id} data.uinfo_dict.<steamid>.credit int 0
485 crane/http/api/match/leetify_rating/{match_id} data.uinfo_dict.<steamid>.trusted_score int 0
486 crane/http/api/match/leetify_rating/{match_id} data.uinfo_dict.<steamid>.trusted_status int 0
487 crane/http/api/match/leetify_rating/{match_id} data.uinfo_dict.<steamid>.plus_info null None
488 crane/http/api/match/leetify_rating/{match_id} data.uinfo_dict.<steamid>.region int 0
489 crane/http/api/match/leetify_rating/{match_id} data.uinfo_dict.<steamid>.province int 0
490 crane/http/api/match/leetify_rating/{match_id} data.uinfo_dict.<steamid>.province_name string
491 crane/http/api/match/leetify_rating/{match_id} data.uinfo_dict.<steamid>.region_name string
492 crane/http/api/match/leetify_rating/{match_id} data.uinfo_dict.<steamid>.college_id int 0
493 crane/http/api/match/leetify_rating/{match_id} data.uinfo_dict.<steamid>.status int 0
494 crane/http/api/match/leetify_rating/{match_id} data.uinfo_dict.<steamid>.identity null None
495 crane/http/api/match/leetify_rating/{match_id} code int 0
496 crane/http/api/match/leetify_rating/{match_id} message string 操作成功
497 crane/http/api/match/leetify_rating/{match_id} status bool True
498 crane/http/api/match/leetify_rating/{match_id} timestamp int 1768833830; 1768833808; 1768833806
499 crane/http/api/match/leetify_rating/{match_id} trace_id string 376e200283d19770bdef6dacf260f40f; a7dd6602d3aedb3017bb37727b5be75a; dab4013545b5581fbb089fb5c273d0a9
500 crane/http/api/match/leetify_rating/{match_id} success bool True
501 crane/http/api/match/leetify_rating/{match_id} errcode int 0
502 crane/http/api/match/round/{match_id} data.round_list[].all_kill[].attacker.name string 5E-Player 我有必胜卡组; 5E-Player 青青C原懒大王w; 5E-Player xiezhongxie1
503 crane/http/api/match/round/{match_id} data.round_list[].all_kill[].attacker.pos.x int 734; 999; 1170
504 crane/http/api/match/round/{match_id} data.round_list[].all_kill[].attacker.pos.y int 125; -77; -772
505 crane/http/api/match/round/{match_id} data.round_list[].all_kill[].attacker.pos.z int 0
506 crane/http/api/match/round/{match_id} data.round_list[].all_kill[].attacker.steamid_64 <steamid> 76561198330488905; 76561199032002725; 76561199076109761
507 crane/http/api/match/round/{match_id} data.round_list[].all_kill[].attacker.team int 1; 2
508 crane/http/api/match/round/{match_id} data.round_list[].all_kill[].attackerblind bool False; True
509 crane/http/api/match/round/{match_id} data.round_list[].all_kill[].headshot bool False; True
510 crane/http/api/match/round/{match_id} data.round_list[].all_kill[].noscope bool False; True
511 crane/http/api/match/round/{match_id} data.round_list[].all_kill[].pasttime int 45; 20; 24
512 crane/http/api/match/round/{match_id} data.round_list[].all_kill[].penetrated bool False; True
513 crane/http/api/match/round/{match_id} data.round_list[].all_kill[].throughsmoke bool False; True
514 crane/http/api/match/round/{match_id} data.round_list[].all_kill[].victim.name <5eid>, string 5E-Player 青青C原懒大王w; 5E-Player 午夜伤心忧郁玫瑰; 5E-Player RRRTINA
515 crane/http/api/match/round/{match_id} data.round_list[].all_kill[].victim.pos.x int 1218; 706; 1298
516 crane/http/api/match/round/{match_id} data.round_list[].all_kill[].victim.pos.y int 627; 587; 219
517 crane/http/api/match/round/{match_id} data.round_list[].all_kill[].victim.pos.z int 0
518 crane/http/api/match/round/{match_id} data.round_list[].all_kill[].victim.steamid_64 <steamid>, string 76561199482118960; 76561199812085195; 76561199207654712
519 crane/http/api/match/round/{match_id} data.round_list[].all_kill[].victim.team int 1; 2
520 crane/http/api/match/round/{match_id} data.round_list[].all_kill[].weapon string usp_silencer; mag7; famas
521 crane/http/api/match/round/{match_id} data.round_list[].kill.<steamid>[].attacker.name string 5E-Player 我有必胜卡组; 5E-Player 青青C原懒大王w; 5E-Player xiezhongxie1
522 crane/http/api/match/round/{match_id} data.round_list[].kill.<steamid>[].attacker.pos.x int 734; 999; 397
523 crane/http/api/match/round/{match_id} data.round_list[].kill.<steamid>[].attacker.pos.y int 149; 125; -77
524 crane/http/api/match/round/{match_id} data.round_list[].kill.<steamid>[].attacker.pos.z int 0
525 crane/http/api/match/round/{match_id} data.round_list[].kill.<steamid>[].attacker.steamid_64 <steamid> 76561198330488905; 76561199032002725; 76561199076109761
526 crane/http/api/match/round/{match_id} data.round_list[].kill.<steamid>[].attacker.team int 1; 2
527 crane/http/api/match/round/{match_id} data.round_list[].kill.<steamid>[].attackerblind bool False; True
528 crane/http/api/match/round/{match_id} data.round_list[].kill.<steamid>[].headshot bool False; True
529 crane/http/api/match/round/{match_id} data.round_list[].kill.<steamid>[].noscope bool False; True
530 crane/http/api/match/round/{match_id} data.round_list[].kill.<steamid>[].pasttime int 24; 57; 20
531 crane/http/api/match/round/{match_id} data.round_list[].kill.<steamid>[].penetrated bool False; True
532 crane/http/api/match/round/{match_id} data.round_list[].kill.<steamid>[].throughsmoke bool False; True
533 crane/http/api/match/round/{match_id} data.round_list[].kill.<steamid>[].victim.name <5eid>, string 5E-Player 青青C原懒大王w; 5E-Player 午夜伤心忧郁玫瑰; 5E-Player _陆小果
534 crane/http/api/match/round/{match_id} data.round_list[].kill.<steamid>[].victim.pos.x int 1218; 706; 1298
535 crane/http/api/match/round/{match_id} data.round_list[].kill.<steamid>[].victim.pos.y int 627; 587; 219
536 crane/http/api/match/round/{match_id} data.round_list[].kill.<steamid>[].victim.pos.z int 0
537 crane/http/api/match/round/{match_id} data.round_list[].kill.<steamid>[].victim.steamid_64 <steamid>, string 76561198812383596; 76561199812085195; 76561199187871084
538 crane/http/api/match/round/{match_id} data.round_list[].kill.<steamid>[].victim.team int 1; 2
539 crane/http/api/match/round/{match_id} data.round_list[].kill.<steamid>[].weapon string usp_silencer; mag7; famas
540 crane/http/api/match/round/{match_id} data.round_list[].c4_event[].event_name string planted_c4
541 crane/http/api/match/round/{match_id} data.round_list[].c4_event[].location string
542 crane/http/api/match/round/{match_id} data.round_list[].c4_event[].name string 5E-Player 我有必胜卡组; 5E-Player RRRTINA; 5E-Player 俺有鱼鱼蒸
543 crane/http/api/match/round/{match_id} data.round_list[].c4_event[].pasttime int 45; 30; 31
544 crane/http/api/match/round/{match_id} data.round_list[].c4_event[].steamid_64 <steamid> 76561198330488905; 76561199812085195; 76561199207654712
545 crane/http/api/match/round/{match_id} data.round_list[].current_score.ct int 2; 10; 1
546 crane/http/api/match/round/{match_id} data.round_list[].current_score.final_round_time int 68; 79; 63
547 crane/http/api/match/round/{match_id} data.round_list[].current_score.pasttime int 57; 47; 62
548 crane/http/api/match/round/{match_id} data.round_list[].current_score.t int 2; 5; 4
549 crane/http/api/match/round/{match_id} data.round_list[].current_score.type int 2; 5; 4
550 crane/http/api/match/round/{match_id} data.round_list[].death_list[] <steamid>, string 76561198812383596; 76561199812085195; 76561199187871084
551 crane/http/api/match/round/{match_id} data.round_list[].equiped.<steamid>[] string usp_silencer; kevlar(100); smokegrenade
552 crane/http/api/match/round/{match_id} data.round_list[].equiped.[] string
553 crane/http/api/match/round/{match_id} data.weapon_list.defuser[] string defuser
554 crane/http/api/match/round/{match_id} data.weapon_list.item[] string incgrenade; flashbang; molotov
555 crane/http/api/match/round/{match_id} data.weapon_list.main_weapon[] string sg556; awp; ssg08
556 crane/http/api/match/round/{match_id} data.weapon_list.other_item[] string kevlar; helmet
557 crane/http/api/match/round/{match_id} data.weapon_list.secondary_weapon[] string usp_silencer; deagle; glock
558 crane/http/api/match/round/{match_id} code int 0
559 crane/http/api/match/round/{match_id} message string 操作成功
560 crane/http/api/match/round/{match_id} status bool True
561 crane/http/api/match/round/{match_id} timestamp int 1768931714; 1768931731; 1768931710
562 crane/http/api/match/round/{match_id} trace_id string c2ee4f45abd89f1c90dc1cc390d21d33; f85069de4d785710dd55301334ff03c0; 98335f4087c76de69e8aeda3ca767d6f
563 crane/http/api/match/round/{match_id} success bool True
564 crane/http/api/match/round/{match_id} errcode int 0

View File

@@ -0,0 +1,708 @@
## Category: `crane/http/api/data/match/{match_id}`
**Total Requests**: 179
- **data** (dict)
- **has_side_data_and_rating2** (bool, e.g. True)
- **main** (dict)
- **demo_url** (string, e.g. )
- **end_time** (int, e.g. 1739528619)
- **game_mode** (int, e.g. 6)
- **game_name** (string, e.g. )
- **group1_all_score** (int, e.g. 10)
- **group1_change_elo** (int, e.g. 0)
- **group1_fh_role** (int, e.g. 1)
- **group1_fh_score** (int, e.g. 6)
- **group1_origin_elo** (float, int, e.g. 1628.1)
- **group1_sh_role** (int, e.g. 0)
- **group1_sh_score** (int, e.g. 6)
- **group1_tid** (int, e.g. 0)
- **group1_uids** (string, e.g. 14869472,14888575,1326932,14869396,14889445)
- **group2_all_score** (int, e.g. 6)
- **group2_change_elo** (int, e.g. 0)
- **group2_fh_role** (int, e.g. 0)
- **group2_fh_score** (int, e.g. 6)
- **group2_origin_elo** (float, int, e.g. 1617.02)
- **group2_sh_role** (int, e.g. 1)
- **group2_sh_score** (int, e.g. 6)
- **group2_tid** (int, e.g. 0)
- **group2_uids** (string, e.g. 7866482,7976557,13918176,7998628,18857497)
- **id** (int, e.g. 232025624)
- **knife_winner** (int, e.g. 0)
- **knife_winner_role** (int, e.g. 0)
- **location** (string, e.g. hz)
- **location_full** (string, e.g. sh_pug-low)
- **map** (string, e.g. de_nuke)
- **map_desc** (string, e.g. 阿努比斯)
- **match_code** (string, e.g. g161-20250215211846894242128)
- **match_mode** (int, e.g. 9)
- **match_winner** (int, e.g. 1)
- **most_1v2_uid** (<5eid>, int, e.g. 14869396)
- **most_assist_uid** (<5eid>, int, e.g. 14869396)
- **most_awp_uid** (<5eid>, int, e.g. 12501578)
- **most_end_uid** (<5eid>, int, e.g. 12501578)
- **most_first_kill_uid** (<5eid>, int, e.g. 18337753)
- **most_headshot_uid** (<5eid>, int, e.g. 17181895)
- **most_jump_uid** (<5eid>, int, e.g. 12501578)
- **mvp_uid** (<5eid>, int, e.g. 19535157)
- **round_total** (int, e.g. 24)
- **season** (string, e.g. 2025s2)
- **server_ip** (string, e.g. )
- **server_port** (string, e.g. 27015)
- **start_time** (int, e.g. 1739523090)
- **status** (int, e.g. 1)
- **waiver** (int, e.g. 0)
- **year** (int, e.g. 2026)
- **cs_type** (int, e.g. 0)
- **priority_show_type** (int, e.g. 3)
- **pug10m_show_type** (int, e.g. 1)
- **credit_match_status** (int, e.g. 1)
- **group_N** (list)
- *[Array Items]*
- **fight_any** (dict)
- **adr** (string, e.g. 106.58)
- **assist** (string, e.g. 2)
- **awp_kill** (string, e.g. 2)
- **benefit_kill** (string, e.g. 6)
- **day** (string, e.g. 20250218)
- **death** (string, e.g. 5)
- **defused_bomb** (string, e.g. 2)
- **end_1v1** (string, e.g. 2)
- **end_1v2** (string, e.g. 2)
- **end_1v3** (string, e.g. 2)
- **end_1v4** (string, e.g. 1)
- **end_1v5** (string, e.g. 1)
- **explode_bomb** (string, e.g. 2)
- **first_death** (string, e.g. 5)
- **first_kill** (string, e.g. 2)
- **flash_enemy** (string, e.g. 43)
- **flash_enemy_time** (string, e.g. 7)
- **flash_team** (string, e.g. 5)
- **flash_team_time** (string, e.g. 21)
- **flash_time** (string, e.g. 6)
- **game_mode** (string, e.g. 6)
- **group_id** (string, e.g. 1)
- **headshot** (string, e.g. 2)
- **hold_total** (string, e.g. 0)
- **id** (string, e.g. 1937230471)
- **is_highlight** (string, e.g. 1)
- **is_most_1v2** (string, e.g. 1)
- **is_most_assist** (string, e.g. 1)
- **is_most_awp** (string, e.g. 1)
- **is_most_end** (string, e.g. 1)
- **is_most_first_kill** (string, e.g. 1)
- **is_most_headshot** (string, e.g. 1)
- **is_most_jump** (string, e.g. 1)
- **is_mvp** (string, e.g. 1)
- **is_svp** (string, e.g. )
- **is_tie** (string, e.g. 1)
- **is_win** (string, e.g. 1)
- **jump_total** (string, e.g. 64)
- **kast** (string, e.g. 0.82)
- **kill** (string, e.g. 14)
- **kill_1** (string, e.g. 5)
- **kill_2** (string, e.g. 2)
- **kill_3** (string, e.g. 2)
- **kill_4** (string, e.g. 3)
- **kill_5** (string, e.g. 2)
- **map** (string, e.g. de_nuke)
- **match_code** (string, e.g. g161-20250215211846894242128)
- **match_mode** (string, e.g. 9)
- **match_team_id** (string, e.g. 2)
- **match_time** (string, e.g. 1739625526)
- **per_headshot** (string, e.g. 0.44)
- **planted_bomb** (string, e.g. 2)
- **rating** (string, e.g. 0.89)
- **many_assists_cnt1** (string, e.g. 6)
- **many_assists_cnt2** (string, e.g. 2)
- **many_assists_cnt3** (string, e.g. 1)
- **many_assists_cnt4** (string, e.g. 1)
- **many_assists_cnt5** (string, e.g. 0)
- **perfect_kill** (string, e.g. 10)
- **assisted_kill** (string, e.g. 5)
- **rating2** (string, e.g. 1.24)
- **rating3** (string, e.g. 2.15)
- **revenge_kill** (string, e.g. 2)
- **round_total** (string, e.g. 17)
- **rws** (string, e.g. 8.41)
- **season** (string, e.g. 2025s2)
- **team_kill** (string, e.g. 1)
- **throw_harm** (string, e.g. 120)
- **throw_harm_enemy** (string, e.g. 10)
- **uid** (<5eid>, string, e.g. 14026928)
- **year** (string, e.g. 2026)
- **sts** (dict)
- **data_tips_detail** (int, e.g. -7)
- **challenge_status** (int, e.g. 1)
- **map_reward_status** (int, e.g. 1)
- **change_rank** (int, e.g. -423964)
- **origin_level_id** (int, e.g. 103)
- **rank_change_type** (int, e.g. 5)
- **star_num** (int, e.g. 0)
- **origin_star_num** (int, e.g. 0)
- **change_elo** (string, e.g. -22.97)
- **id** (string, e.g. 1930709265)
- **level_id** (string, e.g. 103)
- **match_code** (string, e.g. g161-20250215211846894242128)
- **match_flag** (string, e.g. 32)
- **match_mode** (string, e.g. 9)
- **match_status** (string, e.g. 3)
- **origin_elo** (string, e.g. 1214.69)
- **origin_match_total** (string, e.g. 269)
- **placement** (string, e.g. 1)
- **punishment** (string, e.g. 1)
- **rank** (string, e.g. 3251068)
- **origin_rank** (string, e.g. 2293251)
- **season** (string, e.g. 2025s2)
- **special_data** (string, e.g. )
- **uid** (<5eid>, string, e.g. 14026928)
- **level_info** (dict)
- **level_id** (int, e.g. 103)
- **level_name** (string, e.g. C)
- **level_type** (int, e.g. 2)
- **star_num** (int, e.g. 0)
- **origin_star_num** (int, e.g. 0)
- **dragon_flag** (int, e.g. 0)
- **deduct_data** (dict)
- **all_deduct_elo** (int, e.g. 0)
- **deduct_remain_elo** (int, e.g. 0)
- **deduct_elo** (int, e.g. 0)
- **special_data** (list, null)
- *[Array Items]*
- **is_win** (int, e.g. 1)
- **match_id** (string, e.g. )
- **match_status** (int, e.g. 2)
- **change_elo** (float, int, e.g. -100.14724769911413)
- **match_status** (string, e.g. 3)
- **match_flag** (string, e.g. 32)
- **change_elo** (string, e.g. -22.97)
- **origin_elo** (string, e.g. 1214.69)
- **rank** (string, e.g. 3251068)
- **origin_rank** (string, e.g. )
- **trigger_promotion** (int, e.g. 0)
- **special_bo** (int, e.g. 0)
- **rise_type** (int, e.g. 0)
- **tie_status** (int, e.g. 1)
- **level_elo** (int, e.g. 800)
- **max_level** (int, e.g. 19)
- **origin_level_id** (int, e.g. 103)
- **origin_match_total** (int, e.g. 269)
- **star_info** (dict)
- **change_small_star_num** (int, e.g. 0)
- **origin_small_star_num** (int, e.g. 0)
- **change_type** (int, e.g. 0)
- **now_small_star_num** (int, e.g. 0)
- **user_info** (dict)
- **user_data** (dict)
- **uid** (<5eid>, int, e.g. 14026928)
- **username** (<5eid>, string, e.g. Sonka)
- **uuid** (string, e.g. e6f87d93-ea92-11ee-9ce2-ec0d9a495494)
- **email** (string, e.g. )
- **area** (string, e.g. )
- **mobile** (string, e.g. )
- **createdAt** (int, e.g. 1711362715)
- **updatedAt** (int, e.g. 1767921452)
- **profile** (dict)
- **uid** (<5eid>, int, e.g. 14026928)
- **domain** (<5eid>, string, e.g. 123442)
- **nickname** (string, e.g. )
- **avatarUrl** (string, e.g. disguise/images/cf/b2/cfb285c3d8d1c905b648954e42dc8cb0.jpg)
- **avatarAuditStatus** (int, e.g. 1)
- **rgbAvatarUrl** (string, e.g. )
- **photoUrl** (string, e.g. )
- **gender** (int, e.g. 1)
- **birthday** (int, e.g. 1141315200)
- **countryId** (string, e.g. )
- **regionId** (string, e.g. )
- **cityId** (string, e.g. )
- **language** (string, e.g. simplified-chinese)
- **recommendUrl** (string, e.g. )
- **groupId** (int, e.g. 0)
- **regSource** (int, e.g. 5)
- **status** (dict)
- **uid** (<5eid>, int, e.g. 14026928)
- **status** (int, e.g. -4)
- **expire** (int, e.g. 0)
- **cancellationStatus** (int, e.g. 2)
- **newUser** (int, e.g. 0)
- **loginBannedTime** (int, e.g. 1687524902)
- **anticheatType** (int, e.g. 0)
- **flagStatus1** (string, e.g. 32)
- **anticheatStatus** (string, e.g. 0)
- **FlagHonor** (string, e.g. 65548)
- **PrivacyPolicyStatus** (int, e.g. 3)
- **csgoFrozenExptime** (int, e.g. 1766231693)
- **platformExp** (dict)
- **uid** (<5eid>, int, e.g. 14026928)
- **level** (int, e.g. 22)
- **exp** (int, e.g. 12641)
- **steam** (dict)
- **uid** (<5eid>, int, e.g. 14026928)
- **steamId** (<steamid>, e.g. 76561198812383596)
- **steamAccount** (string, e.g. )
- **tradeUrl** (string, e.g. )
- **rentSteamId** (string, e.g. )
- **trusted** (dict)
- **uid** (<5eid>, int, e.g. 14026928)
- **credit** (int, e.g. 2550)
- **creditLevel** (int, e.g. 3)
- **score** (int, e.g. 100000)
- **status** (int, e.g. 1)
- **creditStatus** (int, e.g. 1)
- **certify** (dict)
- **uid** (<5eid>, int, e.g. 14026928)
- **idType** (int, e.g. 0)
- **status** (int, e.g. 1)
- **age** (int, e.g. 20)
- **realName** (string, e.g. )
- **uidList** (list)
- *[Array Items]*
- **auditStatus** (int, e.g. 1)
- **gender** (int, e.g. 1)
- **identity** (dict)
- **uid** (<5eid>, int, e.g. 14026928)
- **type** (int, e.g. 0)
- **extras** (string, e.g. )
- **status** (int, e.g. 0)
- **slogan** (string, e.g. )
- **identity_list** (list)
- *[Array Items]*
- **slogan_ext** (string, e.g. )
- **live_url** (string, e.g. )
- **live_type** (int, e.g. 0)
- **usernameAuditStatus** (int, e.g. 1)
- **Accid** (string, e.g. 263d37a4e1f87bce763e0d1b8ec03982)
- **teamID** (int, e.g. 99868)
- **domain** (<5eid>, string, e.g. 123442)
- **trumpetCount** (int, e.g. 2)
- **plus_info** (dict)
- **is_plus** (int, e.g. 1)
- **plus_icon** (string, e.g. images/act/e9cf57699303d9f6b18e465156fc6291.png)
- **plus_icon_short** (string, e.g. images/act/d53f3bd55c836e057af230e2a138e94a.png)
- **vip_level** (int, e.g. 6)
- **plus_grade** (int, e.g. 6)
- **growth_score** (int, e.g. 540)
- **user_avatar_frame** (null, e.g. None)
- **friend_relation** (int, e.g. 0)
- **level_list** (list, null)
- *[Array Items]*
- **elo** (int, e.g. 1000)
- **remark** (string, e.g. 800-899)
- **level_id** (int, e.g. 2)
- **level_name** (string, e.g. E-)
- **elo_type** (int, e.g. 9)
- **group_id** (int, e.g. 2)
- **level_image** (string, e.g. )
- **rise_type** (int, e.g. 0)
- **shelves_status** (int, e.g. 1)
- **room_card** (dict)
- **id** (string, e.g. 310)
- **category** (string, e.g. 48)
- **describe** (string, e.g. )
- **name** (string, e.g. )
- **propTemplateId** (string, e.g. 133841)
- **getWay** (string, e.g. )
- **onShelf** (int, e.g. 0)
- **shelfAt** (string, e.g. )
- **getButton** (int, e.g. 0)
- **getUrl** (string, e.g. )
- **attrs** (dict)
- **flagAnimation** (string, e.g. )
- **flagAnimationTime** (string, e.g. )
- **flagViewUrl** (string, e.g. https://oss-arena.5eplay.com/prop/images/49/36/49365bf9f2b7fe3ac6a7ded3656e092a.png)
- **flagViewVideo** (string, e.g. )
- **flagViewVideoTime** (string, e.g. )
- **getWay** (string, e.g. 升级至PLUS1级获取)
- **mallJumpLink** (string, e.g. )
- **matchViewUrlLeft** (string, e.g. https://oss-arena.5eplay.com/prop/images/13/fd/13fdb6d3b8dfaca3e8cd4987acc45606.png)
- **matchViewUrlRight** (string, e.g. https://oss-arena.5eplay.com/prop/images/a9/da/a9da623d19cff27141cf6335507071ff.png)
- **mvpSettleAnimation** (string, e.g. https://oss-arena.5eplay.com/dress/room_card/9e2ab6983d4ed9a6d23637abd9cd2152.mp4)
- **mvpSettleColor** (string, e.g. #9f1dea)
- **mvpSettleViewAnimation** (string, e.g. https://oss-arena.5eplay.com/dress/room_card/9e2ab6983d4ed9a6d23637abd9cd2152.mp4)
- **pcImg** (string, e.g. https://oss-arena.5eplay.com/prop/images/1a/47/1a47dda552d9501004d9043f637406d5.png)
- **sort** (int, e.g. 1)
- **templateId** (int, e.g. 2029)
- **rarityLevel** (int, e.g. 3)
- **sourceId** (int, e.g. 3)
- **displayStatus** (int, e.g. 0)
- **sysType** (int, e.g. 0)
- **createdAt** (string, e.g. )
- **updatedAt** (string, e.g. )
- **round_sfui_type** (list)
- *[Array Items]*
- **user_stats** (dict)
- **map_level** (dict)
- **map_exp** (int, e.g. 0)
- **add_exp** (int, e.g. 0)
- **plat_level** (dict)
- **plat_level_exp** (int, e.g. 0)
- **add_exp** (int, e.g. 0)
- **group_1_team_info** (dict)
- **team_id** (string, e.g. )
- **team_name** (string, e.g. )
- **logo_url** (string, e.g. )
- **team_domain** (string, e.g. )
- **team_tag** (string, e.g. )
- **group_2_team_info** (dict)
- **team_id** (string, e.g. )
- **team_name** (string, e.g. )
- **logo_url** (string, e.g. )
- **team_domain** (string, e.g. )
- **team_tag** (string, e.g. )
- **treat_info** (dict, null)
- **user_id** (<5eid>, int, e.g. 13048069)
- **user_data** (dict)
- **uid** (<5eid>, int, e.g. 13048069)
- **username** (string, e.g. 熊出没之深情熊二)
- **uuid** (string, e.g. c9caad5c-a9b3-11ef-848e-506b4bfa3106)
- **email** (string, e.g. )
- **area** (string, e.g. 86)
- **mobile** (string, e.g. )
- **createdAt** (int, e.g. 1667562471)
- **updatedAt** (int, e.g. 1768911939)
- **profile** (dict)
- **uid** (<5eid>, int, e.g. 13048069)
- **domain** (string, e.g. 13048069yf1jto)
- **nickname** (string, e.g. )
- **avatarUrl** (string, e.g. prop/images/3d/c4/3dc4259c07c31adb2439f7acbf1e565f.png)
- **avatarAuditStatus** (int, e.g. 0)
- **rgbAvatarUrl** (string, e.g. )
- **photoUrl** (string, e.g. )
- **gender** (int, e.g. 1)
- **birthday** (int, e.g. 0)
- **countryId** (string, e.g. )
- **regionId** (string, e.g. )
- **cityId** (string, e.g. )
- **language** (string, e.g. simplified-chinese)
- **recommendUrl** (string, e.g. )
- **groupId** (int, e.g. 0)
- **regSource** (int, e.g. 4)
- **status** (dict)
- **uid** (<5eid>, int, e.g. 13048069)
- **status** (int, e.g. 0)
- **expire** (int, e.g. 0)
- **cancellationStatus** (int, e.g. 0)
- **newUser** (int, e.g. 0)
- **loginBannedTime** (int, e.g. 0)
- **anticheatType** (int, e.g. 0)
- **flagStatus1** (string, e.g. 128)
- **anticheatStatus** (string, e.g. 0)
- **FlagHonor** (string, e.g. 1178636)
- **PrivacyPolicyStatus** (int, e.g. 4)
- **csgoFrozenExptime** (int, e.g. 1767707372)
- **platformExp** (dict)
- **uid** (<5eid>, int, e.g. 13048069)
- **level** (int, e.g. 29)
- **exp** (int, e.g. 26803)
- **steam** (dict)
- **uid** (<5eid>, int, e.g. 13048069)
- **steamId** (<steamid>, e.g. 76561199192775594)
- **steamAccount** (string, e.g. )
- **tradeUrl** (string, e.g. )
- **rentSteamId** (string, e.g. )
- **trusted** (dict)
- **uid** (<5eid>, int, e.g. 13048069)
- **credit** (int, e.g. 2200)
- **creditLevel** (int, e.g. 4)
- **score** (int, e.g. 100000)
- **status** (int, e.g. 1)
- **creditStatus** (int, e.g. 1)
- **certify** (dict)
- **uid** (<5eid>, int, e.g. 13048069)
- **idType** (int, e.g. 0)
- **status** (int, e.g. 1)
- **age** (int, e.g. 23)
- **realName** (string, e.g. )
- **uidList** (list)
- *[Array Items]*
- **auditStatus** (int, e.g. 1)
- **gender** (int, e.g. 1)
- **identity** (dict)
- **uid** (<5eid>, int, e.g. 13048069)
- **type** (int, e.g. 0)
- **extras** (string, e.g. )
- **status** (int, e.g. 0)
- **slogan** (string, e.g. )
- **identity_list** (list)
- *[Array Items]*
- **slogan_ext** (string, e.g. )
- **live_url** (string, e.g. )
- **live_type** (int, e.g. 0)
- **usernameAuditStatus** (int, e.g. 1)
- **Accid** (string, e.g. 57cd6b98be64949589a6cecf7d258cd1)
- **teamID** (int, e.g. 0)
- **domain** (string, e.g. 13048069yf1jto)
- **trumpetCount** (int, e.g. 3)
- **season_type** (int, e.g. 0)
- **code** (int, e.g. 0)
- **message** (string, e.g. 操作成功)
- **status** (bool, e.g. True)
- **timestamp** (int, e.g. 1768931731)
- **ext** (list)
- *[Array Items]*
- **trace_id** (string, e.g. 8ae4feeb19cc4ed3a24a8a00f056d023)
- **success** (bool, e.g. True)
- **errcode** (int, e.g. 0)
---
## Category: `crane/http/api/data/vip_plus_match_data/{match_id}`
**Total Requests**: 179
- **data** (dict)
- **<steamid>** (dict)
- **fd_ct** (int, e.g. 2)
- **fd_t** (int, e.g. 2)
- **kast** (float, int, e.g. 0.7)
- **awp_kill** (int, e.g. 2)
- **awp_kill_ct** (int, e.g. 5)
- **awp_kill_t** (int, e.g. 2)
- **damage_stats** (int, e.g. 3)
- **damage_receive** (int, e.g. 0)
- **code** (int, e.g. 0)
- **message** (string, e.g. 操作成功)
- **status** (bool, e.g. True)
- **timestamp** (int, e.g. 1768931714)
- **ext** (list)
- *[Array Items]*
- **trace_id** (string, e.g. cff29d5dcdd6285b80d11bbb4a8a7da0)
- **success** (bool, e.g. True)
- **errcode** (int, e.g. 0)
---
## Category: `crane/http/api/match/leetify_rating/{match_id}`
**Total Requests**: 5
- **data** (dict)
- **leetify_data** (dict)
- **round_stat** (list)
- *[Array Items]*
- **round** (int, e.g. 2)
- **t_money_group** (int, e.g. 3)
- **ct_money_group** (int, e.g. 3)
- **win_reason** (int, e.g. 2)
- **bron_equipment** (dict)
- **<steamid>** (list)
- *[Array Items]*
- **Money** (int, e.g. 400)
- **WeaponName** (string, e.g. weapon_flashbang)
- **Weapon** (int, e.g. 22)
- **player_t_score** (dict)
- **<steamid>** (float, int, e.g. -21.459999999999997)
- **player_ct_score** (dict)
- **<steamid>** (float, int, e.g. 17.099999999999994)
- **player_bron_crash** (dict)
- **<steamid>** (int, e.g. 4200)
- **begin_ts** (string, e.g. 2026-01-18T19:57:29+08:00)
- **sfui_event** (dict)
- **sfui_type** (int, e.g. 2)
- **score_ct** (int, e.g. 2)
- **score_t** (int, e.g. 2)
- **end_ts** (string, e.g. 2026-01-18T19:54:37+08:00)
- **show_event** (list)
- *[Array Items]*
- **ts_real** (string, e.g. 0001-01-01T00:00:00Z)
- **ts** (int, e.g. 45)
- **t_num** (int, e.g. 2)
- **ct_num** (int, e.g. 2)
- **event_type** (int, e.g. 3)
- **kill_event** (dict, null)
- **Ts** (string, e.g. 2026-01-18T19:54:06+08:00)
- **Killer** (<steamid>, e.g. 76561199787406643)
- **Victim** (<steamid>, e.g. 76561199388433802)
- **Weapon** (int, e.g. 6)
- **KillerTeam** (int, e.g. 1)
- **KillerBot** (bool, e.g. False)
- **VictimBot** (bool, e.g. False)
- **WeaponName** (string, e.g. usp_silencer)
- **Headshot** (bool, e.g. False)
- **Penetrated** (bool, e.g. False)
- **ThroughSmoke** (bool, e.g. False)
- **NoScope** (bool, e.g. False)
- **AttackerBlind** (bool, e.g. False)
- **Attackerinair** (bool, e.g. False)
- **twin** (float, int, e.g. 0.143)
- **c_twin** (float, int, e.g. 0.44299999999999995)
- **twin_change** (float, int, e.g. -0.21600000000000003)
- **c_twin_change** (float, int, e.g. 0.21600000000000003)
- **killer_score_change** (dict, null)
- **<steamid>** (dict)
- **score** (float, int, e.g. 17.099999999999994)
- **victim_score_change** (dict, null)
- **<steamid>** (dict)
- **score** (float, int, e.g. -15.8)
- **assist_killer_score_change** (dict, null)
- **<steamid>** (dict)
- **score** (float, e.g. 2.592)
- **trade_score_change** (dict, null)
- **<steamid>** (dict)
- **score** (float, e.g. 2.2100000000000004)
- **flash_assist_killer_score_change** (dict, null)
- **<steamid>** (dict)
- **score** (float, e.g. 1.1520000000000001)
- **protect_gun_player_score_change** (dict, null)
- **<steamid>** (dict)
- **score** (float, e.g. 5.8999999999999995)
- **protect_gun_enemy_score_change** (dict, null)
- **<steamid>** (dict)
- **score** (float, e.g. -1.18)
- **disconnect_player_score_change** (null, e.g. None)
- **disconnect_comp_score_change** (null, e.g. None)
- **round_end_fixed_score_change** (dict, null)
- **<steamid>** (dict)
- **score** (float, int, e.g. 20)
- **win_reason** (int, e.g. 2)
- **side_info** (dict)
- **ct** (list)
- *[Array Items]*
- **t** (list)
- *[Array Items]*
- **player_scores** (dict)
- **<steamid>** (float, e.g. 12.491187500000002)
- **player_t_scores** (dict)
- **<steamid>** (float, e.g. 19.06)
- **player_ct_scores** (dict)
- **<steamid>** (float, e.g. -0.009666666666665455)
- **round_total** (int, e.g. 18)
- **player_round_scores** (dict)
- **<steamid>** (dict)
- **<round_n>** (float, int, e.g. 32.347)
- **uinfo_dict** (dict)
- **<steamid>** (dict)
- **uid** (<5eid>, int, e.g. 14889445)
- **uuid** (string, e.g. 13f7dc52-ea7c-11ed-9ce2-ec0d9a495494)
- **username** (string, e.g. 刚拉)
- **nickname** (string, e.g. )
- **reg_date** (int, e.g. 1683007881)
- **username_spam_status** (int, e.g. 1)
- **steamid_64** (<steamid>, e.g. 76561199032002725)
- **avatar_url** (string, e.g. disguise/images/6f/89/6f89b22633cb95df1754fd30573c5ad6.png)
- **gender** (int, e.g. 1)
- **country_id** (string, e.g. )
- **language** (string, e.g. )
- **domain** (string, e.g. rrrtina)
- **credit** (int, e.g. 0)
- **trusted_score** (int, e.g. 0)
- **trusted_status** (int, e.g. 0)
- **plus_info** (null, e.g. None)
- **region** (int, e.g. 0)
- **province** (int, e.g. 0)
- **province_name** (string, e.g. )
- **region_name** (string, e.g. )
- **college_id** (int, e.g. 0)
- **status** (int, e.g. 0)
- **identity** (null, e.g. None)
- **code** (int, e.g. 0)
- **message** (string, e.g. 操作成功)
- **status** (bool, e.g. True)
- **timestamp** (int, e.g. 1768833830)
- **ext** (list)
- *[Array Items]*
- **trace_id** (string, e.g. 376e200283d19770bdef6dacf260f40f)
- **success** (bool, e.g. True)
- **errcode** (int, e.g. 0)
---
## Category: `crane/http/api/match/round/{match_id}`
**Total Requests**: 174
- **data** (dict)
- **round_list** (list)
- *[Array Items]*
- **all_kill** (list)
- *[Array Items]*
- **attacker** (dict)
- **name** (string, e.g. 5E-Player 我有必胜卡组)
- **pos** (dict)
- **x** (int, e.g. 734)
- **y** (int, e.g. 125)
- **z** (int, e.g. 0)
- **steamid_64** (<steamid>, e.g. 76561198330488905)
- **team** (int, e.g. 1)
- **attackerblind** (bool, e.g. False)
- **headshot** (bool, e.g. False)
- **noscope** (bool, e.g. False)
- **pasttime** (int, e.g. 45)
- **penetrated** (bool, e.g. False)
- **throughsmoke** (bool, e.g. False)
- **victim** (dict)
- **name** (<5eid>, string, e.g. 5E-Player 青青C原懒大王w)
- **pos** (dict)
- **x** (int, e.g. 1218)
- **y** (int, e.g. 627)
- **z** (int, e.g. 0)
- **steamid_64** (<steamid>, string, e.g. 76561199482118960)
- **team** (int, e.g. 1)
- **weapon** (string, e.g. usp_silencer)
- **kill** (dict)
- **<steamid>** (list)
- *[Array Items]*
- **attacker** (dict)
- **name** (string, e.g. 5E-Player 我有必胜卡组)
- **pos** (dict)
- **x** (int, e.g. 734)
- **y** (int, e.g. 149)
- **z** (int, e.g. 0)
- **steamid_64** (<steamid>, e.g. 76561198330488905)
- **team** (int, e.g. 1)
- **attackerblind** (bool, e.g. False)
- **headshot** (bool, e.g. False)
- **noscope** (bool, e.g. False)
- **pasttime** (int, e.g. 24)
- **penetrated** (bool, e.g. False)
- **throughsmoke** (bool, e.g. False)
- **victim** (dict)
- **name** (<5eid>, string, e.g. 5E-Player 青青C原懒大王w)
- **pos** (dict)
- **x** (int, e.g. 1218)
- **y** (int, e.g. 627)
- **z** (int, e.g. 0)
- **steamid_64** (<steamid>, string, e.g. 76561198812383596)
- **team** (int, e.g. 1)
- **weapon** (string, e.g. usp_silencer)
- **c4_event** (list)
- *[Array Items]*
- **event_name** (string, e.g. planted_c4)
- **location** (string, e.g. )
- **name** (string, e.g. 5E-Player 我有必胜卡组)
- **pasttime** (int, e.g. 45)
- **steamid_64** (<steamid>, e.g. 76561198330488905)
- **current_score** (dict)
- **ct** (int, e.g. 2)
- **final_round_time** (int, e.g. 68)
- **pasttime** (int, e.g. 57)
- **t** (int, e.g. 2)
- **type** (int, e.g. 2)
- **death_list** (list)
- *[Array Items]*
- **equiped** (dict)
- **<steamid>** (list)
- *[Array Items]*
- **** (list)
- *[Array Items]*
- **round_kill_event** (list)
- *[Array Items]*
- **weapon_list** (dict)
- **defuser** (list)
- *[Array Items]*
- **item** (list)
- *[Array Items]*
- **main_weapon** (list)
- *[Array Items]*
- **other_item** (list)
- *[Array Items]*
- **secondary_weapon** (list)
- *[Array Items]*
- **code** (int, e.g. 0)
- **message** (string, e.g. 操作成功)
- **status** (bool, e.g. True)
- **timestamp** (int, e.g. 1768931714)
- **ext** (list)
- *[Array Items]*
- **trace_id** (string, e.g. c2ee4f45abd89f1c90dc1cc390d21d33)
- **success** (bool, e.g. True)
- **errcode** (int, e.g. 0)
---

View File

@@ -0,0 +1,90 @@
path,group
data.group_1_team_info.logo_url,data.*
data.group_1_team_info.team_domain,data.*
data.group_1_team_info.team_id,data.*
data.group_1_team_info.team_name,data.*
data.group_1_team_info.team_tag,data.*
data.group_2_team_info.logo_url,data.*
data.group_2_team_info.team_domain,data.*
data.group_2_team_info.team_id,data.*
data.group_2_team_info.team_name,data.*
data.group_2_team_info.team_tag,data.*
data.group_N[].friend_relation,data.*
data.level_list[].elo,data.*
data.level_list[].elo_type,data.*
data.level_list[].group_id,data.*
data.level_list[].level_id,data.*
data.level_list[].level_image,data.*
data.level_list[].level_name,data.*
data.level_list[].remark,data.*
data.level_list[].rise_type,data.*
data.level_list[].shelves_status,data.*
data.room_card.attrs.flagAnimation,data.*
data.room_card.attrs.flagAnimationTime,data.*
data.room_card.attrs.flagViewUrl,data.*
data.room_card.attrs.flagViewVideo,data.*
data.room_card.attrs.flagViewVideoTime,data.*
data.room_card.attrs.getWay,data.*
data.room_card.attrs.mallJumpLink,data.*
data.room_card.attrs.matchViewUrlLeft,data.*
data.room_card.attrs.matchViewUrlRight,data.*
data.room_card.attrs.mvpSettleAnimation,data.*
data.room_card.attrs.mvpSettleColor,data.*
data.room_card.attrs.mvpSettleViewAnimation,data.*
data.room_card.attrs.pcImg,data.*
data.room_card.attrs.rarityLevel,data.*
data.room_card.attrs.sort,data.*
data.room_card.attrs.sourceId,data.*
data.room_card.attrs.templateId,data.*
data.room_card.category,data.*
data.room_card.createdAt,data.*
data.room_card.describe,data.*
data.room_card.displayStatus,data.*
data.room_card.getButton,data.*
data.room_card.getUrl,data.*
data.room_card.getWay,data.*
data.room_card.id,data.*
data.room_card.name,data.*
data.room_card.onShelf,data.*
data.room_card.propTemplateId,data.*
data.room_card.shelfAt,data.*
data.room_card.sysType,data.*
data.room_card.updatedAt,data.*
data.round_sfui_type[],data.*
data.season_type,data.*
data.uinfo_dict.<steamid>.avatar_url,data.*
data.uinfo_dict.<steamid>.college_id,data.*
data.uinfo_dict.<steamid>.country_id,data.*
data.uinfo_dict.<steamid>.credit,data.*
data.uinfo_dict.<steamid>.domain,data.*
data.uinfo_dict.<steamid>.gender,data.*
data.uinfo_dict.<steamid>.identity,data.*
data.uinfo_dict.<steamid>.language,data.*
data.uinfo_dict.<steamid>.nickname,data.*
data.uinfo_dict.<steamid>.plus_info,data.*
data.uinfo_dict.<steamid>.province,data.*
data.uinfo_dict.<steamid>.province_name,data.*
data.uinfo_dict.<steamid>.reg_date,data.*
data.uinfo_dict.<steamid>.region,data.*
data.uinfo_dict.<steamid>.region_name,data.*
data.uinfo_dict.<steamid>.status,data.*
data.uinfo_dict.<steamid>.steamid_64,data.*
data.uinfo_dict.<steamid>.trusted_score,data.*
data.uinfo_dict.<steamid>.trusted_status,data.*
data.uinfo_dict.<steamid>.uid,data.*
data.uinfo_dict.<steamid>.username,data.*
data.uinfo_dict.<steamid>.username_spam_status,data.*
data.uinfo_dict.<steamid>.uuid,data.*
data.user_stats.map_level.add_exp,data.*
data.user_stats.map_level.map_exp,data.*
data.user_stats.plat_level.add_exp,data.*
data.user_stats.plat_level.plat_level_exp,data.*
data.weapon_list.defuser[],data.*
data.weapon_list.item[],data.*
data.weapon_list.main_weapon[],data.*
data.weapon_list.other_item[],data.*
data.weapon_list.secondary_weapon[],data.*
trace_id,other
trace_id,other
trace_id,other
trace_id,other
1 path group
2 data.group_1_team_info.logo_url data.*
3 data.group_1_team_info.team_domain data.*
4 data.group_1_team_info.team_id data.*
5 data.group_1_team_info.team_name data.*
6 data.group_1_team_info.team_tag data.*
7 data.group_2_team_info.logo_url data.*
8 data.group_2_team_info.team_domain data.*
9 data.group_2_team_info.team_id data.*
10 data.group_2_team_info.team_name data.*
11 data.group_2_team_info.team_tag data.*
12 data.group_N[].friend_relation data.*
13 data.level_list[].elo data.*
14 data.level_list[].elo_type data.*
15 data.level_list[].group_id data.*
16 data.level_list[].level_id data.*
17 data.level_list[].level_image data.*
18 data.level_list[].level_name data.*
19 data.level_list[].remark data.*
20 data.level_list[].rise_type data.*
21 data.level_list[].shelves_status data.*
22 data.room_card.attrs.flagAnimation data.*
23 data.room_card.attrs.flagAnimationTime data.*
24 data.room_card.attrs.flagViewUrl data.*
25 data.room_card.attrs.flagViewVideo data.*
26 data.room_card.attrs.flagViewVideoTime data.*
27 data.room_card.attrs.getWay data.*
28 data.room_card.attrs.mallJumpLink data.*
29 data.room_card.attrs.matchViewUrlLeft data.*
30 data.room_card.attrs.matchViewUrlRight data.*
31 data.room_card.attrs.mvpSettleAnimation data.*
32 data.room_card.attrs.mvpSettleColor data.*
33 data.room_card.attrs.mvpSettleViewAnimation data.*
34 data.room_card.attrs.pcImg data.*
35 data.room_card.attrs.rarityLevel data.*
36 data.room_card.attrs.sort data.*
37 data.room_card.attrs.sourceId data.*
38 data.room_card.attrs.templateId data.*
39 data.room_card.category data.*
40 data.room_card.createdAt data.*
41 data.room_card.describe data.*
42 data.room_card.displayStatus data.*
43 data.room_card.getButton data.*
44 data.room_card.getUrl data.*
45 data.room_card.getWay data.*
46 data.room_card.id data.*
47 data.room_card.name data.*
48 data.room_card.onShelf data.*
49 data.room_card.propTemplateId data.*
50 data.room_card.shelfAt data.*
51 data.room_card.sysType data.*
52 data.room_card.updatedAt data.*
53 data.round_sfui_type[] data.*
54 data.season_type data.*
55 data.uinfo_dict.<steamid>.avatar_url data.*
56 data.uinfo_dict.<steamid>.college_id data.*
57 data.uinfo_dict.<steamid>.country_id data.*
58 data.uinfo_dict.<steamid>.credit data.*
59 data.uinfo_dict.<steamid>.domain data.*
60 data.uinfo_dict.<steamid>.gender data.*
61 data.uinfo_dict.<steamid>.identity data.*
62 data.uinfo_dict.<steamid>.language data.*
63 data.uinfo_dict.<steamid>.nickname data.*
64 data.uinfo_dict.<steamid>.plus_info data.*
65 data.uinfo_dict.<steamid>.province data.*
66 data.uinfo_dict.<steamid>.province_name data.*
67 data.uinfo_dict.<steamid>.reg_date data.*
68 data.uinfo_dict.<steamid>.region data.*
69 data.uinfo_dict.<steamid>.region_name data.*
70 data.uinfo_dict.<steamid>.status data.*
71 data.uinfo_dict.<steamid>.steamid_64 data.*
72 data.uinfo_dict.<steamid>.trusted_score data.*
73 data.uinfo_dict.<steamid>.trusted_status data.*
74 data.uinfo_dict.<steamid>.uid data.*
75 data.uinfo_dict.<steamid>.username data.*
76 data.uinfo_dict.<steamid>.username_spam_status data.*
77 data.uinfo_dict.<steamid>.uuid data.*
78 data.user_stats.map_level.add_exp data.*
79 data.user_stats.map_level.map_exp data.*
80 data.user_stats.plat_level.add_exp data.*
81 data.user_stats.plat_level.plat_level_exp data.*
82 data.weapon_list.defuser[] data.*
83 data.weapon_list.item[] data.*
84 data.weapon_list.main_weapon[] data.*
85 data.weapon_list.other_item[] data.*
86 data.weapon_list.secondary_weapon[] data.*
87 trace_id other
88 trace_id other
89 trace_id other
90 trace_id other

83
docs/6D_README.md Normal file
View 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
View File

@@ -0,0 +1,44 @@
---
## demo维度
### d1、经济管理特征
1. 每局平均道具数量与使用率(烟雾、闪光、燃烧弹、手雷)
2. 伤害性道具效率(手雷/燃烧弹造成伤害值/投掷次数)
3. 细分武器KDAWP、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下还会有哪些需要打包成可快速调用的工具针对这个项目你有什么先见

85
docs/FeatureRDD.md Normal file
View File

@@ -0,0 +1,85 @@
## basic、个人基础数据特征
1. 平均Rating每局
2. 平均KD值每局
3. 平均KAST每局
4. 平均RWS每局
5. 每局爆头击杀数
6. 爆头率(爆头击杀/总击杀)
7. 每局首杀次数
8. 每局首死次数
9. 首杀率(首杀次数/首遇交火次数)
10. 首死率(首死次数/首遇交火次数)
11. 每局2+杀/3+杀/4+杀/5杀次数多杀
12. 连续击杀累计次数(连杀)
15. **(New) 助攻次数 (assisted_kill)**
16. **(New) 完美击杀 (perfect_kill)**
17. **(New) 复仇击杀 (revenge_kill)**
18. **(New) AWP击杀数 (awp_kill)**
19. **(New) 总跳跃次数 (jump_count)**
---
## 挖掘能力维度:
### 1、时间稳定序列特征 STA
1. 近30局平均Rating长期Rating
2. 胜局平均Rating
3. 败局平均Rating
4. Rating波动系数近10局Rating计算
5. 同一天内比赛时长与Rating相关性每2小时Rating变化率
6. 连续比赛局数与表现衰减率如第5局后vs前4局的KD变化
### 2、局内对抗能力特征 BAT
1. 对位最高Rating对手的KD差自身击杀-被该对手击杀)
2. 对位最低Rating对手的KD差自身击杀-被该对手击杀)
3. 对位所有对手的胜率(自身击杀>被击杀的对手占比)
4. 平均对枪成功率(对所有对手的对枪成功率求平均)
5. 与单个对手的交火次数(相遇频率)
* ~~A. 对枪反应时间(遇敌到开火平均时长,需录像解析)~~ (Phase 5)
* B. 近/中/远距对枪占比及各自胜率 (仅 Classic 可行)
### 3、高压场景表现特征 HPS (High Pressure Scenario)
1. 1v1/1v2/1v3+残局胜率
2. 赛点12-12、12-11等残局胜率
3. 人数劣势时的平均存活时间/击杀数(少打多能力)
4. 队伍连续丢3+局后自身首杀率(压力下突破能力)
5. 队伍连续赢3+局后自身2+杀率(顺境多杀能力)
6. 受挫后状态下滑率(被刀/被虐泉后3回合内Rating下降值
7. 起势后状态提升率(关键残局/多杀后3回合内Rating上升值
8. 翻盘阶段KD提升值同上场景下自身KD与平均差值
9. 连续丢分抗压性连续丢4+局时自身KD与平均差值
### 4、手枪局专项特征 PTL (Pistol Round)
1. 手枪局首杀次数
2. 手枪局2+杀次数(多杀)
3. 手枪局连杀次数
4. 参与的手枪局胜率(round1 round13)
5. 手枪类武器KD
6. 手枪局道具使用效率(烟雾/闪光帮助队友击杀数/投掷次数)
### 5、阵营倾向T/CT特征 T/CT
1. CT方平均Rating
2. T方平均Rating
3. CT方首杀率
4. T方首杀率
5. CT方守点成功率负责区域未被突破的回合占比
6. T方突破成功率成功突破敌方首道防线的回合占比
7. CT/T方KD差值CT KD - T KD
8. **(New) 下包次数 (planted_bomb)**
9. **(New) 拆包次数 (defused_bomb)**
### 6、道具特征 UTIL
1. 手雷伤害 (`throw_harm`)
2. 闪光致盲时间 (`flash_time`, `flash_enemy_time`, `flash_team_time`)
3. 闪光致盲人数 (`flash_enemy`, `flash_team`)
4. 每局平均道具数量与使用率(烟雾、闪光、燃烧弹、手雷)
### 手调1.、指挥手动调节因子主观评价0-10分
1. 沟通量(信息传递频率与有效性)
2. 辅助决策能力(半区决策建议的合理性)
3. 团队协作倾向(主动帮助队友的频率)
4. 打法激进程度进攻倾向0为保守10为激进
5. 执行力(对指挥战术的落实程度)
6. 临场应变力(突发情况的自主处理能力)
7. 氛围带动性(团队士气影响,正向/负向)

189
docs/WebRDD.md Normal file
View File

@@ -0,0 +1,189 @@
# YRTV 网站需求规格说明书 (RDD)
## 1. 项目概述 (Overview)
### 1.1 项目背景
YRTV 是一个面向 CS2 战队数据洞察与战术研判的 Web 平台,旨在通过 Web 界面提供可视化的数据查询、战队管理、战术模拟及深度分析功能。
### 1.2 核心目标
* **数据可视化**: 将复杂的 SQLite 比赛数据转化为易读的图表、雷达图和趋势线。
* **战术研判**: 提供阵容模拟、协同分析及地图热点情报,辅助战术决策。
* **交互体验**: 通过轻量级前端交互(筛选、对比、点赞、白板)提升数据使用效率。
* **实时动态**: 追踪战队成员的实时竞技状态与近期比赛动态,营造“战队大厅”氛围。
### 1.3 技术栈规划
* **后端框架**: Python Flask (轻量级,易于集成现有 ETL 脚本)
* **数据库**:
* **L2**: SQLite (`database/L2/L2_Main.sqlite`) - 基础事实数据 (Read-Only for Web)
* **L3**: SQLite (`database/L3/L3_Features.sqlite`) - 高级衍生特征 (Read-Only for Web)
* **Web**: SQLite (`database/Web/Web_App.sqlite`) - [新增] 业务数据 (用户、评论、阵容配置、策略板存档)
* **模板引擎**: Jinja2 (服务端渲染)
* **前端样式**: Tailwind CSS (CDN 引入,快速开发) + PC-First 响应式设计 (适配手机、平板与桌面端),主题色紫色,可切换黑白模式。
* **前端交互**:
* **图表**: Chart.js / ECharts (雷达图、趋势图)
* **交互**: Alpine.js 或原生 JS (处理模态框、异步请求)
* **拖拽**: SortableJS (阵容调整)
* **地图**: Leaflet.js 或简单 Canvas (热力图/策略板)
---
## 2. 系统架构 (Architecture)
### 2.1 目录结构规划
```text
yrtv/
├── web/
│ ├── app.py # Flask 应用入口
│ ├── config.py # 配置文件
│ ├── routes/ # 路由模块
│ │ ├── main.py # 首页与通用 (Home)
│ │ ├── players.py # 玩家模块 (List, Detail, Compare)
│ │ ├── teams.py # 战队模块 (Lineup, Stats)
│ │ ├── matches.py # 比赛模块 (List, Detail, Demo)
│ │ ├── tactics.py # 战术模块 (Lineup Builder, Map, Nade)
│ │ ├── wiki.py # 知识库模块 (Wiki, Docs)
│ │ └── admin.py # 管理后台 (ETL Trigger, User Mgmt)
│ ├── services/ # 业务逻辑层 (连接 L2/L3/Web DB)
│ │ ├── stats_service.py # 基础数据查询 (L2)
│ │ ├── feature_service.py # 高级特征查询 (L3)
│ │ ├── wiki_service.py # 知识库管理
│ │ └── user_service.py # 用户与评论管理
│ ├── static/ # 静态资源
│ │ ├── css/
│ │ ├── js/
│ │ └── images/
│ └── templates/ # Jinja2 模板
│ ├── base.html
│ ├── components/
│ ├── home/
│ ├── players/
│ ├── teams/
│ ├── matches/
│ ├── tactics/
│ ├── wiki/
│ └── admin/
├── database/ # 数据存储
│ ├── L1A/ # 原始爬虫数据
│ ├── L2/ # 结构化事实数据
│ ├── L3/ # 衍生特征库 (Feature Store)
│ └── Web/ # [新增] 业务数据库 (User, Comment, Wiki)
└── ETL/ # 数据处理层 (ETL Pipeline)
├── L1A.py # L1A Ingest
├── L2_Builder.py # L2 Transform
└── L3_Builder.py # L3 Feature Engineering (原 feature_store.py 逻辑)
```
### 2.2 数据流向
1. **ETL 层 (数据处理核心)**:
* L1 (Raw): 爬虫 -> JSON 存储。
* L2 (Fact): JSON -> 清洗/标准化 -> Fact/Dim Tables。
* **L3 (Features)**: L2 -> 聚合/滑窗计算/模型推理 -> Player/Team Derived Features。**数据处理逻辑收敛于 ETL 目录下的脚本Web 端仅负责读取 L2/L3 结果。**
2. **Service 层**: Flask Service 仅负责 SQL 查询与简单的业务组装(如评论关联),不再包含复杂的数据计算逻辑。
3. **View 层**: Jinja2 渲染 HTML。
4. **Client 层**: 浏览器交互。
### 2.3 开发与启动 (Development & Startup)
* **启动方式**:
* 在项目根目录下运行: `python web/app.py`
* 访问地址: `http://127.0.0.1:5000`
---
## 3. 功能需求详解 (Functional Requirements)
### 3.1 首页 (Home)
* **功能**: 平台入口与导航聚合。
* **内容**:
* **Hero 区域**: 平台定位文案("JKTV CS2 队伍数据洞察平台")。
* **Live / 战队状态看板 (New)**:
* **正在进行**: 如果监测到战队成员(配置列表内)正在进行比赛(通过 5E 接口轮询或最近 10 分钟内有数据更新),显示 "LIVE" 状态卡片。
* **近期战况**: 滚动显示战队成员最近结束的 5 场比赛结果胜负、比分、MVP
* **状态概览**: 类似 GitHub Contribution 的热力日历,展示战队本月的活跃度。
* **快捷入口卡片**:
* "战术指挥中心": 跳转至阵容模拟。
* "近期比赛": 跳转至最新一场比赛详情。
* "数据中心": 跳转至多维对比。
* **比赛解析器**: 输入 5E 比赛链接,点击按钮触发后台 ETL 任务(异步),前端显示 Loading 状态或 Toast 提示。
### 3.2 玩家模块 (Players)
#### 3.2.1 玩家列表 PlayerList
* **筛选/搜索**: 按 ID/昵称搜索,按 K/D、Rating、MVP 等指标排序。
* **展示**: 卡片式布局显示头像、ID、主队、核心数据 (Rating, K/D, ADR)。
#### 3.2.2 玩家详情 PlayerProfile
* **基础信息**: 头像、SteamID、5E ID、注册时间。可以手动分配Tag。
* **核心指标**: 赛季平均 Rating, ADR, KAST, 首杀成功率等。
* **能力雷达图**: *计算规则需在 Service 层定义*
* **趋势图**: 近 10/20 场比赛 Rating 走势 (Chart.js)。
* **评价板**: 类似于虎扑评分,用户可点赞/踩,显示热门评价(需新增 `web_comments` 表)。增加访问次数统计。
* **管理区** (Admin Only): 修改备注、上传自定义头像。
### 3.3 战队模块 (Teams)
* **阵容视图**: 展示当前核心阵容,手动添加。
* **角色分组**: 手动标签将玩家分组。
* **统计概览**: 战队整体胜率、近期战绩、地图胜率分布,个人关键数据。
### 3.4 比赛模块 (Matches)
#### 3.4.1 比赛列表 MatchList
* **筛选**: 按地图、日期范围筛选。
* **展示**: 列表视图显示时间、地图、比分、胜负、MVP。
#### 3.4.2 比赛详情 MatchDetail
* **头部**: 比分板CT/T 分数、地图、时长、Demo 下载链接。
* **数据表**: 双方队伍的完整数据表K, D, A, FK, FD, ADR, Rating, KAST, AWP Kills 等)。
* *利用 `fact_match_players` 中的丰富字段*
* **原始数据**: 提供 JSON 格式的原始数据查看/下载(`raw_iframe_network` 提取)。
### 3.5 战术模块 (Tactics)
#### 3.5.1 化学反应与战术深度分析 (Deep Analysis)
* **阵容组建**: 交互式界面,从玩家池拖拽 5 名玩家进入“首发名单”。
* **阵容评估**: 实时计算该 5 人组合的平均能力雷达。
* **共同经历**: 查询这 5 人共同参与过的比赛场次及胜率。
* **协同矩阵**: 选择特定阵容展示两两之间的协同数据A 补枪 B 的次数A 与 B 同时在场时的胜率)。
* **最佳/短板分析**: 基于历史数据分析该阵容在特定地图上的强弱项。
#### 3.5.2 数据对比 Data Center
* **多选对比**: 选择多名玩家,并在同一雷达图/柱状图中对比各项数据。
* **地图筛选**: 查看特定玩家在特定地图上的表现差异。
#### 3.5.3 道具与策略板 (Grenades & Strategy Board)
* **道具管理**:
* **道具计算**: 提供特定点位(如 Inferno 香蕉道)的烟雾弹/燃烧弹投掷模拟(基于坐标距离与轨迹公式)。
* **道具库**: 预设主流地图的常见道具点位(图片/视频展示),支持管理员添加新点位。
* **实时互动策略板**:
* **分地图绘制**: 基于 Leaflet.js 或 Canvas加载 CS2 高清鸟瞰图。
* **实时协同**: 支持 WebSocket 多人同屏绘制(类似 Excalidraw即时同步画笔轨迹与标记。
* **快照保存**: 支持一键保存当前战术板状态为图片或 JSON生成分享链接/加入知识库。
#### 3.5.4 经济计算器 (Economy Calculator)
* **功能**: 模拟 CS2 经济系统,辅助指挥决策。
* **输入**: 设定当前回合胜负、存活人数、炸弹状态、当前连败奖励。
* **输出**: 预测下一回合敌我双方的经济状况(最小/最大可用资金给出起枪建议Eco/Force/Full Buy
### 3.6 知识库 (Knowledge Base / Wiki)
* **架构**: 典型的 Wiki 布局。
* **左侧**: 全局文档树状目录(支持多级折叠)。
* **右侧**: 当前文档的页内大纲TOC
* **中间**: Markdown 渲染的正文区域。
* **功能**:
* **快速编辑**: 提供 Web 端 Markdown 编辑器,支持实时预览。
* **简单验证**: 简单的密码或 Token 验证即可保存修改,降低贡献门槛。
* **文件管理**: 支持新建、重命名、删除文档,自动生成目录结构。
### 3.7 管理后台 (Admin)
* **鉴权**: 简单的 Session/Token 登录。
* **数据管理**:
* 手动触发增量/全量 ETL。
* 上传 demo 文件或修正比赛数据。
* **配置**: 管理员账号管理、全局公告设置。查看网站访问数等后台统计。
### 3.8 管理后台查询工具 (SQL Runner)
* **功能**: 提供一个 Web 版的 SQLite 查询窗口。
* **限制**: 只读权限(防止 `DROP/DELETE`),仅供高级用户进行自定义数据挖掘。
---
### Second Stage: Demo 深度解析管线 (Future)
* **目标**: 引入 `demoparser2` (或类似开源库) 实现本地 Demo 文件的深度解析,获取比 Web 爬虫更细粒度的原子级数据。
* **Pipeline**:
1. **Ingest**: 自动/手动上传 `.dem` 文件。
2. **Parse**: 调用 `demoparser2` 提取每 tick/每事件数据 (Player Position, Grenade Trajectory, Weapon Firing)。
3. **Store**: 将海量原子数据存入 ClickHouse 或优化的 SQLite 分表 (L1B/L2+)。
4. **Analyze**: 产出高级分析指标(如:真实拉枪反应时间、道具覆盖效率、非预瞄击杀率)。
5. **Visualize**: 在前端复盘页面实现 2D 回放 (2D Replay) 功能。

View File

@@ -0,0 +1,103 @@
我现在需要你帮助我制作一个cs能力分析器与指挥帮助器命名为csanalyzer首先我们需要沟通确定CS2是分CT与TCT应该有哪几个位置T应该有哪几个位置
常见来说 T包括步枪手 突破手 狙击位 辅助 自由人,其中一位兼任指挥
CT包括小区主防 区域辅助 自由人 狙击位
你认可这样的分析吗请给我你的思路首先我们确定每个位置与其倾向然后再来分析玩家的数据应该包括哪些维度再来分析如何建立python模型分析这个模型我希望有一定的主观调整性因为我是指挥很多地方数据无法提现一个人是怎么玩游戏的例如rating低但是做的事很扎实只是因为碰的人不多这样不应该给低分。
现在我们需要开始构建能力维度,能力维度应该是极其极其丰富的。
首先我给你一张图这是5e主界面截图下来的里面包括一些维度。
但是我认为不管是rating还是rws还是5e评分都并没有考虑到特定玩家在队伍内的现状所以在这个基础上进行能力评分同样我认为是不合理的。
我认为首先应该增加一些维度:
1.玩家时间序列能力评估长期rating胜局rating败局rating等参数波动系数
2.玩家局内对枪能力评估对位对手最高最低rating的KD差对位所有人的胜率或百分比计算例如我对面第一=62就是我杀他6次他杀我2次这个应该与遇到的次数相关而非线性。
3.玩家高压发挥评估:残局能力,赛点残局能力,少打多能力,连续丢分压力下突破能力首杀能力
4.玩家手枪局评估:手枪局首杀能力,多杀能力,连杀能力,回放能力
5.玩家T/CT评估玩家平均在CT表现好还是T表现好倾向于做什么CT首杀率等评估进攻与防守倾向
6.玩家热图评估:常用站位,不同默认站位下打出的效果,哪里杀人多哪里杀人少
7.玩家数据评估常用ratingKDKASTimpactRWS等数据产出
8.玩家分位置能力评估:不同位置要求不同,指挥在能力值上应该有增益,狙击手与步枪手更加看重补枪效率,辅助看中道具能力等
9.玩家经济管理评估每局道具量购买与使用与产生作用关系主要针对伤害性道具武器倾向武器效果武器kd选择倾向与局效果的相关度
10.玩家持续时间评估是否有随着同一天内比赛进行rating下降
11.指挥手动调参维度:作为指挥我知道队伍中谁抗压好,谁抗压不行,谁沟通多,谁可以辅助指挥进行半区决策,谁喜欢帮助队友,谁是激进谁是保守
给我基于这些你的更多想法我来思考与选择。
除了上面给你的图片之外,你还有非常多指标可用,局内爆头击杀 爆头率 首杀首死道具rating残局等等详细内容也可以进行特征工程产出更多的数据维度特征
队伍维度应该有一些倾向分析例如喜欢打哪块胜率如何下包概率回访概率回防成功概率赌点成功概率eco局anti-eco局胜率发生概率帮助指挥进行决策。
### 拓展方向一:团队协同与配合分析
我们之前主要聚焦于单个选手但CS的精髓在于团队。我们可以增加一些维度来衡量选手之间是如何进行 互动 的。
- 补枪与被补枪效率 (Trade & Refrag Efficiency):
- 这是什么: 当一名队员阵亡后,队友立刻补枪完成人数交换的频率有多高?这个反应时间有多快?在队伍里,谁和谁是最高效的“补枪搭档”?
- 价值何在: 这是一个可以直接量化的、衡量团队协调性和沟通水平的指标。高的补枪率意味着队伍像一个整体在移动和战斗;反之则可能说明队员之间站位过远,打得太孤立。它能帮你回答:“我们到底是不是在抱团打?”
- 道具配合得分 (Utility Coordination Score):
- 这是什么: 衡量一名队员击杀的敌人,有多少是被队友的闪光弹致盲的。反过来,一名队员投掷的烟雾弹或燃烧弹,有多少次成功帮助队友完成了下包或拆包?
- 价值何在: 这将分析从“你有没有扔闪”提升到了“你的闪光弹 帮到人 了吗?”。它量化了辅助性道具的真实影响力,并能找出团队中最高效的道具配合二人组。
- “拉枪线”与“卖队友”行为分析 (高级功能):
- 这是什么: 这是一个更细微、也更难量化的指标。我们可以尝试识别一种模式:当一名队员阵亡时,他附近的队友是否在没有交火的情况下存活了下来。这 可能 是卖队友行为。反之,我们也可以识别出,当一名队员的阵亡成功吸引了敌方大量注意力,从而让队友拿到多杀的情况,这就是成功的“拉扯空间”。
- 价值何在: 作为指挥你最清楚什么是必要的牺牲什么是自私的打法。虽然这个指标很难做到100%自动化判断,但它可以将这些“可疑”的回合标记出来,供你亲自复盘,从而对团队内部的动态有更深刻的洞察。
### 拓展方向二:高级经济影响力分析
我们可以进一步优化衡量选手真实影响力的方式,超越原始的伤害或击杀数据。
- 经济扭转因子 (Economic Swing Factor):
- 这是什么: 量化那些对双方经济产生巨大影响的行为。例如:
1. 武器窃取价值: 击杀对方的狙击手并缴获其AWP这相当于一次近$6000的经济优势$4750的武器成本 + 击杀奖励)。
2. “影响力保枪”价值: 成功保下一把有价值的武器如AWP或长枪并在 下一回合 使用这把枪赢得了胜利。
- 价值何在: 这能凸显出那些在数据面板上不显眼,但通过聪明的经济决策改变了战局的选手。
- “回合致胜贡献”评分 (Round-Winning Contribution Score):
- 这是什么: 在任何一个赢下的回合里,哪些行为是 最关键 的一个1v3的残局胜利显然贡献巨大。但那个为团队创造了5v4优势的开局首杀呢那颗为安全下包提供了保障的烟雾弹呢我们可以建立一个模型为回合内的不同行为首杀、残局、关键道具赋予“胜利贡献分”。
- 价值何在: 它能帮助你发现,谁在持续地做出那些 导致胜利的关键决策 ,即便他不是数据榜上的第一名。
### 拓展方向三:心理与势头指标
这个方向尝试量化比赛中的“心态”博弈。
- “上头”与“起势”指标 ("Tilt" & "Snowball" Indicators):
- 这是什么: 一名选手在经历了一次令人沮丧的死亡(比如被刀)后,他的个人表现(如枪法精准度、反应速度)是否会在接下来的几个回合里有明显下滑?反之,在他赢得一个关键残局或拿到多杀后,他的表现是否会飙升(即“滚雪球”效应)?
- 价值何在: 这能帮助你作为指挥,识别出哪些队员心态坚韧,哪些队员在失利后可能需要一句鼓励。同时,也能看出谁是那种能依靠势头越打越好的“顺风神”。
- 翻盘贡献分 (Comeback Contribution Score):
- 这是什么: 在那些队伍完成大翻盘的比赛中例如从4-11追到13-11在翻盘阶段哪位选手的各项表现数据有最大的正面提升
- 价值何在: 这能精准地找出那些在队伍陷入绝境时,能够挺身而出、提升自己状态的选手——这是一个至关重要的领袖和韧性特质。

View File

@@ -0,0 +1,43 @@
## 3. 统一处理方案 (Unified Pipeline Strategy)
为了解决互斥问题,建议在 ETL `L2_Builder` 中建立一个 **中间抽象层 (Unified Event Model)**
### 3.1 统一事件结构
无论来源是 Classic 还是 Leetify都解析为以下标准结构存入 `fact_round_events`
```python
@dataclass
class UnifiedKillEvent:
match_id: str
round_num: int
tick: int = 0 # Web数据通常为0或估算
seconds: float = 0.0 # 回合开始后的秒数
attacker_steam_id: str
victim_steam_id: str
assister_steam_id: str = None
weapon: str
is_headshot: bool
is_wallbang: bool
is_blind: bool # Classic: attackerblind, Leetify: AttackerBlind
is_through_smoke: bool # Classic: throughsmoke, Leetify: ThroughSmoke
is_noscope: bool
# 空间数据 (Classic 有值, Leetify 为 Null)
attacker_pos: Tuple[float, float, float] = None
victim_pos: Tuple[float, float, float] = None
distance: float = None # 有坐标时自动计算
# 来源标记
source_type: str # 'classic' | 'leetify'
```
### 3.2 降级策略 (Graceful Degradation)
在 Web 前端或 API 层:
1. **热力图/站位分析**: 检查 `match.data_source_type`。如果是 `leetify`,显示“该场次不支持热力图数据”,或隐藏相关 Tab。
2. **距离分析**: 同上Leetify 场次不计入“平均交战距离”统计。
3. **经济分析**: Leetify 场次可提供更精准的经济走势图(因为有确切的 `Money` 字段Classic 场次可能需显示估算值。
### 3.3 推荐补充
对于 **反应时间**、**拉枪线**、**精确道具覆盖** 等 `❌` 项,建议列入 **Phase 5 (Demo Parser)** 开发计划,不强行通过 Web 数据拟合,以免误导用户。

85
downloader/README.md Normal file
View File

@@ -0,0 +1,85 @@
# Downloader 使用说明
## 作用
用于从 5E Arena 比赛页面抓取 iframe 内的 JSON 结果,并按需下载 demo 文件到本地目录。
## 运行环境
- Python 3.9+
- Playwright
安装依赖:
```bash
python -m pip install playwright
python -m playwright install
```
## 快速开始
单场下载(默认 URL
```bash
python downloader.py
```
指定比赛 URL
```bash
python downloader.py --url https://arena.5eplay.com/data/match/g161-20260118222715609322516
```
批量下载(从文件读取 URL
```bash
python downloader/downloader.py --url-list downloader/match_list_temp.txt --concurrency 4 --headless true --fetch-type iframe
```
指定输出目录:
```bash
python downloader.py --out output_arena
```
只抓 iframe 数据或只下载 demo
```bash
python downloader.py --fetch-type iframe
python downloader.py --fetch-type demo
```
## 主要参数
- --url单场比赛 URL未传时使用默认值
- --url-list包含多个比赛 URL 的文本文件,一行一个 URL
- --out输出目录默认 output_arena
- --match-name输出目录前缀名默认从 URL 提取
- --headless是否无头模式true/false默认 false
- --timeout-ms页面加载超时毫秒默认 30000
- --capture-ms主页面 JSON 监听时长毫秒,默认 5000
- --iframe-capture-msiframe 页面 JSON 监听时长毫秒,默认 8000
- --concurrency并发数量默认 3
- --goto-retries页面打开重试次数默认 1
- --fetch-type抓取类型iframe/demo/both默认 both
## 输出结构
下载目录会以比赛编号或自定义名称创建子目录:
```
output_arena/
g161-20260118222715609322516/
iframe_network.json
g161-20260118222715609322516_de_ancient.zip
g161-20260118222715609322516_de_ancient.dem
```
## URL 列表格式
文本文件一行一个 URL空行和以 # 开头的行会被忽略:
```
https://arena.5eplay.com/data/match/g161-20260118222715609322516
# 注释
https://arena.5eplay.com/data/match/g161-20260118212021710292006
```
## 常见问题
- 如果提示 Playwright 未安装,请先执行安装命令再运行脚本
- 如果下载目录已有文件,会跳过重复下载

416
downloader/downloader.py Normal file
View File

@@ -0,0 +1,416 @@
import argparse
import asyncio
import json
import os
import sys
import time
import urllib.request
from pathlib import Path
from urllib.parse import urlparse
def build_args():
parser = argparse.ArgumentParser()
parser.add_argument(
"--url",
default="https://arena.5eplay.com/data/match/g161-20260118222715609322516",
)
parser.add_argument("--url-list", default="")
parser.add_argument("--out", default="output_arena")
parser.add_argument("--match-name", default="")
parser.add_argument("--headless", default="false")
parser.add_argument("--timeout-ms", type=int, default=30000)
parser.add_argument("--capture-ms", type=int, default=5000)
parser.add_argument("--iframe-capture-ms", type=int, default=8000)
parser.add_argument("--concurrency", type=int, default=3)
parser.add_argument("--goto-retries", type=int, default=1)
parser.add_argument("--fetch-type", default="both", choices=["iframe", "demo", "both"])
return parser
def ensure_dir(path):
Path(path).mkdir(parents=True, exist_ok=True)
def truthy(value):
return str(value).lower() in {"1", "true", "yes", "y", "on"}
def log(message):
stamp = time.strftime("%H:%M:%S")
print(f"[{stamp}] {message}")
def safe_folder(value):
keep = []
for ch in value:
if ch.isalnum() or ch in {"-", "_"}:
keep.append(ch)
return "".join(keep) or "match"
def extract_match_code(url):
for part in url.split("/"):
if part.startswith("g") and "-" in part:
return part
return ""
def read_url_list(path):
if not path:
return []
if not os.path.exists(path):
return []
urls = []
with open(path, "r", encoding="utf-8-sig") as f:
for line in f:
value = line.strip()
if not value or value.startswith("#"):
continue
urls.append(value)
return urls
def collect_demo_urls(value, results):
if isinstance(value, dict):
for key, item in value.items():
if key == "demo_url" and isinstance(item, str):
results.add(item)
collect_demo_urls(item, results)
elif isinstance(value, list):
for item in value:
collect_demo_urls(item, results)
def extract_demo_urls_from_payloads(payloads):
results = set()
for payload in payloads:
collect_demo_urls(payload, results)
return list(results)
def extract_demo_urls_from_network(path):
if not os.path.exists(path):
return []
try:
with open(path, "r", encoding="utf-8") as f:
payload = json.load(f)
except Exception:
return []
return extract_demo_urls_from_payloads([payload])
def download_file(url, dest_dir):
if not url:
return ""
ensure_dir(dest_dir)
filename = os.path.basename(urlparse(url).path) or "demo.zip"
dest_path = os.path.join(dest_dir, filename)
if os.path.exists(dest_path):
return dest_path
temp_path = dest_path + ".part"
try:
with urllib.request.urlopen(url) as response, open(temp_path, "wb") as f:
while True:
chunk = response.read(1024 * 1024)
if not chunk:
break
f.write(chunk)
os.replace(temp_path, dest_path)
return dest_path
except Exception:
try:
if os.path.exists(temp_path):
os.remove(temp_path)
except Exception:
pass
return ""
def download_demo_from_iframe(out_dir, iframe_payloads=None):
if iframe_payloads is None:
network_path = os.path.join(out_dir, "iframe_network.json")
demo_urls = extract_demo_urls_from_network(network_path)
else:
demo_urls = extract_demo_urls_from_payloads(iframe_payloads)
downloaded = []
for url in demo_urls:
path = download_file(url, out_dir)
if path:
downloaded.append(path)
return downloaded
async def safe_goto(page, url, timeout_ms, retries):
attempt = 0
while True:
try:
await page.goto(url, wait_until="domcontentloaded", timeout=timeout_ms)
return True
except Exception as exc:
attempt += 1
if attempt > retries:
log(f"打开失败 {url} {exc}")
return False
await page.wait_for_timeout(1000)
async def intercept_json_responses(page, sink, capture_ms):
active = True
async def handle_response(response):
try:
if not active:
return
headers = response.headers
content_type = headers.get("content-type", "")
if "application/json" in content_type or "json" in content_type:
body = await response.json()
sink.append(
{
"url": response.url,
"status": response.status,
"body": body,
}
)
except Exception:
return
page.on("response", handle_response)
await page.wait_for_timeout(capture_ms)
active = False
async def open_iframe_page(
context, iframe_url, out_dir, timeout_ms, capture_ms, goto_retries, write_iframe_network
):
iframe_page = await context.new_page()
json_sink = []
response_task = asyncio.create_task(intercept_json_responses(iframe_page, json_sink, capture_ms))
ok = await safe_goto(iframe_page, iframe_url, timeout_ms, goto_retries)
if not ok:
await response_task
await iframe_page.close()
return json_sink
try:
await iframe_page.wait_for_load_state("domcontentloaded", timeout=timeout_ms)
except Exception:
pass
clicked = False
try:
await iframe_page.wait_for_timeout(1000)
try:
await iframe_page.wait_for_selector(".ya-tab", timeout=timeout_ms)
except Exception:
pass
tab_names = ["5E Swing Score", "5E 摆动分", "摆动分", "Swing Score", "Swing", "SS"]
for name in tab_names:
locator = iframe_page.locator(".ya-tab", has_text=name)
if await locator.count() > 0:
await locator.first.scroll_into_view_if_needed()
await locator.first.click(timeout=timeout_ms, force=True)
clicked = True
break
locator = iframe_page.get_by_role("tab", name=name)
if await locator.count() > 0:
await locator.first.scroll_into_view_if_needed()
await locator.first.click(timeout=timeout_ms, force=True)
clicked = True
break
locator = iframe_page.get_by_role("button", name=name)
if await locator.count() > 0:
await locator.first.scroll_into_view_if_needed()
await locator.first.click(timeout=timeout_ms, force=True)
clicked = True
break
locator = iframe_page.get_by_text(name, exact=True)
if await locator.count() > 0:
await locator.first.scroll_into_view_if_needed()
await locator.first.click(timeout=timeout_ms, force=True)
clicked = True
break
locator = iframe_page.get_by_text(name, exact=False)
if await locator.count() > 0:
await locator.first.scroll_into_view_if_needed()
await locator.first.click(timeout=timeout_ms, force=True)
clicked = True
break
if not clicked:
clicked = await iframe_page.evaluate(
"""() => {
const labels = ["5E Swing Score", "5E 摆动分", "摆动分", "Swing Score", "Swing", "SS"];
const roots = [document];
const elements = [];
while (roots.length) {
const root = roots.pop();
const tree = root.querySelectorAll ? Array.from(root.querySelectorAll("*")) : [];
for (const el of tree) {
elements.push(el);
if (el.shadowRoot) roots.push(el.shadowRoot);
}
}
const target = elements.find(el => {
const text = (el.textContent || "").trim();
if (!text) return false;
if (!labels.some(l => text.includes(l))) return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
});
if (target) {
target.scrollIntoView({block: "center", inline: "center"});
const rect = target.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
const events = ["pointerdown", "mousedown", "pointerup", "mouseup", "click"];
for (const type of events) {
target.dispatchEvent(new MouseEvent(type, {bubbles: true, cancelable: true, clientX: x, clientY: y}));
}
return true;
}
return false;
}"""
)
if not clicked:
clicked = await iframe_page.evaluate(
"""() => {
const tabs = Array.from(document.querySelectorAll(".ya-tab"));
if (tabs.length === 0) return false;
const target = tabs.find(tab => {
const text = (tab.textContent || "").replace(/\\s+/g, " ").trim();
return text.includes("5E Swing Score") || text.includes("5E 摆动分") || text.includes("摆动分");
}) || tabs[tabs.length - 1];
if (!target) return false;
target.scrollIntoView({block: "center", inline: "center"});
const rect = target.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
const events = ["pointerdown", "mousedown", "pointerup", "mouseup", "click"];
for (const type of events) {
target.dispatchEvent(new MouseEvent(type, {bubbles: true, cancelable: true, clientX: x, clientY: y}));
}
return true;
}"""
)
if not clicked:
tab_locator = iframe_page.locator(".ya-tab")
if await tab_locator.count() > 0:
target = tab_locator.nth(await tab_locator.count() - 1)
box = await target.bounding_box()
if box:
await iframe_page.mouse.click(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
clicked = True
except Exception:
clicked = False
if clicked:
await iframe_page.wait_for_timeout(1500)
await intercept_json_responses(iframe_page, json_sink, capture_ms)
try:
await iframe_page.wait_for_load_state("networkidle", timeout=timeout_ms)
except Exception:
pass
await response_task
if write_iframe_network:
with open(os.path.join(out_dir, "iframe_network.json"), "w", encoding="utf-8") as f:
json.dump(json_sink, f, ensure_ascii=False, indent=2)
await iframe_page.close()
return json_sink
async def run_match(pw, args, url, index, total):
base_out = os.path.abspath(args.out)
ensure_dir(base_out)
match_code = extract_match_code(url)
base_name = args.match_name.strip() or match_code or "match"
if total > 1:
suffix = match_code or str(index + 1)
if base_name != suffix:
name = f"{base_name}-{suffix}"
else:
name = base_name
else:
name = base_name
out_dir = os.path.join(base_out, safe_folder(name))
ensure_dir(out_dir)
headless = truthy(args.headless)
timeout_ms = args.timeout_ms
capture_ms = args.capture_ms
iframe_capture_ms = args.iframe_capture_ms
goto_retries = args.goto_retries
fetch_type = str(args.fetch_type or "both").lower()
want_iframe = fetch_type in {"iframe", "both"}
want_demo = fetch_type in {"demo", "both"}
browser = await pw.chromium.launch(headless=headless, slow_mo=50)
context = await browser.new_context(accept_downloads=True)
page = await context.new_page()
log(f"打开比赛页 {index + 1}/{total}")
ok = await safe_goto(page, url, timeout_ms, goto_retries)
if not ok:
await browser.close()
return
try:
await page.wait_for_load_state("networkidle", timeout=timeout_ms)
except Exception:
pass
iframe_url = await page.evaluate(
"""() => {
const iframe = document.querySelector('iframe')
return iframe ? iframe.getAttribute('src') : null
}"""
)
iframe_sink = []
if iframe_url and (want_iframe or want_demo):
log(f"进入内嵌页面 {iframe_url}")
iframe_sink = await open_iframe_page(
context, iframe_url, out_dir, timeout_ms, iframe_capture_ms, goto_retries, want_iframe
)
if want_demo:
downloaded = download_demo_from_iframe(out_dir, iframe_sink if iframe_sink else None)
if downloaded:
log(f"已下载 demo: {len(downloaded)}")
await browser.close()
async def run_match_with_semaphore(semaphore, pw, args, url, index, total):
async with semaphore:
try:
await run_match(pw, args, url, index, total)
except Exception as exc:
log(f"任务失败 {url} {exc}")
async def run():
args = build_args().parse_args()
try:
from playwright.async_api import async_playwright
except Exception:
print("Playwright 未安装,请先安装: python -m pip install playwright && python -m playwright install")
sys.exit(1)
urls = read_url_list(args.url_list)
if not urls:
urls = [args.url]
async with async_playwright() as pw:
concurrency = max(1, int(args.concurrency or 1))
semaphore = asyncio.Semaphore(concurrency)
tasks = [
asyncio.create_task(run_match_with_semaphore(semaphore, pw, args, url, index, len(urls)))
for index, url in enumerate(urls)
]
if tasks:
await asyncio.gather(*tasks)
log("完成")
def main():
asyncio.run(run())
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,47 @@
https://arena.5eplay.com/data/match/g161-20260118222715609322516
https://arena.5eplay.com/data/match/g161-20260118215640650728700
https://arena.5eplay.com/data/match/g161-20260118212021710292006
https://arena.5eplay.com/data/match/g161-20260118202243599083093
https://arena.5eplay.com/data/match/g161-20260118195105311656229
https://arena.5eplay.com/data/match/g161-20251227204147532432472
https://arena.5eplay.com/data/match/g161-20251224212749300709409
https://arena.5eplay.com/data/match/g161-20251224204010707719140
https://arena.5eplay.com/data/match/g161-n-20251130213145958206941
https://arena.5eplay.com/data/match/g161-n-20251130210025158075163
https://arena.5eplay.com/data/match/g161-20251130202604606424766
https://arena.5eplay.com/data/match/g161-n-20251121221256211567778
https://arena.5eplay.com/data/match/g161-20251121213002842778327
https://arena.5eplay.com/data/match/g161-20251121204534531429599
https://arena.5eplay.com/data/match/g161-20251120225541418811147
https://arena.5eplay.com/data/match/g161-n-20251120215752770546182
https://arena.5eplay.com/data/match/g161-n-20251120212307767251203
https://arena.5eplay.com/data/match/g161-n-20251120204855361553501
https://arena.5eplay.com/data/match/g161-20251119224637611106951
https://arena.5eplay.com/data/match/g161-20251119220301211708132
https://arena.5eplay.com/data/match/g161-20251119212237018904830
https://arena.5eplay.com/data/match/g161-20251113221747008211552
https://arena.5eplay.com/data/match/g161-20251113213926308316564
https://arena.5eplay.com/data/match/g161-20251113205020504700482
https://arena.5eplay.com/data/match/g161-n-20251222211554225486531
https://arena.5eplay.com/data/match/g161-n-20251222204652101389654
https://arena.5eplay.com/data/match/g161-20251213224016824985377
https://arena.5eplay.com/data/match/g161-n-20251031232529838133039
https://arena.5eplay.com/data/match/g161-n-20251031222014957918049
https://arena.5eplay.com/data/match/g161-n-20251031214157458692406
https://arena.5eplay.com/data/match/g161-n-20251031210748072610729
https://arena.5eplay.com/data/match/g161-n-20251030222146222677830
https://arena.5eplay.com/data/match/g161-n-20251030213304728467793
https://arena.5eplay.com/data/match/g161-n-20251030205820720066790
https://arena.5eplay.com/data/match/g161-n-20251029215222528748730
https://arena.5eplay.com/data/match/g161-n-20251029223307353807510
https://arena.5eplay.com/data/match/g161-n-20251027231404235379274
https://arena.5eplay.com/data/match/g161-n-20251028213320660376574
https://arena.5eplay.com/data/match/g161-n-20251028221342615577217
https://arena.5eplay.com/data/match/g161-n-20251027223836601395494
https://arena.5eplay.com/data/match/g161-n-20251027215238222152932
https://arena.5eplay.com/data/match/g161-n-20251027210631831497570
https://arena.5eplay.com/data/match/g161-n-20251025230600131718164
https://arena.5eplay.com/data/match/g161-n-20251025213429016677232
https://arena.5eplay.com/data/match/g161-n-20251025210415433542948
https://arena.5eplay.com/data/match/g161-n-20251025203218851223471
https://arena.5eplay.com/data/match/g161-n-20251025195106739608572

View File

@@ -0,0 +1,48 @@
https://arena.5eplay.com/data/match/g161-n-20250913220512141946989
https://arena.5eplay.com/data/match/g161-n-20250913213107816808164
https://arena.5eplay.com/data/match/g161-20250913205742414202329
https://arena.5eplay.com/data/match/g161-n-20250827221331843083555
https://arena.5eplay.com/data/match/g161-20250817225217269787769
https://arena.5eplay.com/data/match/g161-20250817221445650638471
https://arena.5eplay.com/data/match/g161-20250817213333244382504
https://arena.5eplay.com/data/match/g161-20250817204703953154600
https://arena.5eplay.com/data/match/g161-n-20250816230720637945240
https://arena.5eplay.com/data/match/g161-n-20250816223209989476278
https://arena.5eplay.com/data/match/g161-n-20250816215000584183999
https://arena.5eplay.com/data/match/g161-n-20250810000507840654837
https://arena.5eplay.com/data/match/g161-n-20250809232857469499842
https://arena.5eplay.com/data/match/g161-n-20250809224113646082440
https://arena.5eplay.com/data/match/g161-20250805224735339106659
https://arena.5eplay.com/data/match/g161-20250805221246768259380
https://arena.5eplay.com/data/match/g161-20250805213044671459165
https://arena.5eplay.com/data/match/g161-n-20250729224539870249509
https://arena.5eplay.com/data/match/g161-n-20250729221017411617812
https://arena.5eplay.com/data/match/g161-n-20250726230753271236792
https://arena.5eplay.com/data/match/g161-n-20250726222011747090952
https://arena.5eplay.com/data/match/g161-n-20250726213213252258654
https://arena.5eplay.com/data/match/g161-n-20250726210250462966112
https://arena.5eplay.com/data/match/g161-n-20250726202108438713376
https://arena.5eplay.com/data/match/g161-n-20250708223526502973398
https://arena.5eplay.com/data/match/g161-n-20250629224717702923977
https://arena.5eplay.com/data/match/g161-n-20250629221632707741592
https://arena.5eplay.com/data/match/g161-n-20250629214005898851985
https://arena.5eplay.com/data/match/g161-n-20250625233517097081378
https://arena.5eplay.com/data/match/g161-n-20250625233517097081378
https://arena.5eplay.com/data/match/g161-n-20250625233517097081378
https://arena.5eplay.com/data/match/g161-n-20250625225637201689118
https://arena.5eplay.com/data/match/g161-n-20250625220051296084673
https://arena.5eplay.com/data/match/g161-n-20250625212340196552999
https://arena.5eplay.com/data/match/g161-n-20250625204055608218332
https://arena.5eplay.com/data/match/g161-n-20250624224559896152236
https://arena.5eplay.com/data/match/g161-n-20250624221215091912088
https://arena.5eplay.com/data/match/g161-n-20250624213649835216392
https://arena.5eplay.com/data/match/g161-20250329215431484950790
https://arena.5eplay.com/data/match/g161-20250404102704857102834
https://arena.5eplay.com/data/match/g161-20250404110639758722580
https://arena.5eplay.com/data/match/g161-20250404113912053638456
https://arena.5eplay.com/data/match/g161-20250404124315256663822
https://arena.5eplay.com/data/match/g161-n-20250418212920157087385
https://arena.5eplay.com/data/match/g161-n-20250423212911381760420
https://arena.5eplay.com/data/match/g161-n-20250423221015836808051
https://arena.5eplay.com/data/match/g161-n-20250505212901236776044
https://arena.5eplay.com/data/match/g161-n-20250505210156662230606

View File

@@ -0,0 +1,23 @@
https://arena.5eplay.com/data/match/g161-n-20251012225545036903374
https://arena.5eplay.com/data/match/g161-n-20251012220151962958852
https://arena.5eplay.com/data/match/g161-n-20251012220151962958852
https://arena.5eplay.com/data/match/g161-n-20251012211416764734636
https://arena.5eplay.com/data/match/g161-n-20251003170554517340798
https://arena.5eplay.com/data/match/g161-n-20251006130250489051437
https://arena.5eplay.com/data/match/g161-n-20251006122000914844735
https://arena.5eplay.com/data/match/g161-n-20251005185512726501951
https://arena.5eplay.com/data/match/g161-n-20251005182335443677587
https://arena.5eplay.com/data/match/g161-n-20251003192720361556278
https://arena.5eplay.com/data/match/g161-n-20251003185649812523095
https://arena.5eplay.com/data/match/g161-n-20251003182922419032199
https://arena.5eplay.com/data/match/g161-n-20251003175831422195120
https://arena.5eplay.com/data/match/g161-n-20251003170554517340798
https://arena.5eplay.com/data/match/g161-n-20251003161937522875514
https://arena.5eplay.com/data/match/g161-n-20250913220512141946989
https://arena.5eplay.com/data/match/g161-20250913205742414202329
https://arena.5eplay.com/data/match/g161-n-20250913213107816808164
https://arena.5eplay.com/data/match/g161-n-20250729221017411617812
https://arena.5eplay.com/data/match/g161-n-20250816215000584183999
https://arena.5eplay.com/data/match/g161-n-20250816223209989476278
https://arena.5eplay.com/data/match/g161-n-20250810000507840654837
https://arena.5eplay.com/data/match/g161-n-20250809224113646082440

View File

@@ -0,0 +1,73 @@
https://arena.5eplay.com/data/match/g161-n-20250103201445137702215
https://arena.5eplay.com/data/match/g161-n-20250103203331443454143
https://arena.5eplay.com/data/match/g161-n-20250103211644789725355
https://arena.5eplay.com/data/match/g161-n-20250105000114157444753
https://arena.5eplay.com/data/match/g161-n-20250105004102938304243
https://arena.5eplay.com/data/match/g161-n-20250109205825766219524
https://arena.5eplay.com/data/match/g161-n-20250109214524585140725
https://arena.5eplay.com/data/match/g161-n-20250109222317807381679
https://arena.5eplay.com/data/match/g161-n-20250109225725438125765
https://arena.5eplay.com/data/match/g161-n-20250110000800438550163
https://arena.5eplay.com/data/match/g161-n-20250115210950870494621
https://arena.5eplay.com/data/match/g161-n-20250115214227730237642
https://arena.5eplay.com/data/match/g161-n-20250115222151238089028
https://arena.5eplay.com/data/match/g161-n-20250115224837069753503
https://arena.5eplay.com/data/match/g161-n-20250119201843917352000
https://arena.5eplay.com/data/match/g161-n-20250119205646572572033
https://arena.5eplay.com/data/match/g161-n-20250119214057134288558
https://arena.5eplay.com/data/match/g161-n-20250119221209668234775
https://arena.5eplay.com/data/match/g161-n-20250212194801048099163
https://arena.5eplay.com/data/match/g161-n-20250212204500213129957
https://arena.5eplay.com/data/match/g161-n-20250212211417251548261
https://arena.5eplay.com/data/match/g161-n-20250212224659856768179
https://arena.5eplay.com/data/match/g161-n-20250212232524442488205
https://arena.5eplay.com/data/match/g161-20250214164955786323546
https://arena.5eplay.com/data/match/g161-20250214172202090993964
https://arena.5eplay.com/data/match/g161-20250214174757585798948
https://arena.5eplay.com/data/match/g161-20250215204022294779045
https://arena.5eplay.com/data/match/g161-20250215211846894242128
https://arena.5eplay.com/data/match/g161-20250217202409685923399
https://arena.5eplay.com/data/match/g161-20250217205402386409635
https://arena.5eplay.com/data/match/g161-20250217212436510051874
https://arena.5eplay.com/data/match/g161-20250217220552927034811
https://arena.5eplay.com/data/match/g161-20250218160114138124831
https://arena.5eplay.com/data/match/g161-20250218162428685487349
https://arena.5eplay.com/data/match/g161-20250218165542404622024
https://arena.5eplay.com/data/match/g161-20250218211240395943608
https://arena.5eplay.com/data/match/g161-20250218214056585823614
https://arena.5eplay.com/data/match/g161-20250218221355585818088
https://arena.5eplay.com/data/match/g161-n-20250221200134537532083
https://arena.5eplay.com/data/match/g161-n-20250221202611846934043
https://arena.5eplay.com/data/match/g161-n-20250221205801951388015
https://arena.5eplay.com/data/match/g161-n-20250221212924852778522
https://arena.5eplay.com/data/match/g161-n-20250221220520358691141
https://arena.5eplay.com/data/match/g161-n-20250224190530943492421
https://arena.5eplay.com/data/match/g161-n-20250224192756599598828
https://arena.5eplay.com/data/match/g161-n-20250224211003642995175
https://arena.5eplay.com/data/match/g161-n-20250224214246751262216
https://arena.5eplay.com/data/match/g161-n-20250224221018957359594
https://arena.5eplay.com/data/match/g161-n-20250227201006443002972
https://arena.5eplay.com/data/match/g161-n-20250227204400163237739
https://arena.5eplay.com/data/match/g161-n-20250227211802698292906
https://arena.5eplay.com/data/match/g161-n-20250301200647442341789
https://arena.5eplay.com/data/match/g161-n-20250301204325972686590
https://arena.5eplay.com/data/match/g161-n-20250301211319138257939
https://arena.5eplay.com/data/match/g161-n-20250301214842394094370
https://arena.5eplay.com/data/match/g161-n-20250301221920464983026
https://arena.5eplay.com/data/match/g161-20250301225228585801638
https://arena.5eplay.com/data/match/g161-20250302154200385322147
https://arena.5eplay.com/data/match/g161-20250302161030995093939
https://arena.5eplay.com/data/match/g161-20250302165056088320401
https://arena.5eplay.com/data/match/g161-20250306212929308811302
https://arena.5eplay.com/data/match/g161-20250306220339391113038
https://arena.5eplay.com/data/match/g161-n-20250307202729007357677
https://arena.5eplay.com/data/match/g161-n-20250307205954649678046
https://arena.5eplay.com/data/match/g161-n-20250307214542342522277
https://arena.5eplay.com/data/match/g161-n-20250307220959454626136
https://arena.5eplay.com/data/match/g161-n-20250311202342544577031
https://arena.5eplay.com/data/match/g161-n-20250311220347557866712
https://arena.5eplay.com/data/match/g161-n-20250311212924644001588
https://arena.5eplay.com/data/match/g161-n-20250311205101348741496
https://arena.5eplay.com/data/match/g161-n-20250313200635729548487
https://arena.5eplay.com/data/match/g161-n-20250313204903360834136
https://arena.5eplay.com/data/match/g161-n-20250313211821260060301

View File

@@ -0,0 +1,12 @@
https://arena.5eplay.com/data/match/g161-20260120090500700546858
https://arena.5eplay.com/data/match/g161-20260123152313646137189
https://arena.5eplay.com/data/match/g161-20260123155331151172258
https://arena.5eplay.com/data/match/g161-20260123163155468519060
https://arena.5eplay.com/data/match/g161-20260125163636663072260
https://arena.5eplay.com/data/match/g161-20260125171525375681453
https://arena.5eplay.com/data/match/g161-20260125174806246015320
https://arena.5eplay.com/data/match/g161-20260125182858851607650
https://arena.5eplay.com/data/match/g161-20260127133354952029097
https://arena.5eplay.com/data/match/g161-20260127141401965388621
https://arena.5eplay.com/data/match/g161-20260127144918246454523
https://arena.5eplay.com/data/match/g161-20260127161541951490476

View File

@@ -0,0 +1,21 @@
https://arena.5eplay.com/data/match/g161-20260116113753599674563
https://arena.5eplay.com/data/match/g161-20260116105442247840198
https://arena.5eplay.com/data/match/g161-20260116102417845632390
https://arena.5eplay.com/data/match/g161-20260116091335547226912
https://arena.5eplay.com/data/match/g161-20260115174926535143518
https://arena.5eplay.com/data/match/g161-20260115171408550328234
https://arena.5eplay.com/data/match/g161-20260115161507644198027
https://arena.5eplay.com/data/match/g161-20260115153741594547847
https://arena.5eplay.com/data/match/g161-20260115150134653528666
https://arena.5eplay.com/data/match/g161-20260115142248467942413
https://arena.5eplay.com/data/match/g161-20260115134537148483852
https://arena.5eplay.com/data/match/g161-b-20251220170603831835021
https://arena.5eplay.com/data/match/g161-b-20251220163145714630262
https://arena.5eplay.com/data/match/g161-b-20251220154644424162461
https://arena.5eplay.com/data/match/g161-20251220151348629917836
https://arena.5eplay.com/data/match/g161-20251220143804815413986
https://arena.5eplay.com/data/match/g161-20251213224016824985377
https://arena.5eplay.com/data/match/g161-20251119220301211708132
https://arena.5eplay.com/data/match/g161-20251119212237018904830
https://arena.5eplay.com/data/match/g161-20251119220301211708132
https://arena.5eplay.com/data/match/g161-20251114142342512006943

7
requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
Flask
pandas
numpy
playwright
gunicorn
gevent
matplotlib

0
utils/__init__.py Normal file
View File

View File

@@ -0,0 +1,65 @@
# JSON Schema Extractor
用于从大量 5E Arena 比赛数据 (`iframe_network.json`) 中提取、归纳和分析 JSON Schema 的工具。它能够自动处理复杂的嵌套结构,识别动态 Key如 SteamID、5E ID、Round Number并生成层级清晰的结构报告。
## ✨ 核心功能
* **批量处理**: 自动扫描并处理目录下的所有 `iframe_network.json` 文件。
* **智能归并**:
* **动态 Key 掩码**: 自动识别并掩盖 SteamID (`<steamid>`)、5E ID (`<5eid>`) 和回合数 (`<round_n>`)。
* **结构合并**: 自动将 `group_1`/`group_2` 合并为 `group_N`,将 `fight`/`fight_t`/`fight_ct` 合并为 `fight_any`
* **多格式输出**:
* `schema_summary.md`: 易于阅读的 Markdown 层级报告。
* `schema_full.json`: 包含类型统计和完整结构的机器可读 JSON。
* `schema_flat.csv`: 扁平化的 CSV 字段列表,方便 Excel 查看。
* **智能分类**: 根据 URL 路径自动将数据归类(如 Match Data, Leetify Rating, Round Data 等)。
## 🚀 快速开始
### 1. 运行提取器
在项目根目录下运行:
```bash
# 使用默认配置 (输入: output_arena, 输出: output_reports/)
python utils/json_extractor/main.py
# 自定义输入输出
python utils/json_extractor/main.py --input my_data_folder --output-md my_report.md
```
### 2. 查看报告
运行完成后,在 `output_reports/` 目录下查看结果:
* **[schema_summary.md](../../output_reports/schema_summary.md)**: 推荐首先查看此文件,快速了解数据结构。
* **[schema_flat.csv](../../output_reports/schema_flat.csv)**: 需要查找特定字段(如 `adr`)在哪些层级出现时使用。
## 🛠️ 规则配置
核心规则定义在 `utils/json_extractor/rules.py` 中,你可以根据需要修改:
* **ID 识别**: 修改 `STEAMID_REGEX``FIVE_E_ID_REGEX` 正则。
* **URL 过滤**: 修改 `IGNORE_URL_PATTERNS` 列表以忽略无关请求(如 sentry 日志)。
* **Key 归并**: 修改 `get_key_mask` 函数来添加新的归并逻辑。
## 📊 结构分析工具
如果需要深入分析某些结构(如 `fight` 对象的变体),可以使用分析脚本:
```bash
python utils/json_extractor/analyze_structure.py
```
该脚本会统计特定字段的覆盖率,并检查不同 API如 Round API 与 Leetify API的共存情况。
## 📁 目录结构
```
utils/json_extractor/
├── extractor.py # 核心提取逻辑 (SchemaExtractor 类)
├── main.py # 命令行入口
├── rules.py # 正则与归并规则定义
├── analyze_structure.py # 结构差异分析辅助脚本
└── README.md # 本说明文件
```

View File

@@ -0,0 +1,101 @@
import json
import os
from pathlib import Path
from collections import defaultdict
def analyze_structures(root_dir):
p = Path(root_dir)
files = list(p.rglob("iframe_network.json"))
fight_keys = set()
fight_t_keys = set()
fight_ct_keys = set()
file_categories = defaultdict(set)
for filepath in files:
try:
with open(filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
except:
continue
if not isinstance(data, list):
continue
has_round = False
has_leetify = False
for entry in data:
url = entry.get('url', '')
body = entry.get('body')
if "api/match/round/" in url:
has_round = True
if "api/match/leetify_rating/" in url:
has_leetify = True
# Check for fight structures in data/match
if "api/data/match/" in url and isinstance(body, dict):
main_data = body.get('data', {})
if isinstance(main_data, dict):
# Check group_N -> items -> fight/fight_t/fight_ct
for k, v in main_data.items():
if k.startswith('group_') and isinstance(v, list):
for player in v:
if isinstance(player, dict):
if 'fight' in player and isinstance(player['fight'], dict):
fight_keys.update(player['fight'].keys())
if 'fight_t' in player and isinstance(player['fight_t'], dict):
fight_t_keys.update(player['fight_t'].keys())
if 'fight_ct' in player and isinstance(player['fight_ct'], dict):
fight_ct_keys.update(player['fight_ct'].keys())
if has_round:
file_categories['round_only'].add(str(filepath))
if has_leetify:
file_categories['leetify_only'].add(str(filepath))
if has_round and has_leetify:
file_categories['both'].add(str(filepath))
print("Structure Analysis Results:")
print("-" * 30)
print(f"Files with Round API: {len(file_categories['round_only'])}")
print(f"Files with Leetify API: {len(file_categories['leetify_only'])}")
print(f"Files with BOTH: {len(file_categories['both'])}")
# Calculate intersections for files
round_files = file_categories['round_only']
leetify_files = file_categories['leetify_only']
intersection = round_files.intersection(leetify_files) # This should be same as 'both' logic above if set correctly, but let's be explicit
# Actually my logic above adds to sets independently.
only_round = round_files - leetify_files
only_leetify = leetify_files - round_files
both = round_files.intersection(leetify_files)
print(f"Files with ONLY Round: {len(only_round)}")
print(f"Files with ONLY Leetify: {len(only_leetify)}")
print(f"Files with BOTH: {len(both)}")
print("\nFight Structure Analysis:")
print("-" * 30)
print(f"Fight keys count: {len(fight_keys)}")
print(f"Fight_T keys count: {len(fight_t_keys)}")
print(f"Fight_CT keys count: {len(fight_ct_keys)}")
all_keys = fight_keys | fight_t_keys | fight_ct_keys
missing_in_fight = all_keys - fight_keys
missing_in_t = all_keys - fight_t_keys
missing_in_ct = all_keys - fight_ct_keys
if not missing_in_fight and not missing_in_t and not missing_in_ct:
print("PERFECT MATCH: fight, fight_t, and fight_ct have identical keys.")
else:
if missing_in_fight: print(f"Keys missing in 'fight': {missing_in_fight}")
if missing_in_t: print(f"Keys missing in 'fight_t': {missing_in_t}")
if missing_in_ct: print(f"Keys missing in 'fight_ct': {missing_in_ct}")
if __name__ == "__main__":
analyze_structures("output_arena")

View File

@@ -0,0 +1,243 @@
import json
import os
from pathlib import Path
from urllib.parse import urlparse
from collections import defaultdict
from .rules import is_ignored_url, get_key_mask, get_value_type
class SchemaExtractor:
def __init__(self):
# schemas: category -> schema_node
self.schemas = {}
self.url_counts = defaultdict(int)
def get_url_category(self, url):
"""
Derives a category name from the URL.
"""
parsed = urlparse(url)
path = parsed.path
parts = path.strip('/').split('/')
cleaned_parts = []
for p in parts:
# Mask Match IDs (e.g., g161-...)
if p.startswith('g161-'):
cleaned_parts.append('{match_id}')
# Mask other long numeric IDs
elif p.isdigit() and len(p) > 4:
cleaned_parts.append('{id}')
else:
cleaned_parts.append(p)
category = "/".join(cleaned_parts)
if not category:
category = "root"
return category
def process_directory(self, root_dir):
"""
Iterates over all iframe_network.json files in the directory.
"""
p = Path(root_dir)
# Use rglob to find all iframe_network.json files
files = list(p.rglob("iframe_network.json"))
print(f"Found {len(files)} files to process.")
for i, filepath in enumerate(files):
if i % 10 == 0:
print(f"Processing {i}/{len(files)}: {filepath}")
self.process_file(filepath)
def process_file(self, filepath):
try:
with open(filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
except Exception as e:
# print(f"Error reading {filepath}: {e}")
return
if not isinstance(data, list):
return
for entry in data:
url = entry.get('url', '')
if not url or is_ignored_url(url):
continue
status = entry.get('status')
if status != 200:
continue
body = entry.get('body')
# Skip empty bodies or bodies that are just empty dicts if that's not useful
if not body:
continue
category = self.get_url_category(url)
self.url_counts[category] += 1
if category not in self.schemas:
self.schemas[category] = None
self.schemas[category] = self.merge_value(self.schemas[category], body)
def merge_value(self, schema, value):
"""
Merges a value into the existing schema.
"""
val_type = get_value_type(value)
if schema is None:
schema = {
"types": {val_type},
"count": 1
}
else:
schema["count"] += 1
schema["types"].add(val_type)
# Handle Dicts
if isinstance(value, dict):
if "properties" not in schema:
schema["properties"] = {}
for k, v in value.items():
masked_key = get_key_mask(k)
schema["properties"][masked_key] = self.merge_value(
schema["properties"].get(masked_key),
v
)
# Handle Lists
elif isinstance(value, list):
if "items" not in schema:
schema["items"] = None
for item in value:
schema["items"] = self.merge_value(schema["items"], item)
# Handle Primitives (Capture examples if needed, currently just tracking types)
else:
if "examples" not in schema:
schema["examples"] = set()
if len(schema["examples"]) < 5:
# Store string representation to avoid type issues in set
schema["examples"].add(str(value))
return schema
def to_serializable(self, schema):
"""
Converts the internal schema structure (with sets) to a JSON-serializable format.
"""
if schema is None:
return None
res = {
"types": list(sorted(schema["types"])),
"count": schema["count"]
}
if "properties" in schema:
res["properties"] = {
k: self.to_serializable(v)
for k, v in sorted(schema["properties"].items())
}
if "items" in schema:
res["items"] = self.to_serializable(schema["items"])
if "examples" in schema:
res["examples"] = list(sorted(schema["examples"]))
return res
def export_report(self, output_path):
report = {}
for category, schema in self.schemas.items():
report[category] = self.to_serializable(schema)
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(report, f, indent=2, ensure_ascii=False)
print(f"Report saved to {output_path}")
def export_markdown_summary(self, output_path):
"""
Generates a Markdown summary of the hierarchy.
"""
with open(output_path, 'w', encoding='utf-8') as f:
f.write("# Schema Hierarchy Report\n\n")
for category, schema in sorted(self.schemas.items()):
f.write(f"## Category: `{category}`\n")
f.write(f"**Total Requests**: {self.url_counts[category]}\n\n")
self._write_markdown_schema(f, schema, level=0)
f.write("\n---\n\n")
print(f"Markdown summary saved to {output_path}")
def export_csv_summary(self, output_path):
"""
Generates a CSV summary of the flattened schema.
"""
import csv
with open(output_path, 'w', encoding='utf-8', newline='') as f:
writer = csv.writer(f)
writer.writerow(["Category", "Path", "Types", "Examples"])
for category, schema in sorted(self.schemas.items()):
self._write_csv_schema(writer, category, schema, path="")
print(f"CSV summary saved to {output_path}")
def _write_csv_schema(self, writer, category, schema, path):
if schema is None:
return
current_types = list(sorted(schema["types"]))
type_str = ", ".join(map(str, current_types))
# If it's a leaf or has no properties/items
is_leaf = "properties" not in schema and "items" not in schema
if is_leaf:
examples = list(schema.get("examples", []))
ex_str = "; ".join(examples[:3]) if examples else ""
writer.writerow([category, path, type_str, ex_str])
if "properties" in schema:
for k, v in schema["properties"].items():
new_path = f"{path}.{k}" if path else k
self._write_csv_schema(writer, category, v, new_path)
if "items" in schema:
new_path = f"{path}[]"
self._write_csv_schema(writer, category, schema["items"], new_path)
def _write_markdown_schema(self, f, schema, level=0):
if schema is None:
return
indent = " " * level
types = schema["types"]
type_str = ", ".join([str(t) for t in types])
# If it's a leaf (no props, no items)
if "properties" not in schema and "items" not in schema:
# Show examples
examples = schema.get("examples", [])
ex_str = f" (e.g., {', '.join(list(examples)[:3])})" if examples else ""
return # We handle leaf printing in the parent loop for keys, or here if it's a root/list item
if "properties" in schema:
for k, v in schema["properties"].items():
v_types = ", ".join(list(sorted(v["types"])))
v_ex = list(v.get("examples", []))
v_ex_str = f", e.g. {v_ex[0]}" if v_ex and "dict" not in v["types"] and "list" not in v["types"] else ""
f.write(f"{indent}- **{k}** ({v_types}{v_ex_str})\n")
self._write_markdown_schema(f, v, level + 1)
if "items" in schema:
f.write(f"{indent}- *[Array Items]*\n")
self._write_markdown_schema(f, schema["items"], level + 1)

View File

@@ -0,0 +1,35 @@
import sys
import os
import argparse
# Add project root to path so we can import utils.json_extractor
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(os.path.dirname(current_dir))
sys.path.append(project_root)
from utils.json_extractor.extractor import SchemaExtractor
def main():
parser = argparse.ArgumentParser(description="Extract JSON schema from 5E Arena data.")
parser.add_argument("--input", default="output_arena", help="Input directory containing iframe_network.json files")
parser.add_argument("--output-json", default="output_reports/schema_full.json", help="Output JSON report path")
parser.add_argument("--output-md", default="output_reports/schema_summary.md", help="Output Markdown summary path")
parser.add_argument("--output-csv", default="output_reports/schema_flat.csv", help="Output CSV flat report path")
args = parser.parse_args()
print(f"Starting extraction from {args.input}...")
extractor = SchemaExtractor()
extractor.process_directory(args.input)
# Ensure output directory exists
os.makedirs(os.path.dirname(args.output_json), exist_ok=True)
os.makedirs(os.path.dirname(args.output_md), exist_ok=True)
extractor.export_report(args.output_json)
extractor.export_markdown_summary(args.output_md)
extractor.export_csv_summary(args.output_csv)
print("Done.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,81 @@
import re
# Regex patterns for masking sensitive/dynamic data
STEAMID_REGEX = re.compile(r"^7656\d+$")
FIVE_E_ID_REGEX = re.compile(r"^1\d{7}$") # 1 followed by 7 digits (8 digits total)
# Group merging
GROUP_KEY_REGEX = re.compile(r"^group_\d+$")
# URL Exclusion patterns
# We skip these URLs as they are analytics/auth related and not data payload
IGNORE_URL_PATTERNS = [
r"sentry_key=",
r"gate\.5eplay\.com/blacklistfront",
r"favicon\.ico",
]
# URL Inclusion/Interest patterns (Optional, if we want to be strict)
# INTEREST_URL_PATTERNS = [
# r"api/data/match",
# r"leetify",
# ]
def is_ignored_url(url):
for pattern in IGNORE_URL_PATTERNS:
if re.search(pattern, url):
return True
return False
def get_key_mask(key):
"""
Returns a masked key name if it matches a pattern (e.g. group_1 -> group_N).
Otherwise returns the key itself.
"""
if GROUP_KEY_REGEX.match(key):
return "group_N"
if STEAMID_REGEX.match(key):
return "<steamid>"
if FIVE_E_ID_REGEX.match(key):
return "<5eid>"
# Merge fight variants
if key in ["fight", "fight_t", "fight_ct"]:
return "fight_any"
# Merge numeric keys (likely round numbers)
if key.isdigit():
return "<round_n>"
return key
def get_value_type(value):
"""
Returns a generalized type string for a value, masking IDs.
"""
if value is None:
return "null"
if isinstance(value, bool):
return "bool"
if isinstance(value, int):
# Check for IDs
s_val = str(value)
if FIVE_E_ID_REGEX.match(s_val):
return "<5eid>"
if STEAMID_REGEX.match(s_val):
return "<steamid>"
return "int"
if isinstance(value, float):
return "float"
if isinstance(value, str):
if FIVE_E_ID_REGEX.match(value):
return "<5eid>"
if STEAMID_REGEX.match(value):
return "<steamid>"
# Heuristic for other IDs or timestamps could go here
return "string"
if isinstance(value, list):
return "list"
if isinstance(value, dict):
return "dict"
return "unknown"

36
web/app.py Normal file
View File

@@ -0,0 +1,36 @@
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, opponents
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.register_blueprint(opponents.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
View 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
View 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
View 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
View 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
View 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
View 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
View 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')

35
web/routes/opponents.py Normal file
View File

@@ -0,0 +1,35 @@
from flask import Blueprint, render_template, request, jsonify
from web.services.opponent_service import OpponentService
from web.config import Config
bp = Blueprint('opponents', __name__, url_prefix='/opponents')
@bp.route('/')
def index():
page = request.args.get('page', 1, type=int)
sort_by = request.args.get('sort', 'matches')
search = request.args.get('search')
opponents, total = OpponentService.get_opponent_list(page, Config.ITEMS_PER_PAGE, sort_by, search)
total_pages = (total + Config.ITEMS_PER_PAGE - 1) // Config.ITEMS_PER_PAGE
# Global stats for dashboard
stats_summary = OpponentService.get_global_opponent_stats()
map_stats = OpponentService.get_map_opponent_stats()
return render_template('opponents/index.html',
opponents=opponents,
total=total,
page=page,
total_pages=total_pages,
sort_by=sort_by,
stats_summary=stats_summary,
map_stats=map_stats)
@bp.route('/<steam_id>')
def detail(steam_id):
data = OpponentService.get_opponent_detail(steam_id)
if not data:
return "Opponent not found", 404
return render_template('opponents/detail.html', **data)

431
web/routes/players.py Normal file
View File

@@ -0,0 +1,431 @@
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)
# --- New: Fetch Detailed Stats from L2 (Clutch, Multi-Kill, Multi-Assist) ---
sql_l2 = """
SELECT
SUM(p.clutch_1v1) as c1, SUM(p.clutch_1v2) as c2, SUM(p.clutch_1v3) as c3, SUM(p.clutch_1v4) as c4, SUM(p.clutch_1v5) as c5,
SUM(a.attempt_1v1) as att1, SUM(a.attempt_1v2) as att2, SUM(a.attempt_1v3) as att3, SUM(a.attempt_1v4) as att4, SUM(a.attempt_1v5) as att5,
SUM(p.kill_2) as k2, SUM(p.kill_3) as k3, SUM(p.kill_4) as k4, SUM(p.kill_5) as k5,
SUM(p.many_assists_cnt2) as a2, SUM(p.many_assists_cnt3) as a3, SUM(p.many_assists_cnt4) as a4, SUM(p.many_assists_cnt5) as a5,
COUNT(*) as matches,
SUM(p.round_total) as total_rounds
FROM fact_match_players p
LEFT JOIN fact_match_clutch_attempts a ON p.match_id = a.match_id AND p.steam_id_64 = a.steam_id_64
WHERE p.steam_id_64 = ?
"""
l2_stats = query_db('l2', sql_l2, [steam_id], one=True)
l2_stats = dict(l2_stats) if l2_stats else {}
# Fetch T/CT splits for comparison
# Note: We use SUM(clutch...) as Total Clutch Wins. We don't have attempts, so 'Win Rate' is effectively Wins/Rounds or just Wins count.
# User asked for 'Win Rate', but without attempts data, we'll provide Rate per Round or just Count.
# Let's provide Rate per Round for Multi-Kill/Assist, and maybe just Count for Clutch?
# User said: "总残局胜率...分t和ct在下方加入对比".
# Since we found clutch == end in DB, we treat it as Wins. We can't calc Win %.
# We will display "Clutch Wins / Round" or just "Clutch Wins".
sql_side = """
SELECT
'T' as side,
SUM(clutch_1v1+clutch_1v2+clutch_1v3+clutch_1v4+clutch_1v5) as total_clutch,
SUM(kill_2+kill_3+kill_4+kill_5) as total_multikill,
SUM(many_assists_cnt2+many_assists_cnt3+many_assists_cnt4+many_assists_cnt5) as total_multiassist,
SUM(round_total) as rounds
FROM fact_match_players_t WHERE steam_id_64 = ?
UNION ALL
SELECT
'CT' as side,
SUM(clutch_1v1+clutch_1v2+clutch_1v3+clutch_1v4+clutch_1v5) as total_clutch,
SUM(kill_2+kill_3+kill_4+kill_5) as total_multikill,
SUM(many_assists_cnt2+many_assists_cnt3+many_assists_cnt4+many_assists_cnt5) as total_multiassist,
SUM(round_total) as rounds
FROM fact_match_players_ct WHERE steam_id_64 = ?
"""
side_rows = query_db('l2', sql_side, [steam_id, steam_id])
side_stats = {row['side']: dict(row) for row in side_rows} if side_rows else {}
# 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)
# --- New: Recent Performance Stats ---
recent_stats = StatsService.get_recent_performance_stats(steam_id)
return render_template('players/profile.html',
player=player,
features=features,
comments=comments,
metadata=metadata,
history=history,
distribution=distribution,
map_stats=map_stats_list,
l2_stats=l2_stats,
side_stats=side_stats,
recent_stats=recent_stats)
@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'),
'ECO': get_score('score_eco'),
'PACE': get_score('score_pace')
}
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
View 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
View 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
View 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)

View 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)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,404 @@
from web.database import query_db
from web.services.web_service import WebService
import json
class OpponentService:
@staticmethod
def _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
return active_roster_ids
@staticmethod
def get_opponent_list(page=1, per_page=20, sort_by='matches', search=None):
roster_ids = OpponentService._get_active_roster_ids()
if not roster_ids:
return [], 0
# Placeholders
roster_ph = ','.join('?' for _ in roster_ids)
# 1. Identify Matches involving our roster (at least 1 member? usually 2 for 'team' match)
# Let's say at least 1 for broader coverage as requested ("1 match sample")
# But "Our Team" usually implies the entity. Let's stick to matches where we can identify "Us".
# If we use >=1, we catch solo Q matches of roster members. The user said "Non-team members or 1 match sample",
# but implied "facing different our team lineups".
# Let's use the standard "candidate matches" logic (>=2 roster members) to represent "The Team".
# OR, if user wants "Opponent Analysis" for even 1 match, maybe they mean ANY match in DB?
# "Left Top add Opponent Analysis... (non-team member or 1 sample)"
# This implies we analyze PLAYERS who are NOT us.
# Let's stick to matches where >= 1 roster member played, to define "Us" vs "Them".
# Actually, let's look at ALL matches in DB, and any player NOT in active roster is an "Opponent".
# This covers "1 sample".
# Query:
# Select all players who are NOT in active roster.
# Group by steam_id.
# Aggregate stats.
where_clauses = [f"CAST(mp.steam_id_64 AS TEXT) NOT IN ({roster_ph})"]
args = list(roster_ids)
if search:
where_clauses.append("(LOWER(p.username) LIKE LOWER(?) OR mp.steam_id_64 LIKE ?)")
args.extend([f"%{search}%", f"%{search}%"])
where_str = " AND ".join(where_clauses)
# Sort mapping
sort_sql = "matches DESC"
if sort_by == 'rating':
sort_sql = "avg_rating DESC"
elif sort_by == 'kd':
sort_sql = "avg_kd DESC"
elif sort_by == 'win_rate':
sort_sql = "win_rate DESC"
# Main Aggregation Query
# We need to join fact_matches to get match info (win/loss, elo) if needed,
# but fact_match_players has is_win (boolean) usually? No, it has team_id.
# We need to determine if THEY won.
# fact_match_players doesn't store is_win directly in schema (I should check schema, but stats_service calculates it).
# Wait, stats_service.get_player_trend uses `mp.is_win`?
# Let's check schema. `fact_match_players` usually has `match_id`, `team_id`.
# `fact_matches` has `winner_team`.
# So we join.
offset = (page - 1) * per_page
sql = f"""
SELECT
mp.steam_id_64,
MAX(p.username) as username,
MAX(p.avatar_url) as avatar_url,
COUNT(DISTINCT mp.match_id) as matches,
AVG(mp.rating) as avg_rating,
AVG(mp.kd_ratio) as avg_kd,
AVG(mp.adr) as avg_adr,
SUM(CASE WHEN mp.is_win = 1 THEN 1 ELSE 0 END) as wins,
AVG(NULLIF(COALESCE(fmt_gid.group_origin_elo, fmt_tid.group_origin_elo), 0)) as avg_match_elo
FROM fact_match_players mp
JOIN fact_matches m ON mp.match_id = m.match_id
LEFT JOIN dim_players p ON mp.steam_id_64 = p.steam_id_64
LEFT JOIN fact_match_teams fmt_gid ON mp.match_id = fmt_gid.match_id AND fmt_gid.group_id = mp.team_id
LEFT JOIN fact_match_teams fmt_tid ON mp.match_id = fmt_tid.match_id AND fmt_tid.group_tid = mp.match_team_id
WHERE {where_str}
GROUP BY mp.steam_id_64
ORDER BY {sort_sql}
LIMIT ? OFFSET ?
"""
# Count query
count_sql = f"""
SELECT COUNT(DISTINCT mp.steam_id_64) as cnt
FROM fact_match_players mp
LEFT JOIN dim_players p ON mp.steam_id_64 = p.steam_id_64
WHERE {where_str}
"""
query_args = args + [per_page, offset]
rows = query_db('l2', sql, query_args)
total = query_db('l2', count_sql, args, one=True)['cnt']
# Post-process for derived stats
results = []
# Resolve avatar fallback from local static if missing
from web.services.stats_service import StatsService
for r in rows or []:
d = dict(r)
d['win_rate'] = (d['wins'] / d['matches']) if d['matches'] else 0
d['avatar_url'] = StatsService.resolve_avatar_url(d.get('steam_id_64'), d.get('avatar_url'))
results.append(d)
return results, total
@staticmethod
def get_global_opponent_stats():
"""
Calculates aggregate statistics for ALL opponents.
Returns:
{
'elo_dist': {'<1200': 10, '1200-1500': 20...},
'rating_dist': {'<0.8': 5, '0.8-1.0': 15...},
'win_rate_dist': {'<40%': 5, '40-60%': 10...} (Opponent Win Rate)
}
"""
roster_ids = OpponentService._get_active_roster_ids()
if not roster_ids:
return {}
roster_ph = ','.join('?' for _ in roster_ids)
# 1. Fetch Aggregated Stats for ALL opponents
# We group by steam_id first to get each opponent's AVG stats
sql = f"""
SELECT
mp.steam_id_64,
COUNT(DISTINCT mp.match_id) as matches,
AVG(mp.rating) as avg_rating,
AVG(NULLIF(COALESCE(fmt_gid.group_origin_elo, fmt_tid.group_origin_elo), 0)) as avg_match_elo,
SUM(CASE WHEN mp.is_win = 1 THEN 1 ELSE 0 END) as wins
FROM fact_match_players mp
JOIN fact_matches m ON mp.match_id = m.match_id
LEFT JOIN fact_match_teams fmt_gid ON mp.match_id = fmt_gid.match_id AND fmt_gid.group_id = mp.team_id
LEFT JOIN fact_match_teams fmt_tid ON mp.match_id = fmt_tid.match_id AND fmt_tid.group_tid = mp.match_team_id
WHERE CAST(mp.steam_id_64 AS TEXT) NOT IN ({roster_ph})
GROUP BY mp.steam_id_64
"""
rows = query_db('l2', sql, roster_ids)
# Initialize Buckets
elo_buckets = {'<1000': 0, '1000-1200': 0, '1200-1400': 0, '1400-1600': 0, '1600-1800': 0, '1800-2000': 0, '>2000': 0}
rating_buckets = {'<0.8': 0, '0.8-1.0': 0, '1.0-1.2': 0, '1.2-1.4': 0, '>1.4': 0}
win_rate_buckets = {'<30%': 0, '30-45%': 0, '45-55%': 0, '55-70%': 0, '>70%': 0}
elo_values = []
rating_values = []
for r in rows:
elo_val = r['avg_match_elo']
if elo_val is None or elo_val <= 0:
pass
else:
elo = elo_val
if elo < 1000: k = '<1000'
elif elo < 1200: k = '1000-1200'
elif elo < 1400: k = '1200-1400'
elif elo < 1600: k = '1400-1600'
elif elo < 1800: k = '1600-1800'
elif elo < 2000: k = '1800-2000'
else: k = '>2000'
elo_buckets[k] += 1
elo_values.append(float(elo))
rtg = r['avg_rating'] or 0
if rtg < 0.8: k = '<0.8'
elif rtg < 1.0: k = '0.8-1.0'
elif rtg < 1.2: k = '1.0-1.2'
elif rtg < 1.4: k = '1.2-1.4'
else: k = '>1.4'
rating_buckets[k] += 1
rating_values.append(float(rtg))
matches = r['matches'] or 0
if matches > 0:
wr = (r['wins'] or 0) / matches
if wr < 0.30: k = '<30%'
elif wr < 0.45: k = '30-45%'
elif wr < 0.55: k = '45-55%'
elif wr < 0.70: k = '55-70%'
else: k = '>70%'
win_rate_buckets[k] += 1
return {
'elo_dist': elo_buckets,
'rating_dist': rating_buckets,
'win_rate_dist': win_rate_buckets,
'elo_values': elo_values,
'rating_values': rating_values
}
@staticmethod
def get_opponent_detail(steam_id):
# 1. Basic Info
info = query_db('l2', "SELECT * FROM dim_players WHERE steam_id_64 = ?", [steam_id], one=True)
if not info:
return None
from web.services.stats_service import StatsService
player = dict(info)
player['avatar_url'] = StatsService.resolve_avatar_url(steam_id, player.get('avatar_url'))
# 2. Match History vs Us (All matches this player played)
# We define "Us" as matches where this player is an opponent.
# But actually, we just show ALL their matches in our DB, assuming our DB only contains matches relevant to us?
# Usually yes, but if we have a huge DB, we might want to filter by "Contains Roster Member".
# For now, show all matches in DB for this player.
sql_history = """
SELECT
m.match_id, m.start_time, m.map_name, m.score_team1, m.score_team2, m.winner_team,
mp.team_id, mp.match_team_id, mp.rating, mp.kd_ratio, mp.adr, mp.kills, mp.deaths,
mp.is_win as is_win,
CASE
WHEN COALESCE(fmt_gid.group_origin_elo, fmt_tid.group_origin_elo) > 0
THEN COALESCE(fmt_gid.group_origin_elo, fmt_tid.group_origin_elo)
END as elo
FROM fact_match_players mp
JOIN fact_matches m ON mp.match_id = m.match_id
LEFT JOIN fact_match_teams fmt_gid ON mp.match_id = fmt_gid.match_id AND fmt_gid.group_id = mp.team_id
LEFT JOIN fact_match_teams fmt_tid ON mp.match_id = fmt_tid.match_id AND fmt_tid.group_tid = mp.match_team_id
WHERE mp.steam_id_64 = ?
ORDER BY m.start_time DESC
"""
history = query_db('l2', sql_history, [steam_id])
# 3. Aggregation by ELO
elo_buckets = {
'<1200': {'matches': 0, 'rating_sum': 0, 'kd_sum': 0},
'1200-1500': {'matches': 0, 'rating_sum': 0, 'kd_sum': 0},
'1500-1800': {'matches': 0, 'rating_sum': 0, 'kd_sum': 0},
'1800-2100': {'matches': 0, 'rating_sum': 0, 'kd_sum': 0},
'>2100': {'matches': 0, 'rating_sum': 0, 'kd_sum': 0}
}
# 4. Aggregation by Side (T/CT)
# Using fact_match_players_t / ct
sql_side = """
SELECT
(SELECT CASE
WHEN SUM(CASE WHEN t.rating2 IS NOT NULL AND t.rating2 != 0 THEN t.round_total END) > 0
THEN SUM(CASE WHEN t.rating2 IS NOT NULL AND t.rating2 != 0 THEN t.rating2 * t.round_total END)
/ SUM(CASE WHEN t.rating2 IS NOT NULL AND t.rating2 != 0 THEN t.round_total END)
WHEN COUNT(*) > 0
THEN AVG(NULLIF(t.rating2, 0))
END
FROM fact_match_players_t t WHERE t.steam_id_64 = ?) as rating_t,
(SELECT CASE
WHEN SUM(CASE WHEN ct.rating2 IS NOT NULL AND ct.rating2 != 0 THEN ct.round_total END) > 0
THEN SUM(CASE WHEN ct.rating2 IS NOT NULL AND ct.rating2 != 0 THEN ct.rating2 * ct.round_total END)
/ SUM(CASE WHEN ct.rating2 IS NOT NULL AND ct.rating2 != 0 THEN ct.round_total END)
WHEN COUNT(*) > 0
THEN AVG(NULLIF(ct.rating2, 0))
END
FROM fact_match_players_ct ct WHERE ct.steam_id_64 = ?) as rating_ct,
(SELECT CASE
WHEN SUM(t.deaths) > 0 THEN SUM(t.kills) * 1.0 / SUM(t.deaths)
WHEN SUM(t.kills) > 0 THEN SUM(t.kills) * 1.0
WHEN COUNT(*) > 0 THEN AVG(NULLIF(t.kd_ratio, 0))
END
FROM fact_match_players_t t WHERE t.steam_id_64 = ?) as kd_t,
(SELECT CASE
WHEN SUM(ct.deaths) > 0 THEN SUM(ct.kills) * 1.0 / SUM(ct.deaths)
WHEN SUM(ct.kills) > 0 THEN SUM(ct.kills) * 1.0
WHEN COUNT(*) > 0 THEN AVG(NULLIF(ct.kd_ratio, 0))
END
FROM fact_match_players_ct ct WHERE ct.steam_id_64 = ?) as kd_ct,
(SELECT SUM(t.round_total) FROM fact_match_players_t t WHERE t.steam_id_64 = ?) as rounds_t,
(SELECT SUM(ct.round_total) FROM fact_match_players_ct ct WHERE ct.steam_id_64 = ?) as rounds_ct
"""
side_stats = query_db('l2', sql_side, [steam_id, steam_id, steam_id, steam_id, steam_id, steam_id], one=True)
# Process History for ELO & KD Diff
# We also want "Our Team KD" in these matches to calc Diff.
# This requires querying the OTHER team in these matches.
match_ids = [h['match_id'] for h in history]
# Get Our Team Stats per match
# "Our Team" = All players in the match EXCEPT this opponent (and their teammates?)
# Simplification: "Avg Lobby KD" vs "Opponent KD".
# Or better: "Avg KD of Opposing Team".
match_stats_map = {}
if match_ids:
ph = ','.join('?' for _ in match_ids)
# Calculate Avg KD of the team that is NOT the opponent's team
opp_stats_sql = f"""
SELECT match_id, match_team_id, AVG(kd_ratio) as team_avg_kd
FROM fact_match_players
WHERE match_id IN ({ph})
GROUP BY match_id, match_team_id
"""
opp_rows = query_db('l2', opp_stats_sql, match_ids)
# Organize by match
for r in opp_rows:
mid = r['match_id']
tid = r['match_team_id']
if mid not in match_stats_map:
match_stats_map[mid] = {}
match_stats_map[mid][tid] = r['team_avg_kd']
processed_history = []
for h in history:
# ELO Bucketing
elo = h['elo'] or 0
if elo < 1200: b = '<1200'
elif elo < 1500: b = '1200-1500'
elif elo < 1800: b = '1500-1800'
elif elo < 2100: b = '1800-2100'
else: b = '>2100'
elo_buckets[b]['matches'] += 1
elo_buckets[b]['rating_sum'] += (h['rating'] or 0)
elo_buckets[b]['kd_sum'] += (h['kd_ratio'] or 0)
# KD Diff
# Find the OTHER team's avg KD
my_tid = h['match_team_id']
# Assuming 2 teams: if my_tid is 1, other is 2. But IDs can be anything.
# Look at match_stats_map[mid] keys.
mid = h['match_id']
other_team_kd = 1.0 # Default
if mid in match_stats_map:
for tid, avg_kd in match_stats_map[mid].items():
if tid != my_tid:
other_team_kd = avg_kd
break
kd_diff = (h['kd_ratio'] or 0) - other_team_kd
d = dict(h)
d['kd_diff'] = kd_diff
d['other_team_kd'] = other_team_kd
processed_history.append(d)
# Format ELO Stats
elo_stats = []
for k, v in elo_buckets.items():
if v['matches'] > 0:
elo_stats.append({
'range': k,
'matches': v['matches'],
'avg_rating': v['rating_sum'] / v['matches'],
'avg_kd': v['kd_sum'] / v['matches']
})
return {
'player': player,
'history': processed_history,
'elo_stats': elo_stats,
'side_stats': dict(side_stats) if side_stats else {}
}
@staticmethod
def get_map_opponent_stats():
roster_ids = OpponentService._get_active_roster_ids()
if not roster_ids:
return []
roster_ph = ','.join('?' for _ in roster_ids)
sql = f"""
SELECT
m.map_name as map_name,
COUNT(DISTINCT mp.match_id) as matches,
AVG(mp.rating) as avg_rating,
AVG(mp.kd_ratio) as avg_kd,
AVG(NULLIF(COALESCE(fmt_gid.group_origin_elo, fmt_tid.group_origin_elo), 0)) as avg_elo,
COUNT(DISTINCT CASE WHEN mp.is_win = 1 THEN mp.match_id END) as wins,
COUNT(DISTINCT CASE WHEN mp.rating > 1.5 THEN mp.match_id END) as shark_matches
FROM fact_match_players mp
JOIN fact_matches m ON mp.match_id = m.match_id
LEFT JOIN fact_match_teams fmt_gid ON mp.match_id = fmt_gid.match_id AND fmt_gid.group_id = mp.team_id
LEFT JOIN fact_match_teams fmt_tid ON mp.match_id = fmt_tid.match_id AND fmt_tid.group_tid = mp.match_team_id
WHERE CAST(mp.steam_id_64 AS TEXT) NOT IN ({roster_ph})
AND m.map_name IS NOT NULL AND m.map_name <> ''
GROUP BY m.map_name
ORDER BY matches DESC
"""
rows = query_db('l2', sql, roster_ids)
results = []
for r in rows:
d = dict(r)
matches = d.get('matches') or 0
wins = d.get('wins') or 0
d['win_rate'] = (wins / matches) if matches else 0
results.append(d)
return results

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,119 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
class WeaponInfo:
name: str
price: int
side: str
category: str
_WEAPON_TABLE = {
"glock": WeaponInfo(name="Glock-18", price=200, side="T", category="pistol"),
"hkp2000": WeaponInfo(name="P2000", price=200, side="CT", category="pistol"),
"usp_silencer": WeaponInfo(name="USP-S", price=200, side="CT", category="pistol"),
"elite": WeaponInfo(name="Dual Berettas", price=300, side="Both", category="pistol"),
"p250": WeaponInfo(name="P250", price=300, side="Both", category="pistol"),
"tec9": WeaponInfo(name="Tec-9", price=500, side="T", category="pistol"),
"fiveseven": WeaponInfo(name="Five-SeveN", price=500, side="CT", category="pistol"),
"cz75a": WeaponInfo(name="CZ75-Auto", price=500, side="Both", category="pistol"),
"revolver": WeaponInfo(name="R8 Revolver", price=600, side="Both", category="pistol"),
"deagle": WeaponInfo(name="Desert Eagle", price=700, side="Both", category="pistol"),
"mac10": WeaponInfo(name="MAC-10", price=1050, side="T", category="smg"),
"mp9": WeaponInfo(name="MP9", price=1250, side="CT", category="smg"),
"ump45": WeaponInfo(name="UMP-45", price=1200, side="Both", category="smg"),
"bizon": WeaponInfo(name="PP-Bizon", price=1400, side="Both", category="smg"),
"mp7": WeaponInfo(name="MP7", price=1500, side="Both", category="smg"),
"mp5sd": WeaponInfo(name="MP5-SD", price=1500, side="Both", category="smg"),
"nova": WeaponInfo(name="Nova", price=1050, side="Both", category="shotgun"),
"mag7": WeaponInfo(name="MAG-7", price=1300, side="CT", category="shotgun"),
"sawedoff": WeaponInfo(name="Sawed-Off", price=1100, side="T", category="shotgun"),
"xm1014": WeaponInfo(name="XM1014", price=2000, side="Both", category="shotgun"),
"galilar": WeaponInfo(name="Galil AR", price=1800, side="T", category="rifle"),
"famas": WeaponInfo(name="FAMAS", price=2050, side="CT", category="rifle"),
"ak47": WeaponInfo(name="AK-47", price=2700, side="T", category="rifle"),
"m4a1": WeaponInfo(name="M4A4", price=2900, side="CT", category="rifle"),
"m4a1_silencer": WeaponInfo(name="M4A1-S", price=2900, side="CT", category="rifle"),
"aug": WeaponInfo(name="AUG", price=3300, side="CT", category="rifle"),
"sg556": WeaponInfo(name="SG 553", price=3300, side="T", category="rifle"),
"awp": WeaponInfo(name="AWP", price=4750, side="Both", category="sniper"),
"scar20": WeaponInfo(name="SCAR-20", price=5000, side="CT", category="sniper"),
"g3sg1": WeaponInfo(name="G3SG1", price=5000, side="T", category="sniper"),
"negev": WeaponInfo(name="Negev", price=1700, side="Both", category="lmg"),
"m249": WeaponInfo(name="M249", price=5200, side="Both", category="lmg"),
}
_ALIASES = {
"weapon_glock": "glock",
"weapon_hkp2000": "hkp2000",
"weapon_usp_silencer": "usp_silencer",
"weapon_elite": "elite",
"weapon_p250": "p250",
"weapon_tec9": "tec9",
"weapon_fiveseven": "fiveseven",
"weapon_cz75a": "cz75a",
"weapon_revolver": "revolver",
"weapon_deagle": "deagle",
"weapon_mac10": "mac10",
"weapon_mp9": "mp9",
"weapon_ump45": "ump45",
"weapon_bizon": "bizon",
"weapon_mp7": "mp7",
"weapon_mp5sd": "mp5sd",
"weapon_nova": "nova",
"weapon_mag7": "mag7",
"weapon_sawedoff": "sawedoff",
"weapon_xm1014": "xm1014",
"weapon_galilar": "galilar",
"weapon_famas": "famas",
"weapon_ak47": "ak47",
"weapon_m4a1": "m4a1",
"weapon_m4a1_silencer": "m4a1_silencer",
"weapon_aug": "aug",
"weapon_sg556": "sg556",
"weapon_awp": "awp",
"weapon_scar20": "scar20",
"weapon_g3sg1": "g3sg1",
"weapon_negev": "negev",
"weapon_m249": "m249",
"m4a4": "m4a1",
"m4a1-s": "m4a1_silencer",
"m4a1s": "m4a1_silencer",
"sg553": "sg556",
"pp-bizon": "bizon",
}
def normalize_weapon_name(raw: Optional[str]) -> str:
if not raw:
return ""
s = str(raw).strip().lower()
if not s:
return ""
s = s.replace(" ", "").replace("\t", "").replace("\n", "")
s = s.replace("weapon_", "weapon_")
if s in _ALIASES:
return _ALIASES[s]
if s.startswith("weapon_") and s in _ALIASES:
return _ALIASES[s]
if s.startswith("weapon_"):
s2 = s[len("weapon_") :]
return _ALIASES.get(s2, s2)
return _ALIASES.get(s, s)
def get_weapon_info(raw: Optional[str]) -> Optional[WeaponInfo]:
key = normalize_weapon_name(raw)
if not key:
return None
return _WEAPON_TABLE.get(key)
def get_weapon_price(raw: Optional[str]) -> Optional[int]:
info = get_weapon_info(raw)
return info.price if info else None

120
web/services/web_service.py Normal file
View 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])

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View 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 %}

View 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 %}

View 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 %}

160
web/templates/base.html Normal file
View File

@@ -0,0 +1,160 @@
<!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('opponents.index') }}" class="{% if request.endpoint and 'opponents' 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('opponents.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">&copy; 2026 YRTV Data Platform. All rights reserved. 赣ICP备2026001600号</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>

View 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 %}

View File

@@ -0,0 +1,395 @@
{% 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;">
<div class="flex justify-between items-end mb-6">
<div>
<h3 class="text-lg font-bold text-gray-900 dark:text-white">Head-to-Head Matrix</h3>
<p class="text-sm text-gray-500 mt-1">Shows <span class="font-bold text-green-600 bg-green-50 px-1 rounded">Kills</span> : <span class="font-bold text-red-500 bg-red-50 px-1 rounded">Deaths</span> interaction between players</p>
</div>
<div class="text-xs text-gray-400 font-mono">
Row: Team 1 Players<br>
Col: Team 2 Players
</div>
</div>
<div class="overflow-x-auto rounded-xl border border-gray-200 dark:border-gray-700">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-slate-700/50">
<tr>
<th class="px-4 py-3 text-left text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider bg-gray-50 dark:bg-slate-700/50 sticky left-0 z-10">
Team 1 \ Team 2
</th>
{% for victim in team2_players %}
<th class="px-2 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-300 tracking-wider min-w-[80px]" title="{{ victim.username }}">
<div class="flex flex-col items-center group">
<div class="relative">
{% if victim.avatar_url %}
<img class="h-8 w-8 rounded-full mb-1 border-2 border-transparent group-hover:border-yrtv-400 transition-all" src="{{ victim.avatar_url }}">
{% 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-2 border-yrtv-200 mb-1 group-hover:border-yrtv-400 transition-all">
{{ (victim.username or victim.steam_id_64)[:2] | upper }}
</div>
{% endif %}
</div>
<span class="truncate w-20 text-center font-bold text-gray-700 dark:text-gray-300 group-hover:text-yrtv-600 transition-colors text-[10px]">{{ victim.username or 'Player' }}</span>
</div>
</th>
{% endfor %}
</tr>
</thead>
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-100 dark:divide-gray-700">
{% for killer in team1_players %}
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30 transition-colors">
<td class="px-4 py-3 whitespace-nowrap font-medium text-gray-900 dark:text-white bg-white dark:bg-slate-800 sticky left-0 z-10 border-r border-gray-100 dark:border-gray-700 shadow-sm">
<div class="flex items-center group">
{% if killer.avatar_url %}
<img class="h-8 w-8 rounded-full mr-3 border-2 border-transparent group-hover:border-blue-400 transition-all" src="{{ killer.avatar_url }}">
{% else %}
<div class="h-8 w-8 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold text-xs border-2 border-blue-200 mr-3 group-hover:border-blue-400 transition-all">
{{ (killer.username or killer.steam_id_64)[:2] | upper }}
</div>
{% endif %}
<span class="truncate w-28 font-bold group-hover:text-blue-600 transition-colors">{{ killer.username or 'Player' }}</span>
</div>
</td>
{% for victim in team2_players %}
<!-- Kills: Killer -> Victim -->
{% set kills = h2h_matrix.get(killer.steam_id_64, {}).get(victim.steam_id_64, 0) %}
<!-- Deaths: Victim -> Killer (which is Killer's death) -->
{% set deaths = h2h_matrix.get(victim.steam_id_64, {}).get(killer.steam_id_64, 0) %}
<td class="px-2 py-3 text-center border-l border-gray-50 dark:border-gray-700/50">
<div class="flex items-center justify-center gap-1.5 font-mono">
<!-- Kills -->
<span class="{% if kills > deaths %}font-black text-lg text-green-600{% elif kills > 0 %}font-bold text-gray-900 dark:text-white{% else %}text-gray-300 dark:text-gray-600 text-xs{% endif %}">
{{ kills }}
</span>
<span class="text-gray-300 dark:text-gray-600 text-[10px]">:</span>
<!-- Deaths -->
<span class="{% if deaths > kills %}font-black text-lg text-red-500{% elif deaths > 0 %}font-bold text-gray-900 dark:text-white{% else %}text-gray-300 dark:text-gray-600 text-xs{% endif %}">
{{ deaths }}
</span>
</div>
<!-- Interaction Bar (Optional visual) -->
{% if kills + deaths > 0 %}
<div class="w-full h-1 bg-gray-100 dark:bg-slate-700 rounded-full mt-1 overflow-hidden flex">
{% set total = kills + deaths %}
<div class="bg-green-500 h-full" style="width: {{ (kills / total * 100) }}%"></div>
<div class="bg-red-500 h-full" style="width: {{ (deaths / total * 100) }}%"></div>
</div>
{% endif %}
</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 %}

View 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 %}

View File

@@ -0,0 +1,251 @@
{% extends "base.html" %}
{% block content %}
<div class="space-y-8">
<!-- 1. Header & Summary -->
<div class="bg-white dark:bg-slate-800 shadow-xl rounded-2xl overflow-hidden border border-gray-100 dark:border-slate-700 p-8">
<div class="flex flex-col md:flex-row items-center md:items-start gap-8">
<!-- Avatar -->
<div class="flex-shrink-0">
{% if player.avatar_url %}
<img class="h-32 w-32 rounded-2xl object-cover border-4 border-white shadow-lg" src="{{ player.avatar_url }}">
{% else %}
<div class="h-32 w-32 rounded-2xl bg-gradient-to-br from-red-100 to-red-200 flex items-center justify-center text-red-600 font-bold text-4xl border-4 border-white shadow-lg">
{{ player.username[:2]|upper if player.username else '??' }}
</div>
{% endif %}
</div>
<div class="flex-1 text-center md:text-left">
<div class="flex items-center justify-center md:justify-start gap-3 mb-2">
<h1 class="text-3xl font-black text-gray-900 dark:text-white">{{ player.username }}</h1>
<span class="px-2.5 py-0.5 rounded-md text-xs font-bold bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-gray-300 font-mono">
OPPONENT
</span>
</div>
<p class="text-sm font-mono text-gray-500 mb-6">{{ player.steam_id_64 }}</p>
<!-- Summary Stats -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div class="bg-gray-50 dark:bg-slate-700/50 p-4 rounded-xl border border-gray-100 dark:border-slate-600">
<div class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1">Matches vs Us</div>
<div class="text-2xl font-black text-gray-900 dark:text-white">{{ history|length }}</div>
</div>
{% set wins = history | selectattr('is_win') | list | length %}
{% set wr = (wins / history|length * 100) if history else 0 %}
<div class="bg-gray-50 dark:bg-slate-700/50 p-4 rounded-xl border border-gray-100 dark:border-slate-600">
<div class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1">Their Win Rate</div>
<div class="text-2xl font-black {% if wr > 50 %}text-red-500{% else %}text-green-500{% endif %}">
{{ "%.1f"|format(wr) }}%
</div>
</div>
{% set avg_rating = history | map(attribute='rating') | sum / history|length if history else 0 %}
<div class="bg-gray-50 dark:bg-slate-700/50 p-4 rounded-xl border border-gray-100 dark:border-slate-600">
<div class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1">Their Avg Rating</div>
<div class="text-2xl font-black text-gray-900 dark:text-white">{{ "%.2f"|format(avg_rating) }}</div>
</div>
{% set avg_kd_diff = history | map(attribute='kd_diff') | sum / history|length if history else 0 %}
<div class="bg-gray-50 dark:bg-slate-700/50 p-4 rounded-xl border border-gray-100 dark:border-slate-600">
<div class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1">Avg K/D Diff</div>
<div class="text-2xl font-black {% if avg_kd_diff > 0 %}text-red-500{% else %}text-green-500{% endif %}">
{{ "%+.2f"|format(avg_kd_diff) }}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 2. Charts & Side Analysis -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- ELO Performance 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">
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-6 flex items-center gap-2">
<span>📈</span> Performance vs ELO Segments
</h3>
<div class="relative h-80 w-full">
<canvas id="eloChart"></canvas>
</div>
</div>
<!-- Side 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-6 flex items-center gap-2">
<span>🛡️</span> Side Preference (vs Us)
</h3>
{% macro side_row(label, t_val, ct_val, format_str='{:.2f}') %}
<div class="mb-6">
<div class="flex justify-between text-xs font-bold text-gray-500 uppercase mb-2">
<span>{{ label }}</span>
</div>
<div class="flex items-end justify-between gap-2 mb-2">
<span class="text-2xl font-black text-amber-500">{{ (format_str.format(t_val) if t_val is not none else '—') }}</span>
<span class="text-xs font-bold text-gray-400">vs</span>
<span class="text-2xl font-black text-blue-500">{{ (format_str.format(ct_val) if ct_val is not none else '—') }}</span>
</div>
<div class="flex h-2 w-full rounded-full overflow-hidden bg-gray-200 dark:bg-slate-600">
{% set has_t = t_val is not none %}
{% set has_ct = ct_val is not none %}
{% set total = (t_val or 0) + (ct_val or 0) %}
{% if total > 0 and has_t and has_ct %}
{% set t_pct = ((t_val or 0) / 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 class="flex justify-between text-[10px] font-bold text-gray-400 mt-1">
<span>T-Side</span>
<span>CT-Side</span>
</div>
</div>
{% endmacro %}
{{ side_row('Rating', side_stats.get('rating_t'), side_stats.get('rating_ct')) }}
{{ side_row('K/D Ratio', side_stats.get('kd_t'), side_stats.get('kd_ct')) }}
<div class="mt-8 p-4 bg-gray-50 dark:bg-slate-700/30 rounded-xl text-center">
<div class="text-xs font-bold text-gray-400 uppercase mb-1">Rounds Sampled</div>
<div class="text-xl font-black text-gray-700 dark:text-gray-200">
{{ (side_stats.get('rounds_t', 0) or 0) + (side_stats.get('rounds_ct', 0) or 0) }}
</div>
</div>
</div>
</div>
<!-- 3. Match History Table -->
<div class="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">
<h3 class="text-lg font-bold text-gray-900 dark:text-white">Match History (Head-to-Head)</h3>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700">
<thead class="bg-gray-50 dark:bg-slate-700/50">
<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">Their Result</th>
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Match Elo</th>
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Their Rating</th>
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Their K/D</th>
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">K/D Diff (vs Team)</th>
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">K / D</th>
<th class="px-6 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-slate-700 bg-white dark:bg-slate-800">
{% for m in history %}
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors">
<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">
<span class="inline-flex items-center 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 %}">
{{ 'WON' if m.is_win else 'LOST' }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center text-sm font-mono text-gray-500">
{{ "%.0f"|format(m.elo or 0) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<span class="text-sm font-bold font-mono">{{ "%.2f"|format(m.rating or 0) }}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center text-sm font-mono text-gray-600 dark:text-gray-400">
{{ "%.2f"|format(m.kd_ratio or 0) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
{% set diff = m.kd_diff %}
<span class="text-sm font-bold font-mono {% if diff > 0 %}text-red-500{% else %}text-green-500{% endif %}">
{{ "%+.2f"|format(diff) }}
</span>
<div class="text-[10px] text-gray-400">vs Team Avg {{ "%.2f"|format(m.other_team_kd or 0) }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center text-sm font-mono text-gray-500">
{{ m.kills }} / {{ m.deaths }}
</td>
<td class="px-6 py-4 text-right">
<a href="{{ url_for('matches.detail', match_id=m.match_id) }}" class="text-gray-400 hover:text-yrtv-600 transition">
<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>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const eloData = {{ elo_stats | tojson }};
const labels = eloData.map(d => d.range);
const ratings = eloData.map(d => d.avg_rating);
const kds = eloData.map(d => d.avg_kd);
const ctx = document.getElementById('eloChart').getContext('2d');
new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [
{
label: 'Avg Rating',
data: ratings,
backgroundColor: 'rgba(124, 58, 237, 0.6)',
borderColor: 'rgba(124, 58, 237, 1)',
borderWidth: 1,
yAxisID: 'y'
},
{
type: 'line',
label: 'Avg K/D',
data: kds,
borderColor: 'rgba(234, 179, 8, 1)',
borderWidth: 2,
tension: 0.3,
pointBackgroundColor: '#fff',
yAxisID: 'y1'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
scales: {
y: {
type: 'linear',
display: true,
position: 'left',
title: { display: true, text: 'Rating' },
grid: { color: 'rgba(156, 163, 175, 0.1)' }
},
y1: {
type: 'linear',
display: true,
position: 'right',
title: { display: true, text: 'K/D Ratio' },
grid: { drawOnChartArea: false }
}
}
}
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,329 @@
{% extends "base.html" %}
{% block content %}
<div class="space-y-6">
<!-- Global Stats Dashboard -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Opponent ELO Distribution -->
<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-sm font-bold text-gray-500 uppercase tracking-wider mb-4">Opponent ELO Curve</h3>
<div class="relative h-48 w-full">
<canvas id="eloDistChart"></canvas>
</div>
</div>
<!-- Opponent Rating Distribution -->
<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-sm font-bold text-gray-500 uppercase tracking-wider mb-4">Opponent Rating Curve</h3>
<div class="relative h-48 w-full">
<canvas id="ratingDistChart"></canvas>
</div>
</div>
</div>
<!-- Map-specific Opponent Stats -->
<div class="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">
<h3 class="text-lg font-bold text-gray-900 dark:text-white">分地图对手统计</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">各地图下遇到对手的胜率、ELO、Rating、K/D</p>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700">
<thead class="bg-gray-50 dark:bg-slate-700/50">
<tr>
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Map</th>
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Matches</th>
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Win Rate</th>
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Avg Rating</th>
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Avg K/D</th>
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Avg Elo</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-slate-700">
{% for m in map_stats %}
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors">
<td class="px-6 py-3 whitespace-nowrap text-sm font-bold text-gray-900 dark:text-white">{{ m.map_name }}</td>
<td class="px-6 py-3 whitespace-nowrap text-center">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-slate-700 dark:text-gray-300">
{{ m.matches }}
</span>
</td>
<td class="px-6 py-3 whitespace-nowrap text-center">
{% set wr = (m.win_rate or 0) * 100 %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-bold
{% if wr > 60 %}bg-red-100 text-red-800 border border-red-200
{% elif wr < 40 %}bg-green-100 text-green-800 border border-green-200
{% else %}bg-gray-100 text-gray-800 border border-gray-200{% endif %}">
{{ "%.1f"|format(wr) }}%
</span>
</td>
<td class="px-6 py-3 whitespace-nowrap text-center text-sm font-mono font-bold text-gray-700 dark:text-gray-300">
{{ "%.2f"|format(m.avg_rating or 0) }}
</td>
<td class="px-6 py-3 whitespace-nowrap text-center text-sm font-mono text-gray-600 dark:text-gray-400">
{{ "%.2f"|format(m.avg_kd or 0) }}
</td>
<td class="px-6 py-3 whitespace-nowrap text-center text-sm font-mono text-gray-500">
{% if m.avg_elo %}{{ "%.0f"|format(m.avg_elo) }}{% else %}—{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">暂无地图统计数据</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Map-specific Shark Encounters -->
<div class="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">
<h3 class="text-lg font-bold text-gray-900 dark:text-white">分地图炸鱼哥遭遇次数</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">统计各地图出现 rating > 1.5 对手的比赛次数</p>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700">
<thead class="bg-gray-50 dark:bg-slate-700/50">
<tr>
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Map</th>
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Encounters</th>
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Frequency</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-slate-700">
{% for m in map_stats %}
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors">
<td class="px-6 py-3 whitespace-nowrap text-sm font-bold text-gray-900 dark:text-white">{{ m.map_name }}</td>
<td class="px-6 py-3 whitespace-nowrap text-center">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 border border-amber-200 dark:bg-slate-700 dark:text-amber-300 dark:border-slate-600">
{{ m.shark_matches or 0 }}
</span>
</td>
<td class="px-6 py-3 whitespace-nowrap text-center">
{% set freq = ( (m.shark_matches or 0) / (m.matches or 1) ) * 100 %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-md text-[10px] font-bold bg-gray-100 text-gray-800 border border-gray-200 dark:bg-slate-700 dark:text-gray-300 dark:border-slate-600">
{{ "%.1f"|format(freq) }}%
</span>
</td>
</tr>
{% else %}
<tr>
<td colspan="3" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">暂无炸鱼哥统计数据</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-2xl overflow-hidden border border-gray-100 dark:border-slate-700 p-6">
<div class="flex flex-col sm:flex-row justify-between items-center mb-6 gap-4">
<div>
<h2 class="text-2xl font-black text-gray-900 dark:text-white flex items-center gap-2">
<span>⚔️</span> 对手分析 (Opponent Analysis)
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Analyze performance against specific players encountered in matches.
</p>
</div>
<div class="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
<!-- Sort Dropdown -->
<div class="relative">
<select onchange="location = this.value;" class="w-full sm:w-auto appearance-none pl-3 pr-10 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-sm focus:outline-none focus:ring-2 focus:ring-yrtv-500 dark:text-white">
<option value="{{ url_for('opponents.index', search=request.args.get('search', ''), sort='matches') }}" {% if sort_by == 'matches' %}selected{% endif %}>Sort by Matches</option>
<option value="{{ url_for('opponents.index', search=request.args.get('search', ''), sort='rating') }}" {% if sort_by == 'rating' %}selected{% endif %}>Sort by Rating</option>
<option value="{{ url_for('opponents.index', search=request.args.get('search', ''), sort='kd') }}" {% if sort_by == 'kd' %}selected{% endif %}>Sort by K/D</option>
<option value="{{ url_for('opponents.index', search=request.args.get('search', ''), sort='win_rate') }}" {% if sort_by == 'win_rate' %}selected{% endif %}>Sort by Win Rate (Nemesis)</option>
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-500">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
</div>
</div>
<form action="{{ url_for('opponents.index') }}" method="get" class="flex gap-2">
<input type="hidden" name="sort" value="{{ sort_by }}">
<input type="text" name="search" placeholder="Search opponent..." class="w-full sm:w-64 px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-gray-50 dark:bg-slate-700/50 focus:outline-none focus:ring-2 focus:ring-yrtv-500 dark:text-white transition" value="{{ request.args.get('search', '') }}">
<button type="submit" class="px-4 py-2 bg-yrtv-600 text-white font-bold rounded-lg hover:bg-yrtv-700 transition shadow-lg shadow-yrtv-500/30">
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
</button>
</form>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700">
<thead class="bg-gray-50 dark:bg-slate-700/50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Opponent</th>
<th scope="col" class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Matches vs Us</th>
<th scope="col" class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Their Win Rate</th>
<th scope="col" class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Their Rating</th>
<th scope="col" class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Their K/D</th>
<th scope="col" class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Avg Match Elo</th>
<th scope="col" class="relative px-6 py-3"><span class="sr-only">View</span></th>
</tr>
</thead>
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-slate-700">
{% for op in opponents %}
<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="flex items-center">
<div class="flex-shrink-0 h-10 w-10">
{% if op.avatar_url %}
<img class="h-10 w-10 rounded-full object-cover border-2 border-white shadow-sm" src="{{ op.avatar_url }}" alt="">
{% else %}
<div class="h-10 w-10 rounded-full bg-gradient-to-br from-gray-100 to-gray-300 flex items-center justify-center text-gray-500 font-bold text-xs">
{{ op.username[:2]|upper if op.username else '??' }}
</div>
{% endif %}
</div>
<div class="ml-4">
<div class="text-sm font-bold text-gray-900 dark:text-white">{{ op.username }}</div>
<div class="text-xs text-gray-500 font-mono">{{ op.steam_id_64 }}</div>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-slate-700 dark:text-gray-300">
{{ op.matches }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
{% set wr = op.win_rate * 100 %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-bold
{% if wr > 60 %}bg-red-100 text-red-800 border border-red-200
{% elif wr < 40 %}bg-green-100 text-green-800 border border-green-200
{% else %}bg-gray-100 text-gray-800 border border-gray-200{% endif %}">
{{ "%.1f"|format(wr) }}%
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center text-sm font-mono font-bold text-gray-700 dark:text-gray-300">
{{ "%.2f"|format(op.avg_rating or 0) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-center text-sm font-mono text-gray-600 dark:text-gray-400">
{{ "%.2f"|format(op.avg_kd or 0) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-center text-sm font-mono text-gray-500">
{% if op.avg_match_elo %}
{{ "%.0f"|format(op.avg_match_elo) }}
{% else %}—{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<a href="{{ url_for('opponents.detail', steam_id=op.steam_id_64) }}" class="text-yrtv-600 hover:text-yrtv-900 font-bold hover:underline">Analyze &rarr;</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="7" class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
No opponents found.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="mt-6 flex justify-between items-center border-t border-gray-200 dark:border-slate-700 pt-4">
<div class="text-sm text-gray-700 dark:text-gray-400">
Total <span class="font-bold">{{ total }}</span> opponents found
</div>
<div class="flex gap-2">
{% if page > 1 %}
<a href="{{ url_for('opponents.index', page=page-1, search=request.args.get('search', ''), sort=sort_by) }}" class="px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 dark:bg-slate-700 dark:text-white dark:border-slate-600 dark:hover:bg-slate-600 transition">Previous</a>
{% endif %}
{% if page < total_pages %}
<a href="{{ url_for('opponents.index', page=page+1, search=request.args.get('search', ''), sort=sort_by) }}" class="px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 dark:bg-slate-700 dark:text-white dark:border-slate-600 dark:hover:bg-slate-600 transition">Next</a>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Data from Backend
const stats = {{ stats_summary | tojson }};
const createChart = (id, label, labels, data, color, type='line') => {
const ctx = document.getElementById(id).getContext('2d');
new Chart(ctx, {
type: type,
data: {
labels: labels,
datasets: [{
label: label,
data: data,
backgroundColor: 'rgba(124, 58, 237, 0.1)',
borderColor: color,
tension: 0.35,
fill: true,
borderRadius: 4,
barPercentage: 0.6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
y: {
beginAtZero: true,
grid: { color: 'rgba(156, 163, 175, 0.1)' },
ticks: { display: false } // Hide Y axis labels for cleaner look
},
x: {
grid: { display: false },
ticks: { font: { size: 10 } }
}
}
}
});
};
const buildBins = (values, step, roundFn) => {
if (!values || values.length === 0) return { labels: [], data: [] };
const min = Math.min(...values);
const max = Math.max(...values);
let start = Math.floor(min / step) * step;
let end = Math.ceil(max / step) * step;
const bins = [];
const labels = [];
for (let v = start; v <= end; v += step) {
bins.push(0);
labels.push(roundFn(v));
}
values.forEach(val => {
const idx = Math.floor((val - start) / step);
if (idx >= 0 && idx < bins.length) bins[idx] += 1;
});
return { labels, data: bins };
};
if (stats.elo_values && stats.elo_values.length) {
const eloStep = 100; // 可按需改为50
const { labels, data } = buildBins(stats.elo_values, eloStep, v => Math.round(v));
createChart('eloDistChart', 'Opponents', labels, data, 'rgba(124, 58, 237, 1)', 'line');
} else if (stats.elo_dist) {
createChart('eloDistChart', 'Opponents', Object.keys(stats.elo_dist), Object.values(stats.elo_dist), 'rgba(124, 58, 237, 1)', 'line');
}
if (stats.rating_values && stats.rating_values.length) {
const rStep = 0.1; // 可按需改为0.2
const { labels, data } = buildBins(stats.rating_values, rStep, v => Number(v.toFixed(1)));
createChart('ratingDistChart', 'Opponents', labels, data, 'rgba(234, 179, 8, 1)', 'line');
} else if (stats.rating_dist) {
const order = ['<0.8','0.8-1.0','1.0-1.2','1.2-1.4','>1.4'];
const labels = order.filter(k => stats.rating_dist.hasOwnProperty(k));
const data = labels.map(k => stats.rating_dist[k]);
createChart('ratingDistChart', 'Opponents', labels, data, 'rgba(234, 179, 8, 1)', 'line');
}
});
</script>
{% endblock %}

View 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 %}

File diff suppressed because it is too large Load Diff

View 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 %}

View 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 %}

View 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>
&times;
</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 %}

View 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">
&times;
</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>

View 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 %}

View 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">&times;</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 %}

View 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 %}

View 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 %}

View 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">&#8203;</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 %}

View 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">
&times;
</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">
&times;
</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 %}

View 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 %}

View 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">
查看分析 &rarr;
</a>
</div>
{% endfor %}
</div>
</div>
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More