commit 48f1f71d3a0bbc29cd914e64163ac01807178ce2 Author: bloodystream <77095318@qq.com> Date: Wed Jan 28 14:04:32 2026 +0800 feat: Add recent performance stability stats (matches/days) to player profile diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..af70359 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/ETL/L1A.py b/ETL/L1A.py new file mode 100644 index 0000000..cd488bb --- /dev/null +++ b/ETL/L1A.py @@ -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() \ No newline at end of file diff --git a/ETL/L2_Builder.py b/ETL/L2_Builder.py new file mode 100644 index 0000000..50f13e1 --- /dev/null +++ b/ETL/L2_Builder.py @@ -0,0 +1,1469 @@ +import sqlite3 +import json +import os +import sys +import logging +from dataclasses import dataclass, field +from typing import List, Dict, Optional, Any, Tuple +from datetime import datetime + +# Setup logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +# Constants +L1A_DB_PATH = 'database/L1A/L1A.sqlite' +L2_DB_PATH = 'database/L2/L2_Main.sqlite' +SCHEMA_PATH = 'database/L2/schema.sql' + +# --- Data Structures for Unification --- + +@dataclass +class PlayerStats: + steam_id_64: str + team_id: int = 0 + kills: int = 0 + deaths: int = 0 + assists: int = 0 + headshot_count: int = 0 + kd_ratio: float = 0.0 + adr: float = 0.0 + rating: float = 0.0 + rating2: float = 0.0 + rating3: float = 0.0 + rws: float = 0.0 + mvp_count: int = 0 + elo_change: float = 0.0 + rank_score: int = 0 + is_win: bool = False + + # VIP Stats + kast: float = 0.0 + entry_kills: int = 0 + entry_deaths: int = 0 + awp_kills: int = 0 + clutch_1v1: int = 0 + clutch_1v2: int = 0 + clutch_1v3: int = 0 + clutch_1v4: int = 0 + clutch_1v5: int = 0 + flash_assists: int = 0 + flash_duration: float = 0.0 + jump_count: int = 0 + damage_total: int = 0 + damage_received: int = 0 + damage_receive: int = 0 + damage_stats: int = 0 + assisted_kill: int = 0 + awp_kill: int = 0 + awp_kill_ct: int = 0 + awp_kill_t: int = 0 + benefit_kill: int = 0 + day: str = "" + defused_bomb: int = 0 + end_1v1: int = 0 + end_1v2: int = 0 + end_1v3: int = 0 + end_1v4: int = 0 + end_1v5: int = 0 + explode_bomb: int = 0 + first_death: int = 0 + fd_ct: int = 0 + fd_t: int = 0 + first_kill: int = 0 + flash_enemy: int = 0 + flash_team: int = 0 + flash_team_time: float = 0.0 + flash_time: float = 0.0 + game_mode: str = "" + group_id: int = 0 + hold_total: int = 0 + id: int = 0 + is_highlight: int = 0 + is_most_1v2: int = 0 + is_most_assist: int = 0 + is_most_awp: int = 0 + is_most_end: int = 0 + is_most_first_kill: int = 0 + is_most_headshot: int = 0 + is_most_jump: int = 0 + is_svp: int = 0 + is_tie: int = 0 + kill_1: int = 0 + kill_2: int = 0 + kill_3: int = 0 + kill_4: int = 0 + kill_5: int = 0 + many_assists_cnt1: int = 0 + many_assists_cnt2: int = 0 + many_assists_cnt3: int = 0 + many_assists_cnt4: int = 0 + many_assists_cnt5: int = 0 + map: str = "" + match_code: str = "" + match_mode: str = "" + match_team_id: int = 0 + match_time: int = 0 + per_headshot: float = 0.0 + perfect_kill: int = 0 + planted_bomb: int = 0 + revenge_kill: int = 0 + round_total: int = 0 + season: str = "" + team_kill: int = 0 + throw_harm: int = 0 + throw_harm_enemy: int = 0 + uid: int = 0 + year: str = "" + sts_raw: str = "" + level_info_raw: str = "" + + # Utility Usage + util_flash_usage: int = 0 + util_smoke_usage: int = 0 + util_molotov_usage: int = 0 + util_he_usage: int = 0 + util_decoy_usage: int = 0 + +@dataclass +class RoundEvent: + event_id: str + event_type: str # 'kill', 'bomb_plant', etc. + event_time: int + attacker_steam_id: Optional[str] = None + victim_steam_id: Optional[str] = None + assister_steam_id: Optional[str] = None + flash_assist_steam_id: Optional[str] = None + trade_killer_steam_id: Optional[str] = None + weapon: Optional[str] = None + is_headshot: bool = False + is_wallbang: bool = False + is_blind: bool = False + is_through_smoke: bool = False + is_noscope: bool = False + # Spatial + attacker_pos: Optional[Tuple[int, int, int]] = None + victim_pos: Optional[Tuple[int, int, int]] = None + # Score + score_change_attacker: float = 0.0 + score_change_victim: float = 0.0 + +@dataclass +class PlayerEconomy: + steam_id_64: str + side: str + start_money: int = 0 + equipment_value: int = 0 + main_weapon: str = "" + has_helmet: bool = False + has_defuser: bool = False + has_zeus: bool = False + round_performance_score: float = 0.0 + +@dataclass +class RoundData: + round_num: int + winner_side: str + win_reason: int + win_reason_desc: str + duration: float + end_time_stamp: str + ct_score: int + t_score: int + ct_money_start: int = 0 + t_money_start: int = 0 + events: List[RoundEvent] = field(default_factory=list) + economies: List[PlayerEconomy] = field(default_factory=list) + +@dataclass +class MatchTeamData: + group_id: int + group_all_score: int = 0 + group_change_elo: float = 0.0 + group_fh_role: int = 0 + group_fh_score: int = 0 + group_origin_elo: float = 0.0 + group_sh_role: int = 0 + group_sh_score: int = 0 + group_tid: int = 0 + group_uids: str = "" + +@dataclass +class MatchData: + match_id: str + match_code: str = "" + map_name: str = "" + start_time: int = 0 + end_time: int = 0 + duration: int = 0 + winner_team: int = 0 + score_team1: int = 0 + score_team2: int = 0 + server_ip: str = "" + server_port: int = 0 + location: str = "" + has_side_data_and_rating2: int = 0 + match_main_id: int = 0 + demo_url: str = "" + game_mode: int = 0 + game_name: str = "" + map_desc: str = "" + location_full: str = "" + match_mode: int = 0 + match_status: int = 0 + match_flag: int = 0 + status: int = 0 + waiver: int = 0 + year: int = 0 + season: str = "" + round_total: int = 0 + cs_type: int = 0 + priority_show_type: int = 0 + pug10m_show_type: int = 0 + credit_match_status: int = 0 + knife_winner: int = 0 + knife_winner_role: int = 0 + most_1v2_uid: int = 0 + most_assist_uid: int = 0 + most_awp_uid: int = 0 + most_end_uid: int = 0 + most_first_kill_uid: int = 0 + most_headshot_uid: int = 0 + most_jump_uid: int = 0 + mvp_uid: int = 0 + response_code: int = 0 + response_message: str = "" + response_status: int = 0 + response_timestamp: int = 0 + response_trace_id: str = "" + response_success: int = 0 + response_errcode: int = 0 + treat_info_raw: str = "" + round_list_raw: str = "" + leetify_data_raw: str = "" + data_source_type: str = "unknown" + players: Dict[str, PlayerStats] = field(default_factory=dict) # Key: steam_id_64 + players_t: Dict[str, PlayerStats] = field(default_factory=dict) + players_ct: Dict[str, PlayerStats] = field(default_factory=dict) + rounds: List[RoundData] = field(default_factory=list) + player_meta: Dict[str, Dict] = field(default_factory=dict) # steam_id -> {uid, name, avatar, ...} + teams: List[MatchTeamData] = field(default_factory=list) + +# --- Database Helper --- + +def init_db(): + if os.path.exists(L2_DB_PATH): + logger.info(f"Removing existing L2 DB at {L2_DB_PATH}") + try: + os.remove(L2_DB_PATH) + except PermissionError: + logger.error("Cannot remove L2 DB, it might be open.") + return False + + conn = sqlite3.connect(L2_DB_PATH) + with open(SCHEMA_PATH, 'r', encoding='utf-8') as f: + schema_sql = f.read() + conn.executescript(schema_sql) + conn.commit() + conn.close() + logger.info("L2 DB Initialized.") + return True + +# --- Parsers --- + +class MatchParser: + def __init__(self, match_id, raw_requests): + self.match_id = match_id + self.raw_requests = raw_requests + self.match_data = MatchData(match_id=match_id) + + # Extracted JSON bodies + self.data_match = None + self.data_match_wrapper = None + self.data_vip = None + self.data_leetify = None + self.data_round_list = None + + self._extract_payloads() + + def _extract_payloads(self): + for req in self.raw_requests: + url = req.get('url', '') + body = req.get('body', {}) + + if not body: + continue + + # Check URLs + if 'crane/http/api/data/match/' in url: + self.data_match_wrapper = body + self.data_match = body.get('data', {}) + elif 'crane/http/api/data/vip_plus_match_data/' in url: + self.data_vip = body.get('data', {}) + elif 'crane/http/api/match/leetify_rating/' in url: + self.data_leetify = body.get('data', {}) + elif 'crane/http/api/match/round/' in url: + self.data_round_list = body.get('data', {}) + + def parse(self) -> MatchData: + if not self.data_match: + logger.warning(f"No base match data found for {self.match_id}") + return self.match_data + + self._parse_base_info() + self._parse_players_base() + self._parse_players_vip() + + # Decide which round source to use + if self.data_leetify and self.data_leetify.get('leetify_data'): + self.match_data.data_source_type = 'leetify' + try: + self.match_data.leetify_data_raw = json.dumps(self.data_leetify.get('leetify_data', {}), ensure_ascii=False) + except: + self.match_data.leetify_data_raw = "" + self.match_data.round_list_raw = "" + self._parse_leetify_rounds() + elif self.data_round_list and self.data_round_list.get('round_list'): + self.match_data.data_source_type = 'classic' + try: + self.match_data.round_list_raw = json.dumps(self.data_round_list.get('round_list', []), ensure_ascii=False) + except: + self.match_data.round_list_raw = "" + self.match_data.leetify_data_raw = "" + self._parse_classic_rounds() + else: + self.match_data.data_source_type = 'unknown' + self.match_data.round_list_raw = "" + self.match_data.leetify_data_raw = "" + logger.info(f"No round data found for {self.match_id}") + + return self.match_data + + def _parse_base_info(self): + m = self.data_match.get('main', {}) + self.match_data.match_code = m.get('match_code', '') + self.match_data.map_name = m.get('map', '') + self.match_data.start_time = m.get('start_time', 0) + self.match_data.end_time = m.get('end_time', 0) + self.match_data.duration = self.match_data.end_time - self.match_data.start_time if self.match_data.end_time else 0 + self.match_data.winner_team = m.get('match_winner', 0) + self.match_data.score_team1 = m.get('group1_all_score', 0) + self.match_data.score_team2 = m.get('group2_all_score', 0) + self.match_data.server_ip = m.get('server_ip', '') + # Port is sometimes string + try: + self.match_data.server_port = int(m.get('server_port', 0)) + except: + self.match_data.server_port = 0 + self.match_data.location = m.get('location', '') + def safe_int(val): + try: + return int(float(val)) if val is not None else 0 + except: + return 0 + def safe_float(val): + try: + return float(val) if val is not None else 0.0 + except: + return 0.0 + def safe_text(val): + return "" if val is None else str(val) + wrapper = self.data_match_wrapper or {} + self.match_data.response_code = safe_int(wrapper.get('code')) + self.match_data.response_message = safe_text(wrapper.get('message')) + self.match_data.response_status = safe_int(wrapper.get('status')) + self.match_data.response_timestamp = safe_int(wrapper.get('timeStamp') if wrapper.get('timeStamp') is not None else wrapper.get('timestamp')) + self.match_data.response_trace_id = safe_text(wrapper.get('traceId') if wrapper.get('traceId') is not None else wrapper.get('trace_id')) + self.match_data.response_success = safe_int(wrapper.get('success')) + self.match_data.response_errcode = safe_int(wrapper.get('errcode')) + self.match_data.has_side_data_and_rating2 = safe_int(self.data_match.get('has_side_data_and_rating2')) + self.match_data.match_main_id = safe_int(m.get('id')) + self.match_data.demo_url = safe_text(m.get('demo_url')) + self.match_data.game_mode = safe_int(m.get('game_mode')) + self.match_data.game_name = safe_text(m.get('game_name')) + self.match_data.map_desc = safe_text(m.get('map_desc')) + self.match_data.location_full = safe_text(m.get('location_full')) + self.match_data.match_mode = safe_int(m.get('match_mode')) + self.match_data.match_status = safe_int(m.get('match_status')) + self.match_data.match_flag = safe_int(m.get('match_flag')) + self.match_data.status = safe_int(m.get('status')) + self.match_data.waiver = safe_int(m.get('waiver')) + self.match_data.year = safe_int(m.get('year')) + self.match_data.season = safe_text(m.get('season')) + self.match_data.round_total = safe_int(m.get('round_total')) + self.match_data.cs_type = safe_int(m.get('cs_type')) + self.match_data.priority_show_type = safe_int(m.get('priority_show_type')) + self.match_data.pug10m_show_type = safe_int(m.get('pug10m_show_type')) + self.match_data.credit_match_status = safe_int(m.get('credit_match_status')) + self.match_data.knife_winner = safe_int(m.get('knife_winner')) + self.match_data.knife_winner_role = safe_int(m.get('knife_winner_role')) + self.match_data.most_1v2_uid = safe_int(m.get('most_1v2_uid')) + self.match_data.most_assist_uid = safe_int(m.get('most_assist_uid')) + self.match_data.most_awp_uid = safe_int(m.get('most_awp_uid')) + self.match_data.most_end_uid = safe_int(m.get('most_end_uid')) + self.match_data.most_first_kill_uid = safe_int(m.get('most_first_kill_uid')) + self.match_data.most_headshot_uid = safe_int(m.get('most_headshot_uid')) + self.match_data.most_jump_uid = safe_int(m.get('most_jump_uid')) + self.match_data.mvp_uid = safe_int(m.get('mvp_uid')) + treat_info = self.data_match.get('treat_info') + if treat_info is not None: + try: + self.match_data.treat_info_raw = json.dumps(treat_info, ensure_ascii=False) + except: + self.match_data.treat_info_raw = "" + self.match_data.teams = [] + for idx in [1, 2]: + team = MatchTeamData( + group_id=idx, + group_all_score=safe_int(m.get(f"group{idx}_all_score")), + group_change_elo=safe_float(m.get(f"group{idx}_change_elo")), + group_fh_role=safe_int(m.get(f"group{idx}_fh_role")), + group_fh_score=safe_int(m.get(f"group{idx}_fh_score")), + group_origin_elo=safe_float(m.get(f"group{idx}_origin_elo")), + group_sh_role=safe_int(m.get(f"group{idx}_sh_role")), + group_sh_score=safe_int(m.get(f"group{idx}_sh_score")), + group_tid=safe_int(m.get(f"group{idx}_tid")), + group_uids=safe_text(m.get(f"group{idx}_uids")) + ) + self.match_data.teams.append(team) + + def _parse_players_base(self): + # Players are in group_1 and group_2 lists in data_match + groups = [] + if 'group_1' in self.data_match: groups.extend(self.data_match['group_1']) + if 'group_2' in self.data_match: groups.extend(self.data_match['group_2']) + def safe_int(val): + try: + return int(float(val)) if val is not None else 0 + except: + return 0 + def safe_text(val): + return "" if val is None else str(val) + + for p in groups: + # We need steam_id. + # Structure: user_info -> user_data -> steam -> steamId + user_info = p.get('user_info', {}) + user_data = user_info.get('user_data', {}) + steam_data = user_data.get('steam', {}) + steam_id = str(steam_data.get('steamId', '')) + + fight = p.get('fight', {}) + fight_t = p.get('fight_t', {}) + fight_ct = p.get('fight_ct', {}) + uid = fight.get('uid') + + # Store meta for dim_players + user_data = user_info.get('user_data', {}) + profile = user_data.get('profile', {}) + + # If steam_id is empty, use temporary placeholder '5E:{uid}' + # Ideally we want steam_id_64. + if not steam_id and uid: + steam_id = f"5E:{uid}" + + if not steam_id: + continue + + status = user_data.get('status', {}) + platform_exp = user_data.get('platformExp', {}) + trusted = user_data.get('trusted', {}) + certify = user_data.get('certify', {}) + identity = user_data.get('identity', {}) + plus_info = user_info.get('plus_info', {}) or p.get('plus_info', {}) + user_info_raw = "" + try: + user_info_raw = json.dumps(user_info, ensure_ascii=False) + except: + user_info_raw = "" + + self.match_data.player_meta[steam_id] = { + 'uid': safe_int(uid), + 'username': safe_text(user_data.get('username')), + 'uuid': safe_text(user_data.get('uuid')), + 'email': safe_text(user_data.get('email')), + 'area': safe_text(user_data.get('area')), + 'mobile': safe_text(user_data.get('mobile')), + 'avatar_url': safe_text(profile.get('avatarUrl')), + 'domain': safe_text(profile.get('domain')), + 'user_domain': safe_text(user_data.get('domain')), + 'created_at': safe_int(user_data.get('createdAt')), + 'updated_at': safe_int(user_data.get('updatedAt')), + 'username_audit_status': safe_int(user_data.get('usernameAuditStatus')), + 'accid': safe_text(user_data.get('Accid')), + 'team_id': safe_int(user_data.get('teamID')), + 'trumpet_count': safe_int(user_data.get('trumpetCount')), + 'profile_nickname': safe_text(profile.get('nickname')), + 'profile_avatar_audit_status': safe_int(profile.get('avatarAuditStatus')), + 'profile_rgb_avatar_url': safe_text(profile.get('rgbAvatarUrl')), + 'profile_photo_url': safe_text(profile.get('photoUrl')), + 'profile_gender': safe_int(profile.get('gender')), + 'profile_birthday': safe_int(profile.get('birthday')), + 'profile_country_id': safe_text(profile.get('countryId')), + 'profile_region_id': safe_text(profile.get('regionId')), + 'profile_city_id': safe_text(profile.get('cityId')), + 'profile_language': safe_text(profile.get('language')), + 'profile_recommend_url': safe_text(profile.get('recommendUrl')), + 'profile_group_id': safe_int(profile.get('groupId')), + 'profile_reg_source': safe_int(profile.get('regSource')), + 'status_status': safe_int(status.get('status')), + 'status_expire': safe_int(status.get('expire')), + 'status_cancellation_status': safe_int(status.get('cancellationStatus')), + 'status_new_user': safe_int(status.get('newUser')), + 'status_login_banned_time': safe_int(status.get('loginBannedTime')), + 'status_anticheat_type': safe_int(status.get('anticheatType')), + 'status_flag_status1': safe_text(status.get('flagStatus1')), + 'status_anticheat_status': safe_text(status.get('anticheatStatus')), + 'status_flag_honor': safe_text(status.get('FlagHonor')), + 'status_privacy_policy_status': safe_int(status.get('PrivacyPolicyStatus')), + 'status_csgo_frozen_exptime': safe_int(status.get('csgoFrozenExptime')), + 'platformexp_level': safe_int(platform_exp.get('level')), + 'platformexp_exp': safe_int(platform_exp.get('exp')), + 'steam_account': safe_text(steam_data.get('steamAccount')), + 'steam_trade_url': safe_text(steam_data.get('tradeUrl')), + 'steam_rent_id': safe_text(steam_data.get('rentSteamId')), + 'trusted_credit': safe_int(trusted.get('credit')), + 'trusted_credit_level': safe_int(trusted.get('creditLevel')), + 'trusted_score': safe_int(trusted.get('score')), + 'trusted_status': safe_int(trusted.get('status')), + 'trusted_credit_status': safe_int(trusted.get('creditStatus')), + 'certify_id_type': safe_int(certify.get('idType')), + 'certify_status': safe_int(certify.get('status')), + 'certify_age': safe_int(certify.get('age')), + 'certify_real_name': safe_text(certify.get('realName')), + 'certify_uid_list': safe_text(json.dumps(certify.get('uidList'), ensure_ascii=False)) if certify.get('uidList') is not None else "", + 'certify_audit_status': safe_int(certify.get('auditStatus')), + 'certify_gender': safe_int(certify.get('gender')), + 'identity_type': safe_int(identity.get('type')), + 'identity_extras': safe_text(identity.get('extras')), + 'identity_status': safe_int(identity.get('status')), + 'identity_slogan': safe_text(identity.get('slogan')), + 'identity_list': safe_text(json.dumps(identity.get('identity_list'), ensure_ascii=False)) if identity.get('identity_list') is not None else "", + 'identity_slogan_ext': safe_text(identity.get('slogan_ext')), + 'identity_live_url': safe_text(identity.get('live_url')), + 'identity_live_type': safe_int(identity.get('live_type')), + 'plus_is_plus': safe_int(plus_info.get('is_plus')), + 'user_info_raw': user_info_raw + } + + stats = PlayerStats(steam_id_64=steam_id) + sts = p.get('sts', {}) + level_info = p.get('level_info', {}) + + try: + # Use safe conversion helper + def safe_int(val): + try: return int(float(val)) if val is not None else 0 + except: return 0 + + def safe_float(val): + try: return float(val) if val is not None else 0.0 + except: return 0.0 + + def safe_text(val): + return "" if val is None else str(val) + if sts is not None: + try: + stats.sts_raw = json.dumps(sts, ensure_ascii=False) + except: + stats.sts_raw = "" + if level_info is not None: + try: + stats.level_info_raw = json.dumps(level_info, ensure_ascii=False) + except: + stats.level_info_raw = "" + + def get_stat(key): + if key in fight and fight.get(key) not in [None, ""]: + return fight.get(key) + return 0 + + def build_side_stats(fight_side, team_id_value): + side_stats = PlayerStats(steam_id_64=steam_id) + side_stats.team_id = team_id_value + side_stats.kills = safe_int(fight_side.get('kill')) + side_stats.deaths = safe_int(fight_side.get('death')) + side_stats.assists = safe_int(fight_side.get('assist')) + side_stats.headshot_count = safe_int(fight_side.get('headshot')) + side_stats.adr = safe_float(fight_side.get('adr')) + side_stats.rating = safe_float(fight_side.get('rating')) + side_stats.rating2 = safe_float(fight_side.get('rating2')) + side_stats.rating3 = safe_float(fight_side.get('rating3')) + side_stats.rws = safe_float(fight_side.get('rws')) + side_stats.kast = safe_float(fight_side.get('kast')) + side_stats.mvp_count = safe_int(fight_side.get('is_mvp')) + side_stats.flash_duration = safe_float(fight_side.get('flash_enemy_time')) + side_stats.jump_count = safe_int(fight_side.get('jump_total')) + side_stats.is_win = bool(safe_int(fight_side.get('is_win'))) + side_stats.assisted_kill = safe_int(fight_side.get('assisted_kill')) + side_stats.awp_kill = safe_int(fight_side.get('awp_kill')) + side_stats.benefit_kill = safe_int(fight_side.get('benefit_kill')) + side_stats.day = safe_text(fight_side.get('day')) + side_stats.defused_bomb = safe_int(fight_side.get('defused_bomb')) + side_stats.end_1v1 = safe_int(fight_side.get('end_1v1')) + side_stats.end_1v2 = safe_int(fight_side.get('end_1v2')) + side_stats.end_1v3 = safe_int(fight_side.get('end_1v3')) + side_stats.end_1v4 = safe_int(fight_side.get('end_1v4')) + side_stats.end_1v5 = safe_int(fight_side.get('end_1v5')) + side_stats.explode_bomb = safe_int(fight_side.get('explode_bomb')) + side_stats.first_death = safe_int(fight_side.get('first_death')) + side_stats.first_kill = safe_int(fight_side.get('first_kill')) + side_stats.flash_enemy = safe_int(fight_side.get('flash_enemy')) + side_stats.flash_team = safe_int(fight_side.get('flash_team')) + side_stats.flash_team_time = safe_float(fight_side.get('flash_team_time')) + side_stats.flash_time = safe_float(fight_side.get('flash_time')) + side_stats.game_mode = safe_text(fight_side.get('game_mode')) + side_stats.group_id = safe_int(fight_side.get('group_id')) + side_stats.hold_total = safe_int(fight_side.get('hold_total')) + side_stats.id = safe_int(fight_side.get('id')) + side_stats.is_highlight = safe_int(fight_side.get('is_highlight')) + side_stats.is_most_1v2 = safe_int(fight_side.get('is_most_1v2')) + side_stats.is_most_assist = safe_int(fight_side.get('is_most_assist')) + side_stats.is_most_awp = safe_int(fight_side.get('is_most_awp')) + side_stats.is_most_end = safe_int(fight_side.get('is_most_end')) + side_stats.is_most_first_kill = safe_int(fight_side.get('is_most_first_kill')) + side_stats.is_most_headshot = safe_int(fight_side.get('is_most_headshot')) + side_stats.is_most_jump = safe_int(fight_side.get('is_most_jump')) + side_stats.is_svp = safe_int(fight_side.get('is_svp')) + side_stats.is_tie = safe_int(fight_side.get('is_tie')) + side_stats.kill_1 = safe_int(fight_side.get('kill_1')) + side_stats.kill_2 = safe_int(fight_side.get('kill_2')) + side_stats.kill_3 = safe_int(fight_side.get('kill_3')) + side_stats.kill_4 = safe_int(fight_side.get('kill_4')) + side_stats.kill_5 = safe_int(fight_side.get('kill_5')) + side_stats.many_assists_cnt1 = safe_int(fight_side.get('many_assists_cnt1')) + side_stats.many_assists_cnt2 = safe_int(fight_side.get('many_assists_cnt2')) + side_stats.many_assists_cnt3 = safe_int(fight_side.get('many_assists_cnt3')) + side_stats.many_assists_cnt4 = safe_int(fight_side.get('many_assists_cnt4')) + side_stats.many_assists_cnt5 = safe_int(fight_side.get('many_assists_cnt5')) + side_stats.map = safe_text(fight_side.get('map')) + side_stats.match_code = safe_text(fight_side.get('match_code')) + side_stats.match_mode = safe_text(fight_side.get('match_mode')) + side_stats.match_team_id = safe_int(fight_side.get('match_team_id')) + side_stats.match_time = safe_int(fight_side.get('match_time')) + side_stats.per_headshot = safe_float(fight_side.get('per_headshot')) + side_stats.perfect_kill = safe_int(fight_side.get('perfect_kill')) + side_stats.planted_bomb = safe_int(fight_side.get('planted_bomb')) + side_stats.revenge_kill = safe_int(fight_side.get('revenge_kill')) + side_stats.round_total = safe_int(fight_side.get('round_total')) + side_stats.season = safe_text(fight_side.get('season')) + side_stats.team_kill = safe_int(fight_side.get('team_kill')) + side_stats.throw_harm = safe_int(fight_side.get('throw_harm')) + side_stats.throw_harm_enemy = safe_int(fight_side.get('throw_harm_enemy')) + side_stats.uid = safe_int(fight_side.get('uid')) + side_stats.year = safe_text(fight_side.get('year')) + + # Map missing fields + side_stats.clutch_1v1 = side_stats.end_1v1 + side_stats.clutch_1v2 = side_stats.end_1v2 + side_stats.clutch_1v3 = side_stats.end_1v3 + side_stats.clutch_1v4 = side_stats.end_1v4 + side_stats.clutch_1v5 = side_stats.end_1v5 + side_stats.entry_kills = side_stats.first_kill + side_stats.entry_deaths = side_stats.first_death + + return side_stats + + team_id_value = safe_int(fight.get('match_team_id')) + stats.team_id = team_id_value + stats.kills = safe_int(get_stat('kill')) + stats.deaths = safe_int(get_stat('death')) + + # Force calculate K/D + if stats.deaths > 0: + stats.kd_ratio = stats.kills / stats.deaths + else: + stats.kd_ratio = float(stats.kills) + + stats.assists = safe_int(get_stat('assist')) + stats.headshot_count = safe_int(get_stat('headshot')) + + stats.adr = safe_float(get_stat('adr')) + stats.rating = safe_float(get_stat('rating')) + stats.rating2 = safe_float(get_stat('rating2')) + stats.rating3 = safe_float(get_stat('rating3')) + stats.rws = safe_float(get_stat('rws')) + + # is_mvp might be string "1" or int 1 + stats.mvp_count = safe_int(get_stat('is_mvp')) + + stats.flash_duration = safe_float(get_stat('flash_enemy_time')) + stats.jump_count = safe_int(get_stat('jump_total')) + stats.is_win = bool(safe_int(get_stat('is_win'))) + + stats.elo_change = safe_float(sts.get('change_elo')) + stats.rank_score = safe_int(sts.get('rank')) + stats.assisted_kill = safe_int(fight.get('assisted_kill')) + stats.awp_kill = safe_int(fight.get('awp_kill')) + stats.benefit_kill = safe_int(fight.get('benefit_kill')) + stats.day = safe_text(fight.get('day')) + stats.defused_bomb = safe_int(fight.get('defused_bomb')) + stats.end_1v1 = safe_int(fight.get('end_1v1')) + stats.end_1v2 = safe_int(fight.get('end_1v2')) + stats.end_1v3 = safe_int(fight.get('end_1v3')) + stats.end_1v4 = safe_int(fight.get('end_1v4')) + stats.end_1v5 = safe_int(fight.get('end_1v5')) + stats.explode_bomb = safe_int(fight.get('explode_bomb')) + stats.first_death = safe_int(fight.get('first_death')) + stats.first_kill = safe_int(fight.get('first_kill')) + stats.flash_enemy = safe_int(fight.get('flash_enemy')) + stats.flash_team = safe_int(fight.get('flash_team')) + stats.flash_team_time = safe_float(fight.get('flash_team_time')) + stats.flash_time = safe_float(fight.get('flash_time')) + stats.game_mode = safe_text(fight.get('game_mode')) + stats.group_id = safe_int(fight.get('group_id')) + stats.hold_total = safe_int(fight.get('hold_total')) + stats.id = safe_int(fight.get('id')) + stats.is_highlight = safe_int(fight.get('is_highlight')) + stats.is_most_1v2 = safe_int(fight.get('is_most_1v2')) + stats.is_most_assist = safe_int(fight.get('is_most_assist')) + stats.is_most_awp = safe_int(fight.get('is_most_awp')) + stats.is_most_end = safe_int(fight.get('is_most_end')) + stats.is_most_first_kill = safe_int(fight.get('is_most_first_kill')) + stats.is_most_headshot = safe_int(fight.get('is_most_headshot')) + stats.is_most_jump = safe_int(fight.get('is_most_jump')) + stats.is_svp = safe_int(fight.get('is_svp')) + stats.is_tie = safe_int(fight.get('is_tie')) + stats.kill_1 = safe_int(fight.get('kill_1')) + stats.kill_2 = safe_int(fight.get('kill_2')) + stats.kill_3 = safe_int(fight.get('kill_3')) + stats.kill_4 = safe_int(fight.get('kill_4')) + stats.kill_5 = safe_int(fight.get('kill_5')) + stats.many_assists_cnt1 = safe_int(fight.get('many_assists_cnt1')) + stats.many_assists_cnt2 = safe_int(fight.get('many_assists_cnt2')) + stats.many_assists_cnt3 = safe_int(fight.get('many_assists_cnt3')) + stats.many_assists_cnt4 = safe_int(fight.get('many_assists_cnt4')) + stats.many_assists_cnt5 = safe_int(fight.get('many_assists_cnt5')) + stats.map = safe_text(fight.get('map')) + stats.match_code = safe_text(fight.get('match_code')) + stats.match_mode = safe_text(fight.get('match_mode')) + stats.match_team_id = safe_int(fight.get('match_team_id')) + stats.match_time = safe_int(fight.get('match_time')) + stats.per_headshot = safe_float(fight.get('per_headshot')) + stats.perfect_kill = safe_int(fight.get('perfect_kill')) + stats.planted_bomb = safe_int(fight.get('planted_bomb')) + stats.revenge_kill = safe_int(fight.get('revenge_kill')) + stats.round_total = safe_int(fight.get('round_total')) + stats.season = safe_text(fight.get('season')) + stats.team_kill = safe_int(fight.get('team_kill')) + stats.throw_harm = safe_int(fight.get('throw_harm')) + stats.throw_harm_enemy = safe_int(fight.get('throw_harm_enemy')) + stats.uid = safe_int(fight.get('uid')) + stats.year = safe_text(fight.get('year')) + + # Map missing fields + stats.clutch_1v1 = stats.end_1v1 + stats.clutch_1v2 = stats.end_1v2 + stats.clutch_1v3 = stats.end_1v3 + stats.clutch_1v4 = stats.end_1v4 + stats.clutch_1v5 = stats.end_1v5 + stats.entry_kills = stats.first_kill + stats.entry_deaths = stats.first_death + + except Exception as e: + logger.error(f"Error parsing stats for {steam_id} in {self.match_id}: {e}") + pass + + self.match_data.players[steam_id] = stats + if isinstance(fight_t, dict) and fight_t: + t_team_id = team_id_value or safe_int(fight_t.get('match_team_id')) + self.match_data.players_t[steam_id] = build_side_stats(fight_t, t_team_id) + if isinstance(fight_ct, dict) and fight_ct: + ct_team_id = team_id_value or safe_int(fight_ct.get('match_team_id')) + self.match_data.players_ct[steam_id] = build_side_stats(fight_ct, ct_team_id) + + def _parse_players_vip(self): + if not self.data_vip: + return + + # Structure: data_vip -> steamid (key) -> dict + for sid, vdata in self.data_vip.items(): + # SID might be steam_id_64 directly + if sid in self.match_data.players: + p = self.match_data.players[sid] + p.kast = float(vdata.get('kast', 0)) + p.awp_kills = int(vdata.get('awp_kill', 0)) + p.awp_kill_ct = int(vdata.get('awp_kill_ct', 0)) + p.awp_kill_t = int(vdata.get('awp_kill_t', 0)) + p.fd_ct = int(vdata.get('fd_ct', 0)) + p.fd_t = int(vdata.get('fd_t', 0)) + p.damage_receive = int(vdata.get('damage_receive', 0)) + p.damage_stats = int(vdata.get('damage_stats', 0)) + p.damage_total = int(vdata.get('damage_total', 0)) + p.damage_received = int(vdata.get('damage_received', 0)) + p.flash_assists = int(vdata.get('flash_assists', 0)) + else: + # Try to match by 5E ID if possible, but here keys are steamids usually + pass + for sid, p in self.match_data.players.items(): + if sid in self.match_data.players_t: + self.match_data.players_t[sid].awp_kill_t = p.awp_kill_t + self.match_data.players_t[sid].fd_t = p.fd_t + if sid in self.match_data.players_ct: + self.match_data.players_ct[sid].awp_kill_ct = p.awp_kill_ct + self.match_data.players_ct[sid].fd_ct = p.fd_ct + + def _parse_leetify_rounds(self): + l_data = self.data_leetify.get('leetify_data', {}) + round_list = l_data.get('round_stat', []) + + for idx, r in enumerate(round_list): + # Utility Usage (Leetify) + bron = r.get('bron_equipment', {}) + for sid, items in bron.items(): + sid = str(sid) + if sid in self.match_data.players: + p = self.match_data.players[sid] + if isinstance(items, list): + for item in items: + if not isinstance(item, dict): continue + name = item.get('WeaponName', '') + if name == 'weapon_flashbang': p.util_flash_usage += 1 + elif name == 'weapon_smokegrenade': p.util_smoke_usage += 1 + elif name in ['weapon_molotov', 'weapon_incgrenade']: p.util_molotov_usage += 1 + elif name == 'weapon_hegrenade': p.util_he_usage += 1 + elif name == 'weapon_decoy': p.util_decoy_usage += 1 + + rd = RoundData( + round_num=r.get('round', idx + 1), + winner_side='CT' if r.get('win_reason') in [7, 8, 9] else 'T', # Approximate logic, need real enum + win_reason=r.get('win_reason', 0), + win_reason_desc=str(r.get('win_reason', 0)), + duration=0, # Leetify might not have exact duration easily + end_time_stamp=r.get('end_ts', ''), + ct_score=r.get('sfui_event', {}).get('score_ct', 0), + t_score=r.get('sfui_event', {}).get('score_t', 0), + ct_money_start=r.get('ct_money_group', 0), + t_money_start=r.get('t_money_group', 0) + ) + + # Events + # Leetify has 'show_event' list + events = r.get('show_event', []) + for evt in events: + e_type_code = evt.get('event_type') + # Mapping needed for event types. + # Assuming 3 is kill based on schema 'kill_event' presence + + if evt.get('kill_event'): + k = evt['kill_event'] + re = RoundEvent( + event_id=f"{self.match_id}_{rd.round_num}_{k.get('Ts', '')}_{k.get('Killer')}", + event_type='kill', + event_time=evt.get('ts', 0), + attacker_steam_id=k.get('Killer'), + victim_steam_id=k.get('Victim'), + weapon=k.get('WeaponName'), + is_headshot=k.get('Headshot', False), + is_wallbang=k.get('Penetrated', False), + is_blind=k.get('AttackerBlind', False), + is_through_smoke=k.get('ThroughSmoke', False), + is_noscope=k.get('NoScope', False) + ) + + # Leetify specifics + # Trade? + if evt.get('trade_score_change'): + re.trade_killer_steam_id = list(evt['trade_score_change'].keys())[0] + + if evt.get('assist_killer_score_change'): + re.assister_steam_id = list(evt['assist_killer_score_change'].keys())[0] + + if evt.get('flash_assist_killer_score_change'): + re.flash_assist_steam_id = list(evt['flash_assist_killer_score_change'].keys())[0] + + # Score changes + if evt.get('killer_score_change'): + # e.g. {'': {'score': 17.0}} + vals = list(evt['killer_score_change'].values()) + if vals: re.score_change_attacker = vals[0].get('score', 0) + + if evt.get('victim_score_change'): + vals = list(evt['victim_score_change'].values()) + if vals: re.score_change_victim = vals[0].get('score', 0) + + rd.events.append(re) + + bron_equipment = r.get('bron_equipment') or {} + player_t_score = r.get('player_t_score') or {} + player_ct_score = r.get('player_ct_score') or {} + player_bron_crash = r.get('player_bron_crash') or {} + + def pick_main_weapon(items): + if not isinstance(items, list): + return "" + ignore = { + "weapon_knife", + "weapon_knife_t", + "weapon_knife_gg", + "weapon_knife_ct", + "weapon_c4", + "weapon_flashbang", + "weapon_hegrenade", + "weapon_smokegrenade", + "weapon_molotov", + "weapon_incgrenade", + "weapon_decoy" + } + for it in items: + if not isinstance(it, dict): + continue + name = it.get('WeaponName') + if name and name not in ignore: + return name + for it in items: + if not isinstance(it, dict): + continue + name = it.get('WeaponName') + if name: + return name + return "" + + def pick_money(items): + if not isinstance(items, list): + return 0 + vals = [] + for it in items: + if isinstance(it, dict) and it.get('Money') is not None: + vals.append(it.get('Money')) + return int(max(vals)) if vals else 0 + + side_scores = {} + for sid, val in player_t_score.items(): + side_scores[str(sid)] = ("T", float(val) if val is not None else 0.0) + for sid, val in player_ct_score.items(): + side_scores[str(sid)] = ("CT", float(val) if val is not None else 0.0) + + for sid in set(list(side_scores.keys()) + [str(k) for k in bron_equipment.keys()]): + if sid not in side_scores: + continue + side, score = side_scores[sid] + items = bron_equipment.get(sid) or bron_equipment.get(str(sid)) or [] + start_money = pick_money(items) + equipment_value = player_bron_crash.get(sid) + if equipment_value is None: + equipment_value = player_bron_crash.get(str(sid)) + equipment_value = int(equipment_value) if equipment_value is not None else 0 + main_weapon = pick_main_weapon(items) + + has_helmet = False + has_defuser = False + has_zeus = False + if isinstance(items, list): + for it in items: + if isinstance(it, dict): + name = it.get('WeaponName', '') + if name == 'item_assaultsuit': + has_helmet = True + elif name == 'item_defuser': + has_defuser = True + elif name and ('taser' in name or 'zeus' in name): + has_zeus = True + + rd.economies.append(PlayerEconomy( + steam_id_64=str(sid), + side=side, + start_money=start_money, + equipment_value=equipment_value, + main_weapon=main_weapon, + has_helmet=has_helmet, + has_defuser=has_defuser, + has_zeus=has_zeus, + round_performance_score=float(score) + )) + + self.match_data.rounds.append(rd) + + def _parse_classic_rounds(self): + r_list = self.data_round_list.get('round_list', []) + for idx, r in enumerate(r_list): + # Classic round data often lacks score/winner in the list root? + # Check schema: 'current_score' -> ct/t + cur_score = r.get('current_score', {}) + + # Utility Usage (Classic) + equiped = r.get('equiped', {}) + for sid, items in equiped.items(): + # Ensure sid is string + sid = str(sid) + if sid in self.match_data.players: + p = self.match_data.players[sid] + if isinstance(items, list): + for item in items: + if item == 'flashbang': p.util_flash_usage += 1 + elif item == 'smokegrenade': p.util_smoke_usage += 1 + elif item in ['molotov', 'incgrenade']: p.util_molotov_usage += 1 + elif item == 'hegrenade': p.util_he_usage += 1 + elif item == 'decoy': p.util_decoy_usage += 1 + + rd = RoundData( + round_num=idx + 1, + winner_side='None', # Default to None if unknown + win_reason=0, + win_reason_desc='', + duration=float(cur_score.get('final_round_time', 0)), + end_time_stamp='', + ct_score=cur_score.get('ct', 0), + t_score=cur_score.get('t', 0) + ) + + # Kills + # Classic has 'all_kill' list + kills = r.get('all_kill', []) + for k in kills: + attacker = k.get('attacker', {}) + victim = k.get('victim', {}) + + # Pos extraction + apos = attacker.get('pos', {}) + vpos = victim.get('pos', {}) + + re = RoundEvent( + event_id=f"{self.match_id}_{rd.round_num}_{k.get('pasttime')}_{attacker.get('steamid_64')}", + event_type='kill', + event_time=k.get('pasttime', 0), + attacker_steam_id=str(attacker.get('steamid_64', '')), + victim_steam_id=str(victim.get('steamid_64', '')), + weapon=k.get('weapon', ''), + is_headshot=k.get('headshot', False), + is_wallbang=k.get('penetrated', False), + is_blind=k.get('attackerblind', False), + is_through_smoke=k.get('throughsmoke', False), + is_noscope=k.get('noscope', False), + attacker_pos=(apos.get('x', 0), apos.get('y', 0), apos.get('z', 0)), + victim_pos=(vpos.get('x', 0), vpos.get('y', 0), vpos.get('z', 0)) + ) + rd.events.append(re) + + c4_events = r.get('c4_event', []) + for e in c4_events: + if not isinstance(e, dict): + continue + event_name = str(e.get('event_name') or '').lower() + if not event_name: + continue + if 'plant' in event_name: + etype = 'bomb_plant' + elif 'defus' in event_name: + etype = 'bomb_defuse' + else: + continue + sid = e.get('steamid_64') + re = RoundEvent( + event_id=f"{self.match_id}_{rd.round_num}_{etype}_{e.get('pasttime', 0)}_{sid}", + event_type=etype, + event_time=int(e.get('pasttime', 0) or 0), + attacker_steam_id=str(sid) if sid is not None else None, + ) + rd.events.append(re) + + self.match_data.rounds.append(rd) + +# --- Main Execution --- + +def process_matches(): + if not init_db(): + return + + l1_conn = sqlite3.connect(L1A_DB_PATH) + l1_cursor = l1_conn.cursor() + + l2_conn = sqlite3.connect(L2_DB_PATH) + l2_cursor = l2_conn.cursor() + + logger.info("Reading from L1A...") + l1_cursor.execute("SELECT match_id, content FROM raw_iframe_network") + + count = 0 + while True: + rows = l1_cursor.fetchmany(10) + if not rows: + break + + for row in rows: + match_id, content = row + try: + raw_requests = json.loads(content) + parser = MatchParser(match_id, raw_requests) + match_data = parser.parse() + save_match(l2_cursor, match_data) + count += 1 + if count % 10 == 0: + l2_conn.commit() + print(f"Processed {count} matches...", end='\r') + except Exception as e: + logger.error(f"Error processing match {match_id}: {e}") + # continue + + l2_conn.commit() + l1_conn.close() + l2_conn.close() + logger.info(f"\nDone. Processed {count} matches.") + +def save_match(cursor, m: MatchData): + # 1. Dim Players (Upsert) + player_meta_columns = [ + "steam_id_64", "uid", "username", "avatar_url", "domain", "created_at", "updated_at", + "last_seen_match_id", "uuid", "email", "area", "mobile", "user_domain", + "username_audit_status", "accid", "team_id", "trumpet_count", + "profile_nickname", "profile_avatar_audit_status", "profile_rgb_avatar_url", + "profile_photo_url", "profile_gender", "profile_birthday", "profile_country_id", + "profile_region_id", "profile_city_id", "profile_language", "profile_recommend_url", + "profile_group_id", "profile_reg_source", "status_status", "status_expire", + "status_cancellation_status", "status_new_user", "status_login_banned_time", + "status_anticheat_type", "status_flag_status1", "status_anticheat_status", + "status_flag_honor", "status_privacy_policy_status", "status_csgo_frozen_exptime", + "platformexp_level", "platformexp_exp", "steam_account", "steam_trade_url", + "steam_rent_id", "trusted_credit", "trusted_credit_level", "trusted_score", + "trusted_status", "trusted_credit_status", "certify_id_type", "certify_status", + "certify_age", "certify_real_name", "certify_uid_list", "certify_audit_status", + "certify_gender", "identity_type", "identity_extras", "identity_status", + "identity_slogan", "identity_list", "identity_slogan_ext", "identity_live_url", + "identity_live_type", "plus_is_plus", "user_info_raw" + ] + player_meta_placeholders = ",".join(["?"] * len(player_meta_columns)) + player_meta_columns_sql = ",".join(player_meta_columns) + for sid, meta in m.player_meta.items(): + cursor.execute(""" + INSERT INTO dim_players (""" + player_meta_columns_sql + """) + VALUES (""" + player_meta_placeholders + """) + ON CONFLICT(steam_id_64) DO UPDATE SET + uid=excluded.uid, + username=excluded.username, + avatar_url=CASE + WHEN excluded.avatar_url IS NOT NULL AND excluded.avatar_url != '' + THEN excluded.avatar_url + ELSE dim_players.avatar_url + END, + domain=excluded.domain, + created_at=excluded.created_at, + updated_at=excluded.updated_at, + last_seen_match_id=excluded.last_seen_match_id, + uuid=excluded.uuid, + email=excluded.email, + area=excluded.area, + mobile=excluded.mobile, + user_domain=excluded.user_domain, + username_audit_status=excluded.username_audit_status, + accid=excluded.accid, + team_id=excluded.team_id, + trumpet_count=excluded.trumpet_count, + profile_nickname=excluded.profile_nickname, + profile_avatar_audit_status=excluded.profile_avatar_audit_status, + profile_rgb_avatar_url=excluded.profile_rgb_avatar_url, + profile_photo_url=excluded.profile_photo_url, + profile_gender=excluded.profile_gender, + profile_birthday=excluded.profile_birthday, + profile_country_id=excluded.profile_country_id, + profile_region_id=excluded.profile_region_id, + profile_city_id=excluded.profile_city_id, + profile_language=excluded.profile_language, + profile_recommend_url=excluded.profile_recommend_url, + profile_group_id=excluded.profile_group_id, + profile_reg_source=excluded.profile_reg_source, + status_status=excluded.status_status, + status_expire=excluded.status_expire, + status_cancellation_status=excluded.status_cancellation_status, + status_new_user=excluded.status_new_user, + status_login_banned_time=excluded.status_login_banned_time, + status_anticheat_type=excluded.status_anticheat_type, + status_flag_status1=excluded.status_flag_status1, + status_anticheat_status=excluded.status_anticheat_status, + status_flag_honor=excluded.status_flag_honor, + status_privacy_policy_status=excluded.status_privacy_policy_status, + status_csgo_frozen_exptime=excluded.status_csgo_frozen_exptime, + platformexp_level=excluded.platformexp_level, + platformexp_exp=excluded.platformexp_exp, + steam_account=excluded.steam_account, + steam_trade_url=excluded.steam_trade_url, + steam_rent_id=excluded.steam_rent_id, + trusted_credit=excluded.trusted_credit, + trusted_credit_level=excluded.trusted_credit_level, + trusted_score=excluded.trusted_score, + trusted_status=excluded.trusted_status, + trusted_credit_status=excluded.trusted_credit_status, + certify_id_type=excluded.certify_id_type, + certify_status=excluded.certify_status, + certify_age=excluded.certify_age, + certify_real_name=excluded.certify_real_name, + certify_uid_list=excluded.certify_uid_list, + certify_audit_status=excluded.certify_audit_status, + certify_gender=excluded.certify_gender, + identity_type=excluded.identity_type, + identity_extras=excluded.identity_extras, + identity_status=excluded.identity_status, + identity_slogan=excluded.identity_slogan, + identity_list=excluded.identity_list, + identity_slogan_ext=excluded.identity_slogan_ext, + identity_live_url=excluded.identity_live_url, + identity_live_type=excluded.identity_live_type, + plus_is_plus=excluded.plus_is_plus, + user_info_raw=excluded.user_info_raw + """, ( + sid, meta.get('uid'), meta.get('username'), meta.get('avatar_url'), + meta.get('domain'), meta.get('created_at'), meta.get('updated_at'), + m.match_id, meta.get('uuid'), meta.get('email'), meta.get('area'), + meta.get('mobile'), meta.get('user_domain'), meta.get('username_audit_status'), + meta.get('accid'), meta.get('team_id'), meta.get('trumpet_count'), + meta.get('profile_nickname'), meta.get('profile_avatar_audit_status'), + meta.get('profile_rgb_avatar_url'), meta.get('profile_photo_url'), + meta.get('profile_gender'), meta.get('profile_birthday'), + meta.get('profile_country_id'), meta.get('profile_region_id'), + meta.get('profile_city_id'), meta.get('profile_language'), + meta.get('profile_recommend_url'), meta.get('profile_group_id'), + meta.get('profile_reg_source'), meta.get('status_status'), + meta.get('status_expire'), meta.get('status_cancellation_status'), + meta.get('status_new_user'), meta.get('status_login_banned_time'), + meta.get('status_anticheat_type'), meta.get('status_flag_status1'), + meta.get('status_anticheat_status'), meta.get('status_flag_honor'), + meta.get('status_privacy_policy_status'), meta.get('status_csgo_frozen_exptime'), + meta.get('platformexp_level'), meta.get('platformexp_exp'), + meta.get('steam_account'), meta.get('steam_trade_url'), + meta.get('steam_rent_id'), meta.get('trusted_credit'), + meta.get('trusted_credit_level'), meta.get('trusted_score'), + meta.get('trusted_status'), meta.get('trusted_credit_status'), + meta.get('certify_id_type'), meta.get('certify_status'), + meta.get('certify_age'), meta.get('certify_real_name'), + meta.get('certify_uid_list'), meta.get('certify_audit_status'), + meta.get('certify_gender'), meta.get('identity_type'), + meta.get('identity_extras'), meta.get('identity_status'), + meta.get('identity_slogan'), meta.get('identity_list'), + meta.get('identity_slogan_ext'), meta.get('identity_live_url'), + meta.get('identity_live_type'), meta.get('plus_is_plus'), + meta.get('user_info_raw') + )) + + # 2. Dim Maps (Ignore if exists) + if m.map_name: + cursor.execute(""" + INSERT INTO dim_maps (map_name, map_desc) + VALUES (?, ?) + ON CONFLICT(map_name) DO UPDATE SET + map_desc=excluded.map_desc + """, (m.map_name, m.map_desc)) + + # 3. Fact Matches + cursor.execute(""" + INSERT OR REPLACE INTO fact_matches + (match_id, match_code, map_name, start_time, end_time, duration, winner_team, score_team1, score_team2, server_ip, server_port, location, has_side_data_and_rating2, match_main_id, demo_url, game_mode, game_name, map_desc, location_full, 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, response_code, response_message, response_status, response_timestamp, response_trace_id, response_success, response_errcode, treat_info_raw, round_list_raw, leetify_data_raw, data_source_type) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + m.match_id, m.match_code, m.map_name, m.start_time, m.end_time, m.duration, + m.winner_team, m.score_team1, m.score_team2, m.server_ip, m.server_port, m.location, + m.has_side_data_and_rating2, m.match_main_id, m.demo_url, m.game_mode, m.game_name, m.map_desc, + m.location_full, m.match_mode, m.match_status, m.match_flag, m.status, m.waiver, m.year, m.season, + m.round_total, m.cs_type, m.priority_show_type, m.pug10m_show_type, m.credit_match_status, + m.knife_winner, m.knife_winner_role, m.most_1v2_uid, m.most_assist_uid, m.most_awp_uid, + m.most_end_uid, m.most_first_kill_uid, m.most_headshot_uid, m.most_jump_uid, m.mvp_uid, + m.response_code, m.response_message, m.response_status, m.response_timestamp, m.response_trace_id, + m.response_success, m.response_errcode, m.treat_info_raw, m.round_list_raw, m.leetify_data_raw, m.data_source_type + )) + + for t in m.teams: + cursor.execute(""" + INSERT OR REPLACE INTO fact_match_teams + (match_id, group_id, group_all_score, group_change_elo, group_fh_role, group_fh_score, group_origin_elo, group_sh_role, group_sh_score, group_tid, group_uids) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + m.match_id, t.group_id, t.group_all_score, t.group_change_elo, t.group_fh_role, t.group_fh_score, + t.group_origin_elo, t.group_sh_role, t.group_sh_score, t.group_tid, t.group_uids + )) + + # 4. Fact Match Players + player_columns = [ + "match_id", "steam_id_64", "team_id", "kills", "deaths", "assists", "headshot_count", + "kd_ratio", "adr", "rating", "rating2", "rating3", "rws", "mvp_count", "elo_change", + "rank_score", "is_win", "kast", "entry_kills", "entry_deaths", "awp_kills", + "clutch_1v1", "clutch_1v2", "clutch_1v3", "clutch_1v4", "clutch_1v5", + "flash_assists", "flash_duration", "jump_count", "damage_total", "damage_received", + "damage_receive", "damage_stats", "assisted_kill", "awp_kill", "awp_kill_ct", + "awp_kill_t", "benefit_kill", "day", "defused_bomb", "end_1v1", + "end_1v2", "end_1v3", "end_1v4", "end_1v5", "explode_bomb", "first_death", + "fd_ct", "fd_t", "first_kill", "flash_enemy", "flash_team", "flash_team_time", "flash_time", + "game_mode", "group_id", "hold_total", "id", "is_highlight", "is_most_1v2", + "is_most_assist", "is_most_awp", "is_most_end", "is_most_first_kill", + "is_most_headshot", "is_most_jump", "is_svp", "is_tie", "kill_1", "kill_2", + "kill_3", "kill_4", "kill_5", "many_assists_cnt1", "many_assists_cnt2", + "many_assists_cnt3", "many_assists_cnt4", "many_assists_cnt5", "map", + "match_code", "match_mode", "match_team_id", "match_time", "per_headshot", + "perfect_kill", "planted_bomb", "revenge_kill", "round_total", "season", + "team_kill", "throw_harm", "throw_harm_enemy", "uid", "year", "sts_raw", "level_info_raw", + "util_flash_usage", "util_smoke_usage", "util_molotov_usage", "util_he_usage", "util_decoy_usage" + ] + player_placeholders = ",".join(["?"] * len(player_columns)) + player_columns_sql = ",".join(player_columns) + + def player_values(sid, p): + return [ + m.match_id, sid, p.team_id, p.kills, p.deaths, p.assists, p.headshot_count, + p.kd_ratio, p.adr, p.rating, p.rating2, p.rating3, p.rws, p.mvp_count, + p.elo_change, p.rank_score, p.is_win, p.kast, p.entry_kills, p.entry_deaths, + p.awp_kills, p.clutch_1v1, p.clutch_1v2, p.clutch_1v3, p.clutch_1v4, + p.clutch_1v5, p.flash_assists, p.flash_duration, p.jump_count, p.damage_total, + p.damage_received, p.damage_receive, p.damage_stats, p.assisted_kill, p.awp_kill, + p.awp_kill_ct, p.awp_kill_t, p.benefit_kill, p.day, p.defused_bomb, p.end_1v1, + p.end_1v2, p.end_1v3, p.end_1v4, p.end_1v5, p.explode_bomb, p.first_death, + p.fd_ct, p.fd_t, p.first_kill, p.flash_enemy, p.flash_team, + p.flash_team_time, p.flash_time, p.game_mode, p.group_id, p.hold_total, + p.id, p.is_highlight, p.is_most_1v2, p.is_most_assist, p.is_most_awp, + p.is_most_end, p.is_most_first_kill, p.is_most_headshot, p.is_most_jump, + p.is_svp, p.is_tie, p.kill_1, p.kill_2, p.kill_3, p.kill_4, p.kill_5, + p.many_assists_cnt1, p.many_assists_cnt2, p.many_assists_cnt3, p.many_assists_cnt4, + p.many_assists_cnt5, p.map, p.match_code, p.match_mode, p.match_team_id, + p.match_time, p.per_headshot, p.perfect_kill, p.planted_bomb, p.revenge_kill, + p.round_total, p.season, p.team_kill, p.throw_harm, p.throw_harm_enemy, + p.uid, p.year, p.sts_raw, p.level_info_raw, + p.util_flash_usage, p.util_smoke_usage, p.util_molotov_usage, p.util_he_usage, p.util_decoy_usage + ] + + for sid, p in m.players.items(): + cursor.execute( + f"INSERT OR REPLACE INTO fact_match_players ({player_columns_sql}) VALUES ({player_placeholders})", + player_values(sid, p) + ) + for sid, p in m.players_t.items(): + cursor.execute( + f"INSERT OR REPLACE INTO fact_match_players_t ({player_columns_sql}) VALUES ({player_placeholders})", + player_values(sid, p) + ) + for sid, p in m.players_ct.items(): + cursor.execute( + f"INSERT OR REPLACE INTO fact_match_players_ct ({player_columns_sql}) VALUES ({player_placeholders})", + player_values(sid, p) + ) + + # 5. Rounds & Events + for r in m.rounds: + cursor.execute(""" + INSERT OR REPLACE INTO fact_rounds + (match_id, round_num, winner_side, win_reason, win_reason_desc, duration, end_time_stamp, ct_score, t_score, ct_money_start, t_money_start) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + m.match_id, r.round_num, r.winner_side, r.win_reason, r.win_reason_desc, + r.duration, r.end_time_stamp, r.ct_score, r.t_score, r.ct_money_start, r.t_money_start + )) + + for e in r.events: + # Handle Pos + ax, ay, az = e.attacker_pos if e.attacker_pos else (None, None, None) + vx, vy, vz = e.victim_pos if e.victim_pos else (None, None, None) + + # Use uuid for event_id to ensure uniqueness if logic fails + import uuid + if not e.event_id: + e.event_id = str(uuid.uuid4()) + + cursor.execute(""" + INSERT OR REPLACE INTO fact_round_events + (event_id, match_id, round_num, event_type, event_time, attacker_steam_id, victim_steam_id, assister_steam_id, + weapon, is_headshot, is_wallbang, is_blind, is_through_smoke, is_noscope, + trade_killer_steam_id, flash_assist_steam_id, score_change_attacker, score_change_victim, + attacker_pos_x, attacker_pos_y, attacker_pos_z, victim_pos_x, victim_pos_y, victim_pos_z) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + e.event_id, m.match_id, r.round_num, e.event_type, e.event_time, e.attacker_steam_id, e.victim_steam_id, + e.assister_steam_id, e.weapon, e.is_headshot, e.is_wallbang, e.is_blind, e.is_through_smoke, e.is_noscope, + e.trade_killer_steam_id, e.flash_assist_steam_id, e.score_change_attacker, e.score_change_victim, + ax, ay, az, vx, vy, vz + )) + + for pe in r.economies: + cursor.execute(""" + INSERT OR REPLACE INTO fact_round_player_economy + (match_id, round_num, steam_id_64, side, start_money, equipment_value, main_weapon, has_helmet, has_defuser, has_zeus, round_performance_score) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + m.match_id, r.round_num, pe.steam_id_64, pe.side, pe.start_money, pe.equipment_value, pe.main_weapon, pe.has_helmet, pe.has_defuser, pe.has_zeus, pe.round_performance_score + )) + + # 6. Calculate & Save Clutch Attempts + _calculate_and_save_clutch_attempts(cursor, m.match_id, m.round_list_raw) + +def _calculate_and_save_clutch_attempts(cursor, match_id, round_list_raw): + if not round_list_raw: + return + + try: + round_list = json.loads(round_list_raw) + except: + return + + player_attempts = {} + + for round_data in round_list: + all_kills = round_data.get('all_kill', []) + if not all_kills: + continue + + team_members = {1: set(), 2: set()} + + # Scan for team members + for k in all_kills: + if k.get('attacker') and k['attacker'].get('steamid_64'): + tid = k['attacker'].get('team') + if tid in [1, 2]: + team_members[tid].add(k['attacker']['steamid_64']) + if k.get('victim') and k['victim'].get('steamid_64'): + tid = k['victim'].get('team') + if tid in [1, 2]: + team_members[tid].add(k['victim']['steamid_64']) + + if not team_members[1] or not team_members[2]: + continue + + alive = {1: team_members[1].copy(), 2: team_members[2].copy()} + clutch_triggered_players = set() + + # Sort kills by time + sorted_kills = sorted(all_kills, key=lambda x: x.get('pasttime', 0)) + + for k in sorted_kills: + victim = k.get('victim') + if not victim: continue + + v_sid = victim.get('steamid_64') + v_team = victim.get('team') + + if v_team not in [1, 2] or v_sid not in alive[v_team]: + continue + + alive[v_team].remove(v_sid) + + if len(alive[v_team]) == 1: + survivor_sid = list(alive[v_team])[0] + if survivor_sid not in clutch_triggered_players: + opponent_team = 3 - v_team + opponents_alive_count = len(alive[opponent_team]) + + if opponents_alive_count >= 1: + if survivor_sid not in player_attempts: + player_attempts[survivor_sid] = {'1v1': 0, '1v2': 0, '1v3': 0, '1v4': 0, '1v5': 0} + + n = min(opponents_alive_count, 5) + key = f'1v{n}' + player_attempts[survivor_sid][key] += 1 + clutch_triggered_players.add(survivor_sid) + + # Save to DB + cursor.execute(""" + CREATE TABLE IF NOT EXISTS fact_match_clutch_attempts ( + match_id TEXT, + steam_id_64 TEXT, + attempt_1v1 INTEGER DEFAULT 0, + attempt_1v2 INTEGER DEFAULT 0, + attempt_1v3 INTEGER DEFAULT 0, + attempt_1v4 INTEGER DEFAULT 0, + attempt_1v5 INTEGER DEFAULT 0, + PRIMARY KEY (match_id, steam_id_64) + ) + """) + + for pid, att in player_attempts.items(): + cursor.execute(""" + INSERT OR REPLACE INTO fact_match_clutch_attempts + (match_id, steam_id_64, attempt_1v1, attempt_1v2, attempt_1v3, attempt_1v4, attempt_1v5) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, (match_id, pid, att['1v1'], att['1v2'], att['1v3'], att['1v4'], att['1v5'])) + +if __name__ == "__main__": + process_matches() diff --git a/ETL/L3_Builder.py b/ETL/L3_Builder.py new file mode 100644 index 0000000..3071f0e --- /dev/null +++ b/ETL/L3_Builder.py @@ -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() diff --git a/ETL/README.md b/ETL/README.md new file mode 100644 index 0000000..77d085f --- /dev/null +++ b/ETL/README.md @@ -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 \ No newline at end of file diff --git a/ETL/refresh.py b/ETL/refresh.py new file mode 100644 index 0000000..2930d0e --- /dev/null +++ b/ETL/refresh.py @@ -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() diff --git a/ETL/verify/L1A_incre_test/clean_dirty_data.py b/ETL/verify/L1A_incre_test/clean_dirty_data.py new file mode 100644 index 0000000..60280d5 --- /dev/null +++ b/ETL/verify/L1A_incre_test/clean_dirty_data.py @@ -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() \ No newline at end of file diff --git a/ETL/verify/L1A_incre_test/setup_test_data.py b/ETL/verify/L1A_incre_test/setup_test_data.py new file mode 100644 index 0000000..0641b87 --- /dev/null +++ b/ETL/verify/L1A_incre_test/setup_test_data.py @@ -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() \ No newline at end of file diff --git a/ETL/verify/L1A_incre_test/test_L1_incremental.py b/ETL/verify/L1A_incre_test/test_L1_incremental.py new file mode 100644 index 0000000..e6ab1a1 --- /dev/null +++ b/ETL/verify/L1A_incre_test/test_L1_incremental.py @@ -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() \ No newline at end of file diff --git a/ETL/verify/L2_verify_report.txt b/ETL/verify/L2_verify_report.txt new file mode 100644 index 0000000..641b571 Binary files /dev/null and b/ETL/verify/L2_verify_report.txt differ diff --git a/ETL/verify/verify_L2.py b/ETL/verify/verify_L2.py new file mode 100644 index 0000000..29ec0e2 --- /dev/null +++ b/ETL/verify/verify_L2.py @@ -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.."): + key = path.split("data..")[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() diff --git a/ETL/verify/verify_L3.py b/ETL/verify/verify_L3.py new file mode 100644 index 0000000..42b7576 --- /dev/null +++ b/ETL/verify/verify_L3.py @@ -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() diff --git a/ETL/verify/verify_deep.py b/ETL/verify/verify_deep.py new file mode 100644 index 0000000..f31b1b2 --- /dev/null +++ b/ETL/verify/verify_deep.py @@ -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() diff --git a/README.md b/README.md new file mode 100644 index 0000000..05f52ab --- /dev/null +++ b/README.md @@ -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//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`:无法识别来源 + +入库逻辑保持互斥:同一场比赛只会按其来源覆盖相应字段,避免重复或冲突。 diff --git a/database/L1A/L1A.sqlite b/database/L1A/L1A.sqlite new file mode 100644 index 0000000..0b1e98f Binary files /dev/null and b/database/L1A/L1A.sqlite differ diff --git a/database/L1A/README.md b/database/L1A/README.md new file mode 100644 index 0000000..137bf6b --- /dev/null +++ b/database/L1A/README.md @@ -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 +``` diff --git a/database/L1B/README.md b/database/L1B/README.md new file mode 100644 index 0000000..19af8eb --- /dev/null +++ b/database/L1B/README.md @@ -0,0 +1,4 @@ +L1B demo原始数据。 +ETL Step 2: +从demoparser2提取demo原始数据到L1B级数据库中。 +output_arena/*/iframe_network.json -> database/L1B/L1B.sqlite diff --git a/database/L2/L2_Main.sqlite b/database/L2/L2_Main.sqlite new file mode 100644 index 0000000..f157555 Binary files /dev/null and b/database/L2/L2_Main.sqlite differ diff --git a/database/L2/schema.sql b/database/L2/schema.sql new file mode 100644 index 0000000..0d1d835 --- /dev/null +++ b/database/L2/schema.sql @@ -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 +); diff --git a/database/L3/L3_Features.sqlite b/database/L3/L3_Features.sqlite new file mode 100644 index 0000000..bea49ca Binary files /dev/null and b/database/L3/L3_Features.sqlite differ diff --git a/database/L3/README.md b/database/L3/README.md new file mode 100644 index 0000000..d2f3643 --- /dev/null +++ b/database/L3/README.md @@ -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. 每局平均道具数量与使用率(烟雾、闪光、燃烧弹、手雷) diff --git a/database/L3/schema.sql b/database/L3/schema.sql new file mode 100644 index 0000000..588389e --- /dev/null +++ b/database/L3/schema.sql @@ -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) +); diff --git a/database/Web/Web_App.sqlite b/database/Web/Web_App.sqlite new file mode 100644 index 0000000..5695a31 Binary files /dev/null and b/database/Web/Web_App.sqlite differ diff --git a/database/original_json_schema/schema_flat.csv b/database/original_json_schema/schema_flat.csv new file mode 100644 index 0000000..6b5d6b7 --- /dev/null +++ b/database/original_json_schema/schema_flat.csv @@ -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,,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,,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..fd_ct,int,2; 4; 3 +crane/http/api/data/vip_plus_match_data/{match_id},data..fd_t,int,2; 4; 3 +crane/http/api/data/vip_plus_match_data/{match_id},data..kast,"float, int",0.7; 0.65; 0.48 +crane/http/api/data/vip_plus_match_data/{match_id},data..awp_kill,int,2; 5; 4 +crane/http/api/data/vip_plus_match_data/{match_id},data..awp_kill_ct,int,5; 4; 3 +crane/http/api/data/vip_plus_match_data/{match_id},data..awp_kill_t,int,2; 5; 4 +crane/http/api/data/vip_plus_match_data/{match_id},data..damage_stats,int,3; 5; 50 +crane/http/api/data/vip_plus_match_data/{match_id},data..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.[].Money,int,400; 200; 2900 +crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].bron_equipment.[].WeaponName,string,weapon_flashbang; weapon_tec9; weapon_hegrenade +crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].bron_equipment.[].Weapon,int,22; 33; 37 +crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].player_t_score.,"float, int",-21.459999999999997; -16.640000000000004; 4 +crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].player_ct_score.,"float, int",17.099999999999994; 15.120000000000001; 27.507999999999996 +crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].player_bron_crash.,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,,76561199787406643; 76561199032002725; 76561199078250590 +crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].show_event[].kill_event.Victim,,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..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..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..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..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..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..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..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..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[],,76561199032002725; 76561199078250590; 76561199076109761 +crane/http/api/match/leetify_rating/{match_id},data.leetify_data.round_stat[].side_info.t[],,76561199787406643; 76561199388433802; 76561199250737526 +crane/http/api/match/leetify_rating/{match_id},data.leetify_data.player_scores.,float,12.491187500000002; 1.5764999999999993; 2.073937500000001 +crane/http/api/match/leetify_rating/{match_id},data.leetify_data.player_t_scores.,float,19.06; 6.3349999999999955; -8.872500000000002 +crane/http/api/match/leetify_rating/{match_id},data.leetify_data.player_ct_scores.,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..,"float, int",32.347; -1.100000000000001; 20.040000000000006 +crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict..uid,"<5eid>, int",14889445; 14869396; 14888575 +crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict..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..username,string,刚拉; R1nging; RRRTINA +crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict..nickname,string, +crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict..reg_date,int,1683007881; 1683007342; 1683200437 +crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict..username_spam_status,int,1 +crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict..steamid_64,,76561199032002725; 76561199078250590; 76561199076109761 +crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict..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..gender,int,1; 0 +crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict..country_id,string,; cn +crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict..language,string,; simplified-chinese +crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict..domain,string,rrrtina; 14869396o9jm5g; dxw123452 +crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict..credit,int,0 +crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict..trusted_score,int,0 +crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict..trusted_status,int,0 +crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict..plus_info,null,None +crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict..region,int,0 +crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict..province,int,0 +crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict..province_name,string, +crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict..region_name,string, +crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict..college_id,int,0 +crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict..status,int,0 +crane/http/api/match/leetify_rating/{match_id},data.uinfo_dict..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,,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,", 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.[].attacker.name,string,5E-Player 我有必胜卡组; 5E-Player 青青C原懒大王w; 5E-Player xiezhongxie1 +crane/http/api/match/round/{match_id},data.round_list[].kill.[].attacker.pos.x,int,734; 999; 397 +crane/http/api/match/round/{match_id},data.round_list[].kill.[].attacker.pos.y,int,149; 125; -77 +crane/http/api/match/round/{match_id},data.round_list[].kill.[].attacker.pos.z,int,0 +crane/http/api/match/round/{match_id},data.round_list[].kill.[].attacker.steamid_64,,76561198330488905; 76561199032002725; 76561199076109761 +crane/http/api/match/round/{match_id},data.round_list[].kill.[].attacker.team,int,1; 2 +crane/http/api/match/round/{match_id},data.round_list[].kill.[].attackerblind,bool,False; True +crane/http/api/match/round/{match_id},data.round_list[].kill.[].headshot,bool,False; True +crane/http/api/match/round/{match_id},data.round_list[].kill.[].noscope,bool,False; True +crane/http/api/match/round/{match_id},data.round_list[].kill.[].pasttime,int,24; 57; 20 +crane/http/api/match/round/{match_id},data.round_list[].kill.[].penetrated,bool,False; True +crane/http/api/match/round/{match_id},data.round_list[].kill.[].throughsmoke,bool,False; True +crane/http/api/match/round/{match_id},data.round_list[].kill.[].victim.name,"<5eid>, string",5E-Player 青青C原懒大王w; 5E-Player 午夜伤心忧郁玫瑰; 5E-Player _陆小果 +crane/http/api/match/round/{match_id},data.round_list[].kill.[].victim.pos.x,int,1218; 706; 1298 +crane/http/api/match/round/{match_id},data.round_list[].kill.[].victim.pos.y,int,627; 587; 219 +crane/http/api/match/round/{match_id},data.round_list[].kill.[].victim.pos.z,int,0 +crane/http/api/match/round/{match_id},data.round_list[].kill.[].victim.steamid_64,", string",76561198812383596; 76561199812085195; 76561199187871084 +crane/http/api/match/round/{match_id},data.round_list[].kill.[].victim.team,int,1; 2 +crane/http/api/match/round/{match_id},data.round_list[].kill.[].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,,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[],", string",76561198812383596; 76561199812085195; 76561199187871084 +crane/http/api/match/round/{match_id},data.round_list[].equiped.[],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 diff --git a/database/original_json_schema/schema_summary.md b/database/original_json_schema/schema_summary.md new file mode 100644 index 0000000..1eaf8f5 --- /dev/null +++ b/database/original_json_schema/schema_summary.md @@ -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** (, 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** (, 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) + - **** (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) + - **** (list) + - *[Array Items]* + - **Money** (int, e.g. 400) + - **WeaponName** (string, e.g. weapon_flashbang) + - **Weapon** (int, e.g. 22) + - **player_t_score** (dict) + - **** (float, int, e.g. -21.459999999999997) + - **player_ct_score** (dict) + - **** (float, int, e.g. 17.099999999999994) + - **player_bron_crash** (dict) + - **** (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** (, e.g. 76561199787406643) + - **Victim** (, 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) + - **** (dict) + - **score** (float, int, e.g. 17.099999999999994) + - **victim_score_change** (dict, null) + - **** (dict) + - **score** (float, int, e.g. -15.8) + - **assist_killer_score_change** (dict, null) + - **** (dict) + - **score** (float, e.g. 2.592) + - **trade_score_change** (dict, null) + - **** (dict) + - **score** (float, e.g. 2.2100000000000004) + - **flash_assist_killer_score_change** (dict, null) + - **** (dict) + - **score** (float, e.g. 1.1520000000000001) + - **protect_gun_player_score_change** (dict, null) + - **** (dict) + - **score** (float, e.g. 5.8999999999999995) + - **protect_gun_enemy_score_change** (dict, null) + - **** (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) + - **** (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) + - **** (float, e.g. 12.491187500000002) + - **player_t_scores** (dict) + - **** (float, e.g. 19.06) + - **player_ct_scores** (dict) + - **** (float, e.g. -0.009666666666665455) + - **round_total** (int, e.g. 18) + - **player_round_scores** (dict) + - **** (dict) + - **** (float, int, e.g. 32.347) + - **uinfo_dict** (dict) + - **** (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** (, 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** (, 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** (, string, e.g. 76561199482118960) + - **team** (int, e.g. 1) + - **weapon** (string, e.g. usp_silencer) + - **kill** (dict) + - **** (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** (, 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** (, 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** (, 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) + - **** (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) + +--- + diff --git a/database/original_json_schema/uncovered_features.csv b/database/original_json_schema/uncovered_features.csv new file mode 100644 index 0000000..1da49fb --- /dev/null +++ b/database/original_json_schema/uncovered_features.csv @@ -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..avatar_url,data.* +data.uinfo_dict..college_id,data.* +data.uinfo_dict..country_id,data.* +data.uinfo_dict..credit,data.* +data.uinfo_dict..domain,data.* +data.uinfo_dict..gender,data.* +data.uinfo_dict..identity,data.* +data.uinfo_dict..language,data.* +data.uinfo_dict..nickname,data.* +data.uinfo_dict..plus_info,data.* +data.uinfo_dict..province,data.* +data.uinfo_dict..province_name,data.* +data.uinfo_dict..reg_date,data.* +data.uinfo_dict..region,data.* +data.uinfo_dict..region_name,data.* +data.uinfo_dict..status,data.* +data.uinfo_dict..steamid_64,data.* +data.uinfo_dict..trusted_score,data.* +data.uinfo_dict..trusted_status,data.* +data.uinfo_dict..uid,data.* +data.uinfo_dict..username,data.* +data.uinfo_dict..username_spam_status,data.* +data.uinfo_dict..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 diff --git a/docs/6D_README.md b/docs/6D_README.md new file mode 100644 index 0000000..8ff020f --- /dev/null +++ b/docs/6D_README.md @@ -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. diff --git a/docs/FeatureDemoRDD.md b/docs/FeatureDemoRDD.md new file mode 100644 index 0000000..d477072 --- /dev/null +++ b/docs/FeatureDemoRDD.md @@ -0,0 +1,44 @@ +--- + + +## demo维度: + +### d1、经济管理特征 +1. 每局平均道具数量与使用率(烟雾、闪光、燃烧弹、手雷) +2. 伤害性道具效率(手雷/燃烧弹造成伤害值/投掷次数) +3. 细分武器KD(AWP、AK-47、M4A4等) +4. 武器选择与回合胜率相关系数(某武器使用时胜率-整体胜率) +5. 保枪成功率(需保枪回合中成功保下武器次数/总机会) +6. 经济溢出率(每局剩余金钱>3000的回合占比) + +### d2、团队协同特征(后续进行详细设计计算,暂时有较大缺陷) +1. 补枪成功次数(队友阵亡后10秒内完成击杀) +2. 补枪反应时间(队友阵亡到自身补枪击杀的平均时长) +3. 与队友A的补枪成功率(对队友A的补枪成功次数/其阵亡次数) +4. 被补枪率(自身阵亡后10秒内被队友补枪次数/总阵亡次数) +5. 道具配合得分(被队友闪光致盲后击杀的敌人数量) +6. 辅助道具价值(自身烟雾/燃烧弹帮助队友下包/拆包次数) +7. 拉枪线贡献(自身阵亡后队友获得多杀的次数) +8. 疑似卖队友次数(自身附近队友存活但未补枪的阵亡次数) + +### d3、经济影响力特征(自定义计算方案) +1. 累计缴获敌方武器的经济价值(如AWP按4750计算) +2. 保枪致胜次数(保下的武器在下一回合帮助获胜的次数) +3. 单局经济扭转值(因自身行为导致的双方经济差变化) +4. 回合致胜首杀贡献分(首杀为胜利带来的权重分,如5v4优势计0.3分) +5. 回合致胜道具贡献分(关键烟雾/闪光为胜利带来的权重分) +6. 回合致胜残局贡献分(1vN残局胜利的权重分,1v3+计1分) + +### d4、热图与站位特征(预留demoparser阶段开发) +1. 各地图区域击杀数(如Inferno的A区、B区、中路等) +2. 各地图区域死亡数(同上区域划分) +3. 常用站位区域占比(某区域停留时间/总回合时间) +4. 区域对枪胜率(某区域内击杀数/死亡数) + +--- + +完整了解代码库与web端需求文档 WebRDD.md ,开始计划开发web端,完成web端的所有需求。 +注意不需要实现注册登录系统,最好核心是token系统。 +严格按照需求部分规划开发方案与开发顺序。不要忽略内容。 + +utils下还会有哪些需要打包成可快速调用的工具?针对这个项目,你有什么先见? \ No newline at end of file diff --git a/docs/FeatureRDD.md b/docs/FeatureRDD.md new file mode 100644 index 0000000..f3a79d1 --- /dev/null +++ b/docs/FeatureRDD.md @@ -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. 氛围带动性(团队士气影响,正向/负向) diff --git a/docs/WebRDD.md b/docs/WebRDD.md new file mode 100644 index 0000000..427d014 --- /dev/null +++ b/docs/WebRDD.md @@ -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) 功能。 diff --git a/docs/original/特征维度prompt.md b/docs/original/特征维度prompt.md new file mode 100644 index 0000000..eb92ed8 --- /dev/null +++ b/docs/original/特征维度prompt.md @@ -0,0 +1,103 @@ +我现在需要你帮助我制作一个cs能力分析器与指挥帮助器,命名为csanalyzer,首先我们需要沟通确定,CS2是分CT与T,CT应该有哪几个位置,T应该有哪几个位置? + +常见来说 T包括步枪手 突破手 狙击位 辅助 自由人,其中一位兼任指挥 + +CT包括小区主防 区域辅助 自由人 狙击位 + +你认可这样的分析吗?请给我你的思路,首先我们确定每个位置与其倾向,然后再来分析玩家的数据应该包括哪些维度,再来分析如何建立python模型分析(这个模型我希望有一定的主观调整性,因为我是指挥,很多地方数据无法提现一个人是怎么玩游戏的,例如rating低但是做的事很扎实,只是因为碰的人不多,这样不应该给低分。) + +现在我们需要开始构建能力维度,能力维度应该是极其极其丰富的。 + +首先我给你一张图,这是5e主界面截图下来的,里面包括一些维度。 + +但是我认为不管是rating还是rws还是5e评分都并没有考虑到特定玩家在队伍内的现状,所以在这个基础上进行能力评分同样我认为是不合理的。 + +我认为首先应该增加一些维度: + +1.玩家时间序列能力评估:长期rating,胜局rating,败局rating等参数,波动系数 + +2.玩家局内对枪能力评估:对位对手最高最低rating的KD差,对位所有人的胜率或百分比计算(例如我:对面第一=6:2,就是我杀他6次他杀我2次),这个应该与遇到的次数相关而非线性。 + +3.玩家高压发挥评估:残局能力,赛点残局能力,少打多能力,连续丢分压力下突破能力首杀能力 + +4.玩家手枪局评估:手枪局首杀能力,多杀能力,连杀能力,回放能力 + +5.玩家T/CT评估:玩家平均在CT表现好还是T表现好,倾向于做什么,CT首杀率等评估进攻与防守倾向 + +6.玩家热图评估:常用站位,不同默认站位下打出的效果,哪里杀人多哪里杀人少 + +7.玩家数据评估:常用rating,KD,KAST,impact,RWS等数据产出 + +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),在翻盘阶段,哪位选手的各项表现数据有最大的正面提升? + +- 价值何在: 这能精准地找出那些在队伍陷入绝境时,能够挺身而出、提升自己状态的选手——这是一个至关重要的领袖和韧性特质。 + + diff --git a/docs/事件结构统一方案.md b/docs/事件结构统一方案.md new file mode 100644 index 0000000..7527781 --- /dev/null +++ b/docs/事件结构统一方案.md @@ -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 数据拟合,以免误导用户。 diff --git a/downloader/README.md b/downloader/README.md new file mode 100644 index 0000000..03a6187 --- /dev/null +++ b/downloader/README.md @@ -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-ms:iframe 页面 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 未安装,请先执行安装命令再运行脚本 +- 如果下载目录已有文件,会跳过重复下载 diff --git a/downloader/downloader.py b/downloader/downloader.py new file mode 100644 index 0000000..bf67174 --- /dev/null +++ b/downloader/downloader.py @@ -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() diff --git a/downloader/gamelist/match_list_2026.txt b/downloader/gamelist/match_list_2026.txt new file mode 100644 index 0000000..97c73cf --- /dev/null +++ b/downloader/gamelist/match_list_2026.txt @@ -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 \ No newline at end of file diff --git a/downloader/gamelist/match_list_before_0913.txt b/downloader/gamelist/match_list_before_0913.txt new file mode 100644 index 0000000..ead699f --- /dev/null +++ b/downloader/gamelist/match_list_before_0913.txt @@ -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 \ No newline at end of file diff --git a/downloader/gamelist/match_list_before_1025.txt b/downloader/gamelist/match_list_before_1025.txt new file mode 100644 index 0000000..7fac0a8 --- /dev/null +++ b/downloader/gamelist/match_list_before_1025.txt @@ -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 \ No newline at end of file diff --git a/downloader/gamelist/match_list_early_2025.txt b/downloader/gamelist/match_list_early_2025.txt new file mode 100644 index 0000000..bc2e088 --- /dev/null +++ b/downloader/gamelist/match_list_early_2025.txt @@ -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 \ No newline at end of file diff --git a/downloader/gamelist/match_list_temp copy.txt b/downloader/gamelist/match_list_temp copy.txt new file mode 100644 index 0000000..189db5e --- /dev/null +++ b/downloader/gamelist/match_list_temp copy.txt @@ -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 \ No newline at end of file diff --git a/downloader/match_list_temp.txt b/downloader/match_list_temp.txt new file mode 100644 index 0000000..404ff48 --- /dev/null +++ b/downloader/match_list_temp.txt @@ -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 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0efa20b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +Flask +pandas +numpy +playwright +gunicorn +gevent +matplotlib diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/json_extractor/README.md b/utils/json_extractor/README.md new file mode 100644 index 0000000..cffbf3a --- /dev/null +++ b/utils/json_extractor/README.md @@ -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 (``)、5E ID (`<5eid>`) 和回合数 (``)。 + * **结构合并**: 自动将 `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 # 本说明文件 +``` diff --git a/utils/json_extractor/analyze_structure.py b/utils/json_extractor/analyze_structure.py new file mode 100644 index 0000000..87fbb4c --- /dev/null +++ b/utils/json_extractor/analyze_structure.py @@ -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") diff --git a/utils/json_extractor/extractor.py b/utils/json_extractor/extractor.py new file mode 100644 index 0000000..255306d --- /dev/null +++ b/utils/json_extractor/extractor.py @@ -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) + diff --git a/utils/json_extractor/main.py b/utils/json_extractor/main.py new file mode 100644 index 0000000..0a764eb --- /dev/null +++ b/utils/json_extractor/main.py @@ -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() diff --git a/utils/json_extractor/rules.py b/utils/json_extractor/rules.py new file mode 100644 index 0000000..ccbe180 --- /dev/null +++ b/utils/json_extractor/rules.py @@ -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 "" + 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 "" + + 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 "" + 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 "" + # 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" diff --git a/web/app.py b/web/app.py new file mode 100644 index 0000000..c317bbc --- /dev/null +++ b/web/app.py @@ -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) diff --git a/web/auth.py b/web/auth.py new file mode 100644 index 0000000..f30a982 --- /dev/null +++ b/web/auth.py @@ -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 diff --git a/web/config.py b/web/config.py new file mode 100644 index 0000000..bf288a2 --- /dev/null +++ b/web/config.py @@ -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 diff --git a/web/database.py b/web/database.py new file mode 100644 index 0000000..5982ab1 --- /dev/null +++ b/web/database.py @@ -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 diff --git a/web/debug_roster.py b/web/debug_roster.py new file mode 100644 index 0000000..d1ce632 --- /dev/null +++ b/web/debug_roster.py @@ -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() \ No newline at end of file diff --git a/web/routes/admin.py b/web/routes/admin.py new file mode 100644 index 0000000..ba9f47a --- /dev/null +++ b/web/routes/admin.py @@ -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) diff --git a/web/routes/main.py b/web/routes/main.py new file mode 100644 index 0000000..5384865 --- /dev/null +++ b/web/routes/main.py @@ -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}'}) diff --git a/web/routes/matches.py b/web/routes/matches.py new file mode 100644 index 0000000..026e6c7 --- /dev/null +++ b/web/routes/matches.py @@ -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('/') +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('//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') diff --git a/web/routes/opponents.py b/web/routes/opponents.py new file mode 100644 index 0000000..8083e9a --- /dev/null +++ b/web/routes/opponents.py @@ -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('/') +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) diff --git a/web/routes/players.py b/web/routes/players.py new file mode 100644 index 0000000..1f113c4 --- /dev/null +++ b/web/routes/players.py @@ -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('/', 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//like', methods=['POST']) +def like_comment(comment_id): + WebService.like_comment(comment_id) + return jsonify({'success': True}) + +@bp.route('//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) diff --git a/web/routes/tactics.py b/web/routes/tactics.py new file mode 100644 index 0000000..dcf5d5c --- /dev/null +++ b/web/routes/tactics.py @@ -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'}) diff --git a/web/routes/teams.py b/web/routes/teams.py new file mode 100644 index 0000000..58f7309 --- /dev/null +++ b/web/routes/teams.py @@ -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('/') +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) diff --git a/web/routes/wiki.py b/web/routes/wiki.py new file mode 100644 index 0000000..ca06f1e --- /dev/null +++ b/web/routes/wiki.py @@ -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/') +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/', 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) diff --git a/web/services/etl_service.py b/web/services/etl_service.py new file mode 100644 index 0000000..fdec001 --- /dev/null +++ b/web/services/etl_service.py @@ -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) diff --git a/web/services/feature_service.py b/web/services/feature_service.py new file mode 100644 index 0000000..a052b71 --- /dev/null +++ b/web/services/feature_service.py @@ -0,0 +1,2256 @@ +from web.database import query_db, get_db, execute_db +import sqlite3 +import pandas as pd +import numpy as np +from web.services.weapon_service import get_weapon_info + +class FeatureService: + @staticmethod + def get_player_features(steam_id): + sql = "SELECT * FROM dm_player_features WHERE steam_id_64 = ?" + return query_db('l3', sql, [steam_id], one=True) + + @staticmethod + def get_players_list(page=1, per_page=20, sort_by='rating', search=None): + offset = (page - 1) * per_page + + # Sort Mapping + sort_map = { + 'rating': 'basic_avg_rating', + 'kd': 'basic_avg_kd', + 'kast': 'basic_avg_kast', + 'matches': 'matches_played' + } + order_col = sort_map.get(sort_by, 'basic_avg_rating') + + from web.services.stats_service import StatsService + + # Helper to attach match counts + def attach_match_counts(player_list): + if not player_list: + return + ids = [p['steam_id_64'] for p in player_list] + # Batch query for counts from L2 + placeholders = ','.join('?' for _ in 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, ids) + cnt_dict = {r['steam_id_64']: r['cnt'] for r in counts} + for p in player_list: + p['matches_played'] = cnt_dict.get(p['steam_id_64'], 0) + + if search: + # Get all matching players + l2_players, _ = StatsService.get_players(page=1, per_page=100, search=search) + if not l2_players: + return [], 0 + + steam_ids = [p['steam_id_64'] for p in l2_players] + placeholders = ','.join('?' for _ in steam_ids) + sql = f"SELECT * FROM dm_player_features WHERE steam_id_64 IN ({placeholders})" + features = query_db('l3', sql, steam_ids) + f_dict = {f['steam_id_64']: f for f in features} + + # Get counts for sorting + count_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', count_sql, steam_ids) + cnt_dict = {r['steam_id_64']: r['cnt'] for r in counts} + + merged = [] + for p in l2_players: + f = f_dict.get(p['steam_id_64']) + m = dict(p) + if f: + m.update(dict(f)) + else: + # Fallback Calc + stats = StatsService.get_player_basic_stats(p['steam_id_64']) + if stats: + m['basic_avg_rating'] = stats['rating'] + m['basic_avg_kd'] = stats['kd'] + m['basic_avg_kast'] = stats['kast'] + else: + m['basic_avg_rating'] = 0 + m['basic_avg_kd'] = 0 + m['basic_avg_kast'] = 0 + + m['matches_played'] = cnt_dict.get(p['steam_id_64'], 0) + merged.append(m) + + merged.sort(key=lambda x: x.get(order_col, 0) or 0, reverse=True) + + total = len(merged) + start = (page - 1) * per_page + end = start + per_page + return merged[start:end], total + + else: + # Browse mode + l3_count = query_db('l3', "SELECT COUNT(*) as cnt FROM dm_player_features", one=True)['cnt'] + + if l3_count == 0 or sort_by == 'matches': + if sort_by == 'matches': + sql = """ + SELECT steam_id_64, COUNT(*) as cnt + FROM fact_match_players + GROUP BY steam_id_64 + ORDER BY cnt DESC + LIMIT ? OFFSET ? + """ + top_ids = query_db('l2', sql, [per_page, offset]) + if not top_ids: + return [], 0 + + total = query_db('l2', "SELECT COUNT(DISTINCT steam_id_64) as cnt FROM fact_match_players", one=True)['cnt'] + + ids = [r['steam_id_64'] for r in top_ids] + l2_players = StatsService.get_players_by_ids(ids) + + # Merge logic + merged = [] + p_ph = ','.join('?' for _ in ids) + f_sql = f"SELECT * FROM dm_player_features WHERE steam_id_64 IN ({p_ph})" + features = query_db('l3', f_sql, ids) + f_dict = {f['steam_id_64']: f for f in features} + + p_dict = {p['steam_id_64']: p for p in l2_players} + + for r in top_ids: + sid = r['steam_id_64'] + p = p_dict.get(sid) + if not p: continue + + m = dict(p) + f = f_dict.get(sid) + if f: + m.update(dict(f)) + else: + stats = StatsService.get_player_basic_stats(sid) + if stats: + m['basic_avg_rating'] = stats['rating'] + m['basic_avg_kd'] = stats['kd'] + m['basic_avg_kast'] = stats['kast'] + else: + m['basic_avg_rating'] = 0 + m['basic_avg_kd'] = 0 + m['basic_avg_kast'] = 0 + + m['matches_played'] = r['cnt'] + merged.append(m) + + return merged, total + + # L3 empty fallback + l2_players, total = StatsService.get_players(page, per_page, sort_by=None) + merged = [] + attach_match_counts(l2_players) + + for p in l2_players: + m = dict(p) + stats = StatsService.get_player_basic_stats(p['steam_id_64']) + if stats: + m['basic_avg_rating'] = stats['rating'] + m['basic_avg_kd'] = stats['kd'] + m['basic_avg_kast'] = stats['kast'] + else: + m['basic_avg_rating'] = 0 + m['basic_avg_kd'] = 0 + m['basic_avg_kast'] = 0 + m['matches_played'] = p.get('matches_played', 0) + merged.append(m) + + if sort_by != 'rating': + merged.sort(key=lambda x: x.get(order_col, 0) or 0, reverse=True) + + return merged, total + + # Normal L3 browse + sql = f"SELECT * FROM dm_player_features ORDER BY {order_col} DESC LIMIT ? OFFSET ?" + features = query_db('l3', sql, [per_page, offset]) + + total = query_db('l3', "SELECT COUNT(*) as cnt FROM dm_player_features", one=True)['cnt'] + + if not features: + return [], total + + steam_ids = [f['steam_id_64'] for f in features] + l2_players = StatsService.get_players_by_ids(steam_ids) + p_dict = {p['steam_id_64']: p for p in l2_players} + + merged = [] + for f in features: + m = dict(f) + p = p_dict.get(f['steam_id_64']) + if p: + m.update(dict(p)) + else: + m['username'] = f['steam_id_64'] + m['avatar_url'] = None + merged.append(m) + + return merged, total + + @staticmethod + def rebuild_all_features(min_matches=5): + """ + Refreshes the L3 Data Mart with full feature calculations. + """ + from web.config import Config + from web.services.web_service import WebService + import json + + l3_db_path = Config.DB_L3_PATH + l2_db_path = Config.DB_L2_PATH + + # Get Team Players + lineups = WebService.get_lineups() + team_player_ids = set() + for lineup in lineups: + if lineup['player_ids_json']: + try: + ids = json.loads(lineup['player_ids_json']) + # Ensure IDs are strings + team_player_ids.update([str(i) for i in ids]) + except: + pass + + if not team_player_ids: + print("No players found in any team lineup. Skipping L3 rebuild.") + return 0 + + conn_l2 = sqlite3.connect(l2_db_path) + conn_l2.row_factory = sqlite3.Row + + try: + print(f"Loading L2 data for {len(team_player_ids)} players...") + df = FeatureService._load_and_calculate_dataframe(conn_l2, list(team_player_ids)) + + if df is None or df.empty: + print("No data to process.") + return 0 + + print("Calculating Scores...") + df = FeatureService._calculate_ultimate_scores(df) + + print("Saving to L3...") + conn_l3 = sqlite3.connect(l3_db_path) + + cursor = conn_l3.cursor() + + # Ensure columns exist in DataFrame match DB columns + cursor.execute("PRAGMA table_info(dm_player_features)") + valid_cols = [r[1] for r in cursor.fetchall()] + + # Filter DF columns + df_cols = [c for c in df.columns if c in valid_cols] + df_to_save = df[df_cols].copy() + df_to_save['updated_at'] = pd.Timestamp.now().strftime('%Y-%m-%d %H:%M:%S') + + # Generate Insert SQL + print(f"DEBUG: Saving {len(df_to_save.columns)} columns to L3. Sample side_kd_ct: {df_to_save.get('side_kd_ct', pd.Series([0])).iloc[0]}") + placeholders = ','.join(['?'] * len(df_to_save.columns)) + cols_str = ','.join(df_to_save.columns) + sql = f"INSERT OR REPLACE INTO dm_player_features ({cols_str}) VALUES ({placeholders})" + + data = df_to_save.values.tolist() + cursor.executemany(sql, data) + conn_l3.commit() + conn_l3.close() + + return len(df) + + except Exception as e: + print(f"Rebuild Error: {e}") + import traceback + traceback.print_exc() + return 0 + finally: + conn_l2.close() + + @staticmethod + def _load_and_calculate_dataframe(conn, player_ids): + if not player_ids: + return None + + placeholders = ','.join(['?'] * len(player_ids)) + + # 1. Basic Stats + query_basic = f""" + SELECT + steam_id_64, + COUNT(*) as matches_played, + SUM(round_total) as rounds_played, + AVG(rating) as basic_avg_rating, + AVG(kd_ratio) as basic_avg_kd, + AVG(adr) as basic_avg_adr, + AVG(kast) as basic_avg_kast, + AVG(rws) as basic_avg_rws, + SUM(headshot_count) as sum_hs, + SUM(kills) as sum_kills, + SUM(deaths) as sum_deaths, + SUM(first_kill) as sum_fk, + SUM(first_death) as sum_fd, + SUM(clutch_1v1) as sum_1v1, + SUM(clutch_1v2) as sum_1v2, + SUM(clutch_1v3) + SUM(clutch_1v4) + SUM(clutch_1v5) as sum_1v3p, + SUM(kill_2) as sum_2k, + SUM(kill_3) as sum_3k, + SUM(kill_4) as sum_4k, + SUM(kill_5) as sum_5k, + SUM(assisted_kill) as sum_assist, + SUM(perfect_kill) as sum_perfect, + SUM(revenge_kill) as sum_revenge, + SUM(awp_kill) as sum_awp, + SUM(jump_count) as sum_jump, + SUM(mvp_count) as sum_mvps, + SUM(planted_bomb) as sum_plants, + SUM(defused_bomb) as sum_defuses, + SUM(CASE + WHEN flash_assists > 0 THEN flash_assists + WHEN assists > assisted_kill THEN assists - assisted_kill + ELSE 0 + END) as sum_flash_assists, + SUM(throw_harm) as sum_util_dmg, + SUM(flash_time) as sum_flash_time, + SUM(flash_enemy) as sum_flash_enemy, + SUM(flash_team) as sum_flash_team, + SUM(util_flash_usage) as sum_util_flash, + SUM(util_smoke_usage) as sum_util_smoke, + SUM(util_molotov_usage) as sum_util_molotov, + SUM(util_he_usage) as sum_util_he, + SUM(util_decoy_usage) as sum_util_decoy + FROM fact_match_players + WHERE steam_id_64 IN ({placeholders}) + GROUP BY steam_id_64 + """ + df = pd.read_sql_query(query_basic, conn, params=player_ids) + if df.empty: return None + + # Basic Derived + df['basic_headshot_rate'] = df['sum_hs'] / df['sum_kills'].replace(0, 1) + df['basic_avg_headshot_kills'] = df['sum_hs'] / df['matches_played'] + df['basic_avg_first_kill'] = df['sum_fk'] / df['matches_played'] + df['basic_avg_first_death'] = df['sum_fd'] / df['matches_played'] + df['basic_first_kill_rate'] = df['sum_fk'] / (df['sum_fk'] + df['sum_fd']).replace(0, 1) + df['basic_first_death_rate'] = df['sum_fd'] / (df['sum_fk'] + df['sum_fd']).replace(0, 1) + df['basic_avg_kill_2'] = df['sum_2k'] / df['matches_played'] + df['basic_avg_kill_3'] = df['sum_3k'] / df['matches_played'] + df['basic_avg_kill_4'] = df['sum_4k'] / df['matches_played'] + df['basic_avg_kill_5'] = df['sum_5k'] / df['matches_played'] + df['basic_avg_assisted_kill'] = df['sum_assist'] / df['matches_played'] + df['basic_avg_perfect_kill'] = df['sum_perfect'] / df['matches_played'] + df['basic_avg_revenge_kill'] = df['sum_revenge'] / df['matches_played'] + df['basic_avg_awp_kill'] = df['sum_awp'] / df['matches_played'] + df['basic_avg_jump_count'] = df['sum_jump'] / df['matches_played'] + df['basic_avg_mvps'] = df['sum_mvps'] / df['matches_played'] + df['basic_avg_plants'] = df['sum_plants'] / df['matches_played'] + df['basic_avg_defuses'] = df['sum_defuses'] / df['matches_played'] + df['basic_avg_flash_assists'] = df['sum_flash_assists'] / df['matches_played'] + + # UTIL Basic + df['util_avg_nade_dmg'] = df['sum_util_dmg'] / df['matches_played'] + df['util_avg_flash_time'] = df['sum_flash_time'] / df['matches_played'] + df['util_avg_flash_enemy'] = df['sum_flash_enemy'] / df['matches_played'] + + valid_ids = tuple(df['steam_id_64'].tolist()) + placeholders = ','.join(['?'] * len(valid_ids)) + + try: + query_weapon_kills = f""" + SELECT attacker_steam_id as steam_id_64, + SUM(CASE WHEN lower(weapon) LIKE '%knife%' OR lower(weapon) LIKE '%bayonet%' THEN 1 ELSE 0 END) as knife_kills, + SUM(CASE WHEN lower(weapon) LIKE '%taser%' OR lower(weapon) LIKE '%zeus%' THEN 1 ELSE 0 END) as zeus_kills + FROM fact_round_events + WHERE event_type = 'kill' + AND attacker_steam_id IN ({placeholders}) + GROUP BY attacker_steam_id + """ + df_weapon_kills = pd.read_sql_query(query_weapon_kills, conn, params=valid_ids) + if not df_weapon_kills.empty: + df = df.merge(df_weapon_kills, on='steam_id_64', how='left') + else: + df['knife_kills'] = 0 + df['zeus_kills'] = 0 + except Exception: + df['knife_kills'] = 0 + df['zeus_kills'] = 0 + + df['basic_avg_knife_kill'] = df['knife_kills'].fillna(0) / df['matches_played'].replace(0, 1) + df['basic_avg_zeus_kill'] = df['zeus_kills'].fillna(0) / df['matches_played'].replace(0, 1) + + try: + query_zeus_pick = f""" + SELECT steam_id_64, + AVG(CASE WHEN has_zeus = 1 THEN 1.0 ELSE 0.0 END) as basic_zeus_pick_rate + FROM fact_round_player_economy + WHERE steam_id_64 IN ({placeholders}) + GROUP BY steam_id_64 + """ + df_zeus_pick = pd.read_sql_query(query_zeus_pick, conn, params=valid_ids) + if not df_zeus_pick.empty: + df = df.merge(df_zeus_pick, on='steam_id_64', how='left') + except Exception: + df['basic_zeus_pick_rate'] = 0.0 + + df['basic_zeus_pick_rate'] = df.get('basic_zeus_pick_rate', 0.0) + df['basic_zeus_pick_rate'] = pd.to_numeric(df['basic_zeus_pick_rate'], errors='coerce').fillna(0.0) + + # 2. STA (Detailed) + query_sta = f""" + SELECT mp.steam_id_64, mp.rating, mp.is_win, m.start_time, m.duration + FROM fact_match_players mp + JOIN fact_matches m ON mp.match_id = m.match_id + WHERE mp.steam_id_64 IN ({placeholders}) + ORDER BY mp.steam_id_64, m.start_time + """ + df_matches = pd.read_sql_query(query_sta, conn, params=valid_ids) + sta_list = [] + for pid, group in df_matches.groupby('steam_id_64'): + group = group.sort_values('start_time') + last_30 = group.tail(30) + + # Fatigue Calc + # Simple heuristic: split matches by day, compare early (first 3) vs late (rest) + group['date'] = pd.to_datetime(group['start_time'], unit='s').dt.date + day_counts = group.groupby('date').size() + busy_days = day_counts[day_counts >= 4].index # Days with 4+ matches + + fatigue_decays = [] + for day in busy_days: + day_matches = group[group['date'] == day] + if len(day_matches) >= 4: + early_rating = day_matches.head(3)['rating'].mean() + late_rating = day_matches.tail(len(day_matches) - 3)['rating'].mean() + fatigue_decays.append(early_rating - late_rating) + + avg_fatigue = np.mean(fatigue_decays) if fatigue_decays else 0 + + sta_list.append({ + 'steam_id_64': pid, + 'sta_last_30_rating': last_30['rating'].mean(), + 'sta_win_rating': group[group['is_win']==1]['rating'].mean(), + 'sta_loss_rating': group[group['is_win']==0]['rating'].mean(), + 'sta_rating_volatility': group.tail(10)['rating'].std() if len(group) > 1 else 0, + 'sta_time_rating_corr': group['duration'].corr(group['rating']) if len(group)>2 and group['rating'].std() > 0 else 0, + 'sta_fatigue_decay': avg_fatigue + }) + df = df.merge(pd.DataFrame(sta_list), on='steam_id_64', how='left') + + # 3. BAT (High ELO) + query_elo = f""" + SELECT mp.steam_id_64, mp.kd_ratio, + (SELECT AVG(group_origin_elo) FROM fact_match_teams fmt WHERE fmt.match_id = mp.match_id AND group_origin_elo > 0) as elo + FROM fact_match_players mp + WHERE mp.steam_id_64 IN ({placeholders}) + """ + df_elo = pd.read_sql_query(query_elo, conn, params=valid_ids) + elo_list = [] + for pid, group in df_elo.groupby('steam_id_64'): + avg = group['elo'].mean() or 1000 + elo_list.append({ + 'steam_id_64': pid, + 'bat_kd_diff_high_elo': group[group['elo'] > avg]['kd_ratio'].mean(), + 'bat_kd_diff_low_elo': group[group['elo'] <= avg]['kd_ratio'].mean() + }) + df = df.merge(pd.DataFrame(elo_list), on='steam_id_64', how='left') + + # Duel Win Rate + query_duel = f""" + SELECT steam_id_64, SUM(entry_kills) as ek, SUM(entry_deaths) as ed + FROM fact_match_players WHERE steam_id_64 IN ({placeholders}) GROUP BY steam_id_64 + """ + df_duel = pd.read_sql_query(query_duel, conn, params=valid_ids) + df_duel['bat_avg_duel_win_rate'] = df_duel['ek'] / (df_duel['ek'] + df_duel['ed']).replace(0, 1) + df = df.merge(df_duel[['steam_id_64', 'bat_avg_duel_win_rate']], on='steam_id_64', how='left') + + # 4. HPS + # Clutch Rate + df['hps_clutch_win_rate_1v1'] = df['sum_1v1'] / df['matches_played'] + df['hps_clutch_win_rate_1v3_plus'] = df['sum_1v3p'] / df['matches_played'] + + # Prepare Detailed Event Data for HPS (Comeback), PTL (KD), and T/CT + + # A. Determine Side Info using fact_match_teams + # 1. Get Match Teams + query_teams = f""" + SELECT match_id, group_fh_role, group_uids + FROM fact_match_teams + WHERE match_id IN (SELECT match_id FROM fact_match_players WHERE steam_id_64 IN ({placeholders})) + """ + df_teams = pd.read_sql_query(query_teams, conn, params=valid_ids) + + # 2. Get Player UIDs + query_uids = f"SELECT match_id, steam_id_64, uid FROM fact_match_players WHERE steam_id_64 IN ({placeholders})" + df_uids = pd.read_sql_query(query_uids, conn, params=valid_ids) + + # 3. Get Match Meta (Start Time for MR12/MR15) + query_meta = f"SELECT match_id, start_time FROM fact_matches WHERE match_id IN (SELECT match_id FROM fact_match_players WHERE steam_id_64 IN ({placeholders}))" + df_meta = pd.read_sql_query(query_meta, conn, params=valid_ids) + df_meta['halftime_round'] = np.where(df_meta['start_time'] > 1695772800, 12, 15) # CS2 Release Date approx + + # 4. Build FH Side DataFrame + fh_rows = [] + if not df_teams.empty and not df_uids.empty: + match_teams = {} # match_id -> [(role, [uids])] + for _, row in df_teams.iterrows(): + mid = row['match_id'] + role = row['group_fh_role'] # 1=CT, 0=T + try: + uids = str(row['group_uids']).split(',') + uids = [u.strip() for u in uids if u.strip()] + except: + uids = [] + if mid not in match_teams: match_teams[mid] = [] + match_teams[mid].append((role, uids)) + + for _, row in df_uids.iterrows(): + mid = row['match_id'] + sid = row['steam_id_64'] + uid = str(row['uid']) + if mid in match_teams: + for role, uids in match_teams[mid]: + if uid in uids: + fh_rows.append({ + 'match_id': mid, + 'steam_id_64': sid, + 'fh_side': 'CT' if role == 1 else 'T' + }) + break + + df_fh_sides = pd.DataFrame(fh_rows) + if df_fh_sides.empty: + df_fh_sides = pd.DataFrame(columns=['match_id', 'steam_id_64', 'fh_side', 'halftime_round']) + else: + df_fh_sides = df_fh_sides.merge(df_meta[['match_id', 'halftime_round']], on='match_id', how='left') + if 'halftime_round' not in df_fh_sides.columns: + df_fh_sides['halftime_round'] = 15 + df_fh_sides['halftime_round'] = df_fh_sides['halftime_round'].fillna(15).astype(int) + + # B. Get Kill Events + query_events = f""" + SELECT match_id, round_num, attacker_steam_id, victim_steam_id, event_type, is_headshot, event_time, + weapon, trade_killer_steam_id, flash_assist_steam_id + FROM fact_round_events + WHERE event_type='kill' + AND (attacker_steam_id IN ({placeholders}) OR victim_steam_id IN ({placeholders})) + """ + df_events = pd.read_sql_query(query_events, conn, params=valid_ids + valid_ids) + + # C. Get Round Scores + query_rounds = f""" + SELECT match_id, round_num, ct_score, t_score, winner_side, duration + FROM fact_rounds + WHERE match_id IN (SELECT match_id FROM fact_match_players WHERE steam_id_64 IN ({placeholders})) + """ + df_rounds = pd.read_sql_query(query_rounds, conn, params=valid_ids) + + # Fix missing winner_side by calculating from score changes + if not df_rounds.empty: + df_rounds = df_rounds.sort_values(['match_id', 'round_num']).reset_index(drop=True) + df_rounds['prev_ct'] = df_rounds.groupby('match_id')['ct_score'].shift(1).fillna(0) + df_rounds['prev_t'] = df_rounds.groupby('match_id')['t_score'].shift(1).fillna(0) + + # Determine winner based on score increment + df_rounds['ct_win'] = (df_rounds['ct_score'] > df_rounds['prev_ct']) + df_rounds['t_win'] = (df_rounds['t_score'] > df_rounds['prev_t']) + + df_rounds['calculated_winner'] = np.where(df_rounds['ct_win'], 'CT', + np.where(df_rounds['t_win'], 'T', None)) + + # Force overwrite winner_side with calculated winner since DB data is unreliable (mostly NULL) + df_rounds['winner_side'] = df_rounds['calculated_winner'] + + # Ensure winner_side is string type to match side ('CT', 'T') + df_rounds['winner_side'] = df_rounds['winner_side'].astype(str) + + # Fallback for Round 1 if still None (e.g. if prev is 0 and score is 1) + # Logic above handles Round 1 correctly (prev is 0). + + # --- Process Logic --- + # Logic above handles Round 1 correctly (prev is 0). + + # --- Process Logic --- + has_events = not df_events.empty + has_sides = not df_fh_sides.empty + + if has_events and has_sides: + # 1. Attacker Side + df_events = df_events.merge(df_fh_sides, left_on=['match_id', 'attacker_steam_id'], right_on=['match_id', 'steam_id_64'], how='left') + df_events.rename(columns={'fh_side': 'att_fh_side'}, inplace=True) + df_events.drop(columns=['steam_id_64'], inplace=True) + + # 2. Victim Side + df_events = df_events.merge(df_fh_sides, left_on=['match_id', 'victim_steam_id'], right_on=['match_id', 'steam_id_64'], how='left', suffixes=('', '_vic')) + df_events.rename(columns={'fh_side': 'vic_fh_side'}, inplace=True) + df_events.drop(columns=['steam_id_64'], inplace=True) + + # 3. Determine Actual Side (CT/T) + # Logic: If round <= halftime -> FH Side. Else -> Opposite. + def calc_side(fh_side, round_num, halftime): + if pd.isna(fh_side): return None + if round_num <= halftime: return fh_side + return 'T' if fh_side == 'CT' else 'CT' + + # Vectorized approach + # Attacker + mask_fh_att = df_events['round_num'] <= df_events['halftime_round'] + df_events['attacker_side'] = np.where(mask_fh_att, df_events['att_fh_side'], + np.where(df_events['att_fh_side'] == 'CT', 'T', 'CT')) + # Victim + mask_fh_vic = df_events['round_num'] <= df_events['halftime_round'] + df_events['victim_side'] = np.where(mask_fh_vic, df_events['vic_fh_side'], + np.where(df_events['vic_fh_side'] == 'CT', 'T', 'CT')) + + # Merge Scores + df_events = df_events.merge(df_rounds, on=['match_id', 'round_num'], how='left') + + # --- BAT: Win Rate vs All --- + # Removed as per request (Difficult to calculate / All Zeros) + df['bat_win_rate_vs_all'] = 0 + + # --- HPS: Match Point & Comeback --- + # Match Point Win Rate + mp_rounds = df_rounds[((df_rounds['ct_score'] == 12) | (df_rounds['t_score'] == 12) | + (df_rounds['ct_score'] == 15) | (df_rounds['t_score'] == 15))] + + if not mp_rounds.empty and has_sides: + # Need player side for these rounds + # Expand sides for all rounds + q_all_rounds = f"SELECT match_id, round_num FROM fact_rounds WHERE match_id IN (SELECT match_id FROM fact_match_players WHERE steam_id_64 IN ({placeholders}))" + df_all_rounds = pd.read_sql_query(q_all_rounds, conn, params=valid_ids) + + df_player_rounds = df_all_rounds.merge(df_fh_sides, on='match_id') + mask_fh = df_player_rounds['round_num'] <= df_player_rounds['halftime_round'] + df_player_rounds['side'] = np.where(mask_fh, df_player_rounds['fh_side'], + np.where(df_player_rounds['fh_side'] == 'CT', 'T', 'CT')) + + # Filter for MP rounds + # Join mp_rounds with df_player_rounds + mp_player = df_player_rounds.merge(mp_rounds[['match_id', 'round_num', 'winner_side']], on=['match_id', 'round_num']) + mp_player['is_win'] = (mp_player['side'] == mp_player['winner_side']).astype(int) + + hps_mp = mp_player.groupby('steam_id_64')['is_win'].mean().reset_index() + hps_mp.rename(columns={'is_win': 'hps_match_point_win_rate'}, inplace=True) + df = df.merge(hps_mp, on='steam_id_64', how='left') + else: + df['hps_match_point_win_rate'] = 0.5 + + # Comeback KD Diff + # Attacker Context + df_events['att_team_score'] = np.where(df_events['attacker_side'] == 'CT', df_events['ct_score'], df_events['t_score']) + df_events['att_opp_score'] = np.where(df_events['attacker_side'] == 'CT', df_events['t_score'], df_events['ct_score']) + df_events['is_comeback_att'] = (df_events['att_team_score'] + 4 <= df_events['att_opp_score']) + + # Victim Context + df_events['vic_team_score'] = np.where(df_events['victim_side'] == 'CT', df_events['ct_score'], df_events['t_score']) + df_events['vic_opp_score'] = np.where(df_events['victim_side'] == 'CT', df_events['t_score'], df_events['ct_score']) + df_events['is_comeback_vic'] = (df_events['vic_team_score'] + 4 <= df_events['vic_opp_score']) + + att_k = df_events.groupby('attacker_steam_id').size() + vic_d = df_events.groupby('victim_steam_id').size() + + cb_k = df_events[df_events['is_comeback_att']].groupby('attacker_steam_id').size() + cb_d = df_events[df_events['is_comeback_vic']].groupby('victim_steam_id').size() + + kd_stats = pd.DataFrame({'k': att_k, 'd': vic_d, 'cb_k': cb_k, 'cb_d': cb_d}).fillna(0) + kd_stats['kd'] = kd_stats['k'] / kd_stats['d'].replace(0, 1) + kd_stats['cb_kd'] = kd_stats['cb_k'] / kd_stats['cb_d'].replace(0, 1) + kd_stats['hps_comeback_kd_diff'] = kd_stats['cb_kd'] - kd_stats['kd'] + + kd_stats.index.name = 'steam_id_64' + df = df.merge(kd_stats[['hps_comeback_kd_diff']], on='steam_id_64', how='left') + + # HPS: Losing Streak KD Diff + # Logic: KD in rounds where team has lost >= 3 consecutive rounds vs Global KD + # 1. Identify Streak Rounds + if not df_rounds.empty: + # Ensure sorted + df_rounds = df_rounds.sort_values(['match_id', 'round_num']) + + # Shift to check previous results + # We need to handle match boundaries. Groupby match_id is safer. + # CT Loss Streak + g = df_rounds.groupby('match_id') + df_rounds['ct_lost_1'] = g['t_win'].shift(1).fillna(False) + df_rounds['ct_lost_2'] = g['t_win'].shift(2).fillna(False) + df_rounds['ct_lost_3'] = g['t_win'].shift(3).fillna(False) + df_rounds['ct_in_loss_streak'] = (df_rounds['ct_lost_1'] & df_rounds['ct_lost_2'] & df_rounds['ct_lost_3']) + + # T Loss Streak + df_rounds['t_lost_1'] = g['ct_win'].shift(1).fillna(False) + df_rounds['t_lost_2'] = g['ct_win'].shift(2).fillna(False) + df_rounds['t_lost_3'] = g['ct_win'].shift(3).fillna(False) + df_rounds['t_in_loss_streak'] = (df_rounds['t_lost_1'] & df_rounds['t_lost_2'] & df_rounds['t_lost_3']) + + # Merge into events + # df_events already has 'match_id', 'round_num', 'attacker_side' + # We need to merge streak info + streak_cols = df_rounds[['match_id', 'round_num', 'ct_in_loss_streak', 't_in_loss_streak']] + df_events = df_events.merge(streak_cols, on=['match_id', 'round_num'], how='left') + + # Determine if attacker is in streak + df_events['att_is_loss_streak'] = np.where( + df_events['attacker_side'] == 'CT', df_events['ct_in_loss_streak'], + np.where(df_events['attacker_side'] == 'T', df_events['t_in_loss_streak'], False) + ) + + # Determine if victim is in streak (for deaths) + df_events['vic_is_loss_streak'] = np.where( + df_events['victim_side'] == 'CT', df_events['ct_in_loss_streak'], + np.where(df_events['victim_side'] == 'T', df_events['t_in_loss_streak'], False) + ) + + # Calculate KD in Streak + ls_k = df_events[df_events['att_is_loss_streak']].groupby('attacker_steam_id').size() + ls_d = df_events[df_events['vic_is_loss_streak']].groupby('victim_steam_id').size() + + ls_stats = pd.DataFrame({'ls_k': ls_k, 'ls_d': ls_d}).fillna(0) + ls_stats['ls_kd'] = ls_stats['ls_k'] / ls_stats['ls_d'].replace(0, 1) + + # Compare with Global KD (from df_sides or recomputed) + # Recompute global KD from events to be consistent + g_k = df_events.groupby('attacker_steam_id').size() + g_d = df_events.groupby('victim_steam_id').size() + g_stats = pd.DataFrame({'g_k': g_k, 'g_d': g_d}).fillna(0) + g_stats['g_kd'] = g_stats['g_k'] / g_stats['g_d'].replace(0, 1) + + ls_stats = ls_stats.join(g_stats[['g_kd']], how='outer').fillna(0) + ls_stats['hps_losing_streak_kd_diff'] = ls_stats['ls_kd'] - ls_stats['g_kd'] + + ls_stats.index.name = 'steam_id_64' + df = df.merge(ls_stats[['hps_losing_streak_kd_diff']], on='steam_id_64', how='left') + else: + df['hps_losing_streak_kd_diff'] = 0 + + + # HPS: Momentum Multi-kill Rate + # Team won 3+ rounds -> 2+ kills + # Need sequential win info. + # Hard to vectorise fully without accurate round sequence reconstruction including missing rounds. + # Placeholder: 0 + df['hps_momentum_multikill_rate'] = 0 + + # HPS: Tilt Rating Drop + df['hps_tilt_rating_drop'] = 0 + + # HPS: Clutch Rating Rise + df['hps_clutch_rating_rise'] = 0 + + # HPS: Undermanned Survival + df['hps_undermanned_survival_time'] = 0 + + # --- PTL: Pistol Stats --- + pistol_rounds = [1, 13] + df_pistol = df_events[df_events['round_num'].isin(pistol_rounds)] + + if not df_pistol.empty: + pk = df_pistol.groupby('attacker_steam_id').size() + pd_death = df_pistol.groupby('victim_steam_id').size() + p_stats = pd.DataFrame({'pk': pk, 'pd': pd_death}).fillna(0) + p_stats['ptl_pistol_kd'] = p_stats['pk'] / p_stats['pd'].replace(0, 1) + + phs = df_pistol[df_pistol['is_headshot'] == 1].groupby('attacker_steam_id').size() + p_stats['phs'] = phs + p_stats['phs'] = p_stats['phs'].fillna(0) + p_stats['ptl_pistol_util_efficiency'] = p_stats['phs'] / p_stats['pk'].replace(0, 1) + + p_stats.index.name = 'steam_id_64' + df = df.merge(p_stats[['ptl_pistol_kd', 'ptl_pistol_util_efficiency']], on='steam_id_64', how='left') + else: + df['ptl_pistol_kd'] = 1.0 + df['ptl_pistol_util_efficiency'] = 0.0 + + # --- T/CT Stats (Directly from L2 Side Tables) --- + query_sides_l2 = f""" + SELECT + steam_id_64, + 'CT' as side, + COUNT(*) as matches, + SUM(round_total) as rounds, + AVG(rating2) as rating, + SUM(kills) as kills, + SUM(deaths) as deaths, + SUM(assists) as assists, + AVG(CAST(is_win as FLOAT)) as win_rate, + SUM(first_kill) as fk, + SUM(first_death) as fd, + AVG(kast) as kast, + AVG(rws) as rws, + SUM(kill_2 + kill_3 + kill_4 + kill_5) as multi_kill_rounds, + SUM(headshot_count) as hs + FROM fact_match_players_ct + WHERE steam_id_64 IN ({placeholders}) + GROUP BY steam_id_64 + + UNION ALL + + SELECT + steam_id_64, + 'T' as side, + COUNT(*) as matches, + SUM(round_total) as rounds, + AVG(rating2) as rating, + SUM(kills) as kills, + SUM(deaths) as deaths, + SUM(assists) as assists, + AVG(CAST(is_win as FLOAT)) as win_rate, + SUM(first_kill) as fk, + SUM(first_death) as fd, + AVG(kast) as kast, + AVG(rws) as rws, + SUM(kill_2 + kill_3 + kill_4 + kill_5) as multi_kill_rounds, + SUM(headshot_count) as hs + FROM fact_match_players_t + WHERE steam_id_64 IN ({placeholders}) + GROUP BY steam_id_64 + """ + + df_sides = pd.read_sql_query(query_sides_l2, conn, params=valid_ids + valid_ids) + + if not df_sides.empty: + # Calculate Derived Rates per row before pivoting + df_sides['rounds'] = df_sides['rounds'].replace(0, 1) # Avoid div by zero + + # KD Calculation (Sum of Kills / Sum of Deaths) + df_sides['kd'] = df_sides['kills'] / df_sides['deaths'].replace(0, 1) + + # KAST Proxy (if KAST is 0) + # KAST ~= (Kills + Assists + Survived) / Rounds + # Survived = Rounds - Deaths + if df_sides['kast'].mean() == 0: + df_sides['survived'] = df_sides['rounds'] - df_sides['deaths'] + df_sides['kast'] = (df_sides['kills'] + df_sides['assists'] + df_sides['survived']) / df_sides['rounds'] + + + df_sides['fk_rate'] = df_sides['fk'] / df_sides['rounds'] + df_sides['fd_rate'] = df_sides['fd'] / df_sides['rounds'] + df_sides['mk_rate'] = df_sides['multi_kill_rounds'] / df_sides['rounds'] + df_sides['hs_rate'] = df_sides['hs'] / df_sides['kills'].replace(0, 1) + + # Pivot + # We want columns like side_rating_ct, side_rating_t, etc. + pivoted = df_sides.pivot(index='steam_id_64', columns='side').reset_index() + + # Flatten MultiIndex columns + new_cols = ['steam_id_64'] + for col_name, side in pivoted.columns[1:]: + # Map L2 column names to Feature names + # rating -> side_rating_{side} + # kd -> side_kd_{side} + # win_rate -> side_win_rate_{side} + # fk_rate -> side_first_kill_rate_{side} + # fd_rate -> side_first_death_rate_{side} + # kast -> side_kast_{side} + # rws -> side_rws_{side} + # mk_rate -> side_multikill_rate_{side} + # hs_rate -> side_headshot_rate_{side} + + target_map = { + 'rating': 'side_rating', + 'kd': 'side_kd', + 'win_rate': 'side_win_rate', + 'fk_rate': 'side_first_kill_rate', + 'fd_rate': 'side_first_death_rate', + 'kast': 'side_kast', + 'rws': 'side_rws', + 'mk_rate': 'side_multikill_rate', + 'hs_rate': 'side_headshot_rate' + } + + if col_name in target_map: + new_cols.append(f"{target_map[col_name]}_{side.lower()}") + else: + new_cols.append(f"{col_name}_{side.lower()}") # Fallback for intermediate cols if needed + + pivoted.columns = new_cols + + # Select only relevant columns to merge + cols_to_merge = [c for c in new_cols if c.startswith('side_')] + cols_to_merge.append('steam_id_64') + + df = df.merge(pivoted[cols_to_merge], on='steam_id_64', how='left') + + # Fill NaN with 0 for side stats + for c in cols_to_merge: + if c != 'steam_id_64': + df[c] = df[c].fillna(0) + + # Add calculated diffs for scoring/display if needed (or just let template handle it) + # KD Diff for L3 Score calculation + if 'side_rating_ct' in df.columns and 'side_rating_t' in df.columns: + df['side_kd_diff_ct_t'] = df['side_rating_ct'] - df['side_rating_t'] + else: + df['side_kd_diff_ct_t'] = 0 + + # --- Obj Override from Main Table (sum_plants, sum_defuses) --- + # side_obj_t = sum_plants / matches_played + # side_obj_ct = sum_defuses / matches_played + df['side_obj_t'] = df['sum_plants'] / df['matches_played'].replace(0, 1) + df['side_obj_ct'] = df['sum_defuses'] / df['matches_played'].replace(0, 1) + df['side_obj_t'] = df['side_obj_t'].fillna(0) + df['side_obj_ct'] = df['side_obj_ct'].fillna(0) + + else: + # Fallbacks + cols = ['hps_match_point_win_rate', 'hps_comeback_kd_diff', 'ptl_pistol_kd', 'ptl_pistol_util_efficiency', + 'side_rating_ct', 'side_rating_t', 'side_first_kill_rate_ct', 'side_first_kill_rate_t', 'side_kd_diff_ct_t', + 'bat_win_rate_vs_all', 'hps_losing_streak_kd_diff', 'hps_momentum_multikill_rate', + 'hps_tilt_rating_drop', 'hps_clutch_rating_rise', 'hps_undermanned_survival_time', + 'side_win_rate_ct', 'side_win_rate_t', 'side_kd_ct', 'side_kd_t', + 'side_kast_ct', 'side_kast_t', 'side_rws_ct', 'side_rws_t', + 'side_first_death_rate_ct', 'side_first_death_rate_t', + 'side_multikill_rate_ct', 'side_multikill_rate_t', + 'side_headshot_rate_ct', 'side_headshot_rate_t', + 'side_obj_ct', 'side_obj_t'] + for c in cols: + df[c] = 0 + + df['hps_match_point_win_rate'] = df['hps_match_point_win_rate'].fillna(0.5) + df['bat_win_rate_vs_all'] = df['bat_win_rate_vs_all'].fillna(0.5) + df['hps_losing_streak_kd_diff'] = df['hps_losing_streak_kd_diff'].fillna(0) + + # HPS Pressure Entry Rate (Entry Kills per Round in Losing Matches) + q_mp_team = f"SELECT match_id, steam_id_64, is_win, entry_kills, round_total FROM fact_match_players WHERE steam_id_64 IN ({placeholders})" + df_mp_team = pd.read_sql_query(q_mp_team, conn, params=valid_ids) + if not df_mp_team.empty: + losing_matches = df_mp_team[df_mp_team['is_win'] == 0] + if not losing_matches.empty: + # Sum Entry Kills / Sum Rounds + pressure_entry = losing_matches.groupby('steam_id_64')[['entry_kills', 'round_total']].sum().reset_index() + pressure_entry['hps_pressure_entry_rate'] = pressure_entry['entry_kills'] / pressure_entry['round_total'].replace(0, 1) + df = df.merge(pressure_entry[['steam_id_64', 'hps_pressure_entry_rate']], on='steam_id_64', how='left') + + if 'hps_pressure_entry_rate' not in df.columns: + df['hps_pressure_entry_rate'] = 0 + df['hps_pressure_entry_rate'] = df['hps_pressure_entry_rate'].fillna(0) + + # 5. PTL (Additional Features: Kills & Multi) + query_ptl = f""" + SELECT ev.attacker_steam_id as steam_id_64, COUNT(*) as pistol_kills + FROM fact_round_events ev + WHERE ev.event_type = 'kill' AND ev.round_num IN (1, 13) + AND ev.attacker_steam_id IN ({placeholders}) + GROUP BY ev.attacker_steam_id + """ + df_ptl = pd.read_sql_query(query_ptl, conn, params=valid_ids) + if not df_ptl.empty: + df = df.merge(df_ptl, on='steam_id_64', how='left') + df['ptl_pistol_kills'] = df['pistol_kills'] / df['matches_played'] + else: + df['ptl_pistol_kills'] = 0 + + query_ptl_multi = f""" + SELECT attacker_steam_id as steam_id_64, COUNT(*) as multi_cnt + FROM ( + SELECT match_id, round_num, attacker_steam_id, COUNT(*) as k + FROM fact_round_events + WHERE event_type = 'kill' AND round_num IN (1, 13) + AND attacker_steam_id IN ({placeholders}) + GROUP BY match_id, round_num, attacker_steam_id + HAVING k >= 2 + ) + GROUP BY attacker_steam_id + """ + df_ptl_multi = pd.read_sql_query(query_ptl_multi, conn, params=valid_ids) + if not df_ptl_multi.empty: + df = df.merge(df_ptl_multi, on='steam_id_64', how='left') + df['ptl_pistol_multikills'] = df['multi_cnt'] / df['matches_played'] + else: + df['ptl_pistol_multikills'] = 0 + + # PTL Win Rate (Pandas Logic using fixed winner_side) + if not df_rounds.empty and has_sides: + # Ensure df_player_rounds exists + if 'df_player_rounds' not in locals(): + q_all_rounds = f"SELECT match_id, round_num FROM fact_rounds WHERE match_id IN (SELECT match_id FROM fact_match_players WHERE steam_id_64 IN ({placeholders}))" + df_all_rounds = pd.read_sql_query(q_all_rounds, conn, params=valid_ids) + df_player_rounds = df_all_rounds.merge(df_fh_sides, on='match_id') + mask_fh = df_player_rounds['round_num'] <= df_player_rounds['halftime_round'] + df_player_rounds['side'] = np.where(mask_fh, df_player_rounds['fh_side'], + np.where(df_player_rounds['fh_side'] == 'CT', 'T', 'CT')) + + # Filter for Pistol Rounds (1 and after halftime) + # Use halftime_round logic (MR12: 13, MR15: 16) + player_pistol = df_player_rounds[ + (df_player_rounds['round_num'] == 1) | + (df_player_rounds['round_num'] == df_player_rounds['halftime_round'] + 1) + ].copy() + + # Merge with df_rounds to get calculated winner_side + df_rounds['winner_side'] = df_rounds['winner_side'].astype(str) # Ensure string for merge safety + player_pistol = player_pistol.merge(df_rounds[['match_id', 'round_num', 'winner_side']], on=['match_id', 'round_num'], how='left') + + # Calculate Win + # Ensure winner_side is in player_pistol columns after merge + if 'winner_side' in player_pistol.columns: + player_pistol['is_win'] = (player_pistol['side'] == player_pistol['winner_side']).astype(int) + else: + player_pistol['is_win'] = 0 + + ptl_wins = player_pistol.groupby('steam_id_64')['is_win'].agg(['sum', 'count']).reset_index() + ptl_wins.rename(columns={'sum': 'pistol_wins', 'count': 'pistol_rounds'}, inplace=True) + + ptl_wins['ptl_pistol_win_rate'] = ptl_wins['pistol_wins'] / ptl_wins['pistol_rounds'].replace(0, 1) + df = df.merge(ptl_wins[['steam_id_64', 'ptl_pistol_win_rate']], on='steam_id_64', how='left') + else: + df['ptl_pistol_win_rate'] = 0.5 + + df['ptl_pistol_multikills'] = df['ptl_pistol_multikills'].fillna(0) + df['ptl_pistol_win_rate'] = df['ptl_pistol_win_rate'].fillna(0.5) + + # 7. UTIL (Enhanced with Prop Frequency) + # Usage Rate: Average number of grenades purchased per round + df['util_usage_rate'] = ( + df['sum_util_flash'] + df['sum_util_smoke'] + + df['sum_util_molotov'] + df['sum_util_he'] + df['sum_util_decoy'] + ) / df['rounds_played'].replace(0, 1) * 100 # Multiply by 100 to make it comparable to other metrics (e.g. 1.5 nades/round -> 150) + + # Fallback if no new data yet (rely on old logic or keep 0) + # We can try to fetch equipment_value as backup if sum is 0 + if df['util_usage_rate'].sum() == 0: + query_eco = f""" + SELECT steam_id_64, AVG(equipment_value) as avg_equip_val + FROM fact_round_player_economy + WHERE steam_id_64 IN ({placeholders}) + GROUP BY steam_id_64 + """ + df_eco = pd.read_sql_query(query_eco, conn, params=valid_ids) + if not df_eco.empty: + df_eco['util_usage_rate_backup'] = df_eco['avg_equip_val'] / 50.0 # Scaling factor for equipment value + df = df.merge(df_eco[['steam_id_64', 'util_usage_rate_backup']], on='steam_id_64', how='left') + df['util_usage_rate'] = df['util_usage_rate_backup'].fillna(0) + df.drop(columns=['util_usage_rate_backup'], inplace=True) + + # --- 8. New Feature Dimensions (Party, Rating Dist, ELO) --- + # Fetch Base Data for Calculation + q_new_feats = f""" + SELECT mp.steam_id_64, mp.match_id, mp.match_team_id, mp.team_id, + mp.rating, mp.adr, mp.is_win, mp.map as map_name + FROM fact_match_players mp + WHERE mp.steam_id_64 IN ({placeholders}) + """ + df_base = pd.read_sql_query(q_new_feats, conn, params=valid_ids) + + if not df_base.empty: + # 8.1 Party Size Stats + # Get party sizes for these matches + # We need to query party sizes for ALL matches involved + match_ids = df_base['match_id'].unique() + if len(match_ids) > 0: + match_id_ph = ','.join(['?'] * len(match_ids)) + q_party_size = f""" + SELECT match_id, match_team_id, COUNT(*) as party_size + FROM fact_match_players + WHERE match_id IN ({match_id_ph}) AND match_team_id > 0 + GROUP BY match_id, match_team_id + """ + chunk_size = 900 + party_sizes_list = [] + for i in range(0, len(match_ids), chunk_size): + chunk = match_ids[i:i+chunk_size] + chunk_ph = ','.join(['?'] * len(chunk)) + q_chunk = q_party_size.replace(match_id_ph, chunk_ph) + party_sizes_list.append(pd.read_sql_query(q_chunk, conn, params=list(chunk))) + + if party_sizes_list: + df_party_sizes = pd.concat(party_sizes_list) + df_base_party = df_base.merge(df_party_sizes, on=['match_id', 'match_team_id'], how='left') + else: + df_base_party = df_base.copy() + + df_base_party['party_size'] = df_base_party['party_size'].fillna(1) + df_base_party = df_base_party[df_base_party['party_size'].isin([1, 2, 3, 4, 5])] + + party_stats = df_base_party.groupby(['steam_id_64', 'party_size']).agg({ + 'is_win': 'mean', + 'rating': 'mean', + 'adr': 'mean' + }).reset_index() + + pivoted_party = party_stats.pivot(index='steam_id_64', columns='party_size').reset_index() + + new_party_cols = ['steam_id_64'] + for col in pivoted_party.columns: + if col[0] == 'steam_id_64': continue + metric, size = col + if size in [1, 2, 3, 4, 5]: + metric_name = 'win_rate' if metric == 'is_win' else metric + new_party_cols.append(f"party_{int(size)}_{metric_name}") + + flat_data = {'steam_id_64': pivoted_party['steam_id_64']} + for size in [1, 2, 3, 4, 5]: + if size in pivoted_party['is_win'].columns: + flat_data[f"party_{size}_win_rate"] = pivoted_party['is_win'][size] + if size in pivoted_party['rating'].columns: + flat_data[f"party_{size}_rating"] = pivoted_party['rating'][size] + if size in pivoted_party['adr'].columns: + flat_data[f"party_{size}_adr"] = pivoted_party['adr'][size] + + df_party_flat = pd.DataFrame(flat_data) + df = df.merge(df_party_flat, on='steam_id_64', how='left') + + # 8.2 Rating Distribution + # rating_dist_carry_rate (>1.5), normal (1.0-1.5), sacrifice (0.6-1.0), sleeping (<0.6) + df_base['rating_tier'] = pd.cut(df_base['rating'], + bins=[-1, 0.6, 1.0, 1.5, 100], + labels=['sleeping', 'sacrifice', 'normal', 'carry'], + right=False) # <0.6, 0.6-<1.0, 1.0-<1.5, >=1.5 (wait, cut behavior) + # Standard cut: right=True by default (a, b]. We want: + # < 0.6 + # 0.6 <= x < 1.0 + # 1.0 <= x < 1.5 + # >= 1.5 + # So bins=[-inf, 0.6, 1.0, 1.5, inf], right=False -> [a, b) + df_base['rating_tier'] = pd.cut(df_base['rating'], + bins=[-float('inf'), 0.6, 1.0, 1.5, float('inf')], + labels=['sleeping', 'sacrifice', 'normal', 'carry'], + right=False) + + # Wait, 1.5 should be Normal or Carry? + # User: >1.5 Carry, 1.0~1.5 Normal. So 1.5 is Normal? Or Carry? + # Usually inclusive on lower bound. + # 1.5 -> Carry (>1.5 usually means >= 1.5 or strictly >). + # "1.0~1.5 正常" implies [1.0, 1.5]. ">1.5 Carry" implies (1.5, inf). + # Let's assume >= 1.5 is Carry. + # So bins: (-inf, 0.6), [0.6, 1.0), [1.0, 1.5), [1.5, inf) + # right=False gives [a, b). + # So [1.5, inf) is correct for Carry. + + dist_stats = df_base.groupby(['steam_id_64', 'rating_tier']).size().unstack(fill_value=0) + # Calculate rates + dist_stats = dist_stats.div(dist_stats.sum(axis=1), axis=0) + dist_stats.columns = [f"rating_dist_{c}_rate" for c in dist_stats.columns] + dist_stats = dist_stats.reset_index() + + df = df.merge(dist_stats, on='steam_id_64', how='left') + + # 8.3 ELO Stratification + # Fetch Match Teams ELO + if len(match_ids) > 0: + q_elo = f""" + SELECT match_id, group_id, group_origin_elo + FROM fact_match_teams + WHERE match_id IN ({match_id_ph}) + """ + # Use chunking again + elo_list = [] + for i in range(0, len(match_ids), chunk_size): + chunk = match_ids[i:i+chunk_size] + chunk_ph = ','.join(['?'] * len(chunk)) + q_chunk = q_elo.replace(match_id_ph, chunk_ph) + elo_list.append(pd.read_sql_query(q_chunk, conn, params=list(chunk))) + + if elo_list: + df_elo_teams = pd.concat(elo_list) + + # Merge to get Opponent ELO + # Player has match_id, team_id. + # Join on match_id. + # Filter where group_id != team_id + df_merged_elo = df_base.merge(df_elo_teams, on='match_id', how='left') + df_merged_elo = df_merged_elo[df_merged_elo['group_id'] != df_merged_elo['team_id']] + + # Now df_merged_elo has 'group_origin_elo' which is Opponent ELO + # Binning: <1200, 1200-1400, 1400-1600, 1600-1800, 1800-2000, >2000 + # bins: [-inf, 1200, 1400, 1600, 1800, 2000, inf] + elo_bins = [-float('inf'), 1200, 1400, 1600, 1800, 2000, float('inf')] + elo_labels = ['lt1200', '1200_1400', '1400_1600', '1600_1800', '1800_2000', 'gt2000'] + + df_merged_elo['elo_bin'] = pd.cut(df_merged_elo['group_origin_elo'], bins=elo_bins, labels=elo_labels, right=False) + + elo_stats = df_merged_elo.groupby(['steam_id_64', 'elo_bin']).agg({ + 'rating': 'mean' + }).unstack(fill_value=0) # We only need rating for now + + # Rename columns + # elo_stats columns are MultiIndex (rating, bin). + # We want: elo_{bin}_rating + flat_elo_data = {'steam_id_64': elo_stats.index} + for bin_label in elo_labels: + if bin_label in elo_stats['rating'].columns: + flat_elo_data[f"elo_{bin_label}_rating"] = elo_stats['rating'][bin_label].values + + df_elo_flat = pd.DataFrame(flat_elo_data) + df = df.merge(df_elo_flat, on='steam_id_64', how='left') + + # 9. New Features: Economy & Pace + df_eco = FeatureService._calculate_economy_features(conn, valid_ids) + if df_eco is not None: + df = df.merge(df_eco, on='steam_id_64', how='left') + + df_pace = FeatureService._calculate_pace_features(conn, valid_ids) + if df_pace is not None: + df = df.merge(df_pace, on='steam_id_64', how='left') + + if not df_base.empty: + player_mean = df_base.groupby('steam_id_64', as_index=False)['rating'].mean().rename(columns={'rating': 'player_mean_rating'}) + map_mean = df_base.groupby(['steam_id_64', 'map_name'], as_index=False)['rating'].mean().rename(columns={'rating': 'map_mean_rating'}) + map_dev = map_mean.merge(player_mean, on='steam_id_64', how='left') + map_dev['abs_dev'] = (map_dev['map_mean_rating'] - map_dev['player_mean_rating']).abs() + map_coef = map_dev.groupby('steam_id_64', as_index=False)['abs_dev'].mean().rename(columns={'abs_dev': 'map_stability_coef'}) + df = df.merge(map_coef, on='steam_id_64', how='left') + + import json + + df['rd_phase_kill_early_share'] = 0.0 + df['rd_phase_kill_mid_share'] = 0.0 + df['rd_phase_kill_late_share'] = 0.0 + df['rd_phase_death_early_share'] = 0.0 + df['rd_phase_death_mid_share'] = 0.0 + df['rd_phase_death_late_share'] = 0.0 + df['rd_phase_kill_early_share_t'] = 0.0 + df['rd_phase_kill_mid_share_t'] = 0.0 + df['rd_phase_kill_late_share_t'] = 0.0 + df['rd_phase_kill_early_share_ct'] = 0.0 + df['rd_phase_kill_mid_share_ct'] = 0.0 + df['rd_phase_kill_late_share_ct'] = 0.0 + df['rd_phase_death_early_share_t'] = 0.0 + df['rd_phase_death_mid_share_t'] = 0.0 + df['rd_phase_death_late_share_t'] = 0.0 + df['rd_phase_death_early_share_ct'] = 0.0 + df['rd_phase_death_mid_share_ct'] = 0.0 + df['rd_phase_death_late_share_ct'] = 0.0 + df['rd_firstdeath_team_first_death_rounds'] = 0 + df['rd_firstdeath_team_first_death_win_rate'] = 0.0 + df['rd_invalid_death_rounds'] = 0 + df['rd_invalid_death_rate'] = 0.0 + df['rd_pressure_kpr_ratio'] = 0.0 + df['rd_pressure_perf_ratio'] = 0.0 + df['rd_pressure_rounds_down3'] = 0 + df['rd_pressure_rounds_normal'] = 0 + df['rd_matchpoint_kpr_ratio'] = 0.0 + df['rd_matchpoint_perf_ratio'] = 0.0 + df['rd_matchpoint_rounds'] = 0 + df['rd_comeback_kill_share'] = 0.0 + df['rd_comeback_rounds'] = 0 + df['rd_trade_response_10s_rate'] = 0.0 + df['rd_weapon_top_json'] = "[]" + df['rd_roundtype_split_json'] = "{}" + + if not df_events.empty: + df_events['event_time'] = pd.to_numeric(df_events['event_time'], errors='coerce').fillna(0).astype(int) + + df_events['phase_bucket'] = pd.cut( + df_events['event_time'], + bins=[-1, 30, 60, float('inf')], + labels=['early', 'mid', 'late'] + ) + + k_cnt = df_events.groupby(['attacker_steam_id', 'phase_bucket']).size().unstack(fill_value=0) + k_tot = k_cnt.sum(axis=1).replace(0, 1) + k_share = k_cnt.div(k_tot, axis=0) + k_share.index.name = 'steam_id_64' + k_share = k_share.reset_index().rename(columns={ + 'early': 'rd_phase_kill_early_share', + 'mid': 'rd_phase_kill_mid_share', + 'late': 'rd_phase_kill_late_share' + }) + df = df.merge( + k_share[['steam_id_64', 'rd_phase_kill_early_share', 'rd_phase_kill_mid_share', 'rd_phase_kill_late_share']], + on='steam_id_64', + how='left', + suffixes=('', '_calc') + ) + for c in ['rd_phase_kill_early_share', 'rd_phase_kill_mid_share', 'rd_phase_kill_late_share']: + if f'{c}_calc' in df.columns: + df[c] = df[f'{c}_calc'].fillna(df[c]) + df.drop(columns=[f'{c}_calc'], inplace=True) + + d_cnt = df_events.groupby(['victim_steam_id', 'phase_bucket']).size().unstack(fill_value=0) + d_tot = d_cnt.sum(axis=1).replace(0, 1) + d_share = d_cnt.div(d_tot, axis=0) + d_share.index.name = 'steam_id_64' + d_share = d_share.reset_index().rename(columns={ + 'early': 'rd_phase_death_early_share', + 'mid': 'rd_phase_death_mid_share', + 'late': 'rd_phase_death_late_share' + }) + df = df.merge( + d_share[['steam_id_64', 'rd_phase_death_early_share', 'rd_phase_death_mid_share', 'rd_phase_death_late_share']], + on='steam_id_64', + how='left', + suffixes=('', '_calc') + ) + for c in ['rd_phase_death_early_share', 'rd_phase_death_mid_share', 'rd_phase_death_late_share']: + if f'{c}_calc' in df.columns: + df[c] = df[f'{c}_calc'].fillna(df[c]) + df.drop(columns=[f'{c}_calc'], inplace=True) + + if 'attacker_side' in df_events.columns: + k_side = df_events[df_events['attacker_side'].isin(['CT', 'T'])].copy() + if not k_side.empty: + k_cnt_side = k_side.groupby(['attacker_steam_id', 'attacker_side', 'phase_bucket']).size().reset_index(name='cnt') + k_piv = k_cnt_side.pivot_table(index=['attacker_steam_id', 'attacker_side'], columns='phase_bucket', values='cnt', fill_value=0) + k_piv['tot'] = k_piv.sum(axis=1).replace(0, 1) + k_piv = k_piv.div(k_piv['tot'], axis=0).drop(columns=['tot']) + k_piv = k_piv.reset_index().rename(columns={'attacker_steam_id': 'steam_id_64'}) + + for side, suffix in [('T', '_t'), ('CT', '_ct')]: + tmp = k_piv[k_piv['attacker_side'] == side].copy() + if not tmp.empty: + tmp = tmp.rename(columns={ + 'early': f'rd_phase_kill_early_share{suffix}', + 'mid': f'rd_phase_kill_mid_share{suffix}', + 'late': f'rd_phase_kill_late_share{suffix}', + }) + df = df.merge( + tmp[['steam_id_64', f'rd_phase_kill_early_share{suffix}', f'rd_phase_kill_mid_share{suffix}', f'rd_phase_kill_late_share{suffix}']], + on='steam_id_64', + how='left', + suffixes=('', '_calc') + ) + for c in [f'rd_phase_kill_early_share{suffix}', f'rd_phase_kill_mid_share{suffix}', f'rd_phase_kill_late_share{suffix}']: + if f'{c}_calc' in df.columns: + df[c] = df[f'{c}_calc'].fillna(df[c]) + df.drop(columns=[f'{c}_calc'], inplace=True) + + if 'victim_side' in df_events.columns: + d_side = df_events[df_events['victim_side'].isin(['CT', 'T'])].copy() + if not d_side.empty: + d_cnt_side = d_side.groupby(['victim_steam_id', 'victim_side', 'phase_bucket']).size().reset_index(name='cnt') + d_piv = d_cnt_side.pivot_table(index=['victim_steam_id', 'victim_side'], columns='phase_bucket', values='cnt', fill_value=0) + d_piv['tot'] = d_piv.sum(axis=1).replace(0, 1) + d_piv = d_piv.div(d_piv['tot'], axis=0).drop(columns=['tot']) + d_piv = d_piv.reset_index().rename(columns={'victim_steam_id': 'steam_id_64'}) + + for side, suffix in [('T', '_t'), ('CT', '_ct')]: + tmp = d_piv[d_piv['victim_side'] == side].copy() + if not tmp.empty: + tmp = tmp.rename(columns={ + 'early': f'rd_phase_death_early_share{suffix}', + 'mid': f'rd_phase_death_mid_share{suffix}', + 'late': f'rd_phase_death_late_share{suffix}', + }) + df = df.merge( + tmp[['steam_id_64', f'rd_phase_death_early_share{suffix}', f'rd_phase_death_mid_share{suffix}', f'rd_phase_death_late_share{suffix}']], + on='steam_id_64', + how='left', + suffixes=('', '_calc') + ) + for c in [f'rd_phase_death_early_share{suffix}', f'rd_phase_death_mid_share{suffix}', f'rd_phase_death_late_share{suffix}']: + if f'{c}_calc' in df.columns: + df[c] = df[f'{c}_calc'].fillna(df[c]) + df.drop(columns=[f'{c}_calc'], inplace=True) + + if 'victim_side' in df_events.columns and 'winner_side' in df_events.columns: + death_rows = df_events[['match_id', 'round_num', 'event_time', 'victim_steam_id', 'victim_side', 'winner_side']].copy() + death_rows = death_rows[death_rows['victim_side'].isin(['CT', 'T']) & death_rows['winner_side'].isin(['CT', 'T'])] + if not death_rows.empty: + min_death = death_rows.groupby(['match_id', 'round_num', 'victim_side'], as_index=False)['event_time'].min().rename(columns={'event_time': 'min_time'}) + first_deaths = death_rows.merge(min_death, on=['match_id', 'round_num', 'victim_side'], how='inner') + first_deaths = first_deaths[first_deaths['event_time'] == first_deaths['min_time']] + first_deaths['is_win'] = (first_deaths['victim_side'] == first_deaths['winner_side']).astype(int) + fd_agg = first_deaths.groupby('victim_steam_id')['is_win'].agg(['count', 'mean']).reset_index() + fd_agg.rename(columns={ + 'victim_steam_id': 'steam_id_64', + 'count': 'rd_firstdeath_team_first_death_rounds', + 'mean': 'rd_firstdeath_team_first_death_win_rate' + }, inplace=True) + df = df.merge(fd_agg, on='steam_id_64', how='left', suffixes=('', '_calc')) + for c in ['rd_firstdeath_team_first_death_rounds', 'rd_firstdeath_team_first_death_win_rate']: + if f'{c}_calc' in df.columns: + df[c] = df[f'{c}_calc'].fillna(df[c]) + df.drop(columns=[f'{c}_calc'], inplace=True) + + kills_per_round = df_events.groupby(['match_id', 'round_num', 'attacker_steam_id']).size().reset_index(name='kills') + flash_round = df_events[df_events['flash_assist_steam_id'].notna() & (df_events['flash_assist_steam_id'] != '')] \ + .groupby(['match_id', 'round_num', 'flash_assist_steam_id']).size().reset_index(name='flash_assists') + death_round = df_events.groupby(['match_id', 'round_num', 'victim_steam_id']).size().reset_index(name='deaths') + + death_eval = death_round.rename(columns={'victim_steam_id': 'steam_id_64'}).merge( + kills_per_round.rename(columns={'attacker_steam_id': 'steam_id_64'})[['match_id', 'round_num', 'steam_id_64', 'kills']], + on=['match_id', 'round_num', 'steam_id_64'], + how='left' + ).merge( + flash_round.rename(columns={'flash_assist_steam_id': 'steam_id_64'})[['match_id', 'round_num', 'steam_id_64', 'flash_assists']], + on=['match_id', 'round_num', 'steam_id_64'], + how='left' + ).fillna({'kills': 0, 'flash_assists': 0}) + death_eval['is_invalid'] = ((death_eval['kills'] <= 0) & (death_eval['flash_assists'] <= 0)).astype(int) + invalid_agg = death_eval.groupby('steam_id_64')['is_invalid'].agg(['sum', 'count']).reset_index() + invalid_agg.rename(columns={'sum': 'rd_invalid_death_rounds', 'count': 'death_rounds'}, inplace=True) + invalid_agg['rd_invalid_death_rate'] = invalid_agg['rd_invalid_death_rounds'] / invalid_agg['death_rounds'].replace(0, 1) + df = df.merge( + invalid_agg[['steam_id_64', 'rd_invalid_death_rounds', 'rd_invalid_death_rate']], + on='steam_id_64', + how='left', + suffixes=('', '_calc') + ) + for c in ['rd_invalid_death_rounds', 'rd_invalid_death_rate']: + if f'{c}_calc' in df.columns: + df[c] = df[f'{c}_calc'].fillna(df[c]) + df.drop(columns=[f'{c}_calc'], inplace=True) + + if 'weapon' in df_events.columns: + w = df_events.copy() + w['weapon'] = w['weapon'].fillna('').astype(str) + w = w[w['weapon'] != ''] + if not w.empty: + w_agg = w.groupby(['attacker_steam_id', 'weapon']).agg( + kills=('weapon', 'size'), + hs=('is_headshot', 'sum'), + ).reset_index() + top_json = {} + for pid, g in w_agg.groupby('attacker_steam_id'): + g = g.sort_values('kills', ascending=False) + total = float(g['kills'].sum()) if g['kills'].sum() else 1.0 + top = g.head(5) + items = [] + for _, r in top.iterrows(): + k = float(r['kills']) + hs = float(r['hs']) + wi = get_weapon_info(r['weapon']) + items.append({ + 'weapon': r['weapon'], + 'kills': int(k), + 'share': k / total, + 'hs_rate': hs / k if k else 0.0, + 'price': wi.price if wi else None, + 'side': wi.side if wi else None, + 'category': wi.category if wi else None, + }) + top_json[str(pid)] = json.dumps(items, ensure_ascii=False) + if top_json: + df['rd_weapon_top_json'] = df['steam_id_64'].map(top_json).fillna("[]") + + if not df_rounds.empty and not df_fh_sides.empty and not df_events.empty: + df_rounds2 = df_rounds.copy() + if not df_meta.empty: + df_rounds2 = df_rounds2.merge(df_meta[['match_id', 'halftime_round']], on='match_id', how='left') + df_rounds2 = df_rounds2.sort_values(['match_id', 'round_num']) + df_rounds2['prev_ct'] = df_rounds2.groupby('match_id')['ct_score'].shift(1).fillna(0) + df_rounds2['prev_t'] = df_rounds2.groupby('match_id')['t_score'].shift(1).fillna(0) + df_rounds2['ct_deficit'] = df_rounds2['prev_t'] - df_rounds2['prev_ct'] + df_rounds2['t_deficit'] = df_rounds2['prev_ct'] - df_rounds2['prev_t'] + df_rounds2['mp_score'] = df_rounds2['halftime_round'].fillna(15) + df_rounds2['is_match_point_round'] = (df_rounds2['prev_ct'] == df_rounds2['mp_score']) | (df_rounds2['prev_t'] == df_rounds2['mp_score']) + df_rounds2['reg_rounds'] = (df_rounds2['halftime_round'].fillna(15) * 2).astype(int) + df_rounds2['is_overtime_round'] = df_rounds2['round_num'] > df_rounds2['reg_rounds'] + + all_rounds = df_rounds2[['match_id', 'round_num']].drop_duplicates() + df_player_rounds = all_rounds.merge(df_fh_sides, on='match_id', how='inner') + if 'halftime_round' not in df_player_rounds.columns: + df_player_rounds['halftime_round'] = 15 + df_player_rounds['halftime_round'] = pd.to_numeric(df_player_rounds['halftime_round'], errors='coerce').fillna(15).astype(int) + mask_fh = df_player_rounds['round_num'] <= df_player_rounds['halftime_round'] + df_player_rounds['side'] = np.where(mask_fh, df_player_rounds['fh_side'], np.where(df_player_rounds['fh_side'] == 'CT', 'T', 'CT')) + df_player_rounds = df_player_rounds.merge( + df_rounds2[['match_id', 'round_num', 'ct_deficit', 't_deficit', 'is_match_point_round', 'is_overtime_round', 'reg_rounds']], + on=['match_id', 'round_num'], + how='left' + ) + df_player_rounds['deficit'] = np.where( + df_player_rounds['side'] == 'CT', + df_player_rounds['ct_deficit'], + np.where(df_player_rounds['side'] == 'T', df_player_rounds['t_deficit'], 0) + ) + df_player_rounds['is_pressure_round'] = (df_player_rounds['deficit'] >= 3).astype(int) + df_player_rounds['is_pistol_round'] = ( + (df_player_rounds['round_num'] == 1) | + (df_player_rounds['round_num'] == df_player_rounds['halftime_round'] + 1) + ).astype(int) + + kills_per_round = df_events.groupby(['match_id', 'round_num', 'attacker_steam_id']).size().reset_index(name='kills') + df_player_rounds = df_player_rounds.merge( + kills_per_round.rename(columns={'attacker_steam_id': 'steam_id_64'}), + on=['match_id', 'round_num', 'steam_id_64'], + how='left' + ) + df_player_rounds['kills'] = df_player_rounds['kills'].fillna(0) + + grp = df_player_rounds.groupby(['steam_id_64', 'is_pressure_round'])['kills'].agg(['mean', 'count']).reset_index() + pressure = grp.pivot(index='steam_id_64', columns='is_pressure_round').fillna(0) + if ('mean', 1) in pressure.columns and ('mean', 0) in pressure.columns: + pressure_kpr_ratio = (pressure[('mean', 1)] / pressure[('mean', 0)].replace(0, 1)).reset_index() + pressure_kpr_ratio.columns = ['steam_id_64', 'rd_pressure_kpr_ratio'] + df = df.merge(pressure_kpr_ratio, on='steam_id_64', how='left', suffixes=('', '_calc')) + if 'rd_pressure_kpr_ratio_calc' in df.columns: + df['rd_pressure_kpr_ratio'] = df['rd_pressure_kpr_ratio_calc'].fillna(df['rd_pressure_kpr_ratio']) + df.drop(columns=['rd_pressure_kpr_ratio_calc'], inplace=True) + if ('count', 1) in pressure.columns: + pr_cnt = pressure[('count', 1)].reset_index() + pr_cnt.columns = ['steam_id_64', 'rd_pressure_rounds_down3'] + df = df.merge(pr_cnt, on='steam_id_64', how='left', suffixes=('', '_calc')) + if 'rd_pressure_rounds_down3_calc' in df.columns: + df['rd_pressure_rounds_down3'] = df['rd_pressure_rounds_down3_calc'].fillna(df['rd_pressure_rounds_down3']) + df.drop(columns=['rd_pressure_rounds_down3_calc'], inplace=True) + if ('count', 0) in pressure.columns: + nr_cnt = pressure[('count', 0)].reset_index() + nr_cnt.columns = ['steam_id_64', 'rd_pressure_rounds_normal'] + df = df.merge(nr_cnt, on='steam_id_64', how='left', suffixes=('', '_calc')) + if 'rd_pressure_rounds_normal_calc' in df.columns: + df['rd_pressure_rounds_normal'] = df['rd_pressure_rounds_normal_calc'].fillna(df['rd_pressure_rounds_normal']) + df.drop(columns=['rd_pressure_rounds_normal_calc'], inplace=True) + + mp_grp = df_player_rounds.groupby(['steam_id_64', 'is_match_point_round'])['kills'].agg(['mean', 'count']).reset_index() + mp = mp_grp.pivot(index='steam_id_64', columns='is_match_point_round').fillna(0) + if ('mean', 1) in mp.columns and ('mean', 0) in mp.columns: + mp_ratio = (mp[('mean', 1)] / mp[('mean', 0)].replace(0, 1)).reset_index() + mp_ratio.columns = ['steam_id_64', 'rd_matchpoint_kpr_ratio'] + df = df.merge(mp_ratio, on='steam_id_64', how='left', suffixes=('', '_calc')) + if 'rd_matchpoint_kpr_ratio_calc' in df.columns: + df['rd_matchpoint_kpr_ratio'] = df['rd_matchpoint_kpr_ratio_calc'].fillna(df['rd_matchpoint_kpr_ratio']) + df.drop(columns=['rd_matchpoint_kpr_ratio_calc'], inplace=True) + if ('count', 1) in mp.columns: + mp_cnt = mp[('count', 1)].reset_index() + mp_cnt.columns = ['steam_id_64', 'rd_matchpoint_rounds'] + df = df.merge(mp_cnt, on='steam_id_64', how='left', suffixes=('', '_calc')) + if 'rd_matchpoint_rounds_calc' in df.columns: + df['rd_matchpoint_rounds'] = df['rd_matchpoint_rounds_calc'].fillna(df['rd_matchpoint_rounds']) + df.drop(columns=['rd_matchpoint_rounds_calc'], inplace=True) + + try: + q_player_team = f"SELECT match_id, steam_id_64, team_id FROM fact_match_players WHERE steam_id_64 IN ({placeholders})" + df_player_team = pd.read_sql_query(q_player_team, conn, params=valid_ids) + except Exception: + df_player_team = pd.DataFrame() + + if not df_player_team.empty: + try: + q_team_roles = f""" + SELECT match_id, group_id as team_id, group_fh_role + FROM fact_match_teams + WHERE match_id IN (SELECT match_id FROM fact_match_players WHERE steam_id_64 IN ({placeholders})) + """ + df_team_roles = pd.read_sql_query(q_team_roles, conn, params=valid_ids) + except Exception: + df_team_roles = pd.DataFrame() + + if not df_team_roles.empty: + team_round = df_rounds2[['match_id', 'round_num', 'ct_score', 't_score', 'prev_ct', 'prev_t', 'halftime_round']].merge(df_team_roles, on='match_id', how='inner') + fh_ct = team_round['group_fh_role'] == 1 + mask_fh = team_round['round_num'] <= team_round['halftime_round'] + team_round['team_side'] = np.where(mask_fh, np.where(fh_ct, 'CT', 'T'), np.where(fh_ct, 'T', 'CT')) + team_round['team_prev_score'] = np.where(team_round['team_side'] == 'CT', team_round['prev_ct'], team_round['prev_t']) + team_round['team_score_after'] = np.where(team_round['team_side'] == 'CT', team_round['ct_score'], team_round['t_score']) + team_round['opp_prev_score'] = np.where(team_round['team_side'] == 'CT', team_round['prev_t'], team_round['prev_ct']) + team_round['opp_score_after'] = np.where(team_round['team_side'] == 'CT', team_round['t_score'], team_round['ct_score']) + team_round['deficit_before'] = team_round['opp_prev_score'] - team_round['team_prev_score'] + team_round['deficit_after'] = team_round['opp_score_after'] - team_round['team_score_after'] + team_round['is_comeback_round'] = ((team_round['deficit_before'] > 0) & (team_round['deficit_after'] < team_round['deficit_before'])).astype(int) + comeback_keys = team_round[team_round['is_comeback_round'] == 1][['match_id', 'round_num', 'team_id']].drop_duplicates() + + if not comeback_keys.empty: + ev_att = df_events[['match_id', 'round_num', 'attacker_steam_id', 'event_time']].merge( + df_player_team.rename(columns={'steam_id_64': 'attacker_steam_id', 'team_id': 'att_team_id'}), + on=['match_id', 'attacker_steam_id'], + how='left' + ) + team_kills = ev_att[ev_att['att_team_id'].notna()].groupby(['match_id', 'round_num', 'att_team_id']).size().reset_index(name='team_kills') + player_kills = ev_att.groupby(['match_id', 'round_num', 'attacker_steam_id', 'att_team_id']).size().reset_index(name='player_kills') + + player_kills = player_kills.merge( + comeback_keys.rename(columns={'team_id': 'att_team_id'}), + on=['match_id', 'round_num', 'att_team_id'], + how='inner' + ) + if not player_kills.empty: + player_kills = player_kills.merge(team_kills, on=['match_id', 'round_num', 'att_team_id'], how='left').fillna({'team_kills': 0}) + player_kills['share'] = player_kills['player_kills'] / player_kills['team_kills'].replace(0, 1) + cb_share = player_kills.groupby('attacker_steam_id')['share'].mean().reset_index() + cb_share.rename(columns={'attacker_steam_id': 'steam_id_64', 'share': 'rd_comeback_kill_share'}, inplace=True) + df = df.merge(cb_share, on='steam_id_64', how='left', suffixes=('', '_calc')) + if 'rd_comeback_kill_share_calc' in df.columns: + df['rd_comeback_kill_share'] = df['rd_comeback_kill_share_calc'].fillna(df['rd_comeback_kill_share']) + df.drop(columns=['rd_comeback_kill_share_calc'], inplace=True) + + cb_rounds = comeback_keys.merge(df_player_team, left_on=['match_id', 'team_id'], right_on=['match_id', 'team_id'], how='inner') + cb_cnt = cb_rounds.groupby('steam_id_64').size().reset_index(name='rd_comeback_rounds') + df = df.merge(cb_cnt, on='steam_id_64', how='left', suffixes=('', '_calc')) + if 'rd_comeback_rounds_calc' in df.columns: + df['rd_comeback_rounds'] = df['rd_comeback_rounds_calc'].fillna(df['rd_comeback_rounds']) + df.drop(columns=['rd_comeback_rounds_calc'], inplace=True) + + death_team = df_events[['match_id', 'round_num', 'event_time', 'victim_steam_id']].merge( + df_player_team.rename(columns={'steam_id_64': 'victim_steam_id', 'team_id': 'team_id'}), + on=['match_id', 'victim_steam_id'], + how='left' + ) + death_team = death_team[death_team['team_id'].notna()] + if not death_team.empty: + roster = df_player_team.rename(columns={'steam_id_64': 'steam_id_64', 'team_id': 'team_id'})[['match_id', 'team_id', 'steam_id_64']].drop_duplicates() + opp = death_team.merge(roster, on=['match_id', 'team_id'], how='inner', suffixes=('', '_teammate')) + opp = opp[opp['steam_id_64'] != opp['victim_steam_id']] + opp_time = opp.groupby(['match_id', 'round_num', 'steam_id_64'], as_index=False)['event_time'].min().rename(columns={'event_time': 'teammate_death_time'}) + + kills_time = df_events[['match_id', 'round_num', 'event_time', 'attacker_steam_id']].rename(columns={'attacker_steam_id': 'steam_id_64', 'event_time': 'kill_time'}) + m = opp_time.merge(kills_time, on=['match_id', 'round_num', 'steam_id_64'], how='left') + m['in_window'] = ((m['kill_time'] >= m['teammate_death_time']) & (m['kill_time'] <= m['teammate_death_time'] + 10)).astype(int) + success = m.groupby(['match_id', 'round_num', 'steam_id_64'], as_index=False)['in_window'].max() + rate = success.groupby('steam_id_64')['in_window'].mean().reset_index() + rate.rename(columns={'in_window': 'rd_trade_response_10s_rate'}, inplace=True) + df = df.merge(rate, on='steam_id_64', how='left', suffixes=('', '_calc')) + if 'rd_trade_response_10s_rate_calc' in df.columns: + df['rd_trade_response_10s_rate'] = df['rd_trade_response_10s_rate_calc'].fillna(df['rd_trade_response_10s_rate']) + df.drop(columns=['rd_trade_response_10s_rate_calc'], inplace=True) + + eco_rows = [] + try: + q_econ = f""" + SELECT match_id, round_num, steam_id_64, equipment_value, round_performance_score + FROM fact_round_player_economy + WHERE steam_id_64 IN ({placeholders}) + """ + df_econ = pd.read_sql_query(q_econ, conn, params=valid_ids) + except Exception: + df_econ = pd.DataFrame() + + if not df_econ.empty: + df_econ['equipment_value'] = pd.to_numeric(df_econ['equipment_value'], errors='coerce').fillna(0).astype(int) + df_econ['round_performance_score'] = pd.to_numeric(df_econ['round_performance_score'], errors='coerce').fillna(0.0) + df_econ = df_econ.merge(df_rounds2[['match_id', 'round_num', 'is_overtime_round', 'is_match_point_round', 'ct_deficit', 't_deficit', 'prev_ct', 'prev_t']], on=['match_id', 'round_num'], how='left') + df_econ = df_econ.merge(df_fh_sides[['match_id', 'steam_id_64', 'fh_side', 'halftime_round']], on=['match_id', 'steam_id_64'], how='left') + mask_fh = df_econ['round_num'] <= df_econ['halftime_round'] + df_econ['side'] = np.where(mask_fh, df_econ['fh_side'], np.where(df_econ['fh_side'] == 'CT', 'T', 'CT')) + df_econ['deficit'] = np.where(df_econ['side'] == 'CT', df_econ['ct_deficit'], df_econ['t_deficit']) + df_econ['is_pressure_round'] = (df_econ['deficit'] >= 3).astype(int) + + perf_grp = df_econ.groupby(['steam_id_64', 'is_pressure_round'])['round_performance_score'].agg(['mean', 'count']).reset_index() + perf = perf_grp.pivot(index='steam_id_64', columns='is_pressure_round').fillna(0) + if ('mean', 1) in perf.columns and ('mean', 0) in perf.columns: + perf_ratio = (perf[('mean', 1)] / perf[('mean', 0)].replace(0, 1)).reset_index() + perf_ratio.columns = ['steam_id_64', 'rd_pressure_perf_ratio'] + df = df.merge(perf_ratio, on='steam_id_64', how='left', suffixes=('', '_calc')) + if 'rd_pressure_perf_ratio_calc' in df.columns: + df['rd_pressure_perf_ratio'] = df['rd_pressure_perf_ratio_calc'].fillna(df['rd_pressure_perf_ratio']) + df.drop(columns=['rd_pressure_perf_ratio_calc'], inplace=True) + + mp_perf_grp = df_econ.groupby(['steam_id_64', 'is_match_point_round'])['round_performance_score'].agg(['mean', 'count']).reset_index() + mp_perf = mp_perf_grp.pivot(index='steam_id_64', columns='is_match_point_round').fillna(0) + if ('mean', 1) in mp_perf.columns and ('mean', 0) in mp_perf.columns: + mp_perf_ratio = (mp_perf[('mean', 1)] / mp_perf[('mean', 0)].replace(0, 1)).reset_index() + mp_perf_ratio.columns = ['steam_id_64', 'rd_matchpoint_perf_ratio'] + df = df.merge(mp_perf_ratio, on='steam_id_64', how='left', suffixes=('', '_calc')) + if 'rd_matchpoint_perf_ratio_calc' in df.columns: + df['rd_matchpoint_perf_ratio'] = df['rd_matchpoint_perf_ratio_calc'].fillna(df['rd_matchpoint_perf_ratio']) + df.drop(columns=['rd_matchpoint_perf_ratio_calc'], inplace=True) + + eco = df_econ.copy() + eco['round_type'] = np.select( + [ + eco['is_overtime_round'] == 1, + eco['equipment_value'] < 2000, + eco['equipment_value'] >= 4000, + ], + [ + 'overtime', + 'eco', + 'fullbuy', + ], + default='rifle' + ) + eco_rounds = eco.groupby(['steam_id_64', 'round_type']).size().reset_index(name='rounds') + perf_mean = eco.groupby(['steam_id_64', 'round_type'])['round_performance_score'].mean().reset_index(name='perf') + eco_rows = eco_rounds.merge(perf_mean, on=['steam_id_64', 'round_type'], how='left') + + if eco_rows is not None and len(eco_rows) > 0: + kpr_rounds = df_player_rounds[['match_id', 'round_num', 'steam_id_64', 'kills', 'is_pistol_round', 'is_overtime_round']].copy() + kpr_rounds['round_type'] = np.select( + [ + kpr_rounds['is_overtime_round'] == 1, + kpr_rounds['is_pistol_round'] == 1, + ], + [ + 'overtime', + 'pistol', + ], + default='reg' + ) + kpr = kpr_rounds.groupby(['steam_id_64', 'round_type']).agg(kpr=('kills', 'mean'), rounds=('kills', 'size')).reset_index() + kpr_dict = {} + for pid, g in kpr.groupby('steam_id_64'): + d = {} + for _, r in g.iterrows(): + d[r['round_type']] = {'kpr': float(r['kpr']), 'rounds': int(r['rounds'])} + kpr_dict[str(pid)] = d + + econ_dict = {} + if isinstance(eco_rows, pd.DataFrame) and not eco_rows.empty: + for pid, g in eco_rows.groupby('steam_id_64'): + d = {} + for _, r in g.iterrows(): + d[r['round_type']] = {'perf': float(r['perf']) if r['perf'] is not None else 0.0, 'rounds': int(r['rounds'])} + econ_dict[str(pid)] = d + + out = {} + for pid in df['steam_id_64'].astype(str).tolist(): + merged = {} + if pid in kpr_dict: + merged.update(kpr_dict[pid]) + if pid in econ_dict: + for k, v in econ_dict[pid].items(): + merged.setdefault(k, {}).update(v) + out[pid] = json.dumps(merged, ensure_ascii=False) + df['rd_roundtype_split_json'] = df['steam_id_64'].astype(str).map(out).fillna("{}") + + # Final Mappings + df['total_matches'] = df['matches_played'] + + for c in df.columns: + if df[c].dtype.kind in "biufc": + df[c] = df[c].fillna(0) + else: + df[c] = df[c].fillna("") + return df + + @staticmethod + def _calculate_economy_features(conn, player_ids): + if not player_ids: return None + placeholders = ','.join(['?'] * len(player_ids)) + + # 1. Investment Efficiency (Damage / Equipment Value) + # We need total damage and total equipment value + # fact_match_players has sum_util_dmg (only nade damage), but we need total damage. + # fact_match_players has 'basic_avg_adr' * rounds. + # Better to query fact_round_player_economy for equipment value sum. + + q_eco_val = f""" + SELECT steam_id_64, SUM(equipment_value) as total_spend, COUNT(*) as rounds_tracked + FROM fact_round_player_economy + WHERE steam_id_64 IN ({placeholders}) + GROUP BY steam_id_64 + """ + df_spend = pd.read_sql_query(q_eco_val, conn, params=player_ids) + + # Get Total Damage from fact_match_players (derived from ADR * Rounds) + # MUST filter by matches that actually have economy data to ensure consistency + q_dmg = f""" + SELECT mp.steam_id_64, SUM(mp.adr * mp.round_total) as total_damage + FROM fact_match_players mp + JOIN ( + SELECT DISTINCT match_id, steam_id_64 + FROM fact_round_player_economy + WHERE steam_id_64 IN ({placeholders}) + ) eco ON mp.match_id = eco.match_id AND mp.steam_id_64 = eco.steam_id_64 + WHERE mp.steam_id_64 IN ({placeholders}) + GROUP BY mp.steam_id_64 + """ + df_dmg = pd.read_sql_query(q_dmg, conn, params=player_ids + player_ids) + + df = df_spend.merge(df_dmg, on='steam_id_64', how='inner') + + # Metric 1: Damage per 1000$ + # Avoid div by zero + df['eco_avg_damage_per_1k'] = df['total_damage'] / (df['total_spend'] / 1000.0).replace(0, 1) + + # 2. Eco Round Performance (Equipment < 2000) + # We need kills in these rounds. + # Join economy with events? That's heavy. + # Alternative: Approximate. + # Let's do it properly: Get rounds where equip < 2000, count kills. + + # Subquery for Eco Rounds keys: (match_id, round_num, steam_id_64) + # Then join with events. + + q_eco_perf = f""" + SELECT + e.attacker_steam_id as steam_id_64, + COUNT(*) as eco_kills, + SUM(CASE WHEN e.event_type='death' THEN 1 ELSE 0 END) as eco_deaths + FROM fact_round_events e + JOIN fact_round_player_economy eco + ON e.match_id = eco.match_id + AND e.round_num = eco.round_num + AND (e.attacker_steam_id = eco.steam_id_64 OR e.victim_steam_id = eco.steam_id_64) + WHERE (e.event_type = 'kill' AND e.attacker_steam_id = eco.steam_id_64) + OR (e.event_type = 'kill' AND e.victim_steam_id = eco.steam_id_64) -- Count deaths properly + AND eco.equipment_value < 2000 + AND eco.steam_id_64 IN ({placeholders}) + GROUP BY eco.steam_id_64 + """ + # Wait, the join condition OR is tricky for grouping. + # Let's separate Kills and Deaths or do two queries. + # Simpler: + + # Eco Kills + q_eco_kills = f""" + SELECT + e.attacker_steam_id as steam_id_64, + COUNT(*) as eco_kills + FROM fact_round_events e + JOIN fact_round_player_economy eco + ON e.match_id = eco.match_id + AND e.round_num = eco.round_num + AND e.attacker_steam_id = eco.steam_id_64 + WHERE e.event_type = 'kill' + AND eco.equipment_value < 2000 + AND eco.steam_id_64 IN ({placeholders}) + GROUP BY e.attacker_steam_id + """ + df_eco_kills = pd.read_sql_query(q_eco_kills, conn, params=player_ids) + + # Eco Deaths + q_eco_deaths = f""" + SELECT + e.victim_steam_id as steam_id_64, + COUNT(*) as eco_deaths + FROM fact_round_events e + JOIN fact_round_player_economy eco + ON e.match_id = eco.match_id + AND e.round_num = eco.round_num + AND e.victim_steam_id = eco.steam_id_64 + WHERE e.event_type = 'kill' + AND eco.equipment_value < 2000 + AND eco.steam_id_64 IN ({placeholders}) + GROUP BY e.victim_steam_id + """ + df_eco_deaths = pd.read_sql_query(q_eco_deaths, conn, params=player_ids) + + # Get count of eco rounds + q_eco_rounds = f""" + SELECT steam_id_64, COUNT(*) as eco_round_count + FROM fact_round_player_economy + WHERE equipment_value < 2000 AND steam_id_64 IN ({placeholders}) + GROUP BY steam_id_64 + """ + df_eco_cnt = pd.read_sql_query(q_eco_rounds, conn, params=player_ids) + + df_perf = df_eco_cnt.merge(df_eco_kills, on='steam_id_64', how='left').merge(df_eco_deaths, on='steam_id_64', how='left').fillna(0) + + # Eco Rating (KPR) + df_perf['eco_rating_eco_rounds'] = df_perf['eco_kills'] / df_perf['eco_round_count'].replace(0, 1) + + # Eco KD + df_perf['eco_kd_ratio'] = df_perf['eco_kills'] / df_perf['eco_deaths'].replace(0, 1) + + # Eco Rounds per Match + # We need total matches WHERE economy data exists. + # Otherwise, if we have 100 matches but only 10 with eco data, the avg will be diluted. + q_matches = f""" + SELECT steam_id_64, COUNT(DISTINCT match_id) as matches_tracked + FROM fact_round_player_economy + WHERE steam_id_64 IN ({placeholders}) + GROUP BY steam_id_64 + """ + df_matches = pd.read_sql_query(q_matches, conn, params=player_ids) + + df_perf = df_perf.merge(df_matches, on='steam_id_64', how='left') + df_perf['eco_avg_rounds'] = df_perf['eco_round_count'] / df_perf['matches_tracked'].replace(0, 1) + + # Merge all + df_final = df.merge(df_perf[['steam_id_64', 'eco_rating_eco_rounds', 'eco_kd_ratio', 'eco_avg_rounds']], on='steam_id_64', how='left') + + return df_final[['steam_id_64', 'eco_avg_damage_per_1k', 'eco_rating_eco_rounds', 'eco_kd_ratio', 'eco_avg_rounds']] + + @staticmethod + def _calculate_pace_features(conn, player_ids): + if not player_ids: return None + placeholders = ','.join(['?'] * len(player_ids)) + + # 1. Avg Time to First Contact + # Find min(event_time) per round per player (Attacker or Victim) + q_first_contact = f""" + SELECT + player_id as steam_id_64, + AVG(first_time) as pace_avg_time_to_first_contact + FROM ( + SELECT + match_id, round_num, + CASE + WHEN attacker_steam_id IN ({placeholders}) THEN attacker_steam_id + ELSE victim_steam_id + END as player_id, + MIN(event_time) as first_time + FROM fact_round_events + WHERE (attacker_steam_id IN ({placeholders}) OR victim_steam_id IN ({placeholders})) + AND event_type IN ('kill', 'death') -- focus on combat + GROUP BY match_id, round_num, player_id + ) sub + GROUP BY player_id + """ + # Note: 'death' isn't an event_type, it's 'kill'. + # We check if player is attacker or victim in 'kill' event. + + # Corrected Query: + q_first_contact = f""" + SELECT + player_id as steam_id_64, + AVG(first_time) as pace_avg_time_to_first_contact + FROM ( + SELECT + match_id, round_num, + p_id as player_id, + MIN(event_time) as first_time + FROM ( + SELECT match_id, round_num, event_time, attacker_steam_id as p_id FROM fact_round_events WHERE event_type='kill' + UNION ALL + SELECT match_id, round_num, event_time, victim_steam_id as p_id FROM fact_round_events WHERE event_type='kill' + ) raw + WHERE p_id IN ({placeholders}) + GROUP BY match_id, round_num, p_id + ) sub + GROUP BY player_id + """ + df_time = pd.read_sql_query(q_first_contact, conn, params=player_ids) + # Wait, params=player_ids won't work with f-string placeholders if I use ? inside. + # My placeholders variable is literal string "?,?,?". + # So params should be player_ids. + # But in UNION ALL, I have two WHERE clauses. + # Actually I can optimize: + # WHERE attacker_steam_id IN (...) OR victim_steam_id IN (...) + # Then unpivot in python or SQL. + + # Let's use Python for unpivoting to be safe and clear. + q_events = f""" + SELECT match_id, round_num, event_time, attacker_steam_id, victim_steam_id + FROM fact_round_events + WHERE event_type='kill' + AND (attacker_steam_id IN ({placeholders}) OR victim_steam_id IN ({placeholders})) + """ + # This params needs player_ids * 2 + df_ev = pd.read_sql_query(q_events, conn, params=list(player_ids) + list(player_ids)) + + pace_list = [] + if not df_ev.empty: + # Unpivot + att = df_ev[df_ev['attacker_steam_id'].isin(player_ids)][['match_id', 'round_num', 'event_time', 'attacker_steam_id']].rename(columns={'attacker_steam_id': 'steam_id_64'}) + vic = df_ev[df_ev['victim_steam_id'].isin(player_ids)][['match_id', 'round_num', 'event_time', 'victim_steam_id']].rename(columns={'victim_steam_id': 'steam_id_64'}) + combined = pd.concat([att, vic]) + + # Group by round, get min time + first_contacts = combined.groupby(['match_id', 'round_num', 'steam_id_64'])['event_time'].min().reset_index() + + # Average per player + avg_time = first_contacts.groupby('steam_id_64')['event_time'].mean().reset_index() + avg_time.rename(columns={'event_time': 'pace_avg_time_to_first_contact'}, inplace=True) + pace_list.append(avg_time) + + # 2. Trade Kill Rate + # "Kill a killer within 5s of teammate death" + # We need to reconstruct the flow. + # Iterate matches? Vectorized is hard. + # Let's try a simplified approach: + # For each match, sort events by time. + # If (Kill A->B) at T1, and (Kill C->A) at T2, and T2-T1 <= 5, and C & B are same team. + # We don't have team info in events easily (we have side logic elsewhere). + # Assuming Side logic: If A->B (A=CT, B=T). Then C->A (C=T). + # So B and C are T. + + # Let's fetch basic trade info using self-join in SQL? + # A kills B at T1. + # C kills A at T2. + # T2 > T1 and T2 - T1 <= 5. + # C is the Trader. B is the Victim (Teammate). + # We want C's Trade Rate. + + q_trades = f""" + SELECT + t2.attacker_steam_id as trader_id, + COUNT(*) as trade_count + FROM fact_round_events t1 + JOIN fact_round_events t2 + ON t1.match_id = t2.match_id + AND t1.round_num = t2.round_num + WHERE t1.event_type = 'kill' AND t2.event_type = 'kill' + AND t1.attacker_steam_id = t2.victim_steam_id -- Avenger kills the Killer + AND t2.event_time > t1.event_time + AND t2.event_time - t1.event_time <= 5 + AND t2.attacker_steam_id IN ({placeholders}) + GROUP BY t2.attacker_steam_id + """ + df_trades = pd.read_sql_query(q_trades, conn, params=player_ids) + + # Denominator: Opportunities? Or just Total Kills? + # Trade Kill Rate usually means % of Kills that were Trades. + # Let's use that. + + # Get Total Kills + q_kills = f""" + SELECT attacker_steam_id as steam_id_64, COUNT(*) as total_kills + FROM fact_round_events + WHERE event_type='kill' AND attacker_steam_id IN ({placeholders}) + GROUP BY attacker_steam_id + """ + df_tot_kills = pd.read_sql_query(q_kills, conn, params=player_ids) + + if not df_trades.empty: + df_trades = df_trades.merge(df_tot_kills, left_on='trader_id', right_on='steam_id_64', how='right').fillna(0) + df_trades['pace_trade_kill_rate'] = df_trades['trade_count'] / df_trades['total_kills'].replace(0, 1) + else: + df_trades = df_tot_kills.copy() + df_trades['pace_trade_kill_rate'] = 0 + + df_final = pd.DataFrame({'steam_id_64': list(player_ids)}) + + if pace_list: + df_final = df_final.merge(pace_list[0], on='steam_id_64', how='left') + + # Merge Trade Rate + if not df_trades.empty: + df_final = df_final.merge(df_trades[['steam_id_64', 'pace_trade_kill_rate']], on='steam_id_64', how='left') + + # 3. New Pace Metrics + # pace_opening_kill_time: Avg time of Opening Kills (where attacker_steam_id = player AND is_first_kill = 1?) + # Wait, fact_round_events doesn't store 'is_first_kill' directly? It stores 'first_kill' in fact_match_players but that's aggregate. + # It stores 'event_type'. We need to check if it was the FIRST kill of the round. + # Query: For each round, find the FIRST kill event. Check if attacker is our player. Get time. + + q_opening_time = f""" + SELECT + attacker_steam_id as steam_id_64, + AVG(event_time) as pace_opening_kill_time + FROM ( + SELECT + match_id, round_num, + attacker_steam_id, + MIN(event_time) as event_time + FROM fact_round_events + WHERE event_type='kill' + GROUP BY match_id, round_num + ) first_kills + WHERE attacker_steam_id IN ({placeholders}) + GROUP BY attacker_steam_id + """ + df_opening_time = pd.read_sql_query(q_opening_time, conn, params=player_ids) + + # pace_avg_life_time: Avg time alive per round + # Logic: Round Duration - Death Time (if died). Else Round Duration. + # We need Round Duration (fact_rounds doesn't have duration? fact_matches has match duration). + # Usually round duration is fixed or we use last event time. + # Let's approximate: If died, time = death_time. If survived, time = max_event_time_of_round. + # Better: survival time. + + q_survival = f""" + SELECT + p.steam_id_64, + AVG( + CASE + WHEN d.death_time IS NOT NULL THEN d.death_time + ELSE r.round_end_time -- Use max event time as proxy for round end + END + ) as pace_avg_life_time + FROM fact_match_players p + JOIN ( + SELECT match_id, round_num, MAX(event_time) as round_end_time + FROM fact_round_events + GROUP BY match_id, round_num + ) r ON p.match_id = r.match_id + LEFT JOIN ( + SELECT match_id, round_num, victim_steam_id, MIN(event_time) as death_time + FROM fact_round_events + WHERE event_type='kill' + GROUP BY match_id, round_num, victim_steam_id + ) d ON p.match_id = d.match_id AND p.steam_id_64 = d.victim_steam_id + -- We need to join rounds to ensure we track every round the player played? + -- fact_match_players is per match. We need per round. + -- We can use fact_round_player_economy to get all rounds a player played. + JOIN fact_round_player_economy e ON p.match_id = e.match_id AND p.steam_id_64 = e.steam_id_64 AND r.round_num = e.round_num + WHERE p.steam_id_64 IN ({placeholders}) + GROUP BY p.steam_id_64 + """ + # This join is heavy. Let's simplify. + # Just use death events for "Time of Death". + # And for rounds without death, use 115s (avg round length)? Or max event time? + # Let's stick to what we have. + + df_survival = pd.read_sql_query(q_survival, conn, params=player_ids) + + if not df_opening_time.empty: + df_final = df_final.merge(df_opening_time, on='steam_id_64', how='left') + + if not df_survival.empty: + df_final = df_final.merge(df_survival, on='steam_id_64', how='left') + + return df_final.fillna(0) + + + @staticmethod + def _calculate_ultimate_scores(df): + def n(col): + if col not in df.columns: return 50 + s = df[col] + if s.max() == s.min(): return 50 + return (s - s.min()) / (s.max() - s.min()) * 100 + + df = df.copy() + + # BAT (30%) + df['score_bat'] = ( + 0.25 * n('basic_avg_rating') + + 0.20 * n('basic_avg_kd') + + 0.15 * n('basic_avg_adr') + + 0.10 * n('bat_avg_duel_win_rate') + + 0.10 * n('bat_kd_diff_high_elo') + + 0.10 * n('basic_avg_kill_3') + ) + + # STA (15%) + df['score_sta'] = ( + 0.30 * (100 - n('sta_rating_volatility')) + + 0.30 * n('sta_loss_rating') + + 0.20 * n('sta_win_rating') + + 0.10 * (100 - abs(n('sta_time_rating_corr'))) + ) + + # HPS (20%) + df['score_hps'] = ( + 0.25 * n('sum_1v3p') + + 0.20 * n('hps_match_point_win_rate') + + 0.20 * n('hps_comeback_kd_diff') + + 0.15 * n('hps_pressure_entry_rate') + + 0.20 * n('basic_avg_rating') + ) + + # PTL (10%) + df['score_ptl'] = ( + 0.30 * n('ptl_pistol_kills') + + 0.30 * n('ptl_pistol_win_rate') + + 0.20 * n('ptl_pistol_kd') + + 0.20 * n('ptl_pistol_util_efficiency') + ) + + # T/CT (10%) + df['score_tct'] = ( + 0.35 * n('side_rating_ct') + + 0.35 * n('side_rating_t') + + 0.15 * n('side_first_kill_rate_ct') + + 0.15 * n('side_first_kill_rate_t') + ) + + # UTIL (10%) + # Emphasize prop frequency (usage_rate) + df['score_util'] = ( + 0.35 * n('util_usage_rate') + + 0.25 * n('util_avg_nade_dmg') + + 0.20 * n('util_avg_flash_time') + + 0.20 * n('util_avg_flash_enemy') + ) + + # ECO (New) + df['score_eco'] = ( + 0.50 * n('eco_avg_damage_per_1k') + + 0.50 * n('eco_rating_eco_rounds') + ) + + # PACE (New) + # Aggression Score: Faster first contact (lower time) -> higher score + df['score_pace'] = ( + 0.50 * (100 - n('pace_avg_time_to_first_contact')) + + 0.50 * n('pace_trade_kill_rate') + ) + + return df + + @staticmethod + def get_roster_features_distribution(target_steam_id): + """ + Calculates rank and distribution of the target player's L3 features (Scores) within the active roster. + """ + from web.services.web_service import WebService + import json + + # 1. Get Active Roster IDs + lineups = WebService.get_lineups() + active_roster_ids = [] + if lineups: + try: + raw_ids = json.loads(lineups[0]['player_ids_json']) + active_roster_ids = [str(uid) for uid in raw_ids] + except: + pass + + if not active_roster_ids: + return None + + # 2. Fetch L3 features for all roster members + placeholders = ','.join('?' for _ in active_roster_ids) + # Select all columns (simplified) or explicit list including raw metrics + sql = f"SELECT * FROM dm_player_features WHERE steam_id_64 IN ({placeholders})" + rows = query_db('l3', sql, active_roster_ids) + + if not rows: + return None + + stats_map = {row['steam_id_64']: dict(row) for row in rows} + target_steam_id = str(target_steam_id) + + # If target not in map (maybe no L3 data yet), default to 0 + if target_steam_id not in stats_map: + stats_map[target_steam_id] = {} # Empty dict, will fallback to 0 in loop + + # 3. Calculate Distribution + # Include Scores AND Raw Metrics used in Profile + metrics = [ + # Scores + 'score_bat', 'score_sta', 'score_hps', 'score_ptl', 'score_tct', 'score_util', 'score_eco', 'score_pace', + # Core + 'basic_avg_rating', 'basic_avg_kd', 'basic_avg_adr', 'basic_avg_kast', 'basic_avg_rws', + # Combat + 'basic_avg_headshot_kills', 'basic_headshot_rate', 'basic_avg_assisted_kill', 'basic_avg_awp_kill', 'basic_avg_jump_count', + # Obj + 'basic_avg_mvps', 'basic_avg_plants', 'basic_avg_defuses', 'basic_avg_flash_assists', + # Opening + 'basic_avg_first_kill', 'basic_avg_first_death', 'basic_first_kill_rate', 'basic_first_death_rate', + # Multi + 'basic_avg_kill_2', 'basic_avg_kill_3', 'basic_avg_kill_4', 'basic_avg_kill_5', + 'basic_avg_perfect_kill', 'basic_avg_revenge_kill', + # STA & BAT Details + 'sta_last_30_rating', 'sta_win_rating', 'sta_loss_rating', 'sta_rating_volatility', 'sta_time_rating_corr', + 'bat_kd_diff_high_elo', 'bat_avg_duel_win_rate', + # HPS & PTL Details + 'hps_clutch_win_rate_1v1', 'hps_clutch_win_rate_1v3_plus', 'hps_match_point_win_rate', 'hps_pressure_entry_rate', + 'hps_comeback_kd_diff', 'hps_losing_streak_kd_diff', + 'ptl_pistol_kills', 'ptl_pistol_win_rate', 'ptl_pistol_kd', 'ptl_pistol_util_efficiency', + # UTIL Details + 'util_usage_rate', 'util_avg_nade_dmg', 'util_avg_flash_time', 'util_avg_flash_enemy', + # ECO & PACE (New) + 'eco_avg_damage_per_1k', 'eco_rating_eco_rounds', 'eco_kd_ratio', 'eco_avg_rounds', + 'pace_avg_time_to_first_contact', 'pace_trade_kill_rate', 'pace_opening_kill_time', 'pace_avg_life_time', + # Party + 'party_1_win_rate', 'party_1_rating', 'party_1_adr', + 'party_2_win_rate', 'party_2_rating', 'party_2_adr', + 'party_3_win_rate', 'party_3_rating', 'party_3_adr', + 'party_4_win_rate', 'party_4_rating', 'party_4_adr', + 'party_5_win_rate', 'party_5_rating', 'party_5_adr', + # Rating Dist + 'rating_dist_carry_rate', 'rating_dist_normal_rate', 'rating_dist_sacrifice_rate', 'rating_dist_sleeping_rate', + # ELO + 'elo_lt1200_rating', 'elo_1200_1400_rating', 'elo_1400_1600_rating', 'elo_1600_1800_rating', 'elo_1800_2000_rating', 'elo_gt2000_rating' + ] + + result = {} + + for m in metrics: + # Handle missing columns gracefully + values = [] + for p in stats_map.values(): + val = p.get(m) + if val is None: val = 0 + values.append(float(val)) + + target_val = stats_map[target_steam_id].get(m) + if target_val is None: target_val = 0 + target_val = float(target_val) + + if not values: + result[m] = None + continue + + # For PACE (Time), lower is better usually, but rank logic assumes Higher is Better (reverse=True). + # If we want Rank #1 to be Lowest Time, we should sort normal. + # But standardized scores handle this. For raw metrics, let's keep consistent (Higher = Rank 1) + # unless we explicitly handle "Low is Good". + # For now, keep simple: Rank 1 = Highest Value. + # For Time: Rank 1 = Slowest. (User can interpret) + + values.sort(reverse=True) + + try: + rank = values.index(target_val) + 1 + except ValueError: + rank = len(values) + + result[m] = { + 'val': target_val, + 'rank': rank, + 'total': len(values), + 'min': min(values), + 'max': max(values), + 'avg': sum(values) / len(values) + } + + return result diff --git a/web/services/opponent_service.py b/web/services/opponent_service.py new file mode 100644 index 0000000..efae06b --- /dev/null +++ b/web/services/opponent_service.py @@ -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 diff --git a/web/services/stats_service.py b/web/services/stats_service.py new file mode 100644 index 0000000..118ab7b --- /dev/null +++ b/web/services/stats_service.py @@ -0,0 +1,1112 @@ +from web.database import query_db, execute_db +from flask import current_app, url_for +import os + +class StatsService: + @staticmethod + def resolve_avatar_url(steam_id, avatar_url): + """ + Resolves avatar URL with priority: + 1. Local File (web/static/avatars/{steam_id}.jpg/png) - User override + 2. DB Value (avatar_url) + """ + try: + # Check local file first (User Request: "directly associate if exists") + base = os.path.join(current_app.root_path, 'static', 'avatars') + for ext in ('.jpg', '.png', '.jpeg'): + fname = f"{steam_id}{ext}" + fpath = os.path.join(base, fname) + if os.path.exists(fpath): + return url_for('static', filename=f'avatars/{fname}') + + # Fallback to DB value if valid + if avatar_url and str(avatar_url).strip(): + return avatar_url + + return None + except Exception: + return avatar_url + @staticmethod + def get_team_stats_summary(): + """ + Calculates aggregate statistics for matches where at least 2 roster members played together. + Returns: + { + 'map_stats': [{'map_name', 'count', 'wins', 'win_rate'}], + 'elo_stats': [{'range', 'count', 'wins', 'win_rate'}], + 'duration_stats': [{'range', 'count', 'wins', 'win_rate'}], + 'round_stats': [{'type', 'count', 'wins', 'win_rate'}] + } + """ + # 1. Get Active Roster + from web.services.web_service import WebService + import json + + lineups = WebService.get_lineups() + active_roster_ids = [] + if lineups: + try: + raw_ids = json.loads(lineups[0]['player_ids_json']) + active_roster_ids = [str(uid) for uid in raw_ids] + except: + pass + + if not active_roster_ids: + return {} + + # 2. Find matches with >= 2 roster members + # We need match_id, map_name, scores, winner_team, duration, avg_elo + # And we need to determine if "Our Team" won. + + placeholders = ','.join('?' for _ in active_roster_ids) + + # Step A: Get Candidate Match IDs (matches with >= 2 roster players) + # Also get the team_id of our players in that match to determine win + candidate_sql = f""" + SELECT mp.match_id, MAX(mp.team_id) as our_team_id + FROM fact_match_players mp + WHERE CAST(mp.steam_id_64 AS TEXT) IN ({placeholders}) + GROUP BY mp.match_id + HAVING COUNT(DISTINCT mp.steam_id_64) >= 2 + """ + candidate_rows = query_db('l2', candidate_sql, active_roster_ids) + + if not candidate_rows: + return {} + + candidate_map = {row['match_id']: row['our_team_id'] for row in candidate_rows} + match_ids = list(candidate_map.keys()) + match_placeholders = ','.join('?' for _ in match_ids) + + # Step B: Get Match Details + match_sql = f""" + SELECT m.match_id, m.map_name, m.score_team1, m.score_team2, m.winner_team, m.duration, + AVG(fmt.group_origin_elo) as avg_elo + FROM fact_matches m + LEFT JOIN fact_match_teams fmt ON m.match_id = fmt.match_id AND fmt.group_origin_elo > 0 + WHERE m.match_id IN ({match_placeholders}) + GROUP BY m.match_id + """ + match_rows = query_db('l2', match_sql, match_ids) + + # 3. Process Data + # Buckets initialization + map_stats = {} + elo_ranges = ['<1000', '1000-1200', '1200-1400', '1400-1600', '1600-1800', '1800-2000', '2000+'] + elo_stats = {r: {'wins': 0, 'total': 0} for r in elo_ranges} + + dur_ranges = ['<30m', '30-45m', '45m+'] + dur_stats = {r: {'wins': 0, 'total': 0} for r in dur_ranges} + + round_types = ['Stomp (<15)', 'Normal', 'Close (>23)', 'Choke (24)'] + round_stats = {r: {'wins': 0, 'total': 0} for r in round_types} + + for m in match_rows: + mid = m['match_id'] + # Determine Win + # Use candidate_map to get our_team_id. + # Note: winner_team is usually int (1 or 2) or string. + # our_team_id from fact_match_players is usually int (1 or 2). + # This logic assumes simple team ID matching. + # If sophisticated "UID in Winning Group" logic is needed, we'd need more queries. + # For aggregate stats, let's assume team_id matching is sufficient for 99% cases or fallback to simple check. + # Actually, let's try to be consistent with get_matches logic if possible, + # but getting group_uids for ALL matches is heavy. + # Let's trust team_id for this summary. + + our_tid = candidate_map[mid] + winner_tid = m['winner_team'] + + # Type normalization + try: + is_win = (int(our_tid) == int(winner_tid)) if (our_tid and winner_tid) else False + except: + is_win = (str(our_tid) == str(winner_tid)) if (our_tid and winner_tid) else False + + # 1. Map Stats + map_name = m['map_name'] or 'Unknown' + if map_name not in map_stats: + map_stats[map_name] = {'wins': 0, 'total': 0} + map_stats[map_name]['total'] += 1 + if is_win: map_stats[map_name]['wins'] += 1 + + # 2. ELO Stats + elo = m['avg_elo'] + if elo: + if elo < 1000: e_key = '<1000' + elif elo < 1200: e_key = '1000-1200' + elif elo < 1400: e_key = '1200-1400' + elif elo < 1600: e_key = '1400-1600' + elif elo < 1800: e_key = '1600-1800' + elif elo < 2000: e_key = '1800-2000' + else: e_key = '2000+' + elo_stats[e_key]['total'] += 1 + if is_win: elo_stats[e_key]['wins'] += 1 + + # 3. Duration Stats + dur = m['duration'] # seconds + if dur: + dur_min = dur / 60 + if dur_min < 30: d_key = '<30m' + elif dur_min < 45: d_key = '30-45m' + else: d_key = '45m+' + dur_stats[d_key]['total'] += 1 + if is_win: dur_stats[d_key]['wins'] += 1 + + # 4. Round Stats + s1 = m['score_team1'] or 0 + s2 = m['score_team2'] or 0 + total_rounds = s1 + s2 + + if total_rounds == 24: + r_key = 'Choke (24)' + round_stats[r_key]['total'] += 1 + if is_win: round_stats[r_key]['wins'] += 1 + + # Note: Close (>23) overlaps with Choke (24). + # User requirement: Close > 23 counts ALL matches > 23, regardless of other categories. + if total_rounds > 23: + r_key = 'Close (>23)' + round_stats[r_key]['total'] += 1 + if is_win: round_stats[r_key]['wins'] += 1 + + if total_rounds < 15: + r_key = 'Stomp (<15)' + round_stats[r_key]['total'] += 1 + if is_win: round_stats[r_key]['wins'] += 1 + elif total_rounds <= 23: # Only Normal if NOT Stomp and NOT Close (<= 23 and >= 15) + r_key = 'Normal' + round_stats[r_key]['total'] += 1 + if is_win: round_stats[r_key]['wins'] += 1 + + # 4. Format Results + def fmt(stats_dict): + res = [] + for k, v in stats_dict.items(): + rate = (v['wins'] / v['total'] * 100) if v['total'] > 0 else 0 + res.append({'label': k, 'count': v['total'], 'wins': v['wins'], 'win_rate': rate}) + return res + + # For maps, sort by count + map_res = fmt(map_stats) + map_res.sort(key=lambda x: x['count'], reverse=True) + + return { + 'map_stats': map_res, + 'elo_stats': fmt(elo_stats), # Keep order + 'duration_stats': fmt(dur_stats), # Keep order + 'round_stats': fmt(round_stats) # Keep order + } + + @staticmethod + def get_recent_matches(limit=5): + sql = """ + SELECT m.match_id, m.start_time, m.map_name, m.score_team1, m.score_team2, m.winner_team, + p.username as mvp_name + FROM fact_matches m + LEFT JOIN dim_players p ON m.mvp_uid = p.uid + ORDER BY m.start_time DESC + LIMIT ? + """ + return query_db('l2', sql, [limit]) + + @staticmethod + def get_matches(page=1, per_page=20, map_name=None, date_from=None, date_to=None): + offset = (page - 1) * per_page + args = [] + where_clauses = ["1=1"] + + if map_name: + where_clauses.append("map_name = ?") + args.append(map_name) + + if date_from: + where_clauses.append("start_time >= ?") + args.append(date_from) + + if date_to: + where_clauses.append("start_time <= ?") + args.append(date_to) + + where_str = " AND ".join(where_clauses) + + sql = f""" + SELECT m.match_id, m.start_time, m.map_name, m.score_team1, m.score_team2, m.winner_team, m.duration + FROM fact_matches m + WHERE {where_str} + ORDER BY m.start_time DESC + LIMIT ? OFFSET ? + """ + args.extend([per_page, offset]) + + matches = query_db('l2', sql, args) + + # Enrich matches with Avg ELO, Party info, and Our Team Result + if matches: + match_ids = [m['match_id'] for m in matches] + placeholders = ','.join('?' for _ in match_ids) + + # Fetch ELO + elo_sql = f""" + SELECT match_id, AVG(group_origin_elo) as avg_elo + FROM fact_match_teams + WHERE match_id IN ({placeholders}) AND group_origin_elo > 0 + GROUP BY match_id + """ + elo_rows = query_db('l2', elo_sql, match_ids) + elo_map = {row['match_id']: row['avg_elo'] for row in elo_rows} + + # Fetch Max Party Size + party_sql = f""" + SELECT match_id, MAX(cnt) as max_party + FROM ( + SELECT match_id, match_team_id, COUNT(*) as cnt + FROM fact_match_players + WHERE match_id IN ({placeholders}) AND match_team_id > 0 + GROUP BY match_id, match_team_id + ) + GROUP BY match_id + """ + party_rows = query_db('l2', party_sql, match_ids) + party_map = {row['match_id']: row['max_party'] for row in party_rows} + + # --- New: Determine "Our Team" Result --- + # Logic: Check if any player from `active_roster` played in these matches. + # Use WebService to get the active roster + from web.services.web_service import WebService + import json + + lineups = WebService.get_lineups() + active_roster_ids = [] + if lineups: + try: + # Load IDs and ensure they are all strings for DB comparison consistency + raw_ids = json.loads(lineups[0]['player_ids_json']) + active_roster_ids = [str(uid) for uid in raw_ids] + except: + pass + + # If no roster, we can't determine "Our Result" + if not active_roster_ids: + result_map = {} + else: + # 1. Get UIDs for Roster Members involved in these matches + # We query fact_match_players to ensure we get the UIDs actually used in these matches + roster_placeholders = ','.join('?' for _ in active_roster_ids) + uid_sql = f""" + SELECT DISTINCT steam_id_64, uid + FROM fact_match_players + WHERE match_id IN ({placeholders}) + AND CAST(steam_id_64 AS TEXT) IN ({roster_placeholders}) + """ + combined_args_uid = match_ids + active_roster_ids + uid_rows = query_db('l2', uid_sql, combined_args_uid) + + # Set of "Our UIDs" (as strings) + our_uids = set() + for r in uid_rows: + if r['uid']: + our_uids.add(str(r['uid'])) + + # 2. Get Group UIDs and Winner info from fact_match_teams + # We need to know which group contains our UIDs + teams_sql = f""" + SELECT fmt.match_id, fmt.group_id, fmt.group_uids, m.winner_team + FROM fact_match_teams fmt + JOIN fact_matches m ON fmt.match_id = m.match_id + WHERE fmt.match_id IN ({placeholders}) + """ + teams_rows = query_db('l2', teams_sql, match_ids) + + # 3. Determine Result per Match + result_map = {} + + # Group data by match + match_groups = {} # match_id -> {group_id: [uids...], winner: int} + + for r in teams_rows: + mid = r['match_id'] + gid = r['group_id'] + uids_str = r['group_uids'] or "" + # Split and clean UIDs + uids = set(str(u).strip() for u in uids_str.split(',') if u.strip()) + + if mid not in match_groups: + match_groups[mid] = {'groups': {}, 'winner': r['winner_team']} + + match_groups[mid]['groups'][gid] = uids + + # Analyze + for mid, data in match_groups.items(): + winner_gid = data['winner'] + groups = data['groups'] + + our_in_winner = False + our_in_loser = False + + # Check each group + for gid, uids in groups.items(): + # Intersection of Our UIDs and Group UIDs + common = our_uids.intersection(uids) + if common: + if gid == winner_gid: + our_in_winner = True + else: + our_in_loser = True + + if our_in_winner and not our_in_loser: + result_map[mid] = 'win' + elif our_in_loser and not our_in_winner: + result_map[mid] = 'loss' + elif our_in_winner and our_in_loser: + result_map[mid] = 'mixed' + else: + # Fallback: If UID matching failed (maybe missing UIDs), try old team_id method? + # Or just leave it as None (safe) + pass + + # Convert to dict to modify + matches = [dict(m) for m in matches] + for m in matches: + m['avg_elo'] = elo_map.get(m['match_id'], 0) + m['max_party'] = party_map.get(m['match_id'], 1) + m['our_result'] = result_map.get(m['match_id']) + + # Convert to dict to modify + matches = [dict(m) for m in matches] + for m in matches: + m['avg_elo'] = elo_map.get(m['match_id'], 0) + m['max_party'] = party_map.get(m['match_id'], 1) + m['our_result'] = result_map.get(m['match_id']) + + # Count total for pagination + count_sql = f"SELECT COUNT(*) as cnt FROM fact_matches WHERE {where_str}" + total = query_db('l2', count_sql, args[:-2], one=True)['cnt'] + + return matches, total + + @staticmethod + def get_match_detail(match_id): + sql = "SELECT * FROM fact_matches WHERE match_id = ?" + return query_db('l2', sql, [match_id], one=True) + + @staticmethod + def get_match_players(match_id): + sql = """ + SELECT mp.*, p.username, p.avatar_url + FROM fact_match_players mp + LEFT JOIN dim_players p ON mp.steam_id_64 = p.steam_id_64 + WHERE mp.match_id = ? + ORDER BY mp.team_id, mp.rating DESC + """ + rows = query_db('l2', sql, [match_id]) + result = [] + for r in rows or []: + d = dict(r) + d['avatar_url'] = StatsService.resolve_avatar_url(d.get('steam_id_64'), d.get('avatar_url')) + result.append(d) + return result + + @staticmethod + def get_match_rounds(match_id): + sql = "SELECT * FROM fact_rounds WHERE match_id = ? ORDER BY round_num" + return query_db('l2', sql, [match_id]) + + @staticmethod + def get_players(page=1, per_page=20, search=None, sort_by='rating_desc'): + offset = (page - 1) * per_page + args = [] + where_clauses = ["1=1"] + + if search: + # Force case-insensitive search + where_clauses.append("(LOWER(username) LIKE LOWER(?) OR steam_id_64 LIKE ?)") + args.append(f"%{search}%") + args.append(f"%{search}%") + + where_str = " AND ".join(where_clauses) + + # Sort mapping + order_clause = "rating DESC" # Default logic (this query needs refinement as L2 dim_players doesn't store avg rating) + # Wait, dim_players only has static info. We need aggregated stats. + # Ideally, we should fetch from L3 for player list stats. + # But StatsService is for L2. + # For the Player List, we usually want L3 data (Career stats). + # I will leave the detailed stats logic for FeatureService or do a join here if necessary. + # For now, just listing players from dim_players. + + sql = f""" + SELECT * FROM dim_players + WHERE {where_str} + LIMIT ? OFFSET ? + """ + args.extend([per_page, offset]) + + rows = query_db('l2', sql, args) + players = [] + for r in rows or []: + d = dict(r) + d['avatar_url'] = StatsService.resolve_avatar_url(d.get('steam_id_64'), d.get('avatar_url')) + players.append(d) + total = query_db('l2', f"SELECT COUNT(*) as cnt FROM dim_players WHERE {where_str}", args[:-2], one=True)['cnt'] + + return players, total + + @staticmethod + def get_player_info(steam_id): + sql = "SELECT * FROM dim_players WHERE steam_id_64 = ?" + r = query_db('l2', sql, [steam_id], one=True) + if not r: + return None + d = dict(r) + d['avatar_url'] = StatsService.resolve_avatar_url(steam_id, d.get('avatar_url')) + return d + + @staticmethod + def get_daily_match_counts(days=365): + # Return list of {date: 'YYYY-MM-DD', count: N} + sql = """ + SELECT date(start_time, 'unixepoch') as day, COUNT(*) as count + FROM fact_matches + WHERE start_time > strftime('%s', 'now', ?) + GROUP BY day + ORDER BY day + """ + # sqlite modifier for 'now' needs format like '-365 days' + modifier = f'-{days} days' + rows = query_db('l2', sql, [modifier]) + return rows + + @staticmethod + def get_players_by_ids(steam_ids): + if not steam_ids: + return [] + placeholders = ','.join('?' for _ in steam_ids) + sql = f"SELECT * FROM dim_players WHERE steam_id_64 IN ({placeholders})" + rows = query_db('l2', sql, steam_ids) + result = [] + for r in rows or []: + d = dict(r) + d['avatar_url'] = StatsService.resolve_avatar_url(d.get('steam_id_64'), d.get('avatar_url')) + result.append(d) + return result + + @staticmethod + def get_player_basic_stats(steam_id): + # Calculate stats from fact_match_players + # Prefer calculating from sums (kills/deaths) for K/D accuracy + # AVG(adr) is used as damage_total might be missing in some sources + sql = """ + SELECT + AVG(rating) as rating, + SUM(kills) as total_kills, + SUM(deaths) as total_deaths, + AVG(kd_ratio) as avg_kd, + AVG(kast) as kast, + AVG(adr) as adr, + COUNT(*) as matches_played + FROM fact_match_players + WHERE steam_id_64 = ? + """ + row = query_db('l2', sql, [steam_id], one=True) + + if row and row['matches_played'] > 0: + res = dict(row) + + # Calculate K/D: Sum Kills / Sum Deaths + kills = res.get('total_kills') or 0 + deaths = res.get('total_deaths') or 0 + + if deaths > 0: + res['kd'] = kills / deaths + else: + res['kd'] = kills # If 0 deaths, K/D is kills (or infinity, but kills is safer for display) + + # Fallback to avg_kd if calculation failed (e.g. both 0) but avg_kd exists + if res['kd'] == 0 and res['avg_kd'] and res['avg_kd'] > 0: + res['kd'] = res['avg_kd'] + + # ADR validation + if res['adr'] is None: + res['adr'] = 0.0 + + return res + return None + + @staticmethod + def get_shared_matches(steam_ids): + # Find matches where ALL steam_ids were present + if not steam_ids or len(steam_ids) < 1: + return [] + + placeholders = ','.join('?' for _ in steam_ids) + count = len(steam_ids) + + # We need to know which team the players were on to determine win/loss + # Assuming they were on the SAME team for "shared experience" + # If count=1, it's just match history + + # Query: Get matches where all steam_ids are present + # Also join to get team_id to check if they were on the same team (optional but better) + # For simplicity in v1: Just check presence in the match. + # AND check if the player won. + + # We need to return: match_id, map_name, score, result (Win/Loss) + # "Result" is relative to the lineup. + # If they were on the winning team, it's a Win. + + sql = f""" + SELECT m.match_id, m.start_time, m.map_name, m.score_team1, m.score_team2, m.winner_team, + MAX(mp.team_id) as player_team_id -- Just take one team_id (assuming same) + FROM fact_matches m + JOIN fact_match_players mp ON m.match_id = mp.match_id + WHERE mp.steam_id_64 IN ({placeholders}) + GROUP BY m.match_id + HAVING COUNT(DISTINCT mp.steam_id_64) = ? + ORDER BY m.start_time DESC + """ + + args = list(steam_ids) + args.append(count) + + rows = query_db('l2', sql, args) + + results = [] + for r in rows: + # Determine if Win + # winner_team in DB is 'Team 1' or 'Team 2' usually, or the team name. + # fact_matches.winner_team stores the NAME of the winner? Or 'team1'/'team2'? + # Let's check how L2_Builder stores it. Usually it stores the name. + # But fact_match_players.team_id stores the name too. + + # Logic: If m.winner_team == mp.team_id, then Win. + is_win = (r['winner_team'] == r['player_team_id']) + + # If winner_team is NULL or empty, it's a draw? + if not r['winner_team']: + result_str = 'Draw' + elif is_win: + result_str = 'Win' + else: + result_str = 'Loss' + + res = dict(r) + res['is_win'] = is_win # Boolean for styling + res['result_str'] = result_str # Text for display + results.append(res) + + return results + + @staticmethod + def get_player_trend(steam_id, limit=20): + # We need party_size: count of players with same match_team_id in the same match + # Using a correlated subquery for party_size + sql = """ + SELECT * FROM ( + SELECT + m.start_time, + mp.rating, + mp.kd_ratio, + mp.adr, + m.match_id, + m.map_name, + mp.is_win, + mp.match_team_id, + (SELECT COUNT(*) + FROM fact_match_players p2 + WHERE p2.match_id = mp.match_id + AND p2.match_team_id = mp.match_team_id + AND p2.match_team_id > 0 -- Ensure we don't count 0 (solo default) as a massive party + ) as party_size, + ( + SELECT COUNT(*) + FROM fact_matches m2 + WHERE m2.start_time <= m.start_time + ) as match_index + FROM fact_match_players mp + JOIN fact_matches m ON mp.match_id = m.match_id + WHERE mp.steam_id_64 = ? + ORDER BY m.start_time DESC + LIMIT ? + ) ORDER BY start_time ASC + """ + return query_db('l2', sql, [steam_id, limit]) + + @staticmethod + def get_recent_performance_stats(steam_id): + """ + Calculates Avg Rating and Rating Variance for: + - Last 5, 10, 15 matches + - Last 5, 10, 15 days + """ + import numpy as np + from datetime import datetime, timedelta + + # Fetch all match ratings with timestamps + sql = """ + SELECT m.start_time, mp.rating + FROM fact_match_players mp + JOIN fact_matches m ON mp.match_id = m.match_id + WHERE mp.steam_id_64 = ? + ORDER BY m.start_time DESC + """ + rows = query_db('l2', sql, [steam_id]) + + if not rows: + return {} + + # Convert to list of dicts + matches = [{'time': r['start_time'], 'rating': r['rating'] or 0} for r in rows] + + stats = {} + + # 1. Recent N Matches + for n in [5, 10, 15]: + subset = matches[:n] + if not subset: + stats[f'last_{n}_matches'] = {'avg': 0, 'var': 0, 'count': 0} + continue + + ratings = [m['rating'] for m in subset] + stats[f'last_{n}_matches'] = { + 'avg': np.mean(ratings), + 'var': np.var(ratings), + 'count': len(ratings) + } + + # 2. Recent N Days + # Use server time or max match time? usually server time 'now' is fine if data is fresh. + # But if data is old, 'last 5 days' might be empty. + # User asked for "recent 5/10/15 days", implying calendar days from now. + import time + now = time.time() + + for d in [5, 10, 15]: + cutoff = now - (d * 24 * 3600) + subset = [m for m in matches if m['time'] >= cutoff] + + if not subset: + stats[f'last_{d}_days'] = {'avg': 0, 'var': 0, 'count': 0} + continue + + ratings = [m['rating'] for m in subset] + stats[f'last_{d}_days'] = { + 'avg': np.mean(ratings), + 'var': np.var(ratings), + 'count': len(ratings) + } + + return stats + + @staticmethod + def get_roster_stats_distribution(target_steam_id): + """ + Calculates rank and distribution of the target player within the active roster. + Now covers all L3 Basic Features for Detailed Panel. + """ + from web.services.web_service import WebService + from web.services.feature_service import FeatureService + import json + import numpy as np + + # 1. Get Active Roster IDs + lineups = WebService.get_lineups() + active_roster_ids = [] + if lineups: + try: + raw_ids = json.loads(lineups[0]['player_ids_json']) + active_roster_ids = [str(uid) for uid in raw_ids] + except: + pass + + if not active_roster_ids: + return None + + # 2. Fetch L3 features for all roster members + # We need to use FeatureService to get the full L3 set (including detailed stats) + # Assuming L3 data is up to date. + + placeholders = ','.join('?' for _ in active_roster_ids) + sql = f"SELECT * FROM dm_player_features WHERE steam_id_64 IN ({placeholders})" + rows = query_db('l3', sql, active_roster_ids) + + if not rows: + return None + + stats_map = {row['steam_id_64']: dict(row) for row in rows} + target_steam_id = str(target_steam_id) + + # If target not in map (e.g. no L3 data), try to add empty default + if target_steam_id not in stats_map: + stats_map[target_steam_id] = {} + + # --- New: Enrich with L2 Clutch/Multi Stats for Distribution --- + l2_placeholders = ','.join('?' for _ in active_roster_ids) + sql_l2 = f""" + SELECT + p.steam_id_64, + SUM(p.clutch_1v1) as c1, SUM(p.clutch_1v2) as c2, SUM(p.clutch_1v3) as c3, SUM(p.clutch_1v4) as c4, SUM(p.clutch_1v5) as c5, + SUM(a.attempt_1v1) as att1, SUM(a.attempt_1v2) as att2, SUM(a.attempt_1v3) as att3, SUM(a.attempt_1v4) as att4, SUM(a.attempt_1v5) as att5, + SUM(p.kill_2) as k2, SUM(p.kill_3) as k3, SUM(p.kill_4) as k4, SUM(p.kill_5) as k5, + SUM(p.many_assists_cnt2) as a2, SUM(p.many_assists_cnt3) as a3, SUM(p.many_assists_cnt4) as a4, SUM(p.many_assists_cnt5) as a5, + SUM(p.round_total) as total_rounds + FROM fact_match_players p + LEFT JOIN fact_match_clutch_attempts a ON p.match_id = a.match_id AND p.steam_id_64 = a.steam_id_64 + WHERE CAST(p.steam_id_64 AS TEXT) IN ({l2_placeholders}) + GROUP BY p.steam_id_64 + """ + l2_rows = query_db('l2', sql_l2, active_roster_ids) + + for r in l2_rows: + sid = str(r['steam_id_64']) + if sid not in stats_map: + stats_map[sid] = {} + + # Clutch Rates + for i in range(1, 6): + c = r[f'c{i}'] or 0 + att = r[f'att{i}'] or 0 + rate = (c / att) if att > 0 else 0 + stats_map[sid][f'clutch_rate_1v{i}'] = rate + + # Multi-Kill Rates + rounds = r['total_rounds'] or 1 # Avoid div by 0 + total_mk = 0 + for i in range(2, 6): + k = r[f'k{i}'] or 0 + total_mk += k + stats_map[sid][f'multikill_rate_{i}k'] = k / rounds + stats_map[sid]['total_multikill_rate'] = total_mk / rounds + + # Multi-Assist Rates + total_ma = 0 + for i in range(2, 6): + a = r[f'a{i}'] or 0 + total_ma += a + stats_map[sid][f'multiassist_rate_{i}a'] = a / rounds + stats_map[sid]['total_multiassist_rate'] = total_ma / rounds + + # 3. Calculate Distribution for ALL metrics + # Define metrics list (must match Detailed Panel keys) + metrics = [ + 'basic_avg_rating', 'basic_avg_kd', 'basic_avg_kast', 'basic_avg_rws', 'basic_avg_adr', + 'basic_avg_headshot_kills', 'basic_headshot_rate', 'basic_avg_assisted_kill', 'basic_avg_awp_kill', 'basic_avg_jump_count', + 'basic_avg_knife_kill', 'basic_avg_zeus_kill', 'basic_zeus_pick_rate', + 'basic_avg_mvps', 'basic_avg_plants', 'basic_avg_defuses', 'basic_avg_flash_assists', + 'basic_avg_first_kill', 'basic_avg_first_death', 'basic_first_kill_rate', 'basic_first_death_rate', + 'basic_avg_kill_2', 'basic_avg_kill_3', 'basic_avg_kill_4', 'basic_avg_kill_5', + 'basic_avg_perfect_kill', 'basic_avg_revenge_kill', + # L3 Advanced Dimensions + 'sta_last_30_rating', 'sta_win_rating', 'sta_loss_rating', 'sta_rating_volatility', 'sta_time_rating_corr', + 'bat_kd_diff_high_elo', 'bat_avg_duel_win_rate', 'bat_win_rate_vs_all', + 'hps_clutch_win_rate_1v1', 'hps_clutch_win_rate_1v3_plus', 'hps_match_point_win_rate', 'hps_pressure_entry_rate', 'hps_comeback_kd_diff', 'hps_losing_streak_kd_diff', + 'ptl_pistol_kills', 'ptl_pistol_win_rate', 'ptl_pistol_kd', 'ptl_pistol_util_efficiency', + 'side_rating_ct', 'side_rating_t', 'side_first_kill_rate_ct', 'side_first_kill_rate_t', 'side_kd_diff_ct_t', 'side_hold_success_rate_ct', 'side_entry_success_rate_t', + 'side_win_rate_ct', 'side_win_rate_t', 'side_kd_ct', 'side_kd_t', + 'side_kast_ct', 'side_kast_t', 'side_rws_ct', 'side_rws_t', + 'side_first_death_rate_ct', 'side_first_death_rate_t', + 'side_multikill_rate_ct', 'side_multikill_rate_t', + 'side_headshot_rate_ct', 'side_headshot_rate_t', + 'side_defuses_ct', 'side_plants_t', + 'util_avg_nade_dmg', 'util_avg_flash_time', 'util_avg_flash_enemy', 'util_usage_rate', + # New: ECO & PACE + 'eco_avg_damage_per_1k', 'eco_rating_eco_rounds', 'eco_kd_ratio', 'eco_avg_rounds', + 'pace_avg_time_to_first_contact', 'pace_trade_kill_rate', 'pace_opening_kill_time', 'pace_avg_life_time', + # New: ROUND (Round Dynamics) + 'rd_phase_kill_early_share', 'rd_phase_kill_mid_share', 'rd_phase_kill_late_share', + 'rd_phase_death_early_share', 'rd_phase_death_mid_share', 'rd_phase_death_late_share', + 'rd_phase_kill_early_share_t', 'rd_phase_kill_mid_share_t', 'rd_phase_kill_late_share_t', + 'rd_phase_kill_early_share_ct', 'rd_phase_kill_mid_share_ct', 'rd_phase_kill_late_share_ct', + 'rd_phase_death_early_share_t', 'rd_phase_death_mid_share_t', 'rd_phase_death_late_share_t', + 'rd_phase_death_early_share_ct', 'rd_phase_death_mid_share_ct', 'rd_phase_death_late_share_ct', + 'rd_firstdeath_team_first_death_win_rate', 'rd_invalid_death_rate', + 'rd_pressure_kpr_ratio', 'rd_matchpoint_kpr_ratio', 'rd_trade_response_10s_rate', + 'rd_pressure_perf_ratio', 'rd_matchpoint_perf_ratio', + 'rd_comeback_kill_share', 'map_stability_coef', + # New: Party Size Stats + 'party_1_win_rate', 'party_1_rating', 'party_1_adr', + 'party_2_win_rate', 'party_2_rating', 'party_2_adr', + 'party_3_win_rate', 'party_3_rating', 'party_3_adr', + 'party_4_win_rate', 'party_4_rating', 'party_4_adr', + 'party_5_win_rate', 'party_5_rating', 'party_5_adr', + # New: Rating Distribution + 'rating_dist_carry_rate', 'rating_dist_normal_rate', 'rating_dist_sacrifice_rate', 'rating_dist_sleeping_rate', + # New: ELO Stratification + 'elo_lt1200_rating', 'elo_1200_1400_rating', 'elo_1400_1600_rating', 'elo_1600_1800_rating', 'elo_1800_2000_rating', 'elo_gt2000_rating', + # New: Clutch & Multi (Real Calculation) + 'clutch_rate_1v1', 'clutch_rate_1v2', 'clutch_rate_1v3', 'clutch_rate_1v4', 'clutch_rate_1v5', + 'multikill_rate_2k', 'multikill_rate_3k', 'multikill_rate_4k', 'multikill_rate_5k', + 'multiassist_rate_2a', 'multiassist_rate_3a', 'multiassist_rate_4a', 'multiassist_rate_5a', + 'total_multikill_rate', 'total_multiassist_rate' + ] + + # Mapping for L2 legacy calls (if any) - mainly map 'rating' to 'basic_avg_rating' etc if needed + # But here we just use L3 columns directly. + + # Define metrics where LOWER is BETTER + lower_is_better = ['pace_avg_time_to_first_contact', 'pace_opening_kill_time', 'rd_invalid_death_rate', 'map_stability_coef'] + + result = {} + + for m in metrics: + values = [p.get(m, 0) or 0 for p in stats_map.values()] + target_val = stats_map[target_steam_id].get(m, 0) or 0 + + if not values: + result[m] = None + continue + + # Sort: Reverse (High to Low) by default, unless in lower_is_better + is_reverse = m not in lower_is_better + values.sort(reverse=is_reverse) + + # Rank + try: + rank = values.index(target_val) + 1 + except ValueError: + rank = len(values) + + result[m] = { + 'val': target_val, + 'rank': rank, + 'total': len(values), + 'min': min(values), + 'max': max(values), + 'avg': sum(values) / len(values), + 'inverted': not is_reverse # Flag for frontend to invert bar + } + + # Legacy mapping for top cards (rating, kd, adr, kast) + legacy_map = { + 'basic_avg_rating': 'rating', + 'basic_avg_kd': 'kd', + 'basic_avg_adr': 'adr', + 'basic_avg_kast': 'kast' + } + if m in legacy_map: + result[legacy_map[m]] = result[m] + + def build_roundtype_metric_distribution(metric_key, round_type, subkey): + values2 = [] + for sid, p in stats_map.items(): + raw = p.get('rd_roundtype_split_json') or '' + if not raw: + continue + try: + obj = json.loads(raw) if isinstance(raw, str) else raw + except: + continue + if not isinstance(obj, dict): + continue + bucket = obj.get(round_type) + if not isinstance(bucket, dict): + continue + v = bucket.get(subkey) + if v is None: + continue + try: + v = float(v) + except: + continue + values2.append(v) + raw_target = stats_map.get(target_steam_id, {}).get('rd_roundtype_split_json') or '' + target_val2 = None + if raw_target: + try: + obj_t = json.loads(raw_target) if isinstance(raw_target, str) else raw_target + if isinstance(obj_t, dict) and isinstance(obj_t.get(round_type), dict): + tv = obj_t[round_type].get(subkey) + if tv is not None: + target_val2 = float(tv) + except: + target_val2 = None + if not values2 or target_val2 is None: + return None + values2.sort(reverse=True) + try: + rank2 = values2.index(target_val2) + 1 + except ValueError: + rank2 = len(values2) + return { + 'val': target_val2, + 'rank': rank2, + 'total': len(values2), + 'min': min(values2), + 'max': max(values2), + 'avg': sum(values2) / len(values2), + 'inverted': False + } + + rt_kpr_types = ['pistol', 'reg', 'overtime'] + rt_perf_types = ['eco', 'rifle', 'fullbuy', 'overtime'] + for t in rt_kpr_types: + result[f'rd_rt_kpr_{t}'] = build_roundtype_metric_distribution('rd_roundtype_split_json', t, 'kpr') + for t in rt_perf_types: + result[f'rd_rt_perf_{t}'] = build_roundtype_metric_distribution('rd_roundtype_split_json', t, 'perf') + + top_weapon_rank_map = {} + try: + raw_tw = stats_map.get(target_steam_id, {}).get('rd_weapon_top_json') or '[]' + tw_items = json.loads(raw_tw) if isinstance(raw_tw, str) else raw_tw + weapons = [] + if isinstance(tw_items, list): + for it in tw_items: + if isinstance(it, dict) and it.get('weapon'): + weapons.append(str(it.get('weapon'))) + weapons = weapons[:5] + except Exception: + weapons = [] + + if weapons: + w_placeholders = ','.join('?' for _ in weapons) + sql_w = f""" + SELECT attacker_steam_id as steam_id_64, + weapon, + COUNT(*) as kills, + SUM(is_headshot) as hs + FROM fact_round_events + WHERE event_type='kill' + AND attacker_steam_id IN ({l2_placeholders}) + AND weapon IN ({w_placeholders}) + GROUP BY attacker_steam_id, weapon + """ + weapon_rows = query_db('l2', sql_w, active_roster_ids + weapons) + per_weapon = {} + for r in weapon_rows: + sid = str(r['steam_id_64']) + w = str(r['weapon'] or '') + if not w: + continue + kills = int(r['kills'] or 0) + hs = int(r['hs'] or 0) + mp = stats_map.get(sid, {}).get('total_matches') or 0 + try: + mp = float(mp) + except Exception: + mp = 0 + kpm = (kills / mp) if (kills > 0 and mp > 0) else None + hs_rate = (hs / kills) if kills > 0 else None + per_weapon.setdefault(w, {})[sid] = {"kpm": kpm, "hs_rate": hs_rate} + + for w in weapons: + d = per_weapon.get(w) or {} + target_d = d.get(target_steam_id) or {} + target_kpm = target_d.get("kpm") + target_hs = target_d.get("hs_rate") + + kpm_vals = [v.get("kpm") for v in d.values() if v.get("kpm") is not None] + hs_vals = [v.get("hs_rate") for v in d.values() if v.get("hs_rate") is not None] + + kpm_rank = None + hs_rank = None + if kpm_vals and target_kpm is not None: + kpm_vals.sort(reverse=True) + try: + kpm_rank = kpm_vals.index(target_kpm) + 1 + except ValueError: + kpm_rank = len(kpm_vals) + if hs_vals and target_hs is not None: + hs_vals.sort(reverse=True) + try: + hs_rank = hs_vals.index(target_hs) + 1 + except ValueError: + hs_rank = len(hs_vals) + + top_weapon_rank_map[w] = { + "kpm_rank": kpm_rank, + "kpm_total": len(kpm_vals), + "hs_rank": hs_rank, + "hs_total": len(hs_vals), + } + + result['top_weapon_rank_map'] = top_weapon_rank_map + + return result + + @staticmethod + def get_live_matches(): + # Query matches started in last 2 hours with no winner + # Assuming we have a way to ingest live matches. + # For now, this query is 'formal' but will likely return empty on static dataset. + sql = """ + SELECT m.match_id, m.map_name, m.score_team1, m.score_team2, m.start_time + FROM fact_matches m + WHERE m.winner_team IS NULL + AND m.start_time > strftime('%s', 'now', '-2 hours') + """ + return query_db('l2', sql) + + @staticmethod + def get_head_to_head_stats(match_id): + """ + Returns a matrix of kills between players. + List of {attacker_steam_id, victim_steam_id, kills} + """ + sql = """ + SELECT attacker_steam_id, victim_steam_id, COUNT(*) as kills + FROM fact_round_events + WHERE match_id = ? AND event_type = 'kill' + GROUP BY attacker_steam_id, victim_steam_id + """ + return query_db('l2', sql, [match_id]) + + @staticmethod + def get_match_round_details(match_id): + """ + Returns a detailed dictionary of rounds, events, and economy. + { + round_num: { + info: {winner_side, win_reason_desc, end_time_stamp...}, + events: [ {event_type, event_time, attacker..., weapon...}, ... ], + economy: { steam_id: {main_weapon, equipment_value...}, ... } + } + } + """ + # 1. Base Round Info + rounds_sql = "SELECT * FROM fact_rounds WHERE match_id = ? ORDER BY round_num" + rounds_rows = query_db('l2', rounds_sql, [match_id]) + + if not rounds_rows: + return {} + + # 2. Events + events_sql = """ + SELECT * FROM fact_round_events + WHERE match_id = ? + ORDER BY round_num, event_time + """ + events_rows = query_db('l2', events_sql, [match_id]) + + # 3. Economy (if avail) + eco_sql = """ + SELECT * FROM fact_round_player_economy + WHERE match_id = ? + """ + eco_rows = query_db('l2', eco_sql, [match_id]) + + # Structure Data + result = {} + + # Initialize rounds + for r in rounds_rows: + r_num = r['round_num'] + result[r_num] = { + 'info': dict(r), + 'events': [], + 'economy': {} + } + + # Group events + for e in events_rows: + r_num = e['round_num'] + if r_num in result: + result[r_num]['events'].append(dict(e)) + + # Group economy + for eco in eco_rows: + r_num = eco['round_num'] + sid = eco['steam_id_64'] + if r_num in result: + result[r_num]['economy'][sid] = dict(eco) + + return result diff --git a/web/services/weapon_service.py b/web/services/weapon_service.py new file mode 100644 index 0000000..7b239dc --- /dev/null +++ b/web/services/weapon_service.py @@ -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 + diff --git a/web/services/web_service.py b/web/services/web_service.py new file mode 100644 index 0000000..590f456 --- /dev/null +++ b/web/services/web_service.py @@ -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]) diff --git a/web/static/avatars/76561198330488905.jpg b/web/static/avatars/76561198330488905.jpg new file mode 100644 index 0000000..57edbd7 Binary files /dev/null and b/web/static/avatars/76561198330488905.jpg differ diff --git a/web/static/avatars/76561198970034329.jpg b/web/static/avatars/76561198970034329.jpg new file mode 100644 index 0000000..5650a6b Binary files /dev/null and b/web/static/avatars/76561198970034329.jpg differ diff --git a/web/static/avatars/76561199026688017.jpg b/web/static/avatars/76561199026688017.jpg new file mode 100644 index 0000000..9ae2e6c Binary files /dev/null and b/web/static/avatars/76561199026688017.jpg differ diff --git a/web/static/avatars/76561199032002725.jpg b/web/static/avatars/76561199032002725.jpg new file mode 100644 index 0000000..e0a4962 Binary files /dev/null and b/web/static/avatars/76561199032002725.jpg differ diff --git a/web/static/avatars/76561199076109761.jpg b/web/static/avatars/76561199076109761.jpg new file mode 100644 index 0000000..df93307 Binary files /dev/null and b/web/static/avatars/76561199076109761.jpg differ diff --git a/web/static/avatars/76561199078250590.jpg b/web/static/avatars/76561199078250590.jpg new file mode 100644 index 0000000..d56108b Binary files /dev/null and b/web/static/avatars/76561199078250590.jpg differ diff --git a/web/static/avatars/76561199106558767.jpg b/web/static/avatars/76561199106558767.jpg new file mode 100644 index 0000000..3e24b9f Binary files /dev/null and b/web/static/avatars/76561199106558767.jpg differ diff --git a/web/static/avatars/76561199390145159.jpg b/web/static/avatars/76561199390145159.jpg new file mode 100644 index 0000000..2d0b7a6 Binary files /dev/null and b/web/static/avatars/76561199390145159.jpg differ diff --git a/web/static/avatars/76561199417030350.jpg b/web/static/avatars/76561199417030350.jpg new file mode 100644 index 0000000..7340256 Binary files /dev/null and b/web/static/avatars/76561199417030350.jpg differ diff --git a/web/static/avatars/76561199467422873.jpg b/web/static/avatars/76561199467422873.jpg new file mode 100644 index 0000000..b4f8c68 Binary files /dev/null and b/web/static/avatars/76561199467422873.jpg differ diff --git a/web/static/avatars/76561199526984477.jpg b/web/static/avatars/76561199526984477.jpg new file mode 100644 index 0000000..2d0b7a6 Binary files /dev/null and b/web/static/avatars/76561199526984477.jpg differ diff --git a/web/templates/admin/dashboard.html b/web/templates/admin/dashboard.html new file mode 100644 index 0000000..0959fba --- /dev/null +++ b/web/templates/admin/dashboard.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

管理后台 (Admin Dashboard)

+ Logout +
+ +
+ +
+

数据管线 (ETL)

+
+ + + +
+
+
+ + +
+

工具箱

+ +
+
+
+ + +{% endblock %} diff --git a/web/templates/admin/login.html b/web/templates/admin/login.html new file mode 100644 index 0000000..0a9a6dc --- /dev/null +++ b/web/templates/admin/login.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+

+ Admin Login +

+
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + +
+
+
+ + +
+
+ +
+ +
+
+
+
+{% endblock %} diff --git a/web/templates/admin/sql.html b/web/templates/admin/sql.html new file mode 100644 index 0000000..eeb0a9e --- /dev/null +++ b/web/templates/admin/sql.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} + +{% block content %} +
+

SQL Runner

+ +
+
+ + +
+
+ + +
+ +
+ + {% if error %} +
+ {{ error }} +
+ {% endif %} + + {% if result %} +
+ + + + {% for col in result.columns %} + + {% endfor %} + + + + {% for row in result.rows %} + + {% for col in result.columns %} + + {% endfor %} + + {% endfor %} + +
{{ col }}
{{ row[col] }}
+
+ {% endif %} +
+{% endblock %} diff --git a/web/templates/base.html b/web/templates/base.html new file mode 100644 index 0000000..fdbc9df --- /dev/null +++ b/web/templates/base.html @@ -0,0 +1,160 @@ + + + + + + {% block title %}YRTV - CS2 Data Platform{% endblock %} + + + + + + + + {% block head %}{% endblock %} + + + + + + + +
+ {% block content %}{% endblock %} +
+ + +
+
+

© 2026 YRTV Data Platform. All rights reserved. 赣ICP备2026001600号

+
+
+ + {% block scripts %}{% endblock %} + + + + diff --git a/web/templates/home/index.html b/web/templates/home/index.html new file mode 100644 index 0000000..fa7a2f2 --- /dev/null +++ b/web/templates/home/index.html @@ -0,0 +1,195 @@ +{% extends "base.html" %} + +{% block content %} +
+ +
+
+

+ JKTV CS2 队伍数据洞察平台 +

+

+ 深度挖掘比赛数据,提供战术研判、阵容模拟与多维能力分析。 +

+ + + +
+
+ + + +
+

+
+
+
+ + +
+ +
+

活跃度 (Activity)

+
+
+ +
+
+
+ Less + + + + + + More +
+
+ + +
+
+

正在进行 (Live)

+ + Online + +
+
+ {% if live_matches %} +
    + {% for m in live_matches %} +
  • + {{ m.map_name }}: {{ m.score_team1 }} - {{ m.score_team2 }} +
  • + {% endfor %} +
+ {% else %} +

暂无正在进行的比赛

+ {% endif %} +
+
+ + +
+

近期战况

+
+
    + {% for match in recent_matches %} +
  • +
    +
    +

    + {{ match.map_name }} +

    +

    + {{ match.start_time | default('Unknown Date') }} +

    +
    +
    + {{ match.score_team1 }} : {{ match.score_team2 }} +
    +
    + 详情 +
    +
    +
  • + {% else %} +
  • 暂无比赛数据
  • + {% endfor %} +
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/web/templates/matches/detail.html b/web/templates/matches/detail.html new file mode 100644 index 0000000..cb36129 --- /dev/null +++ b/web/templates/matches/detail.html @@ -0,0 +1,395 @@ +{% extends "base.html" %} + +{% block content %} +
+ +
+
+
+

{{ match.map_name }}

+

Match ID: {{ match.match_id }} | {{ match.start_time }}

+
+
+
+ {{ match.score_team1 }} + : + {{ match.score_team2 }} +
+
+ +
+ + +
+ +
+
+ + +
+ +
+
+

Team 1

+
+
+ + + + + + + + + + + + + + + {% for p in team1_players %} + + + + + + + + + + + {% endfor %} + +
PlayerKDA+/-ADRKASTRating
+
+
+ {% if p.avatar_url %} + + {% else %} +
+ {{ (p.username or p.steam_id_64)[:2] | upper }} +
+ {% endif %} +
+
+
+ + {{ p.username or p.steam_id_64 }} + + {% 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 %} + + + + + {{ p.party_size }} + + {% endif %} +
+
+
+
{{ p.kills }}{{ p.deaths }}{{ p.assists }} + {{ p.kills - p.deaths }} + {{ "%.1f"|format(p.adr or 0) }}{{ "%.1f"|format(p.kast or 0) }}%{{ "%.2f"|format(p.rating or 0) }}
+
+
+ + +
+
+

Team 2

+
+
+ + + + + + + + + + + + + + + {% for p in team2_players %} + + + + + + + + + + + {% endfor %} + +
PlayerKDA+/-ADRKASTRating
+
+
+ {% if p.avatar_url %} + + {% else %} +
+ {{ (p.username or p.steam_id_64)[:2] | upper }} +
+ {% endif %} +
+
+
+ + {{ p.username or p.steam_id_64 }} + + {% 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 %} + + + + + {{ p.party_size }} + + {% endif %} +
+
+
+
{{ p.kills }}{{ p.deaths }}{{ p.assists }} + {{ p.kills - p.deaths }} + {{ "%.1f"|format(p.adr or 0) }}{{ "%.1f"|format(p.kast or 0) }}%{{ "%.2f"|format(p.rating or 0) }}
+
+
+
+ + + + + + +
+ + + + +{% endblock %} diff --git a/web/templates/matches/list.html b/web/templates/matches/list.html new file mode 100644 index 0000000..348f41a --- /dev/null +++ b/web/templates/matches/list.html @@ -0,0 +1,214 @@ +{% extends "base.html" %} + +{% block content %} + +{% if summary_stats %} +
+ +
+

+ 🗺️ + 地图表现 (Party ≥ 2) +

+
+ + + + + + + + + + {% for stat in summary_stats.map_stats[:6] %} + + + + + + {% endfor %} + +
MapMatchesWin Rate
{{ stat.label }}{{ stat.count }} +
+ + {{ "%.1f"|format(stat.win_rate) }}% + +
+
+
+
+
+
+
+ + +
+

+ 📊 + 环境胜率分析 +

+ +
+ +
+

ELO 层级表现

+
+ {% for stat in summary_stats.elo_stats %} +
+
{{ stat.label }}
+
{{ "%.0f"|format(stat.win_rate) }}%
+
({{ stat.count }})
+
+ {% endfor %} +
+
+ + +
+

时长表现

+
+ {% for stat in summary_stats.duration_stats %} +
+
{{ stat.label }}
+
{{ "%.0f"|format(stat.win_rate) }}%
+
({{ stat.count }})
+
+ {% endfor %} +
+
+ + +
+

局势表现 (总回合数)

+
+ {% for stat in summary_stats.round_stats %} +
+
{{ stat.label }}
+
{{ "%.0f"|format(stat.win_rate) }}%
+
({{ stat.count }})
+
+ {% endfor %} +
+
+
+
+
+{% endif %} + +
+
+

比赛列表

+ +
+ +
+
+ +
+ + + + + + + + + + + + + + {% for match in matches %} + + + + + + + + + + {% endfor %} + +
时间地图比分ELOParty时长操作
+ + + {{ match.map_name }} + +
+ + {{ match.score_team1 }} + {% if match.winner_team == 1 %} + + {% endif %} + + - + + {{ match.score_team2 }} + {% if match.winner_team == 2 %} + + {% endif %} + + + + {% if match.our_result %} + {% if match.our_result == 'win' %} + + VICTORY + + {% elif match.our_result == 'loss' %} + + DEFEAT + + {% elif match.our_result == 'mixed' %} + + CIVIL WAR + + {% endif %} + {% endif %} +
+
+ {% if match.avg_elo and match.avg_elo > 0 %} + {{ "%.0f"|format(match.avg_elo) }} + {% else %} + - + {% endif %} + + {% 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 %} + + + 👥 {{ match.max_party }} + + {% else %} + Solo + {% endif %} + + {{ (match.duration / 60) | int }} min + + 详情 +
+
+ + +
+
+ Total {{ total }} matches +
+
+ {% if page > 1 %} + Prev + {% endif %} + {% if page < total_pages %} + Next + {% endif %} +
+
+
+{% endblock %} diff --git a/web/templates/opponents/detail.html b/web/templates/opponents/detail.html new file mode 100644 index 0000000..462fa09 --- /dev/null +++ b/web/templates/opponents/detail.html @@ -0,0 +1,251 @@ +{% extends "base.html" %} + +{% block content %} +
+ +
+
+ +
+ {% if player.avatar_url %} + + {% else %} +
+ {{ player.username[:2]|upper if player.username else '??' }} +
+ {% endif %} +
+ +
+
+

{{ player.username }}

+ + OPPONENT + +
+

{{ player.steam_id_64 }}

+ + +
+
+
Matches vs Us
+
{{ history|length }}
+
+ + {% set wins = history | selectattr('is_win') | list | length %} + {% set wr = (wins / history|length * 100) if history else 0 %} +
+
Their Win Rate
+
+ {{ "%.1f"|format(wr) }}% +
+
+ + {% set avg_rating = history | map(attribute='rating') | sum / history|length if history else 0 %} +
+
Their Avg Rating
+
{{ "%.2f"|format(avg_rating) }}
+
+ + {% set avg_kd_diff = history | map(attribute='kd_diff') | sum / history|length if history else 0 %} +
+
Avg K/D Diff
+
+ {{ "%+.2f"|format(avg_kd_diff) }} +
+
+
+
+
+
+ + +
+ +
+

+ 📈 Performance vs ELO Segments +

+
+ +
+
+ + +
+

+ 🛡️ Side Preference (vs Us) +

+ + {% macro side_row(label, t_val, ct_val, format_str='{:.2f}') %} +
+
+ {{ label }} +
+
+ {{ (format_str.format(t_val) if t_val is not none else '—') }} + vs + {{ (format_str.format(ct_val) if ct_val is not none else '—') }} +
+
+ {% 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 %} +
+
+ {% else %} +
+
+ {% endif %} +
+
+ T-Side + CT-Side +
+
+ {% 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')) }} + +
+
Rounds Sampled
+
+ {{ (side_stats.get('rounds_t', 0) or 0) + (side_stats.get('rounds_ct', 0) or 0) }} +
+
+
+
+ + +
+
+

Match History (Head-to-Head)

+
+
+ + + + + + + + + + + + + + + {% for m in history %} + + + + + + + + + + + {% endfor %} + +
Date / MapTheir ResultMatch EloTheir RatingTheir K/DK/D Diff (vs Team)K / D
+
{{ m.map_name }}
+
+ +
+
+ + {{ 'WON' if m.is_win else 'LOST' }} + + + {{ "%.0f"|format(m.elo or 0) }} + + {{ "%.2f"|format(m.rating or 0) }} + + {{ "%.2f"|format(m.kd_ratio or 0) }} + + {% set diff = m.kd_diff %} + + {{ "%+.2f"|format(diff) }} + +
vs Team Avg {{ "%.2f"|format(m.other_team_kd or 0) }}
+
+ {{ m.kills }} / {{ m.deaths }} + + + + +
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/web/templates/opponents/index.html b/web/templates/opponents/index.html new file mode 100644 index 0000000..b99415c --- /dev/null +++ b/web/templates/opponents/index.html @@ -0,0 +1,329 @@ +{% extends "base.html" %} + +{% block content %} +
+ +
+ +
+

Opponent ELO Curve

+
+ +
+
+ + +
+

Opponent Rating Curve

+
+ +
+
+
+ + +
+
+

分地图对手统计

+

各地图下遇到对手的胜率、ELO、Rating、K/D

+
+
+ + + + + + + + + + + + + {% for m in map_stats %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
MapMatchesWin RateAvg RatingAvg K/DAvg Elo
{{ m.map_name }} + + {{ m.matches }} + + + {% set wr = (m.win_rate or 0) * 100 %} + + {{ "%.1f"|format(wr) }}% + + + {{ "%.2f"|format(m.avg_rating or 0) }} + + {{ "%.2f"|format(m.avg_kd or 0) }} + + {% if m.avg_elo %}{{ "%.0f"|format(m.avg_elo) }}{% else %}—{% endif %} +
暂无地图统计数据
+
+
+ + +
+
+

分地图炸鱼哥遭遇次数

+

统计各地图出现 rating > 1.5 对手的比赛次数

+
+
+ + + + + + + + + + {% for m in map_stats %} + + + + + + {% else %} + + + + {% endfor %} + +
MapEncountersFrequency
{{ m.map_name }} + + {{ m.shark_matches or 0 }} + + + {% set freq = ( (m.shark_matches or 0) / (m.matches or 1) ) * 100 %} + + {{ "%.1f"|format(freq) }}% + +
暂无炸鱼哥统计数据
+
+
+ +
+
+
+

+ ⚔️ 对手分析 (Opponent Analysis) +

+

+ Analyze performance against specific players encountered in matches. +

+
+ +
+ +
+ +
+ +
+
+ +
+ + + +
+
+
+ +
+ + + + + + + + + + + + + + {% for op in opponents %} + + + + + + + + + + {% else %} + + + + {% endfor %} + +
OpponentMatches vs UsTheir Win RateTheir RatingTheir K/DAvg Match EloView
+
+
+ {% if op.avatar_url %} + + {% else %} +
+ {{ op.username[:2]|upper if op.username else '??' }} +
+ {% endif %} +
+
+
{{ op.username }}
+
{{ op.steam_id_64 }}
+
+
+
+ + {{ op.matches }} + + + {% set wr = op.win_rate * 100 %} + + {{ "%.1f"|format(wr) }}% + + + {{ "%.2f"|format(op.avg_rating or 0) }} + + {{ "%.2f"|format(op.avg_kd or 0) }} + + {% if op.avg_match_elo %} + {{ "%.0f"|format(op.avg_match_elo) }} + {% else %}—{% endif %} + + Analyze → +
+ No opponents found. +
+
+ + +
+
+ Total {{ total }} opponents found +
+
+ {% if page > 1 %} + Previous + {% endif %} + {% if page < total_pages %} + Next + {% endif %} +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/web/templates/players/list.html b/web/templates/players/list.html new file mode 100644 index 0000000..eb663ba --- /dev/null +++ b/web/templates/players/list.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

玩家列表

+
+ +
+ +
+ +
+ + + +
+
+
+ +
+ {% for player in players %} +
+ + {% if player.avatar_url %} + {{ player.username }} + {% else %} +
+ {{ player.username[:2] | upper if player.username else '??' }} +
+ {% endif %} +

{{ player.username }}

+

{{ player.steam_id_64 }}

+ + +
+
+ {{ "%.2f"|format(player.basic_avg_rating|default(0)) }} + Rating +
+
+ {{ "%.2f"|format(player.basic_avg_kd|default(0)) }} + K/D +
+
+ {{ "%.1f"|format((player.basic_avg_kast|default(0)) * 100) }}% + KAST +
+
+ + + View Profile + +
+ {% endfor %} +
+ + +
+
+ Total {{ total }} players +
+
+ {% if page > 1 %} + Prev + {% endif %} + {% if page < total_pages %} + Next + {% endif %} +
+
+
+{% endblock %} diff --git a/web/templates/players/profile.html b/web/templates/players/profile.html new file mode 100644 index 0000000..d3bc230 --- /dev/null +++ b/web/templates/players/profile.html @@ -0,0 +1,1276 @@ +{% extends "base.html" %} + +{% block content %} +
+ +
+
+
+ +
+
+ {% if player.avatar_url %} + + {% else %} +
+ {{ player.username[:2] | upper if player.username else '??' }} +
+ {% endif %} + + {% if session.get('is_admin') %} + + {% endif %} +
+ +
+

{{ player.username }}

+

{{ player.steam_id_64 }}

+ + +
+ {% for tag in metadata.tags %} + + {{ tag }} + {% if session.get('is_admin') %} +
+ + + +
+ {% endif %} +
+ {% endfor %} + + {% if session.get('is_admin') %} +
+ + +
+ {% endif %} +
+
+
+ + +
+
+ {% macro stat_card(label, metric_key, format_str, icon) %} + {% set dist = distribution[metric_key] if distribution else None %} +
+
+
+ {{ icon }} {{ label }} +
+ {% if dist %} + + Rank #{{ dist.rank }} + + {% endif %} +
+ +
+ {{ format_str.format(dist.val if dist else 0) }} +
+ + + {% if dist %} +
+ + {% set range = dist.max - dist.min %} + {% set percent = ((dist.val - dist.min) / range * 100) if range > 0 else 100 %} +
+
+
+ {{ format_str.format(dist.min) }} + Avg: {{ format_str.format(dist.avg) }} + {{ format_str.format(dist.max) }} +
+ {% else %} +
No team data
+ {% endif %} +
+ {% endmacro %} + + {{ stat_card('Rating', 'rating', '{:.2f}', '⭐') }} + {{ stat_card('K/D Ratio', 'kd', '{:.2f}', '🔫') }} + {{ stat_card('ADR', 'adr', '{:.1f}', '🔥') }} + {{ stat_card('KAST', 'kast', '{:.1%}', '🛡️') }} +
+
+
+
+
+ + +
+ +
+
+

+ 📈 近期表现走势 (Performance Trend) +

+ +
+ + +
+
+
+ +
+
+
Carry (>1.5)
+
Normal (1.0-1.5)
+
Poor (<0.6)
+
+
+ + +
+

+ 🕸️ 能力六维图 (Capabilities) +

+
+ +
+
+
+ + +
+

+ 📅 近期表现稳定性 (Recent Stability) +

+ +
+ +
+

By Matches

+
+ {% for n in [5, 10, 15] %} + {% set key = 'last_' ~ n ~ '_matches' %} + {% set data = recent_stats.get(key) %} +
+
+ {{ n }} + Matches +
+
+ {% if data and data.count > 0 %} +
{{ "{:.2f}".format(data.avg) }} Rating
+
Var: {{ "{:.3f}".format(data.var) }}
+ {% else %} + N/A + {% endif %} +
+
+ {% endfor %} +
+
+ + +
+

By Days

+
+ {% for n in [5, 10, 15] %} + {% set key = 'last_' ~ n ~ '_days' %} + {% set data = recent_stats.get(key) %} +
+
+ {{ n }} + Days +
+
+ {% if data and data.count > 0 %} +
{{ "{:.2f}".format(data.avg) }} Rating
+
Var: {{ "{:.3f}".format(data.var) }}
+
{{ data.count }} matches
+ {% else %} + No matches + {% endif %} +
+
+ {% endfor %} +
+
+
+
+ + +
+

+ 📊 详细数据面板 (Detailed Stats) +

+
+ {% macro detail_item(label, value, key, format_str='{:.2f}', sublabel=None, count_label=None) %} + {% set dist = distribution[key] if distribution else None %} +
+
+ {{ label }} + {% if dist %} + + #{{ dist.rank }} + + {% endif %} +
+ +
+
+ + {{ format_str.format(value if value is not none else 0) }} + + {% if sublabel %} + {{ sublabel }} + {% endif %} +
+ + {% if count_label is not none %} +
+ {{ count_label }} +
+ {% endif %} +
+ + + {% if dist %} +
+ {% set range = dist.max - dist.min %} + {% set raw_percent = ((dist.val - dist.min) / range * 100) if range > 0 else 100 %} + {% set percent = (100 - raw_percent) if dist.inverted else raw_percent %} +
+ + {% set raw_avg = ((dist.avg - dist.min) / range * 100) if range > 0 else 50 %} + {% set avg_pct = (100 - raw_avg) if dist.inverted else raw_avg %} +
+
+
+ {% if dist.inverted %} + L:{{ format_str.format(dist.max) }} + H:{{ format_str.format(dist.min) }} + {% else %} + L:{{ format_str.format(dist.min) }} + H:{{ format_str.format(dist.max) }} + {% endif %} +
+ {% endif %} +
+ {% endmacro %} + + + {{ detail_item('Rating (评分)', features['basic_avg_rating'], 'basic_avg_rating') }} + {{ detail_item('KD Ratio (击杀比)', features['basic_avg_kd'], 'basic_avg_kd') }} + {{ detail_item('KAST (贡献率)', features['basic_avg_kast'], 'basic_avg_kast', '{:.1%}') }} + {{ detail_item('RWS (每局得分)', features['basic_avg_rws'], 'basic_avg_rws') }} + {{ detail_item('ADR (场均伤害)', features['basic_avg_adr'], 'basic_avg_adr', '{:.1f}') }} + + + {{ detail_item('Avg HS (场均爆头)', features['basic_avg_headshot_kills'], 'basic_avg_headshot_kills') }} + {{ detail_item('HS Rate (爆头率)', features['basic_headshot_rate'], 'basic_headshot_rate', '{:.1%}') }} + {{ detail_item('Assists (场均助攻)', features['basic_avg_assisted_kill'], 'basic_avg_assisted_kill') }} + {{ detail_item('AWP Kills (狙击击杀)', features['basic_avg_awp_kill'], 'basic_avg_awp_kill') }} + {{ detail_item('Jumps (场均跳跃)', features['basic_avg_jump_count'], 'basic_avg_jump_count', '{:.1f}') }} + {{ detail_item('Knife Kills (场均刀杀)', features['basic_avg_knife_kill'], 'basic_avg_knife_kill') }} + {{ detail_item('Zeus Kills (电击枪杀)', features['basic_avg_zeus_kill'], 'basic_avg_zeus_kill') }} + {{ detail_item('Zeus Buy% (起电击枪)', features['basic_zeus_pick_rate'], 'basic_zeus_pick_rate', '{:.1%}') }} + + + {{ detail_item('MVP (最有价值)', features['basic_avg_mvps'], 'basic_avg_mvps') }} + {{ detail_item('Plants (下包)', features['basic_avg_plants'], 'basic_avg_plants') }} + {{ detail_item('Defuses (拆包)', features['basic_avg_defuses'], 'basic_avg_defuses') }} + {{ detail_item('Flash Assist (闪光助攻)', features['basic_avg_flash_assists'], 'basic_avg_flash_assists') }} + + + {{ detail_item('First Kill (场均首杀)', features['basic_avg_first_kill'], 'basic_avg_first_kill') }} + {{ detail_item('First Death (场均首死)', features['basic_avg_first_death'], 'basic_avg_first_death') }} + {{ detail_item('FK Rate (首杀率)', features['basic_first_kill_rate'], 'basic_first_kill_rate', '{:.1%}') }} + {{ detail_item('FD Rate (首死率)', features['basic_first_death_rate'], 'basic_first_death_rate', '{:.1%}') }} + + + {{ detail_item('2K Rounds (双杀)', features['basic_avg_kill_2'], 'basic_avg_kill_2') }} + {{ detail_item('3K Rounds (三杀)', features['basic_avg_kill_3'], 'basic_avg_kill_3') }} + {{ detail_item('4K Rounds (四杀)', features['basic_avg_kill_4'], 'basic_avg_kill_4') }} + {{ detail_item('5K Rounds (五杀)', features['basic_avg_kill_5'], 'basic_avg_kill_5') }} + + + {{ detail_item('Perfect Kills (无伤杀)', features['basic_avg_perfect_kill'], 'basic_avg_perfect_kill') }} + {{ detail_item('Revenge Kills (复仇杀)', features['basic_avg_revenge_kill'], 'basic_avg_revenge_kill') }} +
+
+ + +
+

+ 🔬 深层能力维度 (Deep Capabilities Breakdown) +

+ + + +
+ +
+

+ STA (Stability) & BAT (Aim/Battle) +

+
+ {{ detail_item('Last 30 Rating (近30场)', features['sta_last_30_rating'], 'sta_last_30_rating') }} + {{ detail_item('Win Rating (胜局)', features['sta_win_rating'], 'sta_win_rating') }} + {{ detail_item('Loss Rating (败局)', features['sta_loss_rating'], 'sta_loss_rating') }} + {{ detail_item('Volatility (波动)', features['sta_rating_volatility'], 'sta_rating_volatility') }} + {{ detail_item('Time Corr (耐力)', features['sta_time_rating_corr'], 'sta_time_rating_corr') }} + + {{ detail_item('High Elo KD Diff (高分抗压)', features['bat_kd_diff_high_elo'], 'bat_kd_diff_high_elo') }} + {{ detail_item('Duel Win% (对枪胜率)', features['bat_avg_duel_win_rate'], 'bat_avg_duel_win_rate', '{:.1%}') }} +
+
+ + +
+

+ HPS (Clutch/Pressure) & PTL (Pistol) +

+
+ {{ detail_item('Avg 1v1 (场均1v1)', features['hps_clutch_win_rate_1v1'], 'hps_clutch_win_rate_1v1', '{:.2f}') }} + {{ detail_item('Avg 1v3+ (场均1v3+)', features['hps_clutch_win_rate_1v3_plus'], 'hps_clutch_win_rate_1v3_plus', '{:.2f}') }} + {{ detail_item('Match Pt Win% (赛点胜率)', features['hps_match_point_win_rate'], 'hps_match_point_win_rate', '{:.1%}') }} + {{ detail_item('Pressure Entry (逆风首杀)', features['hps_pressure_entry_rate'], 'hps_pressure_entry_rate', '{:.1%}') }} + {{ detail_item('Comeback KD (翻盘KD)', features['hps_comeback_kd_diff'], 'hps_comeback_kd_diff') }} + {{ detail_item('Loss Streak KD (连败KD)', features['hps_losing_streak_kd_diff'], 'hps_losing_streak_kd_diff') }} + + {{ detail_item('Pistol Kills (手枪击杀)', features['ptl_pistol_kills'], 'ptl_pistol_kills') }} + {{ detail_item('Pistol Win% (手枪胜率)', features['ptl_pistol_win_rate'], 'ptl_pistol_win_rate', '{:.1%}') }} + {{ detail_item('Pistol KD (手枪KD)', features['ptl_pistol_kd'], 'ptl_pistol_kd') }} + {{ detail_item('Pistol Util Eff (手枪道具)', features['ptl_pistol_util_efficiency'], 'ptl_pistol_util_efficiency', '{:.1%}') }} +
+
+ + +
+

+ UTIL (Utility Usage) +

+
+ {{ detail_item('Usage Rate (道具频率)', features['util_usage_rate'], 'util_usage_rate') }} + {{ detail_item('Nade Dmg (雷火伤)', features['util_avg_nade_dmg'], 'util_avg_nade_dmg', '{:.1f}') }} + {{ detail_item('Flash Time (致盲时间)', features['util_avg_flash_time'], 'util_avg_flash_time', '{:.2f}s') }} + {{ detail_item('Flash Enemy (致盲人数)', features['util_avg_flash_enemy'], 'util_avg_flash_enemy') }} +
+
+ + +
+

+ ECO (Economy) & PACE (Tempo) +

+
+ {{ detail_item('Dmg/$1k (性价比)', features['eco_avg_damage_per_1k'], 'eco_avg_damage_per_1k', '{:.1f}') }} + {{ detail_item('Eco KPR (经济局KPR)', features['eco_rating_eco_rounds'], 'eco_rating_eco_rounds') }} + {{ detail_item('Eco KD (经济局KD)', features['eco_kd_ratio'], 'eco_kd_ratio', '{:.2f}') }} + {{ detail_item('Eco Rounds (经济局数)', features['eco_avg_rounds'], 'eco_avg_rounds', '{:.1f}') }} + + {{ detail_item('First Contact (首肯时间)', features['pace_avg_time_to_first_contact'], 'pace_avg_time_to_first_contact', '{:.1f}s') }} + {{ detail_item('Trade Kill% (补枪率)', features['pace_trade_kill_rate'], 'pace_trade_kill_rate', '{:.1%}') }} + {{ detail_item('Opening Time (首杀时间)', features['pace_opening_kill_time'], 'pace_opening_kill_time', '{:.1f}s') }} + {{ detail_item('Avg Life (存活时间)', features['pace_avg_life_time'], 'pace_avg_life_time', '{:.1f}s') }} +
+
+ +
+

+ ROUND (Round Dynamics) +

+
+ {{ detail_item('Kill Early (前30秒击杀)', features['rd_phase_kill_early_share'], 'rd_phase_kill_early_share', '{:.1%}') }} + {{ detail_item('Kill Mid (30-60秒击杀)', features['rd_phase_kill_mid_share'], 'rd_phase_kill_mid_share', '{:.1%}') }} + {{ detail_item('Kill Late (60秒后击杀)', features['rd_phase_kill_late_share'], 'rd_phase_kill_late_share', '{:.1%}') }} + {{ detail_item('Death Early (前30秒死亡)', features['rd_phase_death_early_share'], 'rd_phase_death_early_share', '{:.1%}') }} + {{ detail_item('Death Mid (30-60秒死亡)', features['rd_phase_death_mid_share'], 'rd_phase_death_mid_share', '{:.1%}') }} + {{ detail_item('Death Late (60秒后死亡)', features['rd_phase_death_late_share'], 'rd_phase_death_late_share', '{:.1%}') }} + + {{ detail_item('FirstDeath Win% (首死后胜率)', features['rd_firstdeath_team_first_death_win_rate'], 'rd_firstdeath_team_first_death_win_rate', '{:.1%}', count_label=features['rd_firstdeath_team_first_death_rounds']) }} + {{ detail_item('Invalid Death% (无效死亡)', features['rd_invalid_death_rate'], 'rd_invalid_death_rate', '{:.1%}', count_label=features['rd_invalid_death_rounds']) }} + {{ detail_item('Pressure KPR (落后≥3)', features['rd_pressure_kpr_ratio'], 'rd_pressure_kpr_ratio', '{:.2f}x') }} + {{ detail_item('MatchPt KPR (赛点放大)', features['rd_matchpoint_kpr_ratio'], 'rd_matchpoint_kpr_ratio', '{:.2f}x', count_label=features['rd_matchpoint_rounds']) }} + {{ detail_item('Trade Resp (10s响应)', features['rd_trade_response_10s_rate'], 'rd_trade_response_10s_rate', '{:.1%}') }} + + {{ detail_item('Pressure Perf (Leetify)', features['rd_pressure_perf_ratio'], 'rd_pressure_perf_ratio', '{:.2f}x') }} + {{ detail_item('MatchPt Perf (Leetify)', features['rd_matchpoint_perf_ratio'], 'rd_matchpoint_perf_ratio', '{:.2f}x') }} + {{ detail_item('Comeback KillShare (追分)', features['rd_comeback_kill_share'], 'rd_comeback_kill_share', '{:.1%}', count_label=features['rd_comeback_rounds']) }} + {{ detail_item('Map Stability (地图稳定)', features['map_stability_coef'], 'map_stability_coef', '{:.3f}') }} +
+ +
+
+
Phase Split
+ {% macro phase_row(title, ke, km, kl, de, dm, dl, ke_key, km_key, kl_key, de_key, dm_key, dl_key) %} + {% set ke = ke or 0 %} + {% set km = km or 0 %} + {% set kl = kl or 0 %} + {% set de = de or 0 %} + {% set dm = dm or 0 %} + {% set dl = dl or 0 %} + {% set k_total = ke + km + kl %} + {% set d_total = de + dm + dl %} +
+
{{ title }}
+
+
+ {% if k_total > 0 %} +
+
+
+ {% else %} +
+ {% endif %} +
+
+ + E {{ '{:.0%}'.format(ke) }} + {% if distribution and distribution.get(ke_key) %} (#{{ distribution.get(ke_key).rank }}/{{ distribution.get(ke_key).total }}){% endif %} + + + M {{ '{:.0%}'.format(km) }} + {% if distribution and distribution.get(km_key) %} (#{{ distribution.get(km_key).rank }}/{{ distribution.get(km_key).total }}){% endif %} + + + L {{ '{:.0%}'.format(kl) }} + {% if distribution and distribution.get(kl_key) %} (#{{ distribution.get(kl_key).rank }}/{{ distribution.get(kl_key).total }}){% endif %} + +
+
+
+
+ {% if d_total > 0 %} +
+
+
+ {% else %} +
+ {% endif %} +
+
+ + E {{ '{:.0%}'.format(de) }} + {% if distribution and distribution.get(de_key) %} (#{{ distribution.get(de_key).rank }}/{{ distribution.get(de_key).total }}){% endif %} + + + M {{ '{:.0%}'.format(dm) }} + {% if distribution and distribution.get(dm_key) %} (#{{ distribution.get(dm_key).rank }}/{{ distribution.get(dm_key).total }}){% endif %} + + + L {{ '{:.0%}'.format(dl) }} + {% if distribution and distribution.get(dl_key) %} (#{{ distribution.get(dl_key).rank }}/{{ distribution.get(dl_key).total }}){% endif %} + +
+
+
+ {% endmacro %} + +
+
+
+ KillsE / M / L +
+
+ DeathsE / M / L +
+
+ +
+ {{ phase_row('Total', + features.get('rd_phase_kill_early_share', 0), features.get('rd_phase_kill_mid_share', 0), features.get('rd_phase_kill_late_share', 0), + features.get('rd_phase_death_early_share', 0), features.get('rd_phase_death_mid_share', 0), features.get('rd_phase_death_late_share', 0), + 'rd_phase_kill_early_share', 'rd_phase_kill_mid_share', 'rd_phase_kill_late_share', + 'rd_phase_death_early_share', 'rd_phase_death_mid_share', 'rd_phase_death_late_share' + ) }} + {{ phase_row('T', + features.get('rd_phase_kill_early_share_t', 0), features.get('rd_phase_kill_mid_share_t', 0), features.get('rd_phase_kill_late_share_t', 0), + features.get('rd_phase_death_early_share_t', 0), features.get('rd_phase_death_mid_share_t', 0), features.get('rd_phase_death_late_share_t', 0), + 'rd_phase_kill_early_share_t', 'rd_phase_kill_mid_share_t', 'rd_phase_kill_late_share_t', + 'rd_phase_death_early_share_t', 'rd_phase_death_mid_share_t', 'rd_phase_death_late_share_t' + ) }} + {{ phase_row('CT', + features.get('rd_phase_kill_early_share_ct', 0), features.get('rd_phase_kill_mid_share_ct', 0), features.get('rd_phase_kill_late_share_ct', 0), + features.get('rd_phase_death_early_share_ct', 0), features.get('rd_phase_death_mid_share_ct', 0), features.get('rd_phase_death_late_share_ct', 0), + 'rd_phase_kill_early_share_ct', 'rd_phase_kill_mid_share_ct', 'rd_phase_kill_late_share_ct', + 'rd_phase_death_early_share_ct', 'rd_phase_death_mid_share_ct', 'rd_phase_death_late_share_ct' + ) }} +
+
+
+
Top Weapons
+
+
+
+
Round Type Split
+
+ KPR=Kills per Round(每回合击杀) · Perf=Leetify Round Performance Score(回合表现分) +
+
+
+
+
+ + +
+

+ SPECIAL (Clutch & Multi) +

+ {% set matches = l2_stats.get('matches', 0) or 1 %} + {% set rounds = l2_stats.get('total_rounds', 0) or 1 %} +
+ {% set c1 = l2_stats.get('c1', 0) or 0 %} + {% set a1 = l2_stats.get('att1', 0) or 0 %} + {{ detail_item('1v1 Win% (1v1胜率)', c1 / a1 if a1 > 0 else 0, 'clutch_rate_1v1', '{:.1%}', count_label=c1 ~ '/' ~ a1) }} + + {% set c2 = l2_stats.get('c2', 0) or 0 %} + {% set a2 = l2_stats.get('att2', 0) or 0 %} + {{ detail_item('1v2 Win% (1v2胜率)', c2 / a2 if a2 > 0 else 0, 'clutch_rate_1v2', '{:.1%}', count_label=c2 ~ '/' ~ a2) }} + + {% set c3 = l2_stats.get('c3', 0) or 0 %} + {% set a3 = l2_stats.get('att3', 0) or 0 %} + {{ detail_item('1v3 Win% (1v3胜率)', c3 / a3 if a3 > 0 else 0, 'clutch_rate_1v3', '{:.1%}', count_label=c3 ~ '/' ~ a3) }} + + {% set c4 = l2_stats.get('c4', 0) or 0 %} + {% set a4 = l2_stats.get('att4', 0) or 0 %} + {{ detail_item('1v4 Win% (1v4胜率)', c4 / a4 if a4 > 0 else 0, 'clutch_rate_1v4', '{:.1%}', count_label=c4 ~ '/' ~ a4) }} + + {% set c5 = l2_stats.get('c5', 0) or 0 %} + {% set a5 = l2_stats.get('att5', 0) or 0 %} + {{ detail_item('1v5 Win% (1v5胜率)', c5 / a5 if a5 > 0 else 0, 'clutch_rate_1v5', '{:.1%}', count_label=c5 ~ '/' ~ a5) }} + + {% set mk_count = (l2_stats.get('k2', 0) or 0) + (l2_stats.get('k3', 0) or 0) + (l2_stats.get('k4', 0) or 0) + (l2_stats.get('k5', 0) or 0) %} + {% set ma_count = (l2_stats.get('a2', 0) or 0) + (l2_stats.get('a3', 0) or 0) + (l2_stats.get('a4', 0) or 0) + (l2_stats.get('a5', 0) or 0) %} + + {{ detail_item('Multi-K Rate (多杀率)', mk_count / rounds, 'total_multikill_rate', '{:.1%}', count_label=mk_count) }} + {{ detail_item('Multi-A Rate (多助率)', ma_count / rounds, 'total_multiassist_rate', '{:.1%}', count_label=ma_count) }} +
+
+ + +
+

+ SIDE (T/CT Preference) +

+ + {% macro vs_item_val(label, t_val, ct_val, format_str='{:.2f}') %} + {% set diff = ct_val - t_val %} + + {# Dynamic Sizing #} + {% set t_size = 'text-2xl' if t_val > ct_val else 'text-sm text-gray-500 dark:text-gray-400' %} + {% set ct_size = 'text-2xl' if ct_val > t_val else 'text-sm text-gray-500 dark:text-gray-400' %} + {% if t_val == ct_val %} + {% set t_size = 'text-lg' %} + {% set ct_size = 'text-lg' %} + {% endif %} + +
+ +
+ {{ label }} + + {% if diff|abs > 0.001 %} + + {% if diff > 0 %}CT +{{ format_str.format(diff) }} + {% else %}T +{{ format_str.format(diff|abs) }}{% endif %} + + {% endif %} +
+ + +
+ +
+ T-Side + + {{ format_str.format(t_val) }} + +
+ + +
+ + +
+ CT-Side + + {{ format_str.format(ct_val) }} + +
+
+ + +
+ {% set total = t_val + ct_val %} + {% if total > 0 %} + {% set t_pct = (t_val / total) * 100 %} +
+
+ {% else %} +
+
+ {% endif %} +
+
+ {% endmacro %} + + {% macro vs_item(label, t_key, ct_key, format_str='{:.2f}') %} + {{ vs_item_val(label, features[t_key] or 0, features[ct_key] or 0, format_str) }} + {% endmacro %} + +
+ {{ vs_item('Rating (Rating/KD)', 'side_rating_t', 'side_rating_ct') }} + {{ vs_item('KD Ratio', 'side_kd_t', 'side_kd_ct') }} + {{ vs_item('Win Rate (胜率)', 'side_win_rate_t', 'side_win_rate_ct', '{:.1%}') }} + {{ vs_item('First Kill Rate (首杀率)', 'side_first_kill_rate_t', 'side_first_kill_rate_ct', '{:.1%}') }} + {{ vs_item('First Death Rate (首死率)', 'side_first_death_rate_t', 'side_first_death_rate_ct', '{:.1%}') }} + {{ vs_item('KAST (贡献率)', 'side_kast_t', 'side_kast_ct', '{:.1%}') }} + {{ vs_item('RWS (Round Win Share)', 'side_rws_t', 'side_rws_ct') }} + {{ vs_item('Headshot Rate (爆头率)', 'side_headshot_rate_t', 'side_headshot_rate_ct', '{:.1%}') }} + + {# New Comparisons #} + {% set t_rounds = side_stats.get('T', {}).get('rounds', 0) or 1 %} + {% set ct_rounds = side_stats.get('CT', {}).get('rounds', 0) or 1 %} + + {% set t_clutch = (side_stats.get('T', {}).get('total_clutch', 0) or 0) / t_rounds %} + {% set ct_clutch = (side_stats.get('CT', {}).get('total_clutch', 0) or 0) / ct_rounds %} + {{ vs_item_val('Clutch Win Rate (残局率)', t_clutch, ct_clutch, '{:.1%}') }} + + {% set t_mk = (side_stats.get('T', {}).get('total_multikill', 0) or 0) / t_rounds %} + {% set ct_mk = (side_stats.get('CT', {}).get('total_multikill', 0) or 0) / ct_rounds %} + {{ vs_item_val('Multi-Kill Rate (多杀率)', t_mk, ct_mk, '{:.1%}') }} + + {% set t_ma = (side_stats.get('T', {}).get('total_multiassist', 0) or 0) / t_rounds %} + {% set ct_ma = (side_stats.get('CT', {}).get('total_multiassist', 0) or 0) / ct_rounds %} + {{ vs_item_val('Multi-Assist Rate (多助攻)', t_ma, ct_ma, '{:.1%}') }} +
+
+ + +
+

+ 👥 组排与分层表现 (Party & Stratification) +

+ +
+ +
+
Party Size Performance (组排表现)
+
+ {{ detail_item('Solo Win% (单排胜率)', features['party_1_win_rate'], 'party_1_win_rate', '{:.1%}') }} + {{ detail_item('Solo Rating (单排分)', features['party_1_rating'], 'party_1_rating') }} + {{ detail_item('Solo ADR (单排伤)', features['party_1_adr'], 'party_1_adr', '{:.1f}') }} + + {{ detail_item('Duo Win% (双排胜率)', features['party_2_win_rate'], 'party_2_win_rate', '{:.1%}') }} + {{ detail_item('Duo Rating (双排分)', features['party_2_rating'], 'party_2_rating') }} + {{ detail_item('Duo ADR (双排伤)', features['party_2_adr'], 'party_2_adr', '{:.1f}') }} + + {{ detail_item('Trio Win% (三排胜率)', features['party_3_win_rate'], 'party_3_win_rate', '{:.1%}') }} + {{ detail_item('Trio Rating (三排分)', features['party_3_rating'], 'party_3_rating') }} + {{ detail_item('Trio ADR (三排伤)', features['party_3_adr'], 'party_3_adr', '{:.1f}') }} + + {{ detail_item('Quad Win% (四排胜率)', features['party_4_win_rate'], 'party_4_win_rate', '{:.1%}') }} + {{ detail_item('Quad Rating (四排分)', features['party_4_rating'], 'party_4_rating') }} + {{ detail_item('Quad ADR (四排伤)', features['party_4_adr'], 'party_4_adr', '{:.1f}') }} + + {{ detail_item('Full Win% (五排胜率)', features['party_5_win_rate'], 'party_5_win_rate', '{:.1%}') }} + {{ detail_item('Full Rating (五排分)', features['party_5_rating'], 'party_5_rating') }} + {{ detail_item('Full ADR (五排伤)', features['party_5_adr'], 'party_5_adr', '{:.1f}') }} +
+
+ + +
+
Performance Tiers (表现分层)
+
+ {{ detail_item('Carry Rate (>1.5)', features['rating_dist_carry_rate'], 'rating_dist_carry_rate', '{:.1%}') }} + {{ detail_item('Normal Rate (1.0-1.5)', features['rating_dist_normal_rate'], 'rating_dist_normal_rate', '{:.1%}') }} + {{ detail_item('Sacrifice Rate (0.6-1.0)', features['rating_dist_sacrifice_rate'], 'rating_dist_sacrifice_rate', '{:.1%}') }} + {{ detail_item('Sleeping Rate (<0.6)', features['rating_dist_sleeping_rate'], 'rating_dist_sleeping_rate', '{:.1%}') }} +
+
+ + +
+
Performance vs ELO (不同分段表现)
+
+ {{ detail_item('<1200 Rating', features['elo_lt1200_rating'], 'elo_lt1200_rating') }} + {{ detail_item('1200-1400 Rating', features['elo_1200_1400_rating'], 'elo_1200_1400_rating') }} + {{ detail_item('1400-1600 Rating', features['elo_1400_1600_rating'], 'elo_1400_1600_rating') }} + {{ detail_item('1600-1800 Rating', features['elo_1600_1800_rating'], 'elo_1600_1800_rating') }} + {{ detail_item('1800-2000 Rating', features['elo_1800_2000_rating'], 'elo_1800_2000_rating') }} + {{ detail_item('>2000 Rating', features['elo_gt2000_rating'], 'elo_gt2000_rating') }} +
+
+
+
+
+
+ + +
+ +
+
+

比赛记录 (Match History)

+ + {{ history|length }} Matches + +
+
+ + + + + + + + + + + + + {% for m in history | reverse %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
Date/MapResultRatingK/DADRLink
+
{{ m.map_name }}
+
+ +
+
+
+ + {{ 'WIN' if m.is_win else 'LOSS' }} + + {% if m.party_size and m.party_size > 1 %} + + 👥 {{ m.party_size }} + + {% endif %} +
+
+ {% set r = m.rating or 0 %} +
+ + {{ "%.2f"|format(r) }} + + +
+
+
+
+
+ {{ "%.2f"|format(m.kd_ratio or 0) }} + + {{ "%.1f"|format(m.adr or 0) }} + + + + +
+
🏜️
+ No matches recorded yet. +
+
+
+ + +
+ +
+

地图数据 (Map Stats)

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

留言板 (Comments)

+ +
+ + + +
+ +
+ {% for comment in comments %} +
+
+
+ {{ comment.username[:1] | upper }} +
+
+
+
+ {{ comment.username }} + {{ comment.created_at }} +
+

{{ comment.content }}

+
+ +
+
+
+ {% else %} +
No comments yet.
+ {% endfor %} +
+
+
+
+ + + +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/web/templates/tactics/analysis.html b/web/templates/tactics/analysis.html new file mode 100644 index 0000000..97257ef --- /dev/null +++ b/web/templates/tactics/analysis.html @@ -0,0 +1,25 @@ +{% extends "tactics/layout.html" %} + +{% block title %}Deep Analysis - Tactics{% endblock %} + +{% block tactics_content %} +
+

Deep Analysis: Chemistry & Depth

+ +
+ +
+ +

Lineup Builder

+

Drag 5 players here to analyze chemistry.

+
+ + +
+ +

Synergy Matrix

+

Select lineup to view pair-wise win rates.

+
+
+
+{% endblock %} \ No newline at end of file diff --git a/web/templates/tactics/board.html b/web/templates/tactics/board.html new file mode 100644 index 0000000..29e42c3 --- /dev/null +++ b/web/templates/tactics/board.html @@ -0,0 +1,396 @@ +{% extends "base.html" %} + +{% block title %}Strategy Board - Tactics{% endblock %} + +{% block head %} + + + +{% endblock %} + +{% block content %} +
+ + +
+
+ ← Dashboard + Deep Analysis + Data Center + Strategy Board + Economy +
+
+ Real-time Sync: ● Active +
+
+ + +
+ + +
+ + +
+
+ +
+
+ + +
+
+ + +
+ + +
+

Roster

+
+ + + +
+
+ + +
+

+ On Board + +

+
    + +
+
+ + +
+

Synergy

+
+ +
+
+ +
+
+ + +
+
+ +
+ Drag players to map • Scroll to zoom +
+
+
+
+ + + + + + +{% endblock %} \ No newline at end of file diff --git a/web/templates/tactics/compare.html b/web/templates/tactics/compare.html new file mode 100644 index 0000000..f7acde7 --- /dev/null +++ b/web/templates/tactics/compare.html @@ -0,0 +1,161 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

数据对比中心 (Data Center)

+ + +
+ + + +
+ + +
+ +
+ + +
+ +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/web/templates/tactics/data.html b/web/templates/tactics/data.html new file mode 100644 index 0000000..7b68f2b --- /dev/null +++ b/web/templates/tactics/data.html @@ -0,0 +1,355 @@ + +
+ +
+
+

+ 📊 数据对比中心 (Data Comparison) +

+

拖拽左侧队员至下方区域,或点击搜索添加

+
+
+
+ + +
+ +
+
+ + +
+ + +
+ +
+

+ 对比列表 + 0/5 +

+
+ +
+ + + + +
+
+ + +
+ + +
+ +
+

能力模型对比 (Capability Radar)

+
+ +
+
+ + +
+

基础数据 (Basic Stats)

+
+ + + + + + + + + + + + + + +
PlayerRatingK/DADRKAST
+
+
+
+ + +
+

详细数据对比 (Detailed Stats)

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Metric
Rating (Rating/KD)
KD Ratio
Win Rate (胜率)
First Kill Rate (首杀率)
First Death Rate (首死率)
KAST (贡献率)
RWS (Round Win Share)
Multi-Kill Rate (多杀率)
Headshot Rate (爆头率)
Obj (下包 vs 拆包)
+
+
+ + +
+

地图表现 (Map Performance)

+ +
+ + + + + + + + + + + +
Map
+
+
+ +
+
+
\ No newline at end of file diff --git a/web/templates/tactics/economy.html b/web/templates/tactics/economy.html new file mode 100644 index 0000000..d8ee7c0 --- /dev/null +++ b/web/templates/tactics/economy.html @@ -0,0 +1,65 @@ +{% extends "tactics/layout.html" %} + +{% block title %}Economy Calculator - Tactics{% endblock %} + +{% block tactics_content %} +
+

Economy Calculator

+ +
+ +
+

Current Round State

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+

Prediction

+ +
+
+ Team Money (Min) + $12,400 +
+
+ Team Money (Max) + $18,500 +
+ +
+ Recommendation + Full Buy +
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/web/templates/tactics/index.html b/web/templates/tactics/index.html new file mode 100644 index 0000000..24f172a --- /dev/null +++ b/web/templates/tactics/index.html @@ -0,0 +1,780 @@ +{% extends "base.html" %} + +{% block title %}Tactics Center{% endblock %} + +{% block head %} + + + +{% endblock %} + +{% block content %} +
+ + +
+
+

队员列表 (Roster)

+

拖拽队员至右侧功能区

+
+ +
+ + + +
+
+ + +
+ + +
+ +
+ + +
+ + +
+

阵容化学反应分析

+ +
+ +
+

+ + 🏗️ + 阵容构建 (0/5) + + +

+ +
+ + + + +
+
+ + +
+ + +
+
+
+ + + {% include 'tactics/data.html' %} + + +
+ +
+
+ + + +
+
+ 在场人数: +
+
+ + +
+
+
+
+ + +
+

经济计算器 (Economy Calculator)

+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
下回合收入预测
+
+
+
+
+
+
+ +
+
+
+ + + + + + +{% endblock %} \ No newline at end of file diff --git a/web/templates/tactics/layout.html b/web/templates/tactics/layout.html new file mode 100644 index 0000000..ff1f7c0 --- /dev/null +++ b/web/templates/tactics/layout.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} + +{% block content %} +
+ + + + {% block tactics_content %}{% endblock %} +
+{% endblock %} \ No newline at end of file diff --git a/web/templates/tactics/maps.html b/web/templates/tactics/maps.html new file mode 100644 index 0000000..568efaa --- /dev/null +++ b/web/templates/tactics/maps.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} + +{% block content %} +
+

地图情报

+ +
+ {% for map in maps %} +
+
+ + {{ map.title }} +
+
+

{{ map.title }}

+
+ + +
+
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/web/templates/teams/clubhouse.html b/web/templates/teams/clubhouse.html new file mode 100644 index 0000000..584df20 --- /dev/null +++ b/web/templates/teams/clubhouse.html @@ -0,0 +1,278 @@ +{% extends "base.html" %} + +{% block title %}My Team - Clubhouse{% endblock %} + +{% block content %} +
+ +
+
+

+ + +

+
+
+ {% if session.get('is_admin') %} + + {% endif %} +
+
+ + +
+
+ + + +
+
+ + +
+

Active Roster

+ +
+ + + + + {% if session.get('is_admin') %} +
+
+ +
+ Add Player +
+ {% endif %} +
+
+ + + + + + +
+ + +{% endblock %} diff --git a/web/templates/teams/create.html b/web/templates/teams/create.html new file mode 100644 index 0000000..54a1699 --- /dev/null +++ b/web/templates/teams/create.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} + +{% block content %} +
+

新建战队阵容

+ +
+
+ + +
+ +
+ + +
+ +
+
+ + +
+ + + + + +
+ {% for i in range(1, 6) %} +
+ + +
+ {% endfor %} +
+
+ + + +
+ +
+
+
+{% endblock %} diff --git a/web/templates/teams/detail.html b/web/templates/teams/detail.html new file mode 100644 index 0000000..f643d84 --- /dev/null +++ b/web/templates/teams/detail.html @@ -0,0 +1,116 @@ +{% extends "base.html" %} + +{% block content %} +
+ +
+

{{ lineup.name }}

+

{{ lineup.description }}

+
+ + +
+ {% for p in players %} +
+ + + {{ p.username }} + + Rating: {{ "%.2f"|format(p.rating if p.rating else 0) }} +
+ {% endfor %} +
+ + +
+

阵容综合能力

+
+
+
+
+
平均 Rating
+
{{ "%.2f"|format(agg_stats.avg_rating or 0) }}
+
+
+
平均 K/D
+
{{ "%.2f"|format(agg_stats.avg_kd or 0) }}
+
+
+
+ + +
+ +
+
+
+ + +
+

共同经历 (Shared Matches)

+
+ + + + + + + + + + + {% for m in shared_matches %} + + + + + + + {% else %} + + + + {% endfor %} + +
DateMapScoreLink
{{ m.start_time }}{{ m.map_name }}{{ m.score_team1 }} : {{ m.score_team2 }} + View +
No shared matches found for this lineup.
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/web/templates/teams/list.html b/web/templates/teams/list.html new file mode 100644 index 0000000..94e7ec8 --- /dev/null +++ b/web/templates/teams/list.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

战队阵容库

+ + 新建阵容 + +
+ +
+ {% for lineup in lineups %} +
+

{{ lineup.name }}

+

{{ lineup.description }}

+ +
+ {% for p in lineup.players %} + {{ p.username }} + {% endfor %} +
+ + + 查看分析 → + +
+ {% endfor %} +
+
+{% endblock %} diff --git a/web/templates/wiki/edit.html b/web/templates/wiki/edit.html new file mode 100644 index 0000000..c5c21bb --- /dev/null +++ b/web/templates/wiki/edit.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} + +{% block content %} +
+

Edit Wiki Page

+ +
+
+ + +

Path cannot be changed after creation (unless new).

+
+ +
+ + +
+ +
+ + +
+ +
+ Cancel + +
+
+
+{% endblock %} diff --git a/web/templates/wiki/index.html b/web/templates/wiki/index.html new file mode 100644 index 0000000..2717dc1 --- /dev/null +++ b/web/templates/wiki/index.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

知识库 (Wiki)

+ {% if session.get('is_admin') %} + New Page + {% endif %} +
+ +
+ {% for page in pages %} + +
+ {{ page.title }} + {{ page.path }} +
+
+ {% else %} +

暂无文档。

+ {% endfor %} +
+
+{% endblock %} diff --git a/web/templates/wiki/view.html b/web/templates/wiki/view.html new file mode 100644 index 0000000..c49bcf3 --- /dev/null +++ b/web/templates/wiki/view.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
+
+

{{ page.title }}

+

Path: {{ page.path }} | Updated: {{ page.updated_at }}

+
+ {% if session.get('is_admin') %} + Edit + {% endif %} +
+ +
+ +
+ + + +
+ + +{% endblock %} diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..0d0c430 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,12 @@ +import sys +import os + +# Ensure the project root is in sys.path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from web.app import create_app + +app = create_app() + +if __name__ == "__main__": + app.run()