""" BasicProcessor - Tier 1: CORE Features (41 columns) Calculates fundamental player statistics from fact_match_players: - Basic Performance (15 columns): rating, kd, adr, kast, rws, hs%, kills, deaths, assists - Match Stats (8 columns): win_rate, mvps, duration, elo - Weapon Stats (12 columns): awp, knife, zeus, diversity - Objective Stats (6 columns): plants, defuses, flash_assists """ import sqlite3 from typing import Dict, Any from .base_processor import BaseFeatureProcessor, SafeAggregator, WeaponCategories class BasicProcessor(BaseFeatureProcessor): """Tier 1 CORE processor - Direct aggregations from fact_match_players""" MIN_MATCHES_REQUIRED = 1 # Basic stats work with any match count @staticmethod def calculate(steam_id: str, conn_l2: sqlite3.Connection) -> Dict[str, Any]: """ Calculate all Tier 1 CORE features (41 columns) Returns dict with keys: - core_avg_rating, core_avg_rating2, core_avg_kd, core_avg_adr, etc. """ features = {} # Get match count first match_count = BaseFeatureProcessor.get_player_match_count(steam_id, conn_l2) if match_count == 0: return _get_default_features() # Calculate each sub-section features.update(BasicProcessor._calculate_basic_performance(steam_id, conn_l2)) features.update(BasicProcessor._calculate_match_stats(steam_id, conn_l2)) features.update(BasicProcessor._calculate_weapon_stats(steam_id, conn_l2)) features.update(BasicProcessor._calculate_objective_stats(steam_id, conn_l2)) return features @staticmethod def _calculate_basic_performance(steam_id: str, conn_l2: sqlite3.Connection) -> Dict[str, Any]: """ Calculate Basic Performance (15 columns) Columns: - core_avg_rating, core_avg_rating2 - core_avg_kd, core_avg_adr, core_avg_kast, core_avg_rws - core_avg_hs_kills, core_hs_rate - core_total_kills, core_total_deaths, core_total_assists, core_avg_assists - core_kpr, core_dpr, core_survival_rate """ cursor = conn_l2.cursor() # Main aggregation query cursor.execute(""" SELECT AVG(rating) as avg_rating, AVG(rating2) as avg_rating2, AVG(CAST(kills AS REAL) / NULLIF(deaths, 0)) as avg_kd, AVG(adr) as avg_adr, AVG(kast) as avg_kast, AVG(rws) as avg_rws, AVG(headshot_count) as avg_hs_kills, SUM(kills) as total_kills, SUM(deaths) as total_deaths, SUM(headshot_count) as total_hs, SUM(assists) as total_assists, AVG(assists) as avg_assists, SUM(round_total) as total_rounds FROM fact_match_players WHERE steam_id_64 = ? """, (steam_id,)) row = cursor.fetchone() if not row: return {} total_kills = row[7] if row[7] else 0 total_deaths = row[8] if row[8] else 1 total_hs = row[9] if row[9] else 0 total_rounds = row[12] if row[12] else 1 return { 'core_avg_rating': round(row[0], 3) if row[0] else 0.0, 'core_avg_rating2': round(row[1], 3) if row[1] else 0.0, 'core_avg_kd': round(row[2], 3) if row[2] else 0.0, 'core_avg_adr': round(row[3], 2) if row[3] else 0.0, 'core_avg_kast': round(row[4], 3) if row[4] else 0.0, 'core_avg_rws': round(row[5], 2) if row[5] else 0.0, 'core_avg_hs_kills': round(row[6], 2) if row[6] else 0.0, 'core_hs_rate': round(total_hs / total_kills, 3) if total_kills > 0 else 0.0, 'core_total_kills': total_kills, 'core_total_deaths': total_deaths, 'core_total_assists': row[10] if row[10] else 0, 'core_avg_assists': round(row[11], 2) if row[11] else 0.0, 'core_kpr': round(total_kills / total_rounds, 3) if total_rounds > 0 else 0.0, 'core_dpr': round(total_deaths / total_rounds, 3) if total_rounds > 0 else 0.0, 'core_survival_rate': round((total_rounds - total_deaths) / total_rounds, 3) if total_rounds > 0 else 0.0, } @staticmethod def _calculate_flash_assists(steam_id: str, conn_l2: sqlite3.Connection) -> int: """ Calculate flash assists from fact_match_players (Total - Damage Assists) Returns total flash assist count (Estimated) """ cursor = conn_l2.cursor() # NOTE: Flash Assist Logic # Source 'flash_assists' is often 0. # User Logic: Flash Assists = Total Assists - Damage Assists (assisted_kill) # We take MAX(0, diff) to avoid negative numbers if assisted_kill definition varies. cursor.execute(""" SELECT SUM(MAX(0, assists - assisted_kill)) FROM fact_match_players WHERE steam_id_64 = ? """, (steam_id,)) res = cursor.fetchone() if res and res[0] is not None: return res[0] return 0 @staticmethod def _calculate_match_stats(steam_id: str, conn_l2: sqlite3.Connection) -> Dict[str, Any]: """ Calculate Match Stats (8 columns) Columns: - core_win_rate, core_wins, core_losses - core_avg_match_duration - core_avg_mvps, core_mvp_rate - core_avg_elo_change, core_total_elo_gained """ cursor = conn_l2.cursor() # Win/loss stats cursor.execute(""" SELECT COUNT(*) as total_matches, SUM(CASE WHEN is_win = 1 THEN 1 ELSE 0 END) as wins, SUM(CASE WHEN is_win = 0 THEN 1 ELSE 0 END) as losses, AVG(mvp_count) as avg_mvps, SUM(mvp_count) as total_mvps FROM fact_match_players WHERE steam_id_64 = ? """, (steam_id,)) row = cursor.fetchone() total_matches = row[0] if row[0] else 0 wins = row[1] if row[1] else 0 losses = row[2] if row[2] else 0 avg_mvps = row[3] if row[3] else 0.0 total_mvps = row[4] if row[4] else 0 # Match duration (from fact_matches) cursor.execute(""" SELECT AVG(m.duration) as avg_duration FROM fact_matches m JOIN fact_match_players p ON m.match_id = p.match_id WHERE p.steam_id_64 = ? """, (steam_id,)) duration_row = cursor.fetchone() avg_duration = duration_row[0] if duration_row and duration_row[0] else 0 # ELO stats (from elo_change column) cursor.execute(""" SELECT AVG(elo_change) as avg_elo_change, SUM(elo_change) as total_elo_gained FROM fact_match_players WHERE steam_id_64 = ? """, (steam_id,)) elo_row = cursor.fetchone() avg_elo_change = elo_row[0] if elo_row and elo_row[0] else 0.0 total_elo_gained = elo_row[1] if elo_row and elo_row[1] else 0.0 return { 'core_win_rate': round(wins / total_matches, 3) if total_matches > 0 else 0.0, 'core_wins': wins, 'core_losses': losses, 'core_avg_match_duration': int(avg_duration), 'core_avg_mvps': round(avg_mvps, 2), 'core_mvp_rate': round(total_mvps / total_matches, 2) if total_matches > 0 else 0.0, 'core_avg_elo_change': round(avg_elo_change, 2), 'core_total_elo_gained': round(total_elo_gained, 2), } @staticmethod def _calculate_weapon_stats(steam_id: str, conn_l2: sqlite3.Connection) -> Dict[str, Any]: """ Calculate Weapon Stats (12 columns) Columns: - core_avg_awp_kills, core_awp_usage_rate - core_avg_knife_kills, core_avg_zeus_kills, core_zeus_buy_rate - core_top_weapon, core_top_weapon_kills, core_top_weapon_hs_rate - core_weapon_diversity - core_rifle_hs_rate, core_pistol_hs_rate - core_smg_kills_total """ cursor = conn_l2.cursor() # AWP/Knife/Zeus stats from fact_round_events cursor.execute(""" SELECT weapon, COUNT(*) as kill_count FROM fact_round_events WHERE attacker_steam_id = ? AND weapon IN ('AWP', 'Knife', 'Zeus', 'knife', 'awp', 'zeus') GROUP BY weapon """, (steam_id,)) awp_kills = 0 knife_kills = 0 zeus_kills = 0 for weapon, kills in cursor.fetchall(): weapon_lower = weapon.lower() if weapon else '' if weapon_lower == 'awp': awp_kills += kills elif weapon_lower == 'knife': knife_kills += kills elif weapon_lower == 'zeus': zeus_kills += kills # Get total matches count for rates cursor.execute(""" SELECT COUNT(DISTINCT match_id) FROM fact_match_players WHERE steam_id_64 = ? """, (steam_id,)) total_matches = cursor.fetchone()[0] or 1 avg_awp = awp_kills / total_matches avg_knife = knife_kills / total_matches avg_zeus = zeus_kills / total_matches # Flash assists from fact_round_events flash_assists = BasicProcessor._calculate_flash_assists(steam_id, conn_l2) avg_flash_assists = flash_assists / total_matches # Top weapon from fact_round_events cursor.execute(""" SELECT weapon, COUNT(*) as kill_count, SUM(CASE WHEN is_headshot = 1 THEN 1 ELSE 0 END) as hs_count FROM fact_round_events WHERE attacker_steam_id = ? AND weapon IS NOT NULL AND weapon != 'unknown' GROUP BY weapon ORDER BY kill_count DESC LIMIT 1 """, (steam_id,)) weapon_row = cursor.fetchone() top_weapon = weapon_row[0] if weapon_row else "unknown" top_weapon_kills = weapon_row[1] if weapon_row else 0 top_weapon_hs = weapon_row[2] if weapon_row else 0 top_weapon_hs_rate = top_weapon_hs / top_weapon_kills if top_weapon_kills > 0 else 0.0 # Weapon diversity (number of distinct weapons with 10+ kills) cursor.execute(""" SELECT COUNT(DISTINCT weapon) as weapon_count FROM ( SELECT weapon, COUNT(*) as kills FROM fact_round_events WHERE attacker_steam_id = ? AND weapon IS NOT NULL GROUP BY weapon HAVING kills >= 10 ) """, (steam_id,)) diversity_row = cursor.fetchone() weapon_diversity = diversity_row[0] if diversity_row else 0 # Rifle/Pistol/SMG stats cursor.execute(""" SELECT weapon, COUNT(*) as kills, SUM(CASE WHEN is_headshot = 1 THEN 1 ELSE 0 END) as headshot_kills FROM fact_round_events WHERE attacker_steam_id = ? AND weapon IS NOT NULL GROUP BY weapon """, (steam_id,)) rifle_kills = 0 rifle_hs = 0 pistol_kills = 0 pistol_hs = 0 smg_kills = 0 awp_usage_count = 0 for weapon, kills, hs in cursor.fetchall(): category = WeaponCategories.get_category(weapon) if category == 'rifle': rifle_kills += kills rifle_hs += hs elif category == 'pistol': pistol_kills += kills pistol_hs += hs elif category == 'smg': smg_kills += kills elif weapon.lower() == 'awp': awp_usage_count += kills total_rounds = BaseFeatureProcessor.get_player_round_count(steam_id, conn_l2) return { 'core_avg_awp_kills': round(avg_awp, 2), 'core_awp_usage_rate': round(awp_usage_count / total_rounds, 3) if total_rounds > 0 else 0.0, 'core_avg_knife_kills': round(avg_knife, 3), 'core_avg_zeus_kills': round(avg_zeus, 3), 'core_zeus_buy_rate': round(avg_zeus / total_matches, 3) if total_matches > 0 else 0.0, 'core_avg_flash_assists': round(avg_flash_assists, 2), 'core_top_weapon': top_weapon, 'core_top_weapon_kills': top_weapon_kills, 'core_top_weapon_hs_rate': round(top_weapon_hs_rate, 3), 'core_weapon_diversity': weapon_diversity, 'core_rifle_hs_rate': round(rifle_hs / rifle_kills, 3) if rifle_kills > 0 else 0.0, 'core_pistol_hs_rate': round(pistol_hs / pistol_kills, 3) if pistol_kills > 0 else 0.0, 'core_smg_kills_total': smg_kills, } @staticmethod def _calculate_objective_stats(steam_id: str, conn_l2: sqlite3.Connection) -> Dict[str, Any]: """ Calculate Objective Stats (6 columns) Columns: - core_avg_plants, core_avg_defuses, core_avg_flash_assists - core_plant_success_rate, core_defuse_success_rate - core_objective_impact """ cursor = conn_l2.cursor() # Get data from main table # Updated to use calculated flash assists formula # Calculate flash assists manually first (since column is 0) flash_assists_total = BasicProcessor._calculate_flash_assists(steam_id, conn_l2) match_count = BaseFeatureProcessor.get_player_match_count(steam_id, conn_l2) avg_flash_assists = flash_assists_total / match_count if match_count > 0 else 0.0 cursor.execute(""" SELECT AVG(planted_bomb) as avg_plants, AVG(defused_bomb) as avg_defuses, SUM(planted_bomb) as total_plants, SUM(defused_bomb) as total_defuses FROM fact_match_players WHERE steam_id_64 = ? """, (steam_id,)) row = cursor.fetchone() if not row: return {} avg_plants = row[0] if row[0] else 0.0 avg_defuses = row[1] if row[1] else 0.0 # avg_flash_assists computed above total_plants = row[2] if row[2] else 0 total_defuses = row[3] if row[3] else 0 # Get T side rounds cursor.execute(""" SELECT COALESCE(SUM(round_total), 0) FROM fact_match_players_t WHERE steam_id_64 = ? """, (steam_id,)) t_rounds = cursor.fetchone()[0] or 1 # Get CT side rounds cursor.execute(""" SELECT COALESCE(SUM(round_total), 0) FROM fact_match_players_ct WHERE steam_id_64 = ? """, (steam_id,)) ct_rounds = cursor.fetchone()[0] or 1 # Plant success rate: plants per T round plant_rate = total_plants / t_rounds if t_rounds > 0 else 0.0 # Defuse success rate: approximate as defuses per CT round (simplified) defuse_rate = total_defuses / ct_rounds if ct_rounds > 0 else 0.0 # Objective impact score: weighted combination objective_impact = (total_plants * 2.0 + total_defuses * 3.0 + avg_flash_assists * 0.5) return { 'core_avg_plants': round(avg_plants, 2), 'core_avg_defuses': round(avg_defuses, 2), 'core_avg_flash_assists': round(avg_flash_assists, 2), 'core_plant_success_rate': round(plant_rate, 3), 'core_defuse_success_rate': round(defuse_rate, 3), 'core_objective_impact': round(objective_impact, 2), } def _get_default_features() -> Dict[str, Any]: """Return default zero values for all 41 CORE features""" return { # Basic Performance (15) 'core_avg_rating': 0.0, 'core_avg_rating2': 0.0, 'core_avg_kd': 0.0, 'core_avg_adr': 0.0, 'core_avg_kast': 0.0, 'core_avg_rws': 0.0, 'core_avg_hs_kills': 0.0, 'core_hs_rate': 0.0, 'core_total_kills': 0, 'core_total_deaths': 0, 'core_total_assists': 0, 'core_avg_assists': 0.0, 'core_kpr': 0.0, 'core_dpr': 0.0, 'core_survival_rate': 0.0, # Match Stats (8) 'core_win_rate': 0.0, 'core_wins': 0, 'core_losses': 0, 'core_avg_match_duration': 0, 'core_avg_mvps': 0.0, 'core_mvp_rate': 0.0, 'core_avg_elo_change': 0.0, 'core_total_elo_gained': 0.0, # Weapon Stats (12) 'core_avg_awp_kills': 0.0, 'core_awp_usage_rate': 0.0, 'core_avg_knife_kills': 0.0, 'core_avg_zeus_kills': 0.0, 'core_zeus_buy_rate': 0.0, 'core_top_weapon': 'unknown', 'core_top_weapon_kills': 0, 'core_top_weapon_hs_rate': 0.0, 'core_weapon_diversity': 0, 'core_rifle_hs_rate': 0.0, 'core_pistol_hs_rate': 0.0, 'core_smg_kills_total': 0, # Objective Stats (6) 'core_avg_plants': 0.0, 'core_avg_defuses': 0.0, 'core_avg_flash_assists': 0.0, 'core_plant_success_rate': 0.0, 'core_defuse_success_rate': 0.0, 'core_objective_impact': 0.0, }