""" TacticalProcessor - Tier 2: TACTICAL Features (44 columns) Calculates tactical gameplay features from fact_match_players and fact_round_events: - Opening Impact (8 columns): first kills/deaths, entry duels - Multi-Kill Performance (6 columns): 2k, 3k, 4k, 5k, ace - Clutch Performance (10 columns): 1v1, 1v2, 1v3+ situations - Utility Mastery (12 columns): nade damage, flash efficiency, smoke timing - Economy Efficiency (8 columns): damage/$, eco/force/full round performance """ import sqlite3 from typing import Dict, Any from .base_processor import BaseFeatureProcessor, SafeAggregator class TacticalProcessor(BaseFeatureProcessor): """Tier 2 TACTICAL processor - Multi-table JOINs and conditional aggregations""" MIN_MATCHES_REQUIRED = 5 # Need reasonable sample for tactical analysis @staticmethod def calculate(steam_id: str, conn_l2: sqlite3.Connection) -> Dict[str, Any]: """ Calculate all Tier 2 TACTICAL features (44 columns) Returns dict with keys starting with 'tac_' """ features = {} # Check minimum matches if not BaseFeatureProcessor.check_min_matches(steam_id, conn_l2, TacticalProcessor.MIN_MATCHES_REQUIRED): return _get_default_tactical_features() # Calculate each tactical dimension features.update(TacticalProcessor._calculate_opening_impact(steam_id, conn_l2)) features.update(TacticalProcessor._calculate_multikill(steam_id, conn_l2)) features.update(TacticalProcessor._calculate_clutch(steam_id, conn_l2)) features.update(TacticalProcessor._calculate_utility(steam_id, conn_l2)) features.update(TacticalProcessor._calculate_economy(steam_id, conn_l2)) return features @staticmethod def _calculate_opening_impact(steam_id: str, conn_l2: sqlite3.Connection) -> Dict[str, Any]: """ Calculate Opening Impact (8 columns) Columns: - tac_avg_fk, tac_avg_fd - tac_fk_rate, tac_fd_rate - tac_fk_success_rate (team win rate when player gets FK) - tac_entry_kill_rate, tac_entry_death_rate - tac_opening_duel_winrate """ cursor = conn_l2.cursor() # FK/FD from fact_match_players cursor.execute(""" SELECT AVG(entry_kills) as avg_fk, AVG(entry_deaths) as avg_fd, SUM(entry_kills) as total_fk, SUM(entry_deaths) as total_fd, COUNT(*) as total_matches FROM fact_match_players WHERE steam_id_64 = ? """, (steam_id,)) row = cursor.fetchone() avg_fk = row[0] if row[0] else 0.0 avg_fd = row[1] if row[1] else 0.0 total_fk = row[2] if row[2] else 0 total_fd = row[3] if row[3] else 0 total_matches = row[4] if row[4] else 1 opening_duels = total_fk + total_fd fk_rate = SafeAggregator.safe_divide(total_fk, opening_duels) fd_rate = SafeAggregator.safe_divide(total_fd, opening_duels) opening_duel_winrate = SafeAggregator.safe_divide(total_fk, opening_duels) # FK success rate: team win rate when player gets FK cursor.execute(""" SELECT COUNT(*) as fk_matches, SUM(CASE WHEN is_win = 1 THEN 1 ELSE 0 END) as fk_wins FROM fact_match_players WHERE steam_id_64 = ? AND entry_kills > 0 """, (steam_id,)) fk_row = cursor.fetchone() fk_matches = fk_row[0] if fk_row[0] else 0 fk_wins = fk_row[1] if fk_row[1] else 0 fk_success_rate = SafeAggregator.safe_divide(fk_wins, fk_matches) # Entry kill/death rates (per T round for entry kills, total for entry deaths) 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 cursor.execute(""" SELECT COALESCE(SUM(round_total), 0) FROM fact_match_players WHERE steam_id_64 = ? """, (steam_id,)) total_rounds = cursor.fetchone()[0] or 1 entry_kill_rate = SafeAggregator.safe_divide(total_fk, t_rounds) entry_death_rate = SafeAggregator.safe_divide(total_fd, total_rounds) return { 'tac_avg_fk': round(avg_fk, 2), 'tac_avg_fd': round(avg_fd, 2), 'tac_fk_rate': round(fk_rate, 3), 'tac_fd_rate': round(fd_rate, 3), 'tac_fk_success_rate': round(fk_success_rate, 3), 'tac_entry_kill_rate': round(entry_kill_rate, 3), 'tac_entry_death_rate': round(entry_death_rate, 3), 'tac_opening_duel_winrate': round(opening_duel_winrate, 3), } @staticmethod def _calculate_multikill(steam_id: str, conn_l2: sqlite3.Connection) -> Dict[str, Any]: """ Calculate Multi-Kill Performance (6 columns) Columns: - tac_avg_2k, tac_avg_3k, tac_avg_4k, tac_avg_5k - tac_multikill_rate - tac_ace_count """ cursor = conn_l2.cursor() cursor.execute(""" SELECT AVG(kill_2) as avg_2k, AVG(kill_3) as avg_3k, AVG(kill_4) as avg_4k, AVG(kill_5) as avg_5k, SUM(kill_2) as total_2k, SUM(kill_3) as total_3k, SUM(kill_4) as total_4k, SUM(kill_5) as total_5k, SUM(round_total) as total_rounds FROM fact_match_players WHERE steam_id_64 = ? """, (steam_id,)) row = cursor.fetchone() avg_2k = row[0] if row[0] else 0.0 avg_3k = row[1] if row[1] else 0.0 avg_4k = row[2] if row[2] else 0.0 avg_5k = row[3] if row[3] else 0.0 total_2k = row[4] if row[4] else 0 total_3k = row[5] if row[5] else 0 total_4k = row[6] if row[6] else 0 total_5k = row[7] if row[7] else 0 total_rounds = row[8] if row[8] else 1 total_multikills = total_2k + total_3k + total_4k + total_5k multikill_rate = SafeAggregator.safe_divide(total_multikills, total_rounds) return { 'tac_avg_2k': round(avg_2k, 2), 'tac_avg_3k': round(avg_3k, 2), 'tac_avg_4k': round(avg_4k, 2), 'tac_avg_5k': round(avg_5k, 2), 'tac_multikill_rate': round(multikill_rate, 3), 'tac_ace_count': total_5k, } @staticmethod def _calculate_clutch(steam_id: str, conn_l2: sqlite3.Connection) -> Dict[str, Any]: """ Calculate Clutch Performance (10 columns) Columns: - tac_clutch_1v1_attempts, tac_clutch_1v1_wins, tac_clutch_1v1_rate - tac_clutch_1v2_attempts, tac_clutch_1v2_wins, tac_clutch_1v2_rate - tac_clutch_1v3_plus_attempts, tac_clutch_1v3_plus_wins, tac_clutch_1v3_plus_rate - tac_clutch_impact_score Logic: - Wins: Aggregated directly from fact_match_players (trusting upstream data). - Attempts: Calculated by replaying rounds with 'Active Player' filtering to remove ghosts. """ cursor = conn_l2.cursor() # Step 1: Get Wins from fact_match_players cursor.execute(""" SELECT SUM(clutch_1v1) as c1, SUM(clutch_1v2) as c2, SUM(clutch_1v3) as c3, SUM(clutch_1v4) as c4, SUM(clutch_1v5) as c5 FROM fact_match_players WHERE steam_id_64 = ? """, (steam_id,)) wins_row = cursor.fetchone() clutch_1v1_wins = wins_row[0] if wins_row and wins_row[0] else 0 clutch_1v2_wins = wins_row[1] if wins_row and wins_row[1] else 0 clutch_1v3_wins = wins_row[2] if wins_row and wins_row[2] else 0 clutch_1v4_wins = wins_row[3] if wins_row and wins_row[3] else 0 clutch_1v5_wins = wins_row[4] if wins_row and wins_row[4] else 0 # Group 1v3+ wins clutch_1v3_plus_wins = clutch_1v3_wins + clutch_1v4_wins + clutch_1v5_wins # Step 2: Calculate Attempts cursor.execute("SELECT DISTINCT match_id FROM fact_match_players WHERE steam_id_64 = ?", (steam_id,)) match_ids = [row[0] for row in cursor.fetchall()] clutch_1v1_attempts = 0 clutch_1v2_attempts = 0 clutch_1v3_plus_attempts = 0 for match_id in match_ids: # Get Roster cursor.execute("SELECT steam_id_64, team_id FROM fact_match_players WHERE match_id = ?", (match_id,)) roster = cursor.fetchall() my_team_id = None for pid, tid in roster: if str(pid) == str(steam_id): my_team_id = tid break if my_team_id is None: continue all_teammates = {str(pid) for pid, tid in roster if tid == my_team_id} all_enemies = {str(pid) for pid, tid in roster if tid != my_team_id} # Get Events for this match cursor.execute(""" SELECT round_num, event_type, attacker_steam_id, victim_steam_id, event_time FROM fact_round_events WHERE match_id = ? ORDER BY round_num, event_time """, (match_id,)) all_events = cursor.fetchall() # Group events by round from collections import defaultdict events_by_round = defaultdict(list) active_players_by_round = defaultdict(set) for r_num, e_type, attacker, victim, e_time in all_events: events_by_round[r_num].append((e_type, attacker, victim)) if attacker: active_players_by_round[r_num].add(str(attacker)) if victim: active_players_by_round[r_num].add(str(victim)) # Iterate rounds for r_num, round_events in events_by_round.items(): active_players = active_players_by_round[r_num] # If player not active, skip (probably camping or AFK or not spawned) if str(steam_id) not in active_players: continue # Filter roster to active players only (removes ghosts) alive_teammates = all_teammates.intersection(active_players) alive_enemies = all_enemies.intersection(active_players) # Safety: ensure player is in alive_teammates alive_teammates.add(str(steam_id)) clutch_detected = False for e_type, attacker, victim in round_events: if e_type == 'kill': vic_str = str(victim) if vic_str in alive_teammates: alive_teammates.discard(vic_str) elif vic_str in alive_enemies: alive_enemies.discard(vic_str) # Check clutch condition if not clutch_detected: # Teammates dead (len==1 means only me), Enemies alive if len(alive_teammates) == 1 and str(steam_id) in alive_teammates: enemies_cnt = len(alive_enemies) if enemies_cnt > 0: clutch_detected = True if enemies_cnt == 1: clutch_1v1_attempts += 1 elif enemies_cnt == 2: clutch_1v2_attempts += 1 elif enemies_cnt >= 3: clutch_1v3_plus_attempts += 1 # Calculate win rates rate_1v1 = SafeAggregator.safe_divide(clutch_1v1_wins, clutch_1v1_attempts) rate_1v2 = SafeAggregator.safe_divide(clutch_1v2_wins, clutch_1v2_attempts) rate_1v3_plus = SafeAggregator.safe_divide(clutch_1v3_plus_wins, clutch_1v3_plus_attempts) # Clutch impact score: weighted by difficulty impact_score = (clutch_1v1_wins * 1.0 + clutch_1v2_wins * 3.0 + clutch_1v3_plus_wins * 7.0) return { 'tac_clutch_1v1_attempts': clutch_1v1_attempts, 'tac_clutch_1v1_wins': clutch_1v1_wins, 'tac_clutch_1v1_rate': round(rate_1v1, 3), 'tac_clutch_1v2_attempts': clutch_1v2_attempts, 'tac_clutch_1v2_wins': clutch_1v2_wins, 'tac_clutch_1v2_rate': round(rate_1v2, 3), 'tac_clutch_1v3_plus_attempts': clutch_1v3_plus_attempts, 'tac_clutch_1v3_plus_wins': clutch_1v3_plus_wins, 'tac_clutch_1v3_plus_rate': round(rate_1v3_plus, 3), 'tac_clutch_impact_score': round(impact_score, 2) } @staticmethod def _calculate_utility(steam_id: str, conn_l2: sqlite3.Connection) -> Dict[str, Any]: """ Calculate Utility Mastery (12 columns) Columns: - tac_util_flash_per_round, tac_util_smoke_per_round - tac_util_molotov_per_round, tac_util_he_per_round - tac_util_usage_rate - tac_util_nade_dmg_per_round, tac_util_nade_dmg_per_nade - tac_util_flash_time_per_round, tac_util_flash_enemies_per_round - tac_util_flash_efficiency - tac_util_smoke_timing_score - tac_util_impact_score Note: Requires fact_round_player_economy for detailed utility stats """ cursor = conn_l2.cursor() # Check if economy table exists (leetify mode) cursor.execute(""" SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='fact_round_player_economy' """) has_economy = cursor.fetchone()[0] > 0 if not has_economy: # Return zeros if no economy data return { 'tac_util_flash_per_round': 0.0, 'tac_util_smoke_per_round': 0.0, 'tac_util_molotov_per_round': 0.0, 'tac_util_he_per_round': 0.0, 'tac_util_usage_rate': 0.0, 'tac_util_nade_dmg_per_round': 0.0, 'tac_util_nade_dmg_per_nade': 0.0, 'tac_util_flash_time_per_round': 0.0, 'tac_util_flash_enemies_per_round': 0.0, 'tac_util_flash_efficiency': 0.0, 'tac_util_smoke_timing_score': 0.0, 'tac_util_impact_score': 0.0, } # Get total rounds for per-round calculations total_rounds = BaseFeatureProcessor.get_player_round_count(steam_id, conn_l2) if total_rounds == 0: total_rounds = 1 # Utility usage from fact_match_players cursor.execute(""" SELECT SUM(util_flash_usage) as total_flash, SUM(util_smoke_usage) as total_smoke, SUM(util_molotov_usage) as total_molotov, SUM(util_he_usage) as total_he, SUM(flash_enemy) as enemies_flashed, SUM(damage_total) as total_damage, SUM(throw_harm_enemy) as nade_damage, COUNT(*) as matches FROM fact_match_players WHERE steam_id_64 = ? """, (steam_id,)) row = cursor.fetchone() total_flash = row[0] if row[0] else 0 total_smoke = row[1] if row[1] else 0 total_molotov = row[2] if row[2] else 0 total_he = row[3] if row[3] else 0 enemies_flashed = row[4] if row[4] else 0 total_damage = row[5] if row[5] else 0 nade_damage = row[6] if row[6] else 0 rounds_with_data = row[7] if row[7] else 1 total_nades = total_flash + total_smoke + total_molotov + total_he flash_per_round = total_flash / total_rounds smoke_per_round = total_smoke / total_rounds molotov_per_round = total_molotov / total_rounds he_per_round = total_he / total_rounds usage_rate = total_nades / total_rounds # Nade damage (HE grenade + molotov damage from throw_harm_enemy) nade_dmg_per_round = SafeAggregator.safe_divide(nade_damage, total_rounds) nade_dmg_per_nade = SafeAggregator.safe_divide(nade_damage, total_he + total_molotov) # Flash efficiency (simplified - kills per flash from match data) # DEPRECATED: Replaced by Enemies Blinded per Flash logic below # cursor.execute(""" # SELECT SUM(kills) as total_kills # FROM fact_match_players # WHERE steam_id_64 = ? # """, (steam_id,)) # # total_kills = cursor.fetchone()[0] # total_kills = total_kills if total_kills else 0 # flash_efficiency = SafeAggregator.safe_divide(total_kills, total_flash) # Real flash data from fact_match_players # flash_time in L2 is TOTAL flash time (seconds), not average # flash_enemy is TOTAL enemies flashed cursor.execute(""" SELECT SUM(flash_time) as total_flash_time, SUM(flash_enemy) as total_enemies_flashed, SUM(util_flash_usage) as total_flashes_thrown FROM fact_match_players WHERE steam_id_64 = ? """, (steam_id,)) flash_row = cursor.fetchone() total_flash_time = flash_row[0] if flash_row and flash_row[0] else 0.0 total_enemies_flashed = flash_row[1] if flash_row and flash_row[1] else 0 total_flashes_thrown = flash_row[2] if flash_row and flash_row[2] else 0 flash_time_per_round = total_flash_time / total_rounds if total_rounds > 0 else 0.0 flash_enemies_per_round = total_enemies_flashed / total_rounds if total_rounds > 0 else 0.0 # Flash Efficiency: Enemies Blinded per Flash Thrown (instead of kills per flash) # 100% means 1 enemy blinded per flash # 200% means 2 enemies blinded per flash (very good) flash_efficiency = SafeAggregator.safe_divide(total_enemies_flashed, total_flashes_thrown) # Smoke timing score CANNOT be calculated without bomb plant event timestamps # Would require: SELECT event_time FROM fact_round_events WHERE event_type = 'bomb_plant' # Then correlate with util_smoke_usage timing - currently no timing data for utility usage # Commenting out: tac_util_smoke_timing_score smoke_timing_score = 0.0 # Taser Kills Logic (Zeus) # We want Attempts (shots fired) vs Kills # User requested to track "Equipped Count" instead of "Attempts" (shots) # because event logs often miss weapon_fire for taser. # We check fact_round_player_economy for has_zeus = 1 zeus_equipped_count = 0 if has_economy: cursor.execute(""" SELECT COUNT(*) FROM fact_round_player_economy WHERE steam_id_64 = ? AND has_zeus = 1 """, (steam_id,)) zeus_equipped_count = cursor.fetchone()[0] or 0 # Kills still come from event logs # Removed tac_util_zeus_kills per user request (data not available) # cursor.execute(""" # SELECT # COUNT(CASE WHEN event_type = 'kill' AND weapon = 'taser' THEN 1 END) as kills # FROM fact_round_events # WHERE attacker_steam_id = ? # """, (steam_id,)) # zeus_kills = cursor.fetchone()[0] or 0 # Fallback: if equipped count < kills (shouldn't happen if economy data is good), fix it # if zeus_equipped_count < zeus_kills: # zeus_equipped_count = zeus_kills # Utility impact score (composite) impact_score = ( nade_dmg_per_round * 0.3 + flash_efficiency * 2.0 + usage_rate * 10.0 ) return { 'tac_util_flash_per_round': round(flash_per_round, 2), 'tac_util_smoke_per_round': round(smoke_per_round, 2), 'tac_util_molotov_per_round': round(molotov_per_round, 2), 'tac_util_he_per_round': round(he_per_round, 2), 'tac_util_usage_rate': round(usage_rate, 2), 'tac_util_nade_dmg_per_round': round(nade_dmg_per_round, 2), 'tac_util_nade_dmg_per_nade': round(nade_dmg_per_nade, 2), 'tac_util_flash_time_per_round': round(flash_time_per_round, 2), 'tac_util_flash_enemies_per_round': round(flash_enemies_per_round, 2), 'tac_util_flash_efficiency': round(flash_efficiency, 3), #'tac_util_smoke_timing_score': round(smoke_timing_score, 2), # Removed per user request 'tac_util_impact_score': round(impact_score, 2), 'tac_util_zeus_equipped_count': zeus_equipped_count, #'tac_util_zeus_kills': zeus_kills, # Removed } @staticmethod def _calculate_economy(steam_id: str, conn_l2: sqlite3.Connection) -> Dict[str, Any]: """ Calculate Economy Efficiency (8 columns) Columns: - tac_eco_dmg_per_1k - tac_eco_kpr_eco_rounds, tac_eco_kd_eco_rounds - tac_eco_kpr_force_rounds, tac_eco_kpr_full_rounds - tac_eco_save_discipline - tac_eco_force_success_rate - tac_eco_efficiency_score Note: Requires fact_round_player_economy for equipment values """ cursor = conn_l2.cursor() # Check if economy table exists cursor.execute(""" SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='fact_round_player_economy' """) has_economy = cursor.fetchone()[0] > 0 if not has_economy: # Return zeros if no economy data return { 'tac_eco_dmg_per_1k': 0.0, 'tac_eco_kpr_eco_rounds': 0.0, 'tac_eco_kd_eco_rounds': 0.0, 'tac_eco_kpr_force_rounds': 0.0, 'tac_eco_kpr_full_rounds': 0.0, 'tac_eco_save_discipline': 0.0, 'tac_eco_force_success_rate': 0.0, 'tac_eco_efficiency_score': 0.0, } # REAL economy-based performance from round-level data # Join fact_round_player_economy with fact_round_events to get kills/deaths per economy state # Fallback if no economy table but we want basic DMG/1k approximation from total damage / assumed average buy # But avg_equip_value is from economy table. # If no economy table, we can't do this accurately. # However, user says "Eco Dmg/1k" is 0.00. # If we have NO economy table, we returned early above. # If we reached here, we HAVE economy table (or at least check passed). # Let's check logic. # Get average equipment value cursor.execute(""" SELECT AVG(equipment_value) FROM fact_round_player_economy WHERE steam_id_64 = ? AND equipment_value IS NOT NULL AND equipment_value > 0 -- Filter out zero equipment value rounds? Or include them? """, (steam_id,)) avg_equip_val_res = cursor.fetchone() avg_equip_value = avg_equip_val_res[0] if avg_equip_val_res and avg_equip_val_res[0] else 4000.0 # Avoid division by zero if avg_equip_value is somehow 0 if avg_equip_value < 100: avg_equip_value = 4000.0 # Get total damage and calculate dmg per $1000 cursor.execute(""" SELECT SUM(damage_total), SUM(round_total) FROM fact_match_players WHERE steam_id_64 = ? """, (steam_id,)) damage_row = cursor.fetchone() total_damage = damage_row[0] if damage_row[0] else 0 total_rounds = damage_row[1] if damage_row[1] else 1 avg_dmg_per_round = SafeAggregator.safe_divide(total_damage, total_rounds) # Formula: (ADR) / (AvgSpend / 1000) # e.g. 80 ADR / (4000 / 1000) = 80 / 4 = 20 dmg/$1k dmg_per_1k = SafeAggregator.safe_divide(avg_dmg_per_round, (avg_equip_value / 1000.0)) # ECO rounds: equipment_value < 2000 cursor.execute(""" SELECT e.match_id, e.round_num, e.steam_id_64, COUNT(CASE WHEN fre.event_type = 'kill' AND fre.attacker_steam_id = e.steam_id_64 THEN 1 END) as kills, COUNT(CASE WHEN fre.event_type = 'kill' AND fre.victim_steam_id = e.steam_id_64 THEN 1 END) as deaths FROM fact_round_player_economy e LEFT JOIN fact_round_events fre ON e.match_id = fre.match_id AND e.round_num = fre.round_num WHERE e.steam_id_64 = ? AND e.equipment_value < 2000 GROUP BY e.match_id, e.round_num, e.steam_id_64 """, (steam_id,)) eco_rounds = cursor.fetchall() eco_kills = sum(row[3] for row in eco_rounds) eco_deaths = sum(row[4] for row in eco_rounds) eco_round_count = len(eco_rounds) kpr_eco = SafeAggregator.safe_divide(eco_kills, eco_round_count) kd_eco = SafeAggregator.safe_divide(eco_kills, eco_deaths) # FORCE rounds: 2000 <= equipment_value < 3500 cursor.execute(""" SELECT e.match_id, e.round_num, e.steam_id_64, COUNT(CASE WHEN fre.event_type = 'kill' AND fre.attacker_steam_id = e.steam_id_64 THEN 1 END) as kills, fr.winner_side, e.side FROM fact_round_player_economy e LEFT JOIN fact_round_events fre ON e.match_id = fre.match_id AND e.round_num = fre.round_num LEFT JOIN fact_rounds fr ON e.match_id = fr.match_id AND e.round_num = fr.round_num WHERE e.steam_id_64 = ? AND e.equipment_value >= 2000 AND e.equipment_value < 3500 GROUP BY e.match_id, e.round_num, e.steam_id_64, fr.winner_side, e.side """, (steam_id,)) force_rounds = cursor.fetchall() force_kills = sum(row[3] for row in force_rounds) force_round_count = len(force_rounds) force_wins = sum(1 for row in force_rounds if row[4] == row[5]) # winner_side == player_side kpr_force = SafeAggregator.safe_divide(force_kills, force_round_count) force_success = SafeAggregator.safe_divide(force_wins, force_round_count) # FULL BUY rounds: equipment_value >= 3500 cursor.execute(""" SELECT e.match_id, e.round_num, e.steam_id_64, COUNT(CASE WHEN fre.event_type = 'kill' AND fre.attacker_steam_id = e.steam_id_64 THEN 1 END) as kills FROM fact_round_player_economy e LEFT JOIN fact_round_events fre ON e.match_id = fre.match_id AND e.round_num = fre.round_num WHERE e.steam_id_64 = ? AND e.equipment_value >= 3500 GROUP BY e.match_id, e.round_num, e.steam_id_64 """, (steam_id,)) full_rounds = cursor.fetchall() full_kills = sum(row[3] for row in full_rounds) full_round_count = len(full_rounds) kpr_full = SafeAggregator.safe_divide(full_kills, full_round_count) # Save discipline: ratio of eco rounds to total rounds (lower is better discipline) save_discipline = 1.0 - SafeAggregator.safe_divide(eco_round_count, total_rounds) # Efficiency score: weighted KPR across economy states efficiency_score = (kpr_eco * 1.5 + kpr_force * 1.2 + kpr_full * 1.0) / 3.7 return { 'tac_eco_dmg_per_1k': round(dmg_per_1k, 2), 'tac_eco_kpr_eco_rounds': round(kpr_eco, 3), 'tac_eco_kd_eco_rounds': round(kd_eco, 3), 'tac_eco_kpr_force_rounds': round(kpr_force, 3), 'tac_eco_kpr_full_rounds': round(kpr_full, 3), 'tac_eco_save_discipline': round(save_discipline, 3), 'tac_eco_force_success_rate': round(force_success, 3), 'tac_eco_efficiency_score': round(efficiency_score, 2), } def _get_default_tactical_features() -> Dict[str, Any]: """Return default zero values for all 44 TACTICAL features""" return { # Opening Impact (8) 'tac_avg_fk': 0.0, 'tac_avg_fd': 0.0, 'tac_fk_rate': 0.0, 'tac_fd_rate': 0.0, 'tac_fk_success_rate': 0.0, 'tac_entry_kill_rate': 0.0, 'tac_entry_death_rate': 0.0, 'tac_opening_duel_winrate': 0.0, # Multi-Kill (6) 'tac_avg_2k': 0.0, 'tac_avg_3k': 0.0, 'tac_avg_4k': 0.0, 'tac_avg_5k': 0.0, 'tac_multikill_rate': 0.0, 'tac_ace_count': 0, # Clutch Performance (10) 'tac_clutch_1v1_attempts': 0, 'tac_clutch_1v1_wins': 0, 'tac_clutch_1v1_rate': 0.0, 'tac_clutch_1v2_attempts': 0, 'tac_clutch_1v2_wins': 0, 'tac_clutch_1v2_rate': 0.0, 'tac_clutch_1v3_plus_attempts': 0, 'tac_clutch_1v3_plus_wins': 0, 'tac_clutch_1v3_plus_rate': 0.0, 'tac_clutch_impact_score': 0.0, # Utility Mastery (12) 'tac_util_flash_per_round': 0.0, 'tac_util_smoke_per_round': 0.0, 'tac_util_molotov_per_round': 0.0, 'tac_util_he_per_round': 0.0, 'tac_util_usage_rate': 0.0, 'tac_util_nade_dmg_per_round': 0.0, 'tac_util_nade_dmg_per_nade': 0.0, 'tac_util_flash_time_per_round': 0.0, 'tac_util_flash_enemies_per_round': 0.0, 'tac_util_flash_efficiency': 0.0, # 'tac_util_smoke_timing_score': 0.0, # Removed 'tac_util_impact_score': 0.0, 'tac_util_zeus_equipped_count': 0, # 'tac_util_zeus_kills': 0, # Removed # Economy Efficiency (8) 'tac_eco_dmg_per_1k': 0.0, 'tac_eco_kpr_eco_rounds': 0.0, 'tac_eco_kd_eco_rounds': 0.0, 'tac_eco_kpr_force_rounds': 0.0, 'tac_eco_kpr_full_rounds': 0.0, 'tac_eco_save_discipline': 0.0, 'tac_eco_force_success_rate': 0.0, 'tac_eco_efficiency_score': 0.0, }