add SREBOT, SHARED, TSSBOT contents (fixup for #1223)
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>
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user