1006 lines
34 KiB
Python
1006 lines
34 KiB
Python
|
|
import logging
|
|
import os
|
|
import sys
|
|
import sqlite3
|
|
import json
|
|
import argparse
|
|
import concurrent.futures
|
|
from collections import defaultdict, deque
|
|
from itertools import combinations
|
|
from typing import Optional
|
|
|
|
# Setup logging
|
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Get absolute paths
|
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Points to database/ directory
|
|
PROJECT_ROOT = os.path.dirname(BASE_DIR) # Points to project root
|
|
sys.path.insert(0, PROJECT_ROOT) # Add project root to Python path
|
|
|
|
from database.paths import L2_DB, L3_DB, L3_SCHEMA, WEB_DB
|
|
|
|
L2_DB_PATH = str(L2_DB)
|
|
L3_DB_PATH = str(L3_DB)
|
|
L3_BACKUP_PATH = f"{L3_DB_PATH}.bak"
|
|
WEB_DB_PATH = str(WEB_DB)
|
|
SCHEMA_PATH = str(L3_SCHEMA)
|
|
|
|
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():
|
|
"""Initialize L3 database with new schema"""
|
|
l3_dir = os.path.dirname(L3_DB_PATH)
|
|
if not os.path.exists(l3_dir):
|
|
os.makedirs(l3_dir)
|
|
|
|
logger.info(f"Initializing L3 database at: {L3_DB_PATH}")
|
|
conn = sqlite3.connect(L3_DB_PATH)
|
|
|
|
try:
|
|
with open(SCHEMA_PATH, 'r', encoding='utf-8') as f:
|
|
schema_sql = f.read()
|
|
conn.executescript(schema_sql)
|
|
|
|
conn.commit()
|
|
logger.info("✓ L3 schema created successfully")
|
|
|
|
# Verify tables
|
|
cursor = conn.cursor()
|
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
|
tables = [row[0] for row in cursor.fetchall()]
|
|
logger.info(f"✓ Created {len(tables)} tables: {', '.join(tables)}")
|
|
|
|
# Verify dm_player_features columns
|
|
cursor.execute("PRAGMA table_info(dm_player_features)")
|
|
columns = cursor.fetchall()
|
|
logger.info(f"✓ dm_player_features has {len(columns)} columns")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error initializing L3 database: {e}")
|
|
raise
|
|
finally:
|
|
conn.close()
|
|
|
|
logger.info("L3 DB Initialized with new 5-tier architecture")
|
|
|
|
def _get_team_players():
|
|
"""Get list of steam_ids from Web App team lineups"""
|
|
if not os.path.exists(WEB_DB_PATH):
|
|
logger.warning(f"Web DB not found at {WEB_DB_PATH}, returning empty list")
|
|
return set()
|
|
|
|
try:
|
|
conn = sqlite3.connect(WEB_DB_PATH)
|
|
cursor = conn.cursor()
|
|
columns = {
|
|
row[1] for row in cursor.execute("PRAGMA table_info(team_lineups)")
|
|
}
|
|
if 'is_active' in columns:
|
|
cursor.execute(
|
|
"""
|
|
SELECT player_ids_json
|
|
FROM team_lineups
|
|
WHERE is_active = 1
|
|
ORDER BY created_at DESC, id DESC
|
|
LIMIT 1
|
|
"""
|
|
)
|
|
else:
|
|
cursor.execute(
|
|
"""
|
|
SELECT player_ids_json
|
|
FROM team_lineups
|
|
ORDER BY created_at DESC, id DESC
|
|
LIMIT 1
|
|
"""
|
|
)
|
|
rows = cursor.fetchall()
|
|
|
|
steam_ids = set()
|
|
for row in rows:
|
|
if row[0]:
|
|
try:
|
|
ids = json.loads(row[0])
|
|
if isinstance(ids, list):
|
|
steam_ids.update(ids)
|
|
except json.JSONDecodeError:
|
|
logger.warning(f"Failed to parse player_ids_json: {row[0]}")
|
|
|
|
conn.close()
|
|
logger.info(f"Found {len(steam_ids)} unique players in Team Lineups")
|
|
return steam_ids
|
|
except Exception as e:
|
|
logger.error(f"Error reading Web DB: {e}")
|
|
return set()
|
|
|
|
def _get_match_date_range(steam_id: str, conn_l2: sqlite3.Connection):
|
|
cursor = conn_l2.cursor()
|
|
cursor.execute("""
|
|
SELECT MIN(m.start_time), MAX(m.start_time)
|
|
FROM fact_match_players p
|
|
JOIN fact_matches m ON p.match_id = m.match_id
|
|
WHERE p.steam_id_64 = ?
|
|
""", (steam_id,))
|
|
date_row = cursor.fetchone()
|
|
first_match_date = date_row[0] if date_row and date_row[0] else None
|
|
last_match_date = date_row[1] if date_row and date_row[1] else None
|
|
return first_match_date, last_match_date
|
|
|
|
def _build_player_record(steam_id: str):
|
|
try:
|
|
from database.L3.processors import (
|
|
BasicProcessor,
|
|
TacticalProcessor,
|
|
IntelligenceProcessor,
|
|
MetaProcessor,
|
|
CompositeProcessor
|
|
)
|
|
conn_l2 = sqlite3.connect(L2_DB_PATH)
|
|
conn_l2.row_factory = sqlite3.Row
|
|
features = {}
|
|
features.update(BasicProcessor.calculate(steam_id, conn_l2))
|
|
features.update(TacticalProcessor.calculate(steam_id, conn_l2))
|
|
features.update(IntelligenceProcessor.calculate(steam_id, conn_l2))
|
|
features.update(MetaProcessor.calculate(steam_id, conn_l2))
|
|
features.update(CompositeProcessor.calculate(steam_id, conn_l2, features))
|
|
match_count = _get_match_count(steam_id, conn_l2)
|
|
round_count = _get_round_count(steam_id, conn_l2)
|
|
first_match_date, last_match_date = _get_match_date_range(steam_id, conn_l2)
|
|
conn_l2.close()
|
|
return {
|
|
"steam_id": steam_id,
|
|
"features": features,
|
|
"match_count": match_count,
|
|
"round_count": round_count,
|
|
"first_match_date": first_match_date,
|
|
"last_match_date": last_match_date,
|
|
"error": None,
|
|
}
|
|
except Exception as e:
|
|
return {
|
|
"steam_id": steam_id,
|
|
"features": None,
|
|
"match_count": 0,
|
|
"round_count": 0,
|
|
"first_match_date": None,
|
|
"last_match_date": None,
|
|
"error": str(e),
|
|
}
|
|
|
|
def _backup_l3_database(source_path=L3_DB_PATH, backup_path=L3_BACKUP_PATH):
|
|
if not os.path.exists(source_path):
|
|
return None
|
|
|
|
source = sqlite3.connect(source_path)
|
|
backup = sqlite3.connect(backup_path)
|
|
try:
|
|
source.backup(backup)
|
|
result = backup.execute("PRAGMA quick_check").fetchone()[0]
|
|
if result != 'ok':
|
|
raise RuntimeError(f"L3 backup quick_check failed: {result}")
|
|
finally:
|
|
source.close()
|
|
backup.close()
|
|
logger.info("L3 backup created at %s", backup_path)
|
|
return backup_path
|
|
|
|
|
|
def main(force_all: bool = False, workers: int = 1, create_backup: bool = True):
|
|
"""
|
|
Main L3 feature building pipeline using modular processors
|
|
"""
|
|
logger.info("========================================")
|
|
logger.info("Starting L3 Builder with 5-Tier Architecture")
|
|
logger.info("========================================")
|
|
|
|
if create_backup:
|
|
_backup_l3_database()
|
|
|
|
# 1. Ensure Schema is up to date
|
|
init_db()
|
|
|
|
# 2. Import processors
|
|
try:
|
|
from database.L3.processors import (
|
|
BasicProcessor,
|
|
TacticalProcessor,
|
|
IntelligenceProcessor,
|
|
MetaProcessor,
|
|
CompositeProcessor
|
|
)
|
|
logger.info("✓ All 5 processors imported successfully")
|
|
except ImportError as e:
|
|
logger.error(f"Failed to import processors: {e}")
|
|
return
|
|
|
|
# 3. Connect to databases
|
|
conn_l2 = sqlite3.connect(L2_DB_PATH)
|
|
conn_l2.row_factory = sqlite3.Row
|
|
conn_l3 = sqlite3.connect(L3_DB_PATH)
|
|
|
|
try:
|
|
conn_l3.execute("BEGIN IMMEDIATE")
|
|
cursor_l2 = conn_l2.cursor()
|
|
if force_all:
|
|
logger.info("Force mode enabled: building L3 for all players in L2.")
|
|
sql = """
|
|
SELECT DISTINCT steam_id_64
|
|
FROM dim_players
|
|
ORDER BY steam_id_64
|
|
"""
|
|
cursor_l2.execute(sql)
|
|
else:
|
|
team_players = _get_team_players()
|
|
if not team_players:
|
|
logger.warning("No players found in Team Lineups. Aborting L3 build.")
|
|
return
|
|
|
|
placeholders = ','.join(['?' for _ in team_players])
|
|
sql = f"""
|
|
SELECT DISTINCT steam_id_64
|
|
FROM dim_players
|
|
WHERE steam_id_64 IN ({placeholders})
|
|
ORDER BY steam_id_64
|
|
"""
|
|
cursor_l2.execute(sql, list(team_players))
|
|
|
|
players = cursor_l2.fetchall()
|
|
total_players = len(players)
|
|
logger.info(f"Found {total_players} matching players in L2 to process")
|
|
|
|
if total_players == 0:
|
|
logger.warning("No matching players found in dim_players table")
|
|
return
|
|
|
|
success_count = 0
|
|
error_count = 0
|
|
processed_count = 0
|
|
|
|
if workers and workers > 1:
|
|
steam_ids = [row[0] for row in players]
|
|
with concurrent.futures.ProcessPoolExecutor(max_workers=workers) as executor:
|
|
futures = [executor.submit(_build_player_record, sid) for sid in steam_ids]
|
|
for future in concurrent.futures.as_completed(futures):
|
|
result = future.result()
|
|
processed_count += 1
|
|
if result.get("error"):
|
|
error_count += 1
|
|
logger.error(f"Error processing player {result.get('steam_id')}: {result.get('error')}")
|
|
else:
|
|
_upsert_features(
|
|
conn_l3,
|
|
result["steam_id"],
|
|
result["features"],
|
|
result["match_count"],
|
|
result["round_count"],
|
|
None,
|
|
result["first_match_date"],
|
|
result["last_match_date"],
|
|
)
|
|
success_count += 1
|
|
if processed_count % 2 == 0:
|
|
logger.info(f"Progress: {processed_count}/{total_players} ({success_count} success, {error_count} errors)")
|
|
else:
|
|
for idx, row in enumerate(players, 1):
|
|
steam_id = row[0]
|
|
|
|
try:
|
|
features = {}
|
|
features.update(BasicProcessor.calculate(steam_id, conn_l2))
|
|
features.update(TacticalProcessor.calculate(steam_id, conn_l2))
|
|
features.update(IntelligenceProcessor.calculate(steam_id, conn_l2))
|
|
features.update(MetaProcessor.calculate(steam_id, conn_l2))
|
|
features.update(CompositeProcessor.calculate(steam_id, conn_l2, features))
|
|
match_count = _get_match_count(steam_id, conn_l2)
|
|
round_count = _get_round_count(steam_id, conn_l2)
|
|
first_match_date, last_match_date = _get_match_date_range(steam_id, conn_l2)
|
|
_upsert_features(conn_l3, steam_id, features, match_count, round_count, conn_l2, first_match_date, last_match_date)
|
|
success_count += 1
|
|
except Exception as e:
|
|
error_count += 1
|
|
logger.error(f"Error processing player {steam_id}: {e}")
|
|
if error_count <= 3:
|
|
import traceback
|
|
traceback.print_exc()
|
|
continue
|
|
|
|
processed_count = idx
|
|
if processed_count % 2 == 0:
|
|
logger.info(f"Progress: {processed_count}/{total_players} ({success_count} success, {error_count} errors)")
|
|
|
|
if error_count:
|
|
raise RuntimeError(
|
|
f"L3 feature build failed for {error_count}/{total_players} players"
|
|
)
|
|
|
|
processed_ids = [str(row[0]) for row in players]
|
|
_update_percentiles(conn_l3, processed_ids)
|
|
_rebuild_auxiliary_marts(conn_l2, conn_l3, processed_ids)
|
|
_rebuild_team_marts(conn_l2, conn_l3, processed_ids)
|
|
from database.L3.processors.narrative_processor import NarrativeProcessor
|
|
narrative_counts = NarrativeProcessor.rebuild(
|
|
conn_l2,
|
|
conn_l3,
|
|
processed_ids,
|
|
)
|
|
logger.info("Narrative marts rebuilt: %s", narrative_counts)
|
|
from database.L3.processors.discovery_processor import DiscoveryProcessor
|
|
discovery_counts = DiscoveryProcessor.rebuild(
|
|
conn_l2,
|
|
conn_l3,
|
|
processed_ids,
|
|
)
|
|
logger.info("Discovery marts rebuilt: %s", discovery_counts)
|
|
|
|
quick_check = conn_l3.execute("PRAGMA quick_check").fetchone()[0]
|
|
if quick_check != 'ok':
|
|
raise RuntimeError(f"L3 quick_check failed before commit: {quick_check}")
|
|
conn_l3.commit()
|
|
|
|
logger.info("========================================")
|
|
logger.info(f"L3 Build Complete!")
|
|
logger.info(f" Success: {success_count} players")
|
|
logger.info(f" Errors: {error_count} players")
|
|
logger.info(f" Total: {total_players} players")
|
|
logger.info(f" Success Rate: {success_count/total_players*100:.1f}%")
|
|
logger.info("========================================")
|
|
|
|
except Exception as e:
|
|
conn_l3.rollback()
|
|
logger.error(f"Fatal error during L3 build: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
raise
|
|
|
|
finally:
|
|
conn_l2.close()
|
|
conn_l3.close()
|
|
|
|
|
|
def _get_match_count(steam_id: str, conn_l2: sqlite3.Connection) -> int:
|
|
"""Get total match count for player"""
|
|
cursor = conn_l2.cursor()
|
|
cursor.execute("""
|
|
SELECT COUNT(*) FROM fact_match_players
|
|
WHERE steam_id_64 = ?
|
|
""", (steam_id,))
|
|
return cursor.fetchone()[0]
|
|
|
|
|
|
def _get_round_count(steam_id: str, conn_l2: sqlite3.Connection) -> int:
|
|
"""Get total round count for player"""
|
|
cursor = conn_l2.cursor()
|
|
cursor.execute("""
|
|
SELECT COALESCE(SUM(round_total), 0) FROM fact_match_players
|
|
WHERE steam_id_64 = ?
|
|
""", (steam_id,))
|
|
return cursor.fetchone()[0]
|
|
|
|
|
|
def _upsert_features(conn_l3: sqlite3.Connection, steam_id: str, features: dict,
|
|
match_count: int, round_count: int, conn_l2: Optional[sqlite3.Connection],
|
|
first_match_date=None, last_match_date=None):
|
|
"""
|
|
Insert or update player features in dm_player_features
|
|
"""
|
|
cursor_l3 = conn_l3.cursor()
|
|
if first_match_date is None or last_match_date is None:
|
|
if conn_l2 is not None:
|
|
first_match_date, last_match_date = _get_match_date_range(steam_id, conn_l2)
|
|
else:
|
|
first_match_date = None
|
|
last_match_date = None
|
|
|
|
# Add metadata to features
|
|
features['total_matches'] = match_count
|
|
features['total_rounds'] = round_count
|
|
features['first_match_date'] = first_match_date
|
|
features['last_match_date'] = last_match_date
|
|
|
|
# Build dynamic column list from features dict
|
|
columns = ['steam_id_64'] + list(features.keys())
|
|
placeholders = ','.join(['?' for _ in columns])
|
|
columns_sql = ','.join(columns)
|
|
|
|
# Build UPDATE SET clause for ON CONFLICT
|
|
update_clauses = [f"{col}=excluded.{col}" for col in features.keys()]
|
|
update_clause_sql = ','.join(update_clauses)
|
|
|
|
values = [steam_id] + [features[k] for k in features.keys()]
|
|
|
|
sql = f"""
|
|
INSERT INTO dm_player_features ({columns_sql})
|
|
VALUES ({placeholders})
|
|
ON CONFLICT(steam_id_64) DO UPDATE SET
|
|
{update_clause_sql},
|
|
last_updated=CURRENT_TIMESTAMP
|
|
"""
|
|
|
|
cursor_l3.execute(sql, values)
|
|
|
|
|
|
def _rebuild_auxiliary_marts(conn_l2, conn_l3, steam_ids):
|
|
"""Rebuild player-grain marts used by profiles and trend APIs."""
|
|
if not steam_ids:
|
|
return
|
|
|
|
logger.info("Rebuilding L3 match, map and weapon marts")
|
|
total_history = 0
|
|
total_maps = 0
|
|
total_weapons = 0
|
|
total_periods = 0
|
|
total_records = 0
|
|
|
|
for start in range(0, len(steam_ids), 400):
|
|
chunk = steam_ids[start:start + 400]
|
|
placeholders = ','.join('?' for _ in chunk)
|
|
|
|
for table in (
|
|
'dm_player_match_history',
|
|
'dm_player_map_stats',
|
|
'dm_player_weapon_stats',
|
|
'dm_player_period_stats',
|
|
'dm_player_records',
|
|
):
|
|
conn_l3.execute(
|
|
f"DELETE FROM {table} WHERE steam_id_64 IN ({placeholders})",
|
|
chunk,
|
|
)
|
|
|
|
history_rows = conn_l2.execute(
|
|
f"""
|
|
SELECT
|
|
mp.steam_id_64,
|
|
mp.match_id,
|
|
m.start_time,
|
|
mp.rating,
|
|
mp.kd_ratio,
|
|
mp.adr,
|
|
mp.kast,
|
|
mp.is_win,
|
|
m.map_name,
|
|
mp.kills,
|
|
mp.deaths,
|
|
mp.headshot_count,
|
|
(
|
|
SELECT AVG(teammate.rating)
|
|
FROM fact_match_players teammate
|
|
WHERE teammate.match_id = mp.match_id
|
|
AND teammate.team_id = mp.team_id
|
|
AND teammate.steam_id_64 != mp.steam_id_64
|
|
) AS teammate_avg_rating
|
|
FROM fact_match_players mp
|
|
JOIN fact_matches m ON m.match_id = mp.match_id
|
|
WHERE mp.steam_id_64 IN ({placeholders})
|
|
ORDER BY mp.steam_id_64, m.start_time, mp.match_id
|
|
""",
|
|
chunk,
|
|
).fetchall()
|
|
|
|
history_values = []
|
|
player_state = defaultdict(lambda: {
|
|
'sequence': 0,
|
|
'rating_sum': 0.0,
|
|
'recent': deque(maxlen=10),
|
|
})
|
|
for row in history_rows:
|
|
steam_id = str(row[0])
|
|
state = player_state[steam_id]
|
|
rating = float(row[3] or 0.0)
|
|
state['sequence'] += 1
|
|
state['rating_sum'] += rating
|
|
state['recent'].append(rating)
|
|
history_values.append((
|
|
steam_id,
|
|
row[1],
|
|
row[2],
|
|
state['sequence'],
|
|
row[3],
|
|
row[4],
|
|
row[5],
|
|
row[6],
|
|
row[7],
|
|
row[8],
|
|
None,
|
|
row[12],
|
|
state['rating_sum'] / state['sequence'],
|
|
sum(state['recent']) / len(state['recent']),
|
|
))
|
|
|
|
conn_l3.executemany(
|
|
"""
|
|
INSERT INTO dm_player_match_history (
|
|
steam_id_64, match_id, match_date, match_sequence,
|
|
rating, kd_ratio, adr, kast, is_win, map_name,
|
|
opponent_avg_elo, teammate_avg_rating,
|
|
cumulative_rating, rolling_10_rating
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
history_values,
|
|
)
|
|
total_history += len(history_values)
|
|
|
|
map_rows = conn_l2.execute(
|
|
f"""
|
|
SELECT
|
|
mp.steam_id_64,
|
|
m.map_name,
|
|
COUNT(*) AS matches,
|
|
SUM(CASE WHEN mp.is_win = 1 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,
|
|
AVG(mp.kast) AS avg_kast,
|
|
MAX(mp.rating) AS best_rating,
|
|
MIN(mp.rating) AS worst_rating
|
|
FROM fact_match_players mp
|
|
JOIN fact_matches m ON m.match_id = mp.match_id
|
|
WHERE mp.steam_id_64 IN ({placeholders})
|
|
AND m.map_name IS NOT NULL
|
|
AND m.map_name != ''
|
|
GROUP BY mp.steam_id_64, m.map_name
|
|
""",
|
|
chunk,
|
|
).fetchall()
|
|
map_values = [
|
|
tuple(row[:4]) + (
|
|
(row[3] or 0) / row[2] if row[2] else 0.0,
|
|
) + tuple(row[4:])
|
|
for row in map_rows
|
|
]
|
|
conn_l3.executemany(
|
|
"""
|
|
INSERT INTO dm_player_map_stats (
|
|
steam_id_64, map_name, matches, wins, win_rate,
|
|
avg_rating, avg_kd, avg_adr, avg_kast,
|
|
best_rating, worst_rating
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
map_values,
|
|
)
|
|
total_maps += len(map_values)
|
|
|
|
round_counts = {
|
|
str(row[0]): int(row[1] or 0)
|
|
for row in conn_l2.execute(
|
|
f"""
|
|
SELECT steam_id_64, SUM(round_total)
|
|
FROM fact_match_players
|
|
WHERE steam_id_64 IN ({placeholders})
|
|
GROUP BY steam_id_64
|
|
""",
|
|
chunk,
|
|
)
|
|
}
|
|
weapon_rows = conn_l2.execute(
|
|
f"""
|
|
SELECT
|
|
attacker_steam_id,
|
|
weapon,
|
|
COUNT(*) AS total_kills,
|
|
SUM(CASE WHEN is_headshot = 1 THEN 1 ELSE 0 END) AS total_headshots,
|
|
COUNT(DISTINCT match_id || ':' || round_num) AS usage_rounds
|
|
FROM fact_round_events
|
|
WHERE event_type = 'kill'
|
|
AND attacker_steam_id IN ({placeholders})
|
|
AND weapon IS NOT NULL
|
|
AND weapon != ''
|
|
GROUP BY attacker_steam_id, weapon
|
|
""",
|
|
chunk,
|
|
).fetchall()
|
|
weapon_values = []
|
|
for row in weapon_rows:
|
|
rounds = round_counts.get(str(row[0]), 0)
|
|
kills = int(row[2] or 0)
|
|
headshots = int(row[3] or 0)
|
|
usage_rounds = int(row[4] or 0)
|
|
hs_rate = headshots / kills if kills else 0.0
|
|
usage_rate = usage_rounds / rounds if rounds else 0.0
|
|
kills_per_round = kills / rounds if rounds else 0.0
|
|
effectiveness = kills / usage_rounds if usage_rounds else 0.0
|
|
weapon_values.append((
|
|
str(row[0]),
|
|
row[1],
|
|
kills,
|
|
headshots,
|
|
hs_rate,
|
|
usage_rounds,
|
|
usage_rate,
|
|
kills_per_round,
|
|
effectiveness,
|
|
))
|
|
conn_l3.executemany(
|
|
"""
|
|
INSERT INTO dm_player_weapon_stats (
|
|
steam_id_64, weapon_name, total_kills, total_headshots,
|
|
hs_rate, usage_rounds, usage_rate,
|
|
avg_kills_per_round, effectiveness_score
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
weapon_values,
|
|
)
|
|
total_weapons += len(weapon_values)
|
|
|
|
period_values = _calculate_period_rows(history_rows)
|
|
conn_l3.executemany(
|
|
"""
|
|
INSERT INTO dm_player_period_stats (
|
|
steam_id_64, period_key, period_label,
|
|
period_start, period_end, matches, wins, win_rate,
|
|
avg_rating, avg_kd, avg_adr, avg_kast,
|
|
total_kills, total_deaths, sample_reliable
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
period_values,
|
|
)
|
|
total_periods += len(period_values)
|
|
|
|
record_values = _calculate_record_rows(history_rows)
|
|
conn_l3.executemany(
|
|
"""
|
|
INSERT INTO dm_player_records (
|
|
steam_id_64, record_key, record_label, record_value,
|
|
match_id, map_name, match_date
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
record_values,
|
|
)
|
|
total_records += len(record_values)
|
|
|
|
logger.info(
|
|
"Auxiliary marts rebuilt: %s history, %s map, %s weapon, "
|
|
"%s period, %s record rows",
|
|
total_history,
|
|
total_maps,
|
|
total_weapons,
|
|
total_periods,
|
|
total_records,
|
|
)
|
|
|
|
|
|
def _group_player_match_rows(history_rows):
|
|
grouped = defaultdict(list)
|
|
for row in history_rows:
|
|
grouped[str(row['steam_id_64'])].append(row)
|
|
for rows in grouped.values():
|
|
rows.sort(key=lambda row: (row['start_time'] or 0, row['match_id']))
|
|
return grouped
|
|
|
|
|
|
def _safe_average(rows, key):
|
|
values = [float(row[key]) for row in rows if row[key] is not None]
|
|
return sum(values) / len(values) if values else None
|
|
|
|
|
|
def _calculate_period_rows(history_rows):
|
|
result = []
|
|
for steam_id, all_rows in _group_player_match_rows(history_rows).items():
|
|
latest_time = max(int(row['start_time'] or 0) for row in all_rows)
|
|
period_groups = [
|
|
('career', '生涯', all_rows),
|
|
('last_10', '最近 10 场', all_rows[-10:]),
|
|
('last_20', '最近 20 场', all_rows[-20:]),
|
|
('last_30', '最近 30 场', all_rows[-30:]),
|
|
(
|
|
'days_30',
|
|
'最近 30 天',
|
|
[
|
|
row for row in all_rows
|
|
if int(row['start_time'] or 0) >= latest_time - 30 * 86400
|
|
],
|
|
),
|
|
(
|
|
'days_90',
|
|
'最近 90 天',
|
|
[
|
|
row for row in all_rows
|
|
if int(row['start_time'] or 0) >= latest_time - 90 * 86400
|
|
],
|
|
),
|
|
]
|
|
|
|
for period_key, period_label, rows in period_groups:
|
|
if not rows:
|
|
continue
|
|
matches = len(rows)
|
|
wins = sum(1 for row in rows if row['is_win'])
|
|
kills = sum(int(row['kills'] or 0) for row in rows)
|
|
deaths = sum(int(row['deaths'] or 0) for row in rows)
|
|
result.append((
|
|
steam_id,
|
|
period_key,
|
|
period_label,
|
|
min(int(row['start_time'] or 0) for row in rows),
|
|
max(int(row['start_time'] or 0) for row in rows),
|
|
matches,
|
|
wins,
|
|
wins / matches,
|
|
_safe_average(rows, 'rating'),
|
|
kills / deaths if deaths else float(kills),
|
|
_safe_average(rows, 'adr'),
|
|
_safe_average(rows, 'kast'),
|
|
kills,
|
|
deaths,
|
|
1 if matches >= 10 else 0,
|
|
))
|
|
return result
|
|
|
|
|
|
def _calculate_record_rows(history_rows):
|
|
result = []
|
|
metric_definitions = (
|
|
('highest_rating', '最高 Rating', 'rating'),
|
|
('most_kills', '单场最多击杀', 'kills'),
|
|
('highest_adr', '单场最高 ADR', 'adr'),
|
|
('highest_kd', '单场最高 K/D', 'kd_ratio'),
|
|
('most_headshots', '单场最多爆头', 'headshot_count'),
|
|
)
|
|
|
|
for steam_id, rows in _group_player_match_rows(history_rows).items():
|
|
for record_key, record_label, field in metric_definitions:
|
|
candidates = [row for row in rows if row[field] is not None]
|
|
if not candidates:
|
|
continue
|
|
best = max(
|
|
candidates,
|
|
key=lambda row: (
|
|
float(row[field]),
|
|
int(row['start_time'] or 0),
|
|
),
|
|
)
|
|
result.append((
|
|
steam_id,
|
|
record_key,
|
|
record_label,
|
|
float(best[field]),
|
|
best['match_id'],
|
|
best['map_name'],
|
|
best['start_time'],
|
|
))
|
|
|
|
longest_streak = 0
|
|
current_streak = 0
|
|
streak_end = None
|
|
for row in rows:
|
|
if row['is_win']:
|
|
current_streak += 1
|
|
if current_streak >= longest_streak:
|
|
longest_streak = current_streak
|
|
streak_end = row
|
|
else:
|
|
current_streak = 0
|
|
if streak_end is not None:
|
|
result.append((
|
|
steam_id,
|
|
'longest_win_streak',
|
|
'最长连胜',
|
|
float(longest_streak),
|
|
streak_end['match_id'],
|
|
streak_end['map_name'],
|
|
streak_end['start_time'],
|
|
))
|
|
return result
|
|
|
|
|
|
def _rebuild_team_marts(conn_l2, conn_l3, steam_ids):
|
|
if not steam_ids:
|
|
return
|
|
|
|
conn_l3.execute('DELETE FROM dm_duo_stats')
|
|
conn_l3.execute('DELETE FROM dm_lineup_stats')
|
|
|
|
all_rows = []
|
|
for start in range(0, len(steam_ids), 400):
|
|
chunk = steam_ids[start:start + 400]
|
|
placeholders = ','.join('?' for _ in chunk)
|
|
all_rows.extend(conn_l2.execute(
|
|
f"""
|
|
SELECT
|
|
p.match_id,
|
|
p.steam_id_64,
|
|
CASE
|
|
WHEN p.group_id IN (1, 2) THEN p.group_id
|
|
WHEN p.team_id IN (1, 2) THEN p.team_id
|
|
END AS team_key,
|
|
p.rating,
|
|
p.is_win,
|
|
m.start_time
|
|
FROM fact_match_players p
|
|
JOIN fact_matches m ON m.match_id = p.match_id
|
|
WHERE p.steam_id_64 IN ({placeholders})
|
|
""",
|
|
chunk,
|
|
).fetchall())
|
|
|
|
match_teams = defaultdict(list)
|
|
for row in all_rows:
|
|
if row['team_key'] is None:
|
|
continue
|
|
match_teams[(row['match_id'], row['team_key'])].append(row)
|
|
|
|
duo_accumulator = {}
|
|
lineup_accumulator = {}
|
|
for rows in match_teams.values():
|
|
players = {
|
|
str(row['steam_id_64']): row
|
|
for row in rows
|
|
}
|
|
player_ids = sorted(players)
|
|
if len(player_ids) < 2:
|
|
continue
|
|
is_win = bool(next(iter(players.values()))['is_win'])
|
|
match_date = int(next(iter(players.values()))['start_time'] or 0)
|
|
|
|
for player_a, player_b in combinations(player_ids, 2):
|
|
key = (player_a, player_b)
|
|
accumulator = duo_accumulator.setdefault(key, {
|
|
'matches': 0,
|
|
'wins': 0,
|
|
'rating_a': 0.0,
|
|
'rating_b': 0.0,
|
|
'first': match_date,
|
|
'last': match_date,
|
|
})
|
|
accumulator['matches'] += 1
|
|
accumulator['wins'] += int(is_win)
|
|
accumulator['rating_a'] += float(players[player_a]['rating'] or 0)
|
|
accumulator['rating_b'] += float(players[player_b]['rating'] or 0)
|
|
accumulator['first'] = min(accumulator['first'], match_date)
|
|
accumulator['last'] = max(accumulator['last'], match_date)
|
|
|
|
for size in range(2, min(5, len(player_ids)) + 1):
|
|
for selected_ids in combinations(player_ids, size):
|
|
lineup_key = '|'.join(selected_ids)
|
|
accumulator = lineup_accumulator.setdefault(lineup_key, {
|
|
'player_ids': selected_ids,
|
|
'matches': 0,
|
|
'wins': 0,
|
|
'rating': 0.0,
|
|
'first': match_date,
|
|
'last': match_date,
|
|
})
|
|
accumulator['matches'] += 1
|
|
accumulator['wins'] += int(is_win)
|
|
accumulator['rating'] += (
|
|
sum(float(players[steam_id]['rating'] or 0) for steam_id in selected_ids)
|
|
/ len(selected_ids)
|
|
)
|
|
accumulator['first'] = min(accumulator['first'], match_date)
|
|
accumulator['last'] = max(accumulator['last'], match_date)
|
|
|
|
duo_values = []
|
|
for (player_a, player_b), value in duo_accumulator.items():
|
|
matches = value['matches']
|
|
avg_a = value['rating_a'] / matches
|
|
avg_b = value['rating_b'] / matches
|
|
duo_values.append((
|
|
player_a,
|
|
player_b,
|
|
matches,
|
|
value['wins'],
|
|
value['wins'] / matches,
|
|
avg_a,
|
|
avg_b,
|
|
(avg_a + avg_b) / 2,
|
|
value['first'],
|
|
value['last'],
|
|
1 if matches >= 5 else 0,
|
|
))
|
|
conn_l3.executemany(
|
|
"""
|
|
INSERT INTO dm_duo_stats (
|
|
steam_id_a, steam_id_b, matches, wins, win_rate,
|
|
avg_rating_a, avg_rating_b, avg_combined_rating,
|
|
first_match_date, last_match_date, sample_reliable
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
duo_values,
|
|
)
|
|
|
|
lineup_values = []
|
|
for lineup_key, value in lineup_accumulator.items():
|
|
matches = value['matches']
|
|
lineup_values.append((
|
|
lineup_key,
|
|
json.dumps(value['player_ids']),
|
|
len(value['player_ids']),
|
|
matches,
|
|
value['wins'],
|
|
value['wins'] / matches,
|
|
value['rating'] / matches,
|
|
value['first'],
|
|
value['last'],
|
|
1 if matches >= 3 else 0,
|
|
))
|
|
conn_l3.executemany(
|
|
"""
|
|
INSERT INTO dm_lineup_stats (
|
|
lineup_key, player_ids_json, player_count,
|
|
matches, wins, win_rate, avg_team_rating,
|
|
first_match_date, last_match_date, sample_reliable
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
lineup_values,
|
|
)
|
|
logger.info(
|
|
'Team marts rebuilt: %s duos, %s lineups',
|
|
len(duo_values),
|
|
len(lineup_values),
|
|
)
|
|
|
|
|
|
def _update_percentiles(conn_l3, steam_ids):
|
|
"""Calculate a real percentile among eligible players in this build."""
|
|
if not steam_ids:
|
|
return
|
|
|
|
score_rows = []
|
|
for start in range(0, len(steam_ids), 400):
|
|
chunk = steam_ids[start:start + 400]
|
|
placeholders = ','.join('?' for _ in chunk)
|
|
conn_l3.execute(
|
|
f"""
|
|
UPDATE dm_player_features
|
|
SET tier_percentile = NULL
|
|
WHERE steam_id_64 IN ({placeholders})
|
|
""",
|
|
chunk,
|
|
)
|
|
score_rows.extend(conn_l3.execute(
|
|
f"""
|
|
SELECT steam_id_64, score_overall
|
|
FROM dm_player_features
|
|
WHERE steam_id_64 IN ({placeholders})
|
|
AND score_overall > 0
|
|
""",
|
|
chunk,
|
|
).fetchall())
|
|
|
|
if not score_rows:
|
|
return
|
|
|
|
scores = [float(row[1]) for row in score_rows]
|
|
percentile_values = []
|
|
for row in score_rows:
|
|
score = float(row[1])
|
|
percentile = sum(value <= score for value in scores) / len(scores) * 100
|
|
percentile_values.append((round(percentile, 2), str(row[0])))
|
|
|
|
conn_l3.executemany(
|
|
"""
|
|
UPDATE dm_player_features
|
|
SET tier_percentile = ?
|
|
WHERE steam_id_64 = ?
|
|
""",
|
|
percentile_values,
|
|
)
|
|
logger.info("Updated percentiles for %s eligible players", len(score_rows))
|
|
|
|
|
|
def _parse_args():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--force", action="store_true")
|
|
parser.add_argument("--workers", type=int, default=1)
|
|
parser.add_argument("--no-backup", action="store_true")
|
|
return parser.parse_args()
|
|
|
|
if __name__ == "__main__":
|
|
args = _parse_args()
|
|
main(
|
|
force_all=args.force,
|
|
workers=args.workers,
|
|
create_backup=not args.no_backup,
|
|
)
|