Files
clutch/database/L3/processors/tactical_processor.py
2026-02-05 23:26:03 +08:00

723 lines
30 KiB
Python

"""
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,
}