""" analytics.py SQB session analytics: map win rates, team composition analysis, player consistency, time-of-day performance, and opponent difficulty. """ # Standard Library Imports import json import logging import math import re from collections import Counter, defaultdict from datetime import datetime, timezone # Third-Party Library Imports import aiosqlite # Local Module Imports from data_parser import get_unit_type_abbrev from .utils import SQ_BATTLES_DB_PATH, decompress_json async def get_map_stats(squadron_short: str, start_ts: int = 0) -> list[dict]: """Win rate by map for a squadron. Returns list of dicts sorted by total games desc: [{map_name, wins, losses, total, win_rate}, ...] """ async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=30.0) as db: await db.execute("PRAGMA busy_timeout=30000;") rows = await db.execute_fetchall( """SELECT map_name, winning_sq, losing_sq FROM match_summary WHERE (winning_sq = ? OR losing_sq = ?) AND endtime_unix >= ?""", (squadron_short, squadron_short, start_ts), ) seen: dict[str, str] = {} # lowercase key -> first clean spelling def _normalize_map(raw: str) -> tuple[str, str]: clean = re.sub(r"^\s*\[[^\]]+\]\s*", "", raw).strip() or "Unknown" key = clean.lower() if key not in seen: seen[key] = clean return key, seen[key] stats: dict[str, dict] = {} for raw_name, winning_sq, losing_sq in rows: key, name = _normalize_map(raw_name or "Unknown") entry = stats.setdefault(key, {"map_name": name, "wins": 0, "losses": 0}) if winning_sq == squadron_short: entry["wins"] += 1 else: entry["losses"] += 1 result = list(stats.values()) for r in result: r["total"] = r["wins"] + r["losses"] r["win_rate"] = round(r["wins"] / r["total"] * 100, 1) if r["total"] else 0 result.sort(key=lambda x: x["total"], reverse=True) return result async def get_comp_analysis(squadron_short: str, start_ts: int = 0) -> list[dict]: """Vehicle type composition vs win rate. Parses winning_team_json/losing_team_json, classifies vehicles, builds comp signatures (e.g. "3T 1H 1F"), correlates with outcomes. Returns list sorted by usage: [{comp_signature, wins, losses, total, win_rate}, ...] """ async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=30.0) as db: await db.execute("PRAGMA busy_timeout=30000;") rows = await db.execute_fetchall( """SELECT winning_sq, losing_sq, winning_team_json, losing_team_json FROM match_summary WHERE (winning_sq = ? OR losing_sq = ?) AND endtime_unix >= ?""", (squadron_short, squadron_short, start_ts), ) stats: dict[str, dict] = {} for winning_sq, losing_sq, w_json, l_json in rows: is_win = winning_sq == squadron_short team_json = w_json if is_win else l_json if not team_json: continue try: team = decompress_json(team_json) except (json.JSONDecodeError, TypeError, OSError): continue # Classify vehicles type_counts: Counter = Counter() players = team.get("players", []) for p in players: veh_internal = p.get("vehicle", "") if not veh_internal or veh_internal == "DISCONNECTED": continue vtype = get_unit_type_abbrev(veh_internal) type_counts[vtype] += 1 if not type_counts: continue # Build signature: sorted by count desc, then alpha sig_parts = sorted(type_counts.items(), key=lambda x: (-x[1], x[0])) signature = " ".join(f"{count}{abbr}" for abbr, count in sig_parts) entry = stats.setdefault(signature, {"comp_signature": signature, "wins": 0, "losses": 0}) if is_win: entry["wins"] += 1 else: entry["losses"] += 1 result = list(stats.values()) for r in result: r["total"] = r["wins"] + r["losses"] r["win_rate"] = round(r["wins"] / r["total"] * 100, 1) if r["total"] else 0 result.sort(key=lambda x: x["total"], reverse=True) return result[:20] async def get_player_consistency(squadron_short: str, min_games: int = 50) -> list[dict]: """Per-player performance variance. Returns list sorted by most recently played: [{uid, nick, avg_kills, std_kills, avg_deaths, std_deaths, games, consistency_score, last_played}, ...] """ async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=30.0) as db: await db.execute("PRAGMA busy_timeout=30000;") # squadron_name in player_games_hist stores the short name (e.g. "DSPL") rows = await db.execute_fetchall( """SELECT UID, nick, SUM(ground_kills + air_kills) as total_kills, SUM(deaths) as total_deaths, session_id, MAX(endtime_unix) as last_played FROM player_games_hist WHERE squadron_name = ? GROUP BY UID, session_id""", (squadron_short,), ) # Aggregate per-player player_data: dict[str, dict] = {} for uid, nick, kills, deaths, session_id, last_played in rows: entry = player_data.setdefault(uid, {"uid": uid, "nick": nick, "kills": [], "deaths": [], "last_played": 0}) entry["nick"] = nick # keep latest nick entry["kills"].append(kills or 0) entry["deaths"].append(deaths or 0) entry["last_played"] = max(entry["last_played"], last_played or 0) result = [] for uid, data in player_data.items(): games = len(data["kills"]) if games < min_games: continue avg_kills = sum(data["kills"]) / games avg_deaths = sum(data["deaths"]) / games std_kills = math.sqrt(sum((k - avg_kills) ** 2 for k in data["kills"]) / games) std_deaths = math.sqrt(sum((d - avg_deaths) ** 2 for d in data["deaths"]) / games) # Consistency score: lower is more consistent (normalized std dev) consistency = (std_kills + std_deaths) / max(avg_kills + avg_deaths, 1) result.append({ "uid": uid, "nick": data["nick"], "avg_kills": round(avg_kills, 2), "std_kills": round(std_kills, 2), "avg_deaths": round(avg_deaths, 2), "std_deaths": round(std_deaths, 2), "games": games, "consistency_score": round(consistency, 3), "last_played": data["last_played"], }) result.sort(key=lambda x: x["avg_kills"] / max(x["avg_deaths"], 0.01), reverse=True) return result[:128] async def get_matchup_history(squadron_short: str, start_ts: int = 0) -> dict: """Top opponents this squadron has won against and lost to. Returns: { "won_against": [{opponent, wins, losses, total, win_rate}, ...up to 10], "lost_against": [{opponent, wins, losses, total, win_rate}, ...up to 10], "total_opponents": int, } """ async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=30.0) as db: await db.execute("PRAGMA busy_timeout=30000;") rows = await db.execute_fetchall( """SELECT winning_sq, losing_sq FROM match_summary WHERE (winning_sq = ? OR losing_sq = ?) AND endtime_unix >= ?""", (squadron_short, squadron_short, start_ts), ) stats: dict[str, dict] = {} for winning_sq, losing_sq in rows: if not winning_sq or not losing_sq or winning_sq == losing_sq: continue opponent = losing_sq if winning_sq == squadron_short else winning_sq if not opponent: continue entry = stats.setdefault(opponent, {"opponent": opponent, "wins": 0, "losses": 0}) if winning_sq == squadron_short: entry["wins"] += 1 else: entry["losses"] += 1 enriched = [] for r in stats.values(): r["total"] = r["wins"] + r["losses"] r["win_rate"] = round(r["wins"] / r["total"] * 100, 1) if r["total"] else 0 enriched.append(r) won_against = sorted(enriched, key=lambda x: (x["wins"], x["total"]), reverse=True)[:10] lost_against = sorted(enriched, key=lambda x: (x["losses"], x["total"]), reverse=True)[:10] return { "won_against": won_against, "lost_against": lost_against, "total_opponents": len(enriched), } async def get_time_performance(squadron_short: str) -> dict: """Win rate bucketed by hour of day (UTC). Returns dict keyed by hour (0-23): {hour: {wins, losses, total, win_rate}, ...} """ async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=30.0) as db: await db.execute("PRAGMA busy_timeout=30000;") rows = await db.execute_fetchall( """SELECT endtime_unix, winning_sq, losing_sq FROM match_summary WHERE (winning_sq = ? OR losing_sq = ?) AND endtime_unix > 0""", (squadron_short, squadron_short), ) stats: dict[int, dict] = {} for endtime_unix, winning_sq, losing_sq in rows: hour = datetime.fromtimestamp(endtime_unix, tz=timezone.utc).hour entry = stats.setdefault(hour, {"wins": 0, "losses": 0}) if winning_sq == squadron_short: entry["wins"] += 1 else: entry["losses"] += 1 result = {} for hour in sorted(stats.keys()): e = stats[hour] total = e["wins"] + e["losses"] result[hour] = { "wins": e["wins"], "losses": e["losses"], "total": total, "win_rate": round(e["wins"] / total * 100, 1) if total else 0, } return result