Files
SREBOT/BOT/analytics.py
T
NotSoToothless ff420e131f fix relative .data_parser imports in BOT/* after SHARED move (#1224)
PR #1223 + fixup moved data_parser into BOTS/SHARED, but five BOT modules
(analytics, autologging, botscript, lux_apis, meta_manager) still used
`from .data_parser import ...`. That relative form looks inside the BOT
package, which no longer contains data_parser, so the bot crashed at
startup with ModuleNotFoundError.

Add BOT/__init__.py to put BOTS/SHARED on sys.path at package import,
then switch all five files to absolute `from data_parser import ...`.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:30:15 -07:00

274 lines
9.8 KiB
Python

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