2b399fdb81
PR #1223 only staged the deletions of the old paths because the new top-level directories were still untracked when the commit was authored. This commit adds the actual restructured tree: SREBOT/ (existing bot), SHARED/ (vromfs, data_parser, ICONS/MAPS/FONTS, DAGOR_FILES, update_game_files), and TSSBOT/ (skeleton). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
274 lines
9.8 KiB
Python
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
|