606e174a97
When one team has a dash-tag (e.g. -DSPLA-) and the opponent has a normal tag (e.g. ALUN2), batching both into resolve_clans caused the short-name pass to place the normal team first in results and the tag pass to append the dash-tagged team second — inverting the mapping vs. the teams array. Each team's players were then looked up against the wrong squadron's API, yielding curr=0 for everyone and diffs=0 on the scoreboard. Fix: resolve each team concurrently and independently so results are always index-aligned with the teams list regardless of which resolution path fires. Also propagates squadron_short to the scoreboard renderer so display names are clean (DSPLA not -DSPLA-). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2143 lines
82 KiB
Python
2143 lines
82 KiB
Python
"""
|
|
autologging.py
|
|
|
|
WebSocket auto-logging and session processing. Connects to the replay stream,
|
|
detects squadron battle matches, tracks player states across sessions, generates
|
|
scoreboards, and posts results to configured Discord channels.
|
|
"""
|
|
|
|
# Standard Library Imports
|
|
import asyncio
|
|
import gzip
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import time as time_module
|
|
import traceback
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
# Third-Party Library Imports
|
|
import aiofiles
|
|
import aiohttp
|
|
import aiosqlite
|
|
import discord
|
|
|
|
# Local Module Imports
|
|
from . import utils
|
|
from data_parser import LangTableReader
|
|
from .game_api import get_point_diff
|
|
from .render_replay import load_gob_file, render_gob
|
|
from .health import record_game_processed, record_ws_message
|
|
from .receiver_bridge import publish_replay_batch
|
|
from .utils import t, lang_from_features
|
|
from .scoreboard import create_scoreboard
|
|
from .utils import (
|
|
STORAGE_DIR,
|
|
CACHE_DIR,
|
|
replay_data_path,
|
|
replay_session_dir,
|
|
SQ_BATTLES_DB_PATH,
|
|
SQUADRONS_DB_PATH,
|
|
BLACKLISTED_SERVER_IDS,
|
|
BLACKLISTED_SQUADRONS,
|
|
DEFAULT_FOOTER_CAT,
|
|
compress_json,
|
|
decompress_json,
|
|
get_bot,
|
|
norm,
|
|
resolve_clans,
|
|
resolve_pref_key,
|
|
load_features,
|
|
remove_guild_pref_notification,
|
|
PREMIUM_ACTIVATION_TS,
|
|
is_guild_entitled,
|
|
get_guild_tier,
|
|
refresh_entitled_guilds,
|
|
tier_cap,
|
|
tier_enforcement_active,
|
|
tier_allows_wildcard,
|
|
allowed_pref_keys_for,
|
|
enabled_pref_keys_for,
|
|
WILDCARD_KEYS,
|
|
)
|
|
from .wl import record_result, record_draw, get_standings
|
|
|
|
|
|
# ============================================================================
|
|
# MODULE STATE
|
|
# ============================================================================
|
|
|
|
_process_amount = 15
|
|
_is_running = False
|
|
_process_semaphore: Optional[asyncio.Semaphore] = None # Initialized lazily
|
|
|
|
# Squadron-name → clan_id resolution cache used when persisting player_games_hist
|
|
# rows so each row carries a stable clan_id and survives a future squadron rename.
|
|
# Bounded TTL refresh — squadrons_data is the source of truth and renames are rare.
|
|
_PGH_CLAN_ID_CACHE: dict[str, int] = {}
|
|
_PGH_CLAN_ID_CACHE_AT: float = 0.0
|
|
_PGH_CLAN_ID_CACHE_TTL = 300.0
|
|
|
|
|
|
def _resolve_clan_id_for_pgh(short_or_long_name: str) -> Optional[int]:
|
|
"""Resolve a clan_id from short_name or long_name. Returns None on miss."""
|
|
global _PGH_CLAN_ID_CACHE, _PGH_CLAN_ID_CACHE_AT
|
|
import sqlite3 as _sqlite
|
|
|
|
now = time_module.time()
|
|
if now - _PGH_CLAN_ID_CACHE_AT > _PGH_CLAN_ID_CACHE_TTL:
|
|
_PGH_CLAN_ID_CACHE = {}
|
|
_PGH_CLAN_ID_CACHE_AT = now
|
|
|
|
key = (short_or_long_name or "").lower()
|
|
if not key or key == "unknown":
|
|
return None
|
|
if key in _PGH_CLAN_ID_CACHE:
|
|
cached = _PGH_CLAN_ID_CACHE[key]
|
|
return cached if cached >= 0 else None
|
|
|
|
try:
|
|
with _sqlite.connect(SQUADRONS_DB_PATH) as con:
|
|
row = con.execute(
|
|
"SELECT clan_id FROM squadrons_data "
|
|
"WHERE LOWER(short_name) = ? OR LOWER(long_name) = ? LIMIT 1",
|
|
(key, key),
|
|
).fetchone()
|
|
cid = int(row[0]) if row and row[0] is not None else -1
|
|
except Exception:
|
|
cid = -1
|
|
|
|
_PGH_CLAN_ID_CACHE[key] = cid
|
|
return cid if cid >= 0 else None
|
|
|
|
_scoreboard_locks: dict[str, asyncio.Lock] = {}
|
|
_sent_channels_by_session: dict[str, set[int]] = {}
|
|
_scoreboard_cache: dict[tuple[str, str, str], str] = {}
|
|
_video_render_sem: asyncio.Semaphore = asyncio.Semaphore(2) # Max 2 concurrent video renders
|
|
|
|
DEBUG_GUILD_FILTER: int | None = 0 # Set to None when no filter is required or 0 to turn off
|
|
|
|
|
|
|
|
def _get_semaphore() -> asyncio.Semaphore:
|
|
"""Get or create the process semaphore."""
|
|
global _process_semaphore
|
|
if _process_semaphore is None:
|
|
_process_semaphore = asyncio.Semaphore(_process_amount)
|
|
return _process_semaphore
|
|
|
|
# ============================================================================
|
|
# Module Level DB init
|
|
# ============================================================================
|
|
|
|
_DB_INIT_LOCK = asyncio.Lock()
|
|
_DB_INITIALIZED = False
|
|
|
|
CREATE_TABLE_SQL = """
|
|
CREATE TABLE IF NOT EXISTS player_games_hist (
|
|
UID TEXT NOT NULL,
|
|
nick TEXT NOT NULL,
|
|
squadron_name TEXT NOT NULL,
|
|
squadron_tagged TEXT NOT NULL,
|
|
session_id TEXT NOT NULL,
|
|
vehicle TEXT,
|
|
vehicle_internal TEXT,
|
|
ground_kills INTEGER NOT NULL DEFAULT 0,
|
|
air_kills INTEGER NOT NULL DEFAULT 0,
|
|
assists INTEGER NOT NULL DEFAULT 0,
|
|
captures INTEGER NOT NULL DEFAULT 0,
|
|
deaths INTEGER NOT NULL DEFAULT 0,
|
|
victor_bool TEXT NOT NULL DEFAULT 'Loss',
|
|
endtime_unix INTEGER NOT NULL DEFAULT 0,
|
|
clan_id INTEGER,
|
|
UNIQUE (UID, session_id, vehicle_internal)
|
|
)
|
|
"""
|
|
|
|
INDEXES_SQL = [
|
|
"CREATE INDEX IF NOT EXISTS idx_player_games_hist_session ON player_games_hist(session_id)",
|
|
"CREATE INDEX IF NOT EXISTS idx_player_games_hist_uid_time ON player_games_hist(UID, endtime_unix)",
|
|
"CREATE INDEX IF NOT EXISTS idx_player_games_hist_squad_time ON player_games_hist(squadron_tagged, endtime_unix)",
|
|
"CREATE INDEX IF NOT EXISTS idx_player_games_hist_squadron_name ON player_games_hist(squadron_name, endtime_unix)",
|
|
"CREATE INDEX IF NOT EXISTS idx_player_games_hist_endtime ON player_games_hist(endtime_unix)",
|
|
"CREATE INDEX IF NOT EXISTS idx_player_games_hist_nick ON player_games_hist(nick COLLATE NOCASE)",
|
|
# Composite index for the self-join in gather_player_frequent_teammate
|
|
# (session_id, squadron_name) — without this the planner falls back to a
|
|
# wide squadron_name scan and the recap call takes >1s.
|
|
"CREATE INDEX IF NOT EXISTS idx_player_games_hist_session_squadron ON player_games_hist(session_id, squadron_name)",
|
|
]
|
|
|
|
UPSERT_SQL = """
|
|
INSERT INTO player_games_hist
|
|
(UID, nick, squadron_name, squadron_tagged, session_id, vehicle, vehicle_internal,
|
|
ground_kills, air_kills, assists, captures, deaths,
|
|
victor_bool, endtime_unix, clan_id)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(UID, session_id, vehicle_internal) DO UPDATE SET
|
|
nick=excluded.nick,
|
|
squadron_name=excluded.squadron_name,
|
|
squadron_tagged=excluded.squadron_tagged,
|
|
vehicle=excluded.vehicle,
|
|
ground_kills=excluded.ground_kills,
|
|
air_kills=excluded.air_kills,
|
|
assists=excluded.assists,
|
|
captures=excluded.captures,
|
|
deaths=excluded.deaths,
|
|
victor_bool=excluded.victor_bool,
|
|
endtime_unix=excluded.endtime_unix,
|
|
clan_id=COALESCE(excluded.clan_id, player_games_hist.clan_id)
|
|
"""
|
|
|
|
async def init_players_db(conn: aiosqlite.Connection):
|
|
"""Idempotent: safe to call every time; only does real work once."""
|
|
global _DB_INITIALIZED
|
|
if _DB_INITIALIZED:
|
|
return
|
|
|
|
async with _DB_INIT_LOCK:
|
|
if _DB_INITIALIZED:
|
|
return
|
|
|
|
# Pragmas
|
|
await conn.execute("PRAGMA journal_mode=WAL;")
|
|
await conn.execute("PRAGMA synchronous=NORMAL;")
|
|
await conn.execute("PRAGMA busy_timeout=5000;")
|
|
await conn.execute("PRAGMA temp_store=MEMORY;")
|
|
|
|
await conn.execute(CREATE_TABLE_SQL)
|
|
|
|
# Migrations
|
|
cols = {row[1] for row in await conn.execute_fetchall("PRAGMA table_info(player_games_hist)")}
|
|
if "victor_bool" not in cols:
|
|
await conn.execute("ALTER TABLE player_games_hist ADD COLUMN victor_bool TEXT NOT NULL DEFAULT 'Loss'")
|
|
if "endtime_unix" not in cols:
|
|
await conn.execute("ALTER TABLE player_games_hist ADD COLUMN endtime_unix INTEGER NOT NULL DEFAULT 0")
|
|
if "squadron_name" not in cols:
|
|
await conn.execute("ALTER TABLE player_games_hist ADD COLUMN squadron_name TEXT NOT NULL DEFAULT 'UNKNOWN'")
|
|
if "squadron_tagged" not in cols:
|
|
await conn.execute("ALTER TABLE player_games_hist ADD COLUMN squadron_tagged TEXT NOT NULL DEFAULT 'UNKNOWN'")
|
|
if "clan_id" not in cols:
|
|
await conn.execute("ALTER TABLE player_games_hist ADD COLUMN clan_id INTEGER")
|
|
|
|
# Indexes for read performance
|
|
for sql in INDEXES_SQL:
|
|
await conn.execute(sql)
|
|
# clan_id index added by the clan_id migration; ensure it exists for fresh installs.
|
|
await conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_pgh_clanid_endtime ON player_games_hist(clan_id, endtime_unix)"
|
|
)
|
|
|
|
await conn.commit()
|
|
_DB_INITIALIZED = True
|
|
|
|
# ===========================
|
|
# CACHE for DB
|
|
# ===========================
|
|
|
|
_TRANSLATE = None
|
|
|
|
def get_translator():
|
|
"""Return a cached English LangTableReader singleton.
|
|
|
|
Returns:
|
|
LangTableReader: Shared translator instance for English vehicle names.
|
|
"""
|
|
global _TRANSLATE
|
|
if _TRANSLATE is None:
|
|
_TRANSLATE = LangTableReader("English")
|
|
return _TRANSLATE
|
|
|
|
# ============================================================================
|
|
# TIME WINDOW
|
|
# ============================================================================
|
|
|
|
|
|
|
|
# ============================================================================
|
|
# APERF HELPERS
|
|
# ============================================================================
|
|
|
|
|
|
|
|
# ============================================================================
|
|
# UTILITY FUNCTIONS
|
|
# ============================================================================
|
|
|
|
def ensure_squadrons_path() -> Path:
|
|
"""Ensure the configured HC storage root exists and return the SQUADRONS.json path.
|
|
|
|
Returns:
|
|
Path: Absolute path to SQUADRONS.json (file may not yet exist).
|
|
"""
|
|
STORAGE_DIR.mkdir(parents=True, exist_ok=True)
|
|
return STORAGE_DIR / "SQUADRONS.json"
|
|
|
|
|
|
async def load_json_async(path: Path, default):
|
|
"""Async version of load_json for use in autologging."""
|
|
try:
|
|
async with aiofiles.open(path, "r", encoding="utf-8") as f:
|
|
return json.loads(await f.read())
|
|
except FileNotFoundError:
|
|
return default
|
|
|
|
|
|
async def load_or_init_json(path: Path, default):
|
|
"""Load JSON from *path*, falling back to *default* on corruption.
|
|
|
|
If the file is corrupt (invalid JSON or OS error), the file is deleted
|
|
and a copy of *default* is returned.
|
|
|
|
Args:
|
|
path: Path to the JSON file.
|
|
default: Fallback value returned when the file is missing or corrupt.
|
|
|
|
Returns:
|
|
Parsed JSON content, or a copy of *default* on failure.
|
|
"""
|
|
try:
|
|
return await load_json_async(path, default)
|
|
except (json.JSONDecodeError, OSError):
|
|
logging.warning(f"Corrupt JSON at {path!r}, resetting.")
|
|
try:
|
|
path.unlink()
|
|
except OSError:
|
|
pass
|
|
return default.copy() if isinstance(default, dict) else default
|
|
|
|
|
|
def parse_channel_id(raw: str) -> int | None:
|
|
"""
|
|
Given a raw prefs string, return an int channel ID or None if invalid/disabled.
|
|
"""
|
|
if not raw:
|
|
return None
|
|
|
|
# If the word "DISABLED" appears anywhere, treat as explicitly off
|
|
if "DISABLED" in raw.upper():
|
|
return None
|
|
|
|
# Search for a 17-19 digit sequence
|
|
m = re.search(r"\b(\d{17,19})\b", raw)
|
|
if not m:
|
|
return None
|
|
|
|
return int(m.group(1))
|
|
|
|
|
|
# ── Over-cap warning (tier enforcement) ──────────────────────────────────────
|
|
|
|
|
|
async def send_over_cap_warning(
|
|
channel: discord.abc.Messageable,
|
|
lang: str,
|
|
tier: Optional[str],
|
|
notif_type: str,
|
|
squadron: str,
|
|
*,
|
|
reason: str = "over_cap",
|
|
) -> None:
|
|
"""Send the orange upgrade-warning embed in place of a normal notification.
|
|
|
|
Fires once per dropped game — every over-cap event gets its own embed so the
|
|
guild can see exactly which game was not logged due to the tier limit.
|
|
`reason='wildcard_blocked'` swaps to the wildcard-specific copy.
|
|
"""
|
|
guild_id = getattr(getattr(channel, "guild", None), "id", 0)
|
|
|
|
cap = tier_cap(tier, notif_type)
|
|
cap_str = "∞" if cap is None else str(cap)
|
|
tier_label = (tier or "none").title()
|
|
|
|
if reason == "wildcard_blocked":
|
|
title = t(lang, "autolog.wildcard_blocked_title")
|
|
desc = t(lang, "autolog.wildcard_blocked_desc", tier=tier_label, notif=notif_type)
|
|
else:
|
|
title = t(lang, "autolog.over_cap_title")
|
|
desc = t(
|
|
lang,
|
|
"autolog.over_cap_desc",
|
|
tier=tier_label,
|
|
notif=notif_type,
|
|
cap=cap_str,
|
|
squadron=squadron,
|
|
)
|
|
|
|
embed = discord.Embed(title=title, description=desc, color=discord.Color.orange())
|
|
embed.set_footer(text=t(lang, "autolog.over_cap_footer"))
|
|
try:
|
|
await channel.send(embed=embed)
|
|
except Exception as e:
|
|
logging.warning(f"[TIER] Failed to send over-cap warning to {guild_id}: {e}")
|
|
|
|
|
|
def minutes_ago(ts, now=None):
|
|
"""Format a Unix timestamp as a human-readable relative time string.
|
|
|
|
Args:
|
|
ts: Unix timestamp (seconds) of the past event.
|
|
now: Current Unix timestamp. Defaults to ``time.time()``.
|
|
|
|
Returns:
|
|
str: e.g. ``"3 minutes ago"`` or ``"1 minute ago"`` (minimum 1 minute).
|
|
"""
|
|
now = now if now is not None else int(time_module.time())
|
|
mins = max(1, (now - ts) // 60)
|
|
return f"{mins} minute{'s' if mins != 1 else ''} ago"
|
|
|
|
|
|
def load_replay_data_from_disk(session_id: str):
|
|
"""Load replay_data.json.gz from disk for a session."""
|
|
path = replay_data_path(session_id)
|
|
if path.is_file():
|
|
with gzip.open(path, "rt", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
return None
|
|
|
|
|
|
# ============================================================================
|
|
# WEBSOCKET HANDLERS
|
|
# ============================================================================
|
|
|
|
async def handle_ws_replays(replays: list[dict]):
|
|
"""
|
|
Process incoming WebSocket replay data.
|
|
Called sequentially by ws_replay_listener's queue processor.
|
|
No _is_running guard needed - queue ensures sequential processing.
|
|
"""
|
|
try:
|
|
await process_ws_replays(replays)
|
|
await record_ws_message("sqb_autolog")
|
|
except Exception as e:
|
|
logging.error(f"Error in process_ws_replays: {e}")
|
|
logging.error(traceback.format_exc())
|
|
|
|
|
|
async def process_ws_replays(replays: list[dict]):
|
|
"""
|
|
Process replays received via WebSocket.
|
|
Validates in-memory before writing to disk, uses file existence for dedup.
|
|
"""
|
|
bot = get_bot()
|
|
now_ts = int(time_module.time())
|
|
|
|
# Load squadrons data
|
|
squadrons_path = ensure_squadrons_path()
|
|
squadrons_json = await load_or_init_json(squadrons_path, {})
|
|
if not isinstance(squadrons_json, dict):
|
|
squadrons_json = {}
|
|
|
|
# Transform, validate in-memory, and save only valid replays
|
|
validated_games = []
|
|
forwarded_replays: list[dict[str, Any]] = []
|
|
for replay in replays:
|
|
# Only process squadron battle games (new format may omit type since WS is SQB-specific)
|
|
replay_type = replay.get("type")
|
|
if replay_type is not None and replay_type != "sqb":
|
|
logging.info(f"[WS] Skipping non-sqb replay (type={replay_type!r})")
|
|
continue
|
|
|
|
# Transform to local format first - it handles _id -> hex conversion
|
|
wrapped = {'completed': [replay]}
|
|
local_data = utils.transform_to_local_format(wrapped)
|
|
if not local_data:
|
|
logging.warning(f"[WS] Failed to transform replay")
|
|
continue
|
|
|
|
hex_id = local_data.get("session_id_hex", "")
|
|
if not hex_id:
|
|
continue
|
|
|
|
# Skip if already processed
|
|
replay_dir = replay_session_dir(hex_id)
|
|
if replay_data_path(hex_id).exists():
|
|
continue
|
|
|
|
# Validate in-memory (fail fast - don't write invalid data to disk)
|
|
teams = local_data.get("teams", [])
|
|
squadrons = local_data.get("squadrons", [])
|
|
winning_sq = local_data.get("winning_team_squadron", "")
|
|
losing_sq = local_data.get("losing_team_squadron", "")
|
|
|
|
if not teams or len(teams) < 2:
|
|
logging.warning(f"[WS] Invalid replay {hex_id}: Missing teams")
|
|
continue
|
|
if not all(team.get("players") for team in teams):
|
|
logging.warning(f"[WS] Invalid replay {hex_id}: Teams have no players")
|
|
continue
|
|
if not squadrons or all(sq == "" for sq in squadrons):
|
|
logging.warning(f"[WS] Invalid replay {hex_id}: Empty squadron names")
|
|
continue
|
|
if not winning_sq or not losing_sq:
|
|
logging.warning(f"[WS] Invalid replay {hex_id}: Missing winner/loser")
|
|
continue
|
|
|
|
# Replay is valid - save to disk
|
|
replay_dir = replay_session_dir(hex_id)
|
|
replay_dir.mkdir(parents=True, exist_ok=True)
|
|
replay_file = replay_dir / "replay_data.json.gz"
|
|
|
|
try:
|
|
raw = json.dumps(local_data, ensure_ascii=False).encode("utf-8")
|
|
compressed = await asyncio.to_thread(gzip.compress, raw)
|
|
async with aiofiles.open(replay_file, "wb") as f:
|
|
await f.write(compressed)
|
|
logging.info(f"[WSS] Saved {hex_id} ({len(compressed)} bytes compressed)")
|
|
except Exception as e:
|
|
logging.error(f"[WSS] Failed to save replay {hex_id}: {e}")
|
|
continue
|
|
|
|
scoreboard_context = await build_scoreboard_context(
|
|
hex_id,
|
|
local_data,
|
|
received_time=now_ts,
|
|
end_time=int(replay.get("end_ts") or now_ts),
|
|
)
|
|
local_data["scoreboard_context"] = scoreboard_context
|
|
|
|
forwarded_replays.append(local_data)
|
|
validated_games.append({
|
|
"sessionIdHex": hex_id,
|
|
"endTime": int(replay.get("end_ts") or now_ts),
|
|
"missionName": local_data.get("map", ""),
|
|
"receivedTime": now_ts,
|
|
"scoreboard_context": scoreboard_context,
|
|
})
|
|
|
|
if not validated_games:
|
|
return
|
|
|
|
# Record game count for health dashboard
|
|
for _ in validated_games:
|
|
record_game_processed()
|
|
|
|
# Update comps/stats first so /comp and stats queries see new data immediately
|
|
await process_comps(validated_games)
|
|
await process_stats(validated_games)
|
|
|
|
# Build guild mappings and dispatch scoreboards to Discord
|
|
EVERYTHING_LOGS = True
|
|
hex_plus = await build_hex_plus_guild(validated_games, EVERYTHING_LOGS)
|
|
await refresh_entitled_guilds()
|
|
await prune_invalid_channels(bot, hex_plus)
|
|
await dispatch_processing(hex_plus, squadrons_json)
|
|
|
|
await process_match_summaries(validated_games)
|
|
|
|
try:
|
|
await publish_replay_batch(forwarded_replays)
|
|
except Exception as e:
|
|
logging.warning(f"[BRIDGE] Failed to forward replay batch: {e}")
|
|
|
|
|
|
|
|
# ============================================================================
|
|
# GUILD MAPPING AND DISPATCH
|
|
# ============================================================================
|
|
|
|
async def build_hex_plus_guild(
|
|
games: List[Dict[str, Any]],
|
|
WILDCARD_BOOL: bool
|
|
) -> Dict[str, Tuple[Dict[str, Any], List[Tuple[Any, Any]]]]:
|
|
"""
|
|
For each game, read its replay_data.json, resolve long_clan names, then
|
|
match those against each guild's prefs to decide where to send logs.
|
|
|
|
Optimized: Pre-builds lookup tables from guild prefs, then matches games
|
|
against them. O(guilds + games) instead of O(guilds * games).
|
|
"""
|
|
bot = get_bot()
|
|
mapping: Dict[str, Tuple[Dict[str, Any], List[Tuple[Any, Any]]]] = {}
|
|
|
|
# Build lookups for the prefs "Short"/"Long" refresh below.
|
|
long2short: Dict[str, str] = {}
|
|
clanid2short: Dict[str, str] = {}
|
|
clanid2long: Dict[str, str] = {}
|
|
try:
|
|
async with aiosqlite.connect(SQUADRONS_DB_PATH) as db:
|
|
async with db.execute(
|
|
"SELECT clan_id, long_name, short_name FROM squadrons_data WHERE long_name IS NOT NULL AND short_name IS NOT NULL"
|
|
) as cursor:
|
|
async for row in cursor:
|
|
cid, ln, sn = row
|
|
if ln and sn:
|
|
long2short[ln.strip().lower()] = sn
|
|
if cid is not None and sn:
|
|
clanid2short[str(cid)] = sn
|
|
if cid is not None and ln:
|
|
clanid2long[str(cid)] = ln
|
|
except Exception:
|
|
pass
|
|
|
|
# PHASE 1: Load all guild prefs and build lookup tables (async, with yields)
|
|
# Each indexed entry carries (guild, chan, key, flag) where flag is:
|
|
# "ok" → in-cap, dispatch as normal
|
|
# "over_cap" → enabled but exceeds tier cap, send over-cap warning
|
|
# "wildcard_blocked" → wildcard enabled on tier that doesn't allow wildcards
|
|
squadron_to_guilds: Dict[str, List[Tuple[Any, str, str, str]]] = {}
|
|
wildcard_guilds: List[Tuple[Any, str, str, str]] = []
|
|
prefs_to_save: List[Tuple[int, dict]] = []
|
|
|
|
for i, guild in enumerate(bot.guilds):
|
|
# Yield every 50 guilds to keep event loop responsive
|
|
if i > 0 and i % 50 == 0:
|
|
await asyncio.sleep(0)
|
|
|
|
prefs_path = STORAGE_DIR / "PREFERENCES" / f"{guild.id}-preferences.json"
|
|
try:
|
|
async with aiofiles.open(prefs_path, "r", encoding="utf-8") as fp:
|
|
prefs = json.loads(await fp.read())
|
|
except FileNotFoundError:
|
|
continue
|
|
except Exception:
|
|
continue
|
|
|
|
if not prefs:
|
|
continue
|
|
|
|
# Refresh display fields (Short, Long) on each entry. Keys may be a
|
|
# clan_id (post-migration) or a long_name (legacy), so check both maps.
|
|
updated = False
|
|
for key in prefs.keys():
|
|
entry = prefs.get(key)
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
key_str = str(key)
|
|
short_val: Optional[str] = None
|
|
long_val: Optional[str] = None
|
|
if key_str.isdigit() and key_str in clanid2short:
|
|
short_val = clanid2short[key_str]
|
|
long_val = clanid2long.get(key_str)
|
|
else:
|
|
key_lc = key_str.strip().lower()
|
|
if key_lc in long2short:
|
|
short_val = long2short[key_lc]
|
|
long_val = key_str # the key itself is the long_name
|
|
if short_val and entry.get("Short") != short_val:
|
|
entry["Short"] = short_val
|
|
updated = True
|
|
if long_val and entry.get("Long") != long_val:
|
|
entry["Long"] = long_val
|
|
updated = True
|
|
|
|
if updated:
|
|
prefs_to_save.append((guild.id, prefs))
|
|
|
|
# Resolve tier + enabled/allowed sets for Logs
|
|
tier = await get_guild_tier(guild.id)
|
|
enabled_logs = set(enabled_pref_keys_for(prefs, "Logs"))
|
|
allowed_logs = allowed_pref_keys_for(prefs, tier, "Logs")
|
|
over_cap_logs = enabled_logs - allowed_logs
|
|
enforcement = tier_enforcement_active()
|
|
|
|
# Index wildcard prefs
|
|
if WILDCARD_BOOL:
|
|
wildcard = next((k for k in prefs if k.lower() in WILDCARD_KEYS), None)
|
|
if wildcard:
|
|
chan = prefs[wildcard].get("Logs")
|
|
if chan and "DISABLED" not in str(chan).upper():
|
|
if wildcard in allowed_logs:
|
|
wildcard_guilds.append((guild, chan, wildcard, "ok"))
|
|
elif enforcement and wildcard in over_cap_logs:
|
|
# Dropped by wildcard gate specifically (standard tier)
|
|
flag = "wildcard_blocked" if not tier_allows_wildcard(tier) else "over_cap"
|
|
wildcard_guilds.append((guild, chan, wildcard, flag))
|
|
|
|
# Index squadron prefs.
|
|
# Keys may be a numeric clan_id (post-clan_id-migration), a long_name
|
|
# (legacy / orphan), or a short_name. The replay matcher below works
|
|
# against normalized short/long names, so each pref entry is registered
|
|
# under all of its known names so it can be found regardless of which
|
|
# form ends up in the replay JSON.
|
|
for key, cfg in prefs.items():
|
|
if not isinstance(cfg, dict):
|
|
continue
|
|
chan = cfg.get("Logs")
|
|
if not chan or "DISABLED" in str(chan).upper():
|
|
continue
|
|
key_norm = norm(key)
|
|
if not key_norm or key_norm in {"*", "all", "everything", "global"}:
|
|
continue
|
|
|
|
match_norms: set[str] = set()
|
|
resolved = await resolve_pref_key(key, cfg)
|
|
if resolved:
|
|
if resolved.get("long_name"):
|
|
match_norms.add(norm(resolved["long_name"]))
|
|
if resolved.get("short_name"):
|
|
match_norms.add(norm(resolved["short_name"]))
|
|
if resolved.get("tag_name"):
|
|
match_norms.add(norm(resolved["tag_name"]))
|
|
if not match_norms:
|
|
# Couldn't resolve — fall back to raw key (covers orphaned
|
|
# historical prefs where the squadron isn't in squadrons_data).
|
|
match_norms.add(key_norm)
|
|
|
|
for mn in match_norms:
|
|
if not mn:
|
|
continue
|
|
if key in allowed_logs:
|
|
squadron_to_guilds.setdefault(mn, []).append((guild, chan, key, "ok"))
|
|
elif enforcement and key in over_cap_logs:
|
|
squadron_to_guilds.setdefault(mn, []).append((guild, chan, key, "over_cap"))
|
|
|
|
# Batch save updated prefs (async)
|
|
for guild_id, prefs in prefs_to_save:
|
|
prefs_path = STORAGE_DIR / "PREFERENCES" / f"{guild_id}-preferences.json"
|
|
async with aiofiles.open(prefs_path, "w", encoding="utf-8") as fp:
|
|
await fp.write(json.dumps(prefs))
|
|
|
|
|
|
# PHASE 2: Match games against pre-built lookup tables
|
|
blacklisted_squad_norms = {norm(bl) for bl in BLACKLISTED_SQUADRONS}
|
|
|
|
for g in games:
|
|
sid = g.get("sessionIdHex", "")
|
|
replay_path = replay_data_path(sid)
|
|
|
|
try:
|
|
raw = await asyncio.to_thread(replay_path.read_bytes)
|
|
replay_data = json.loads(gzip.decompress(raw))
|
|
except Exception:
|
|
logging.error(f"SESSION HEX {sid} FAILED TO GET REPLAY DATA")
|
|
mapping[sid] = (g, [])
|
|
continue
|
|
|
|
squads = replay_data.get("squadrons", [])
|
|
tags = replay_data.get("squadrons_tagged", [])
|
|
if not squads:
|
|
logging.error(f"SESSION HEX {sid} HAS NO SQUADRONS")
|
|
mapping[sid] = (g, [])
|
|
continue
|
|
|
|
# Short names come directly from the replay
|
|
squad_shorts = {norm(s) for s in squads}
|
|
|
|
# Resolve to get long names for matching prefs keyed by long name
|
|
resolved = await resolve_clans(shorts=squads, tags=tags)
|
|
squad_longs = {norm(c["long_name"]) for c in resolved if c["long_name"] != "<unresolved>"}
|
|
|
|
all_squad_names = squad_shorts | squad_longs
|
|
|
|
# Match against indexed guilds
|
|
# Dedupe by (guild.id, channel_id). If the same (guild, channel) matches via both
|
|
# an allowed ("ok") key and an over-cap key, the "ok" entry wins — we only want to
|
|
# fire the warning when NO in-cap squadron for this game matches this channel.
|
|
targets_by_key: dict[tuple[int, int], tuple] = {}
|
|
|
|
def _merge(guild, chan, flag, squadron_key):
|
|
chan_id = parse_channel_id(str(chan))
|
|
if not chan_id:
|
|
return
|
|
k = (guild.id, chan_id)
|
|
existing = targets_by_key.get(k)
|
|
if existing is None:
|
|
targets_by_key[k] = (guild, chan, flag, squadron_key)
|
|
return
|
|
# "ok" wins over any warning flag
|
|
if existing[2] != "ok" and flag == "ok":
|
|
targets_by_key[k] = (guild, chan, flag, squadron_key)
|
|
|
|
# Check wildcard guilds
|
|
for guild, chan, wkey, flag in wildcard_guilds:
|
|
_merge(guild, chan, flag, wkey)
|
|
|
|
# Check squadron matches — skip blacklisted squadron keys
|
|
for squad_norm in all_squad_names:
|
|
if squad_norm in blacklisted_squad_norms:
|
|
continue
|
|
for guild, chan, key, flag in squadron_to_guilds.get(squad_norm, []):
|
|
_merge(guild, chan, flag, key)
|
|
|
|
mapping[sid] = (g, list(targets_by_key.values()))
|
|
|
|
return mapping
|
|
|
|
|
|
async def prune_invalid_channels(bot, mapping: dict[str, tuple[dict, list[tuple]]]):
|
|
"""
|
|
Drop any guild-target whose prefs string doesn't parse to a valid channel ID, or whose channel no longer exists.
|
|
Drop any guild-target whose prefs string parse to a channel ID inside a blacklisted server.
|
|
Remove whole sessions that lose all targets.
|
|
|
|
Targets are (guild, raw_chan, flag, squadron_key) — flag is preserved through
|
|
pruning so the dispatch step can decide between a real scoreboard and an
|
|
over-cap/wildcard-blocked warning.
|
|
"""
|
|
for sid in list(mapping):
|
|
game, targets = mapping[sid]
|
|
valid_targets: list[tuple] = []
|
|
|
|
for entry in targets:
|
|
# Backward-compat: older callers may pass (guild, raw_chan) 2-tuples.
|
|
if len(entry) == 4:
|
|
guild, raw_chan, flag, squadron_key = entry
|
|
else:
|
|
guild, raw_chan = entry[0], entry[1]
|
|
flag, squadron_key = "ok", ""
|
|
|
|
chan_id = parse_channel_id(raw_chan)
|
|
if chan_id is None:
|
|
if squadron_key:
|
|
await remove_guild_pref_notification(guild.id, squadron_key, "Logs")
|
|
continue
|
|
|
|
# Drop any target from a blacklisted server
|
|
if guild.id in BLACKLISTED_SERVER_IDS:
|
|
continue
|
|
|
|
# Check channel exists in Discord
|
|
channel = bot.get_channel(chan_id)
|
|
if channel is None:
|
|
try:
|
|
channel = await bot.fetch_channel(chan_id)
|
|
except discord.NotFound:
|
|
if squadron_key:
|
|
await remove_guild_pref_notification(guild.id, squadron_key, "Logs")
|
|
continue
|
|
except discord.Forbidden:
|
|
continue
|
|
|
|
# Premium gate — notify and skip guilds without an active subscription
|
|
if not await is_guild_entitled(guild.id):
|
|
logging.info(f"[PREMIUM] Skipping guild {guild.id} — no active entitlement")
|
|
try:
|
|
_gf = await load_features(guild.id)
|
|
_lang = lang_from_features(_gf)
|
|
embed = discord.Embed(
|
|
title=t(_lang, "autolog.game_not_logged_title"),
|
|
description=t(_lang, "autolog.game_not_logged_desc"),
|
|
color=discord.Color.red(),
|
|
)
|
|
await channel.send(embed=embed)
|
|
except Exception:
|
|
pass
|
|
continue
|
|
|
|
valid_targets.append((guild, chan_id, flag, squadron_key))
|
|
|
|
if valid_targets:
|
|
mapping[sid] = (game, valid_targets)
|
|
else:
|
|
mapping.pop(sid)
|
|
|
|
|
|
async def dispatch_processing(
|
|
mapping: dict[str, tuple[dict, list[tuple]]],
|
|
squadrons_json: dict
|
|
):
|
|
"""
|
|
Process each (game, guild) session concurrently.
|
|
Concurrency is governed by the process semaphore.
|
|
"""
|
|
bot = get_bot()
|
|
semaphore = _get_semaphore()
|
|
|
|
# Flatten out all (game, guild, channel_id, flag, squadron_key) tuples
|
|
sessions = []
|
|
for _, (game, targets) in mapping.items():
|
|
for entry in targets:
|
|
if len(entry) == 4:
|
|
guild, channel_id, flag, squadron_key = entry
|
|
else:
|
|
guild, channel_id = entry[0], entry[1]
|
|
flag, squadron_key = "ok", ""
|
|
sessions.append((game, guild, channel_id, flag, squadron_key))
|
|
|
|
total = len(sessions)
|
|
if total == 0:
|
|
return
|
|
|
|
async def _worker(game, guild, channel_id, flag, squadron_key):
|
|
sid = game.get("sessionIdHex", "")
|
|
mission_name = game.get("missionName", "")
|
|
end_time = game.get("endTime", "")
|
|
received_time = game.get("receivedTime")
|
|
|
|
# Tier over-cap paths: send the orange warning in place of the scoreboard.
|
|
if flag in ("over_cap", "wildcard_blocked"):
|
|
async with semaphore:
|
|
try:
|
|
channel = bot.get_channel(channel_id)
|
|
if channel is None:
|
|
channel = await bot.fetch_channel(channel_id)
|
|
if not isinstance(channel, discord.abc.Messageable):
|
|
return
|
|
_gf = await load_features(guild.id)
|
|
_lang = lang_from_features(_gf)
|
|
tier = await get_guild_tier(guild.id)
|
|
# Resolve the pref key to a readable squadron name. Post-clan_id
|
|
# migration the key is the numeric clan_id, which would otherwise
|
|
# show as a bare ID in the embed.
|
|
display_squadron = squadron_key
|
|
try:
|
|
prefs_path = STORAGE_DIR / "PREFERENCES" / f"{guild.id}-preferences.json"
|
|
async with aiofiles.open(prefs_path, "r", encoding="utf-8") as fp:
|
|
_prefs = json.loads(await fp.read())
|
|
_entry = _prefs.get(squadron_key) if isinstance(_prefs, dict) else None
|
|
if isinstance(_entry, dict):
|
|
display_squadron = _entry.get("Long") or _entry.get("Short") or squadron_key
|
|
except Exception:
|
|
pass
|
|
await send_over_cap_warning(
|
|
channel, _lang, tier, "Logs", display_squadron, reason=flag
|
|
)
|
|
except Exception as err:
|
|
logging.warning(f"[TIER] Over-cap warn failed {guild.id}/{channel_id}: {err}")
|
|
return
|
|
|
|
# Normal scoreboard path
|
|
prefs_path = STORAGE_DIR / "PREFERENCES" / f"{guild.id}-preferences.json"
|
|
try:
|
|
async with aiofiles.open(prefs_path, "r", encoding="utf-8") as fp:
|
|
guild_prefs = json.loads(await fp.read())
|
|
except Exception:
|
|
guild_prefs = {}
|
|
async with semaphore:
|
|
try:
|
|
await process_session(
|
|
bot,
|
|
guild.id,
|
|
guild.name,
|
|
channel_id,
|
|
sid,
|
|
mission_name,
|
|
end_time,
|
|
squadrons_json,
|
|
guild_prefs,
|
|
received_time=received_time,
|
|
session_context=game.get("scoreboard_context"),
|
|
)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as err:
|
|
logging.error(f"Error processing {sid} for {guild.id}: {err}", exc_info=True)
|
|
|
|
tasks = [
|
|
asyncio.create_task(_worker(game, guild, channel_id, flag, squadron_key))
|
|
for game, guild, channel_id, flag, squadron_key in sessions
|
|
]
|
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
|
|
# ============================================================================
|
|
# SESSION PROCESSING
|
|
# ============================================================================
|
|
|
|
async def process_session(
|
|
bot,
|
|
guild_id: int,
|
|
guild_name: str,
|
|
squadron_pref,
|
|
session_id: str,
|
|
map_name: str,
|
|
timestamp: int,
|
|
squadrons_data: dict,
|
|
guild_prefs: dict,
|
|
received_time: Optional[int] = None,
|
|
session_context: Optional[dict[str, Any]] = None,
|
|
):
|
|
"""Process a single game session: build scoreboard and send it to Discord.
|
|
|
|
Loads replay data from disk, translates vehicle names, resolves clan names,
|
|
records W/L results, generates a scoreboard image (cached per session/color/
|
|
language), deduplicates per channel, and sends the image with interactive
|
|
buttons. Appends a premium upsell embed for non-entitled guilds.
|
|
|
|
Args:
|
|
bot: The Discord bot instance.
|
|
guild_id: Discord guild ID receiving the scoreboard.
|
|
guild_name: Human-readable guild name (for logging).
|
|
squadron_pref: Raw channel-ID preference string for the guild.
|
|
session_id: Hex session ID of the replay.
|
|
map_name: Display name of the map played.
|
|
timestamp: UTC Unix timestamp of the match.
|
|
squadrons_data: Mapping of guild IDs to squadron metadata.
|
|
guild_prefs: Full guild preferences dict (passed to points diffs).
|
|
"""
|
|
# Load replay JSON
|
|
base_dir = replay_session_dir(session_id)
|
|
replay_path = replay_data_path(session_id)
|
|
try:
|
|
raw = await asyncio.to_thread(replay_path.read_bytes)
|
|
replay_data = json.loads(gzip.decompress(raw))
|
|
except FileNotFoundError:
|
|
logging.error(f"Replay file not found for session ID {session_id}")
|
|
return
|
|
except (OSError, json.JSONDecodeError) as e:
|
|
logging.error(f"Replay file for session ID {session_id} is invalid: {e}")
|
|
return
|
|
|
|
# Extract winner/loser/draw
|
|
squadrons = replay_data.get("squadrons", [])
|
|
winner = replay_data.get("winning_team_squadron")
|
|
is_draw = replay_data.get("draw", False)
|
|
loser_candidates = [sq for sq in squadrons if sq != winner]
|
|
loser = loser_candidates[0] if loser_candidates else None
|
|
# Guild-specific squadron (short name)
|
|
guild_data = squadrons_data.get(str(guild_id), {})
|
|
guild_squadron = guild_data.get("SQ_ShortHand_Name")
|
|
|
|
# Translation setup. lang/units.csv columns are stored with literal
|
|
# angle brackets (e.g. ``<Russian>``) — pass the column name through
|
|
# unmodified so LangTableReader actually selects the right column.
|
|
# The stripped form is kept only for filesystem-safe filenames below.
|
|
guild_features = await load_features(guild_id)
|
|
lang = lang_from_features(guild_features)
|
|
language_column = guild_features.get("Language", "<English>")
|
|
language = language_column.strip("<>")
|
|
translate = LangTableReader(language_column)
|
|
|
|
# Translate vehicles in memory
|
|
for team in replay_data.get("teams", []):
|
|
for player in team.get("players", []):
|
|
vehicle = player.get("vehicle")
|
|
if vehicle:
|
|
translated = translate.get_translate(vehicle)
|
|
player["vehicle_new"] = translated if translated else vehicle
|
|
else:
|
|
player["vehicle"] = "DISCONNECTED"
|
|
player["vehicle_new"] = "DISCONNECTED"
|
|
|
|
# Clan resolution
|
|
squads = [team.get("squadron") for team in replay_data.get("teams", []) if team.get("squadron")]
|
|
squads_tagged = [team.get("squadron_tagged") for team in replay_data.get("teams", []) if team.get("squadron_tagged")]
|
|
|
|
resolved = await resolve_clans(shorts=squads, tags=squads_tagged)
|
|
long_clans = [c["long_name"] for c in resolved]
|
|
|
|
for team, long_name in zip(replay_data.get("teams", []), long_clans):
|
|
if team and long_name:
|
|
team["squadron_long"] = long_name
|
|
|
|
# Prep scoreboard params
|
|
if session_context and session_context.get("match_details"):
|
|
match_details: Dict[str, Any] = dict(session_context["match_details"])
|
|
else:
|
|
match_details = {"utc_timestamp": str(timestamp), "session_id": session_id}
|
|
if received_time is not None:
|
|
match_details["received_unix"] = int(received_time)
|
|
else:
|
|
try:
|
|
async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as _conn:
|
|
async with _conn.execute(
|
|
"SELECT received_unix FROM match_summary WHERE session_id = ?",
|
|
(session_id,),
|
|
) as _cur:
|
|
_row = await _cur.fetchone()
|
|
if _row and _row[0] is not None:
|
|
match_details["received_unix"] = int(_row[0])
|
|
except Exception:
|
|
pass
|
|
if is_draw:
|
|
bar_color = "draw"
|
|
elif guild_squadron == winner:
|
|
bar_color = "win"
|
|
elif guild_squadron == loser:
|
|
bar_color = "loss"
|
|
elif guild_squadron is None:
|
|
bar_color = "not_set"
|
|
else:
|
|
bar_color = "not_involved"
|
|
|
|
os.makedirs(base_dir, exist_ok=True)
|
|
output_path = base_dir / f"game_result-{bar_color}-{language}.png"
|
|
cache_key = (session_id, bar_color, language)
|
|
|
|
teams: List[Dict[str, Any]] = replay_data.get("teams", [])
|
|
new_wl = session_context.get("wl", {}) if session_context else {}
|
|
diffs = session_context.get("points_diffs", {}) if session_context else {}
|
|
winner_tag: Optional[str] = session_context.get("winner") if session_context else replay_data.get("winning_team_squadron")
|
|
if not winner_tag:
|
|
w_idx = replay_data.get("winner")
|
|
home_tag: Optional[str] = (teams[0] or {}).get("squadron") if len(teams) > 0 else None
|
|
away_tag: Optional[str] = (teams[1] or {}).get("squadron") if len(teams) > 1 else None
|
|
if w_idx in (0, 1):
|
|
winner_tag = (home_tag, away_tag)[w_idx]
|
|
|
|
if not new_wl:
|
|
squadrons_clean: List[str] = [
|
|
t for t in (
|
|
(teams[0] or {}).get("squadron") if len(teams) > 0 else None,
|
|
(teams[1] or {}).get("squadron") if len(teams) > 1 else None,
|
|
)
|
|
if t and t.strip() and t.upper() != "UNKNOWN"
|
|
]
|
|
if is_draw and len(squadrons_clean) >= 2:
|
|
try:
|
|
new_wl = await record_draw(squadrons_clean, session_id)
|
|
except Exception as e:
|
|
logging.error(f"[W/L] record_draw failed ({session_id}): {e}")
|
|
new_wl = get_standings(squadrons_clean)
|
|
elif winner_tag and winner_tag in squadrons_clean and len(squadrons_clean) >= 2:
|
|
try:
|
|
new_wl = await record_result(winner_tag, squadrons_clean, session_id)
|
|
except Exception as e:
|
|
logging.error(f"[W/L] record_result failed ({session_id}): {e}")
|
|
new_wl = get_standings(squadrons_clean)
|
|
else:
|
|
new_wl = get_standings(squadrons_clean)
|
|
|
|
# Scoreboard Build
|
|
lock = _scoreboard_locks.setdefault(session_id, asyncio.Lock())
|
|
async with lock:
|
|
if not output_path.exists():
|
|
try:
|
|
if not diffs:
|
|
diffs = await get_points_diffs(
|
|
session_id,
|
|
guild_id,
|
|
guild_name,
|
|
guild_prefs,
|
|
replay_data
|
|
)
|
|
|
|
await create_scoreboard(
|
|
match_details,
|
|
winner,
|
|
teams[0] or {},
|
|
teams[1] or {},
|
|
map_name,
|
|
str(output_path),
|
|
bar_color,
|
|
diffs,
|
|
WL=new_wl,
|
|
is_draw=is_draw,
|
|
)
|
|
|
|
except Exception as e:
|
|
logging.error(f"create_scoreboard failed: {e}")
|
|
logging.error(traceback.format_exc())
|
|
return
|
|
|
|
if not output_path.exists():
|
|
logging.error(f"Scoreboard image still missing: {output_path}")
|
|
return
|
|
|
|
# DE-DUPING: skip if already sent to this channel
|
|
channel_id = parse_channel_id(str(squadron_pref))
|
|
|
|
if channel_id is None:
|
|
logging.warning(f"[PROCESS] Invalid channel ID format: {squadron_pref} for {guild_name} ({guild_id})")
|
|
return
|
|
|
|
sent_set = _sent_channels_by_session.setdefault(session_id, set())
|
|
if channel_id in sent_set:
|
|
logging.info(f"[SEND] Skipped {session_id} → {guild_name} ({channel_id}): already sent")
|
|
return
|
|
sent_set.add(channel_id)
|
|
|
|
# SEND EMBED + IMAGE
|
|
try:
|
|
# FILE VALIDATION
|
|
if not output_path.exists():
|
|
logging.error(f"[SEND] Scoreboard file missing for {session_id}: {output_path}")
|
|
return
|
|
|
|
if output_path.stat().st_size == 0:
|
|
logging.error(f"[SEND] Scoreboard file empty for {session_id}: {output_path}")
|
|
return
|
|
|
|
# CHANNEL VALIDATION
|
|
channel = bot.get_channel(channel_id)
|
|
if channel is None:
|
|
try:
|
|
channel = await bot.fetch_channel(channel_id)
|
|
except discord.NotFound:
|
|
logging.warning(f"[SEND] Channel not found for {session_id}: {channel_id} in {guild_name}")
|
|
return
|
|
except discord.Forbidden:
|
|
logging.warning(f"[SEND] No access to channel for {session_id}: {channel_id} in {guild_name}")
|
|
return
|
|
|
|
USE_SCOREBOARD_CACHE = False
|
|
|
|
# SEND WITH RETRY LOGIC
|
|
max_retries = 3
|
|
base_delay = 1
|
|
|
|
# Check cache *only if allowed*
|
|
if USE_SCOREBOARD_CACHE:
|
|
cached_url = _scoreboard_cache.get(cache_key)
|
|
if cached_url:
|
|
view = build_scoreboard_view(guild_id, session_id, lang=lang)
|
|
await channel.send(content=cached_url, view=view)
|
|
return
|
|
|
|
# Otherwise, first upload - normal send loop
|
|
for attempt in range(max_retries):
|
|
try:
|
|
view = build_scoreboard_view(guild_id, session_id, lang=lang)
|
|
|
|
with open(output_path, 'rb') as f:
|
|
msg = await channel.send(
|
|
file=discord.File(f, filename="game_result.png"),
|
|
view=view
|
|
)
|
|
|
|
# Cache the file *only if caching is enabled*
|
|
if USE_SCOREBOARD_CACHE and msg.attachments:
|
|
attachment = msg.attachments[0]
|
|
_scoreboard_cache[cache_key] = attachment.url
|
|
|
|
logging.info(f"[SEND] Scoreboard sent for {session_id} → {guild_name} ({channel_id})")
|
|
|
|
# Premium upsell for non-entitled guilds
|
|
if not await is_guild_entitled(guild_id):
|
|
warn_embed = discord.Embed(
|
|
title=t(lang, "autolog.server_not_upgraded_title"),
|
|
description=t(lang, "autolog.server_not_upgraded_autolog_desc", deadline=PREMIUM_ACTIVATION_TS),
|
|
color=discord.Color.orange(),
|
|
)
|
|
warn_embed.set_footer(text=DEFAULT_FOOTER_CAT)
|
|
try:
|
|
await channel.send(embed=warn_embed)
|
|
except Exception as e:
|
|
logging.error("(AUTOLOG) Error sending premium warning: %s", e)
|
|
|
|
return
|
|
|
|
except (BrokenPipeError, ConnectionError, TimeoutError, aiohttp.ClientOSError, ConnectionResetError) as e:
|
|
if attempt < max_retries - 1:
|
|
delay = base_delay * (2 ** attempt)
|
|
await asyncio.sleep(delay)
|
|
continue
|
|
else:
|
|
logging.error(f"[SEND] Network failure after {max_retries} retries for {session_id} → {guild_name} ({channel_id}): {e}")
|
|
return
|
|
|
|
except discord.HTTPException as e:
|
|
logging.error(f"[SEND] Discord HTTP error for {session_id} → {guild_name} ({channel_id}): {e}")
|
|
return
|
|
|
|
except Exception as e:
|
|
logging.error(f"[SEND] Unexpected error for {session_id} → {guild_name} ({channel_id}): {e}")
|
|
return
|
|
|
|
except Exception as e:
|
|
logging.error(f"[SEND] Critical error for {session_id} → {guild_name} ({guild_id}): {e}")
|
|
logging.error(traceback.format_exc())
|
|
|
|
|
|
def build_scoreboard_view(guild_id: int, session_id: str, lang: str = "en") -> discord.ui.View:
|
|
"""Create a Discord UI View with interactive buttons for a scoreboard message.
|
|
|
|
Buttons included: View Replay (link), View on Website (link), View Video,
|
|
View Log, and View Chat (only if a chat log exists in replay data).
|
|
|
|
Args:
|
|
guild_id: Discord guild ID.
|
|
session_id: Hex session ID used to build the replay URL and callbacks.
|
|
|
|
Returns:
|
|
discord.ui.View: Persistent view with the assembled buttons.
|
|
"""
|
|
view = discord.ui.View(timeout=None)
|
|
session_url = f"https://warthunder.com/en/tournament/replay/{int(session_id, 16)}"
|
|
web_url = f"https://sre.pawjob.us/games/{session_id}"
|
|
|
|
replay_button = discord.ui.Button(
|
|
label=t(lang, "buttons.view_replay"),
|
|
style=discord.ButtonStyle.link,
|
|
url=session_url,
|
|
)
|
|
view.add_item(replay_button)
|
|
|
|
website_button = discord.ui.Button(
|
|
label=t(lang, "buttons.view_website"),
|
|
style=discord.ButtonStyle.link,
|
|
url=web_url,
|
|
emoji="🌐",
|
|
)
|
|
view.add_item(website_button)
|
|
|
|
video_button = discord.ui.Button(
|
|
label=t(lang, "buttons.view_video"),
|
|
style=discord.ButtonStyle.blurple,
|
|
emoji="🎬",
|
|
)
|
|
|
|
async def on_video_click(interaction):
|
|
await handle_view_video(interaction, session_id)
|
|
|
|
video_button.callback = on_video_click
|
|
view.add_item(video_button)
|
|
|
|
battlelog_button = discord.ui.Button(
|
|
label=t(lang, "buttons.view_log"),
|
|
style=discord.ButtonStyle.green,
|
|
emoji="📜",
|
|
)
|
|
|
|
async def on_battlelog_click(interaction):
|
|
await handle_view_battlelog(interaction, session_id)
|
|
|
|
battlelog_button.callback = on_battlelog_click
|
|
view.add_item(battlelog_button)
|
|
|
|
# Check if chat_log exists in replay_data
|
|
replay_data = load_replay_data_from_disk(session_id)
|
|
if replay_data and replay_data.get("chat_log"):
|
|
chatlog_button = discord.ui.Button(
|
|
label=t(lang, "buttons.view_chat"),
|
|
style=discord.ButtonStyle.green,
|
|
emoji="💬",
|
|
)
|
|
|
|
async def on_chatlog_click(interaction):
|
|
await handle_view_chatlog(interaction, session_id)
|
|
|
|
chatlog_button.callback = on_chatlog_click
|
|
view.add_item(chatlog_button)
|
|
|
|
return view
|
|
|
|
|
|
async def handle_view_video(interaction: discord.Interaction, session_id: str):
|
|
"""Callback for 'View Video' - renders replay JSON to MP4, sends ephemerally."""
|
|
try:
|
|
try:
|
|
await interaction.response.defer(thinking=True, ephemeral=True)
|
|
except Exception:
|
|
return
|
|
|
|
_gf = await load_features(interaction.guild_id) if interaction.guild_id else {}
|
|
_lang = lang_from_features(_gf)
|
|
|
|
replay_dir = replay_session_dir(session_id)
|
|
replay_json_path = replay_data_path(session_id)
|
|
video_path = replay_dir / "replay_video.mp4"
|
|
|
|
if not replay_json_path.exists():
|
|
await interaction.followup.send(
|
|
t(_lang, "autolog.replay_not_available"),
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
# Serve cached video if it exists
|
|
if not video_path.exists():
|
|
if _video_render_sem._value == 0:
|
|
await interaction.followup.send(
|
|
t(_lang, "autolog.too_many_videos"),
|
|
ephemeral=True
|
|
)
|
|
return
|
|
try:
|
|
def _generate():
|
|
d = load_gob_file(replay_json_path)
|
|
render_gob(d, video_path)
|
|
|
|
logging.info(f"REPLAY ({session_id}) RENDER START")
|
|
async with _video_render_sem:
|
|
await asyncio.get_event_loop().run_in_executor(None, _generate)
|
|
logging.info(f"REPLAY ({session_id}) RENDER END (Success)")
|
|
except Exception as e:
|
|
logging.info(f"REPLAY ({session_id}) RENDER END (Fail)")
|
|
# Clean up broken/partial mp4 so it doesn't get cached
|
|
if video_path.exists():
|
|
video_path.unlink(missing_ok=True)
|
|
error_str = str(e)[:1800] if len(str(e)) > 1800 else str(e)
|
|
await interaction.followup.send(t(_lang, "autolog.video_gen_failed", error=error_str), ephemeral=True)
|
|
return
|
|
|
|
if not video_path.exists() or video_path.stat().st_size == 0:
|
|
await interaction.followup.send(
|
|
t(_lang, "autolog.video_missing"),
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
file_size = video_path.stat().st_size
|
|
guild = interaction.guild
|
|
max_size = guild.filesize_limit if guild else 25 * 1_048_576
|
|
if file_size > max_size:
|
|
file_mb = file_size / 1_048_576
|
|
limit_mb = max_size / 1_048_576
|
|
await interaction.followup.send(
|
|
t(_lang, "autolog.video_too_large", file_mb=file_mb, limit_mb=limit_mb),
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
web_url = f"https://sre.pawjob.us/games/{session_id}"
|
|
try:
|
|
await interaction.followup.send(
|
|
content=t(_lang, "autolog.video_web_fallback", url=web_url),
|
|
file=discord.File(video_path),
|
|
ephemeral=True,
|
|
)
|
|
except discord.HTTPException:
|
|
# Upload failed (too large, rate limited, etc.) — fall back to web link
|
|
await interaction.followup.send(
|
|
content=t(_lang, "autolog.video_upload_failed", url=web_url),
|
|
ephemeral=True,
|
|
)
|
|
|
|
except Exception as e:
|
|
try:
|
|
error_str = str(e)[:1800] if len(str(e)) > 1800 else str(e)
|
|
await interaction.followup.send(
|
|
t("en", "autolog.video_unexpected_error", error=error_str),
|
|
ephemeral=True
|
|
)
|
|
except:
|
|
pass
|
|
|
|
|
|
async def handle_view_chatlog(interaction: discord.Interaction, session_id: str):
|
|
"""Callback for 'View Chat Log' - loads and displays chat_log from replay_data.json."""
|
|
session_url = f"https://warthunder.com/en/tournament/replay/{int(session_id, 16)}"
|
|
|
|
try:
|
|
try:
|
|
await interaction.response.defer(thinking=True, ephemeral=True)
|
|
except Exception:
|
|
return
|
|
|
|
_gf = await load_features(interaction.guild_id) if interaction.guild_id else {}
|
|
_lang = lang_from_features(_gf)
|
|
|
|
replay_data = load_replay_data_from_disk(session_id)
|
|
|
|
if replay_data is None:
|
|
await interaction.followup.send(
|
|
t(_lang, "autolog.replay_not_found", session_id=session_id),
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
# Check if chat_log exists
|
|
chat_log = replay_data.get("chat_log", [])
|
|
if not chat_log:
|
|
await interaction.followup.send(
|
|
t(_lang, "autolog.no_chat_log", session_id=session_id),
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
# Get winning and losing squadrons
|
|
winning_squadron = replay_data.get("winning_team_squadron", "")
|
|
losing_squadron = replay_data.get("losing_team_squadron", "")
|
|
|
|
# Format chat log with diff syntax (green for winners, red for losers)
|
|
formatted_lines = []
|
|
|
|
# Pattern: [timestamp] [TEAM/ALL] [squadron] `username`: message
|
|
pattern = r'\[([^\]]+)\]\s*\[([A-Z]+)\]\s*\[([^\]]+)\]\s*`([^`]+)`:\s*(.+)'
|
|
|
|
for line in chat_log:
|
|
match = re.match(pattern, line)
|
|
if match:
|
|
timestamp = match.group(1)
|
|
scope = match.group(2)
|
|
squadron = match.group(3)
|
|
username = match.group(4)
|
|
message = match.group(5)
|
|
|
|
chat_scope_prefix = "T" if scope == "TEAM" else "A"
|
|
|
|
# Determine prefix based on winning/losing team
|
|
if squadron == winning_squadron:
|
|
prefix = "+"
|
|
elif squadron == losing_squadron:
|
|
prefix = "-"
|
|
else:
|
|
prefix = " "
|
|
|
|
formatted_line = f"{prefix}[{timestamp}] [{chat_scope_prefix}] {f'[{squadron}]':<7} {username}: {message}"
|
|
formatted_lines.append(formatted_line)
|
|
else:
|
|
formatted_lines.append(f" {line}")
|
|
|
|
chat_text = "\n".join(formatted_lines)
|
|
|
|
# Discord message limit is 2000 chars, split if needed
|
|
if len(chat_text) <= 1900:
|
|
await interaction.followup.send(
|
|
t(_lang, "autolog.chat_log_title", session_id=session_id, url=session_url) + f"\n```diff\n{chat_text}\n```",
|
|
ephemeral=True
|
|
)
|
|
else:
|
|
# Split into chunks
|
|
chunks = []
|
|
current_chunk = []
|
|
current_length = 0
|
|
|
|
for line in formatted_lines:
|
|
line_length = len(line) + 1
|
|
if current_length + line_length > 1800:
|
|
chunks.append("\n".join(current_chunk))
|
|
current_chunk = [line]
|
|
current_length = line_length
|
|
else:
|
|
current_chunk.append(line)
|
|
current_length += line_length
|
|
|
|
if current_chunk:
|
|
chunks.append("\n".join(current_chunk))
|
|
|
|
# Send first chunk with header
|
|
await interaction.followup.send(
|
|
t(_lang, "autolog.chat_log_part_title", session_id=session_id, url=session_url, part=1, total=len(chunks)) + f"\n```diff\n{chunks[0]}\n```",
|
|
ephemeral=True
|
|
)
|
|
|
|
# Send remaining chunks
|
|
for i, chunk in enumerate(chunks[1:], start=2):
|
|
await interaction.followup.send(
|
|
t(_lang, "autolog.chat_log_part_only", part=i, total=len(chunks)) + f"\n```diff\n{chunk}\n```",
|
|
ephemeral=True
|
|
)
|
|
|
|
except Exception as e:
|
|
try:
|
|
error_str = str(e)[:1800] if len(str(e)) > 1800 else str(e)
|
|
await interaction.followup.send(
|
|
t("en", "autolog.chat_log_error", error=error_str),
|
|
ephemeral=True
|
|
)
|
|
except:
|
|
pass
|
|
|
|
|
|
async def handle_view_battlelog(interaction: discord.Interaction, session_id: str):
|
|
"""Callback for 'Battle Log' - loads and displays events from replay_data.json."""
|
|
session_url = f"https://warthunder.com/en/tournament/replay/{int(session_id, 16)}"
|
|
|
|
try:
|
|
try:
|
|
await interaction.response.defer(thinking=True, ephemeral=True)
|
|
except Exception:
|
|
return
|
|
|
|
_gf = await load_features(interaction.guild_id) if interaction.guild_id else {}
|
|
_lang = lang_from_features(_gf)
|
|
|
|
replay_data = load_replay_data_from_disk(session_id)
|
|
|
|
if replay_data is None:
|
|
await interaction.followup.send(
|
|
t(_lang, "autolog.replay_not_found", session_id=session_id),
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
formatted_lines = replay_data.get("battle_log", [])
|
|
if not formatted_lines:
|
|
await interaction.followup.send(
|
|
t(_lang, "autolog.no_battle_log", session_id=session_id),
|
|
ephemeral=True
|
|
)
|
|
return
|
|
|
|
log_text = "\n".join(formatted_lines)
|
|
|
|
# Discord message limit is 2000 chars, split if needed
|
|
if len(log_text) <= 1900:
|
|
await interaction.followup.send(
|
|
t(_lang, "autolog.battle_log_title", session_id=session_id, url=session_url) + f"\n```diff\n{log_text}\n```",
|
|
ephemeral=True
|
|
)
|
|
else:
|
|
chunks = []
|
|
current_chunk = []
|
|
current_length = 0
|
|
|
|
for line in formatted_lines:
|
|
line_length = len(line) + 1
|
|
if current_length + line_length > 1800:
|
|
chunks.append("\n".join(current_chunk))
|
|
current_chunk = [line]
|
|
current_length = line_length
|
|
else:
|
|
current_chunk.append(line)
|
|
current_length += line_length
|
|
|
|
if current_chunk:
|
|
chunks.append("\n".join(current_chunk))
|
|
|
|
await interaction.followup.send(
|
|
t(_lang, "autolog.battle_log_part_title", session_id=session_id, url=session_url, part=1, total=len(chunks)) + f"\n```diff\n{chunks[0]}\n```",
|
|
ephemeral=True
|
|
)
|
|
|
|
for i, chunk in enumerate(chunks[1:], start=2):
|
|
await interaction.followup.send(
|
|
t(_lang, "autolog.battle_log_part_only", part=i, total=len(chunks)) + f"\n```diff\n{chunk}\n```",
|
|
ephemeral=True
|
|
)
|
|
|
|
except Exception as e:
|
|
try:
|
|
error_str = str(e)[:1800] if len(str(e)) > 1800 else str(e)
|
|
await interaction.followup.send(
|
|
t("en", "autolog.battle_log_error", error=error_str),
|
|
ephemeral=True
|
|
)
|
|
except:
|
|
pass
|
|
|
|
|
|
async def get_points_diffs(
|
|
sid: str,
|
|
guild_id: int,
|
|
guild_name: str,
|
|
guild_prefs: dict[str, Any],
|
|
replay_data: dict[str, Any]
|
|
) -> dict[str, dict[str, Any]]:
|
|
"""
|
|
Calls the PTS API for both teams in a replay and returns a structured diffs dict.
|
|
Always processes both teams regardless of guild preferences.
|
|
"""
|
|
|
|
diffs: dict[str, dict[str, Any]] = {}
|
|
raw_teams = (replay_data.get("teams") or [])[:2]
|
|
|
|
for team in raw_teams:
|
|
if not team:
|
|
continue
|
|
|
|
long_name = team.get("squadron_long") or ""
|
|
if not long_name:
|
|
continue
|
|
|
|
try:
|
|
points_diff, diff_total, current_points = await get_point_diff(sid, team)
|
|
diffs[long_name] = {
|
|
"points_diff": points_diff,
|
|
"diff_total": diff_total,
|
|
"current_points": current_points,
|
|
}
|
|
except Exception as e:
|
|
logging.error(f"Failed to get point diff for {long_name}: {e}")
|
|
|
|
return diffs
|
|
|
|
|
|
async def build_scoreboard_context(
|
|
session_id: str,
|
|
replay_data: dict[str, Any],
|
|
*,
|
|
received_time: Optional[int] = None,
|
|
end_time: Optional[int] = None,
|
|
) -> dict[str, Any]:
|
|
"""Build the scoreboard payload once per session.
|
|
|
|
The result is reused by the Discord renderer and queued into the external
|
|
websocket bridge so AXBot can render without reconstructing WL or point
|
|
diff state from scratch.
|
|
"""
|
|
teams: List[Dict[str, Any]] = list((replay_data.get("teams") or [])[:2])
|
|
|
|
# Resolve each team independently and concurrently so the result is always
|
|
# index-aligned with the teams list. Batching both teams into one
|
|
# resolve_clans call can invert the order when one team resolves via the
|
|
# short-name pass and the other falls through to the tag pass.
|
|
async def _resolve_team(team: dict) -> Optional[dict]:
|
|
sq = str(team.get("squadron") or "")
|
|
tag = str(team.get("squadron_tagged") or "")
|
|
if not sq and not tag:
|
|
return None
|
|
results = await resolve_clans(shorts=[sq] if sq else [], tags=[tag] if tag else [])
|
|
return results[0] if results else None
|
|
|
|
resolved_teams = await asyncio.gather(*[_resolve_team(t) for t in teams])
|
|
for team, result in zip(teams, resolved_teams):
|
|
if team and result:
|
|
if result.get("long_name") and result["long_name"] != "<unresolved>":
|
|
team["squadron_long"] = result["long_name"]
|
|
if result.get("short_name"):
|
|
team["squadron_short"] = result["short_name"]
|
|
|
|
home_tag: Optional[str] = (teams[0] or {}).get("squadron") if len(teams) > 0 else None
|
|
away_tag: Optional[str] = (teams[1] or {}).get("squadron") if len(teams) > 1 else None
|
|
|
|
def _clean(tag: Optional[str]) -> bool:
|
|
return bool(tag and tag.strip() and tag.upper() != "UNKNOWN")
|
|
|
|
squadrons_clean: List[str] = [t for t in (home_tag, away_tag) if _clean(t) and t is not None]
|
|
|
|
winner_tag: Optional[str] = replay_data.get("winning_team_squadron")
|
|
is_draw = bool(replay_data.get("draw", False))
|
|
if not winner_tag:
|
|
w_idx = replay_data.get("winner")
|
|
if w_idx in (0, 1):
|
|
winner_tag = (home_tag, away_tag)[w_idx]
|
|
|
|
if is_draw and len(squadrons_clean) >= 2:
|
|
try:
|
|
wl = await record_draw(squadrons_clean, session_id)
|
|
except Exception as e:
|
|
logging.error(f"[W/L] record_draw failed ({session_id}): {e}")
|
|
wl = get_standings(squadrons_clean)
|
|
elif winner_tag and winner_tag in squadrons_clean and len(squadrons_clean) >= 2:
|
|
try:
|
|
wl = await record_result(winner_tag, squadrons_clean, session_id)
|
|
except Exception as e:
|
|
logging.error(f"[W/L] record_result failed ({session_id}): {e}")
|
|
wl = get_standings(squadrons_clean)
|
|
else:
|
|
wl = get_standings(squadrons_clean)
|
|
|
|
points_diffs = await get_points_diffs(session_id, 0, "", {}, replay_data)
|
|
|
|
match_details: Dict[str, Any] = {
|
|
"utc_timestamp": int(end_time or replay_data.get("end_ts") or 0),
|
|
"session_id": session_id,
|
|
}
|
|
if received_time is not None:
|
|
match_details["received_unix"] = int(received_time)
|
|
|
|
return {
|
|
"match_details": match_details,
|
|
"winner": winner_tag,
|
|
"is_draw": is_draw,
|
|
"wl": wl,
|
|
"points_diffs": points_diffs,
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# COMPANION PROCESSING
|
|
# ============================================================================
|
|
|
|
async def process_comps(new_games):
|
|
"""Track team composition updates for each squadron.
|
|
|
|
For every game, reads replay data, translates vehicles, and compares the
|
|
player roster against existing compositions stored in per-squadron JSON
|
|
files under ``STORAGE_DIR / "COMPS"``. If a roster matches an existing
|
|
comp (fewer than 2 additions and 2 removals), the comp is updated in place;
|
|
otherwise a new comp entry is created.
|
|
|
|
Args:
|
|
new_games: List of game dicts, each containing ``sessionIdHex`` and
|
|
``endTime`` keys.
|
|
"""
|
|
comp_dir = STORAGE_DIR / "COMPS"
|
|
comp_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
for game in new_games:
|
|
session_id = game.get('sessionIdHex')
|
|
endtime_raw = game.get('endTime')
|
|
|
|
replay_path = replay_data_path(session_id)
|
|
|
|
try:
|
|
raw = await asyncio.to_thread(replay_path.read_bytes)
|
|
replay_data = json.loads(gzip.decompress(raw))
|
|
except FileNotFoundError:
|
|
logging.warning(f"(COMP-WRITE) Replay file not found: {replay_path}")
|
|
continue
|
|
except (OSError, json.JSONDecodeError) as e:
|
|
logging.warning(f"(COMP-WRITE) Invalid replay: {replay_path}: {e}")
|
|
continue
|
|
|
|
# Translate vehicle names
|
|
translate = LangTableReader("English")
|
|
for team in replay_data.get("teams", []):
|
|
for p in team.get("players", []):
|
|
veh = p.get("vehicle")
|
|
if veh:
|
|
p["vehicle_new"] = translate.get_translate(veh)
|
|
else:
|
|
p["vehicle"] = "DISCONNECTED"
|
|
p["vehicle_new"] = "DISCONNECTED"
|
|
|
|
# Compare to existing comps and write new/updated ones
|
|
for team in replay_data.get("teams", []):
|
|
squadron = team.get("squadron", "UNKNOWN")
|
|
squadron = squadron.upper()
|
|
squad_file = comp_dir / f"{squadron}.json"
|
|
|
|
# Load existing comps_data (or start fresh)
|
|
comps_data = {}
|
|
if squad_file.exists():
|
|
try:
|
|
async with aiofiles.open(squad_file, "r", encoding="utf-8") as f:
|
|
comps_data = json.loads(await f.read())
|
|
except json.JSONDecodeError:
|
|
comps_data = {}
|
|
|
|
# Build this game's player list
|
|
players_list = [
|
|
{
|
|
"UID": p["uid"],
|
|
"nick": (p.get("nick") or "").replace("coop/", "") or "Unknown",
|
|
"vehicle": p.get("vehicle_new") or "DISCONNECTED",
|
|
"vehicle_internal": p.get("vehicle") or "DISCONNECTED"
|
|
}
|
|
for p in team.get("players", [])
|
|
]
|
|
new_uids = {p["UID"]: p for p in players_list}
|
|
|
|
# Try to match an existing COMP by roster similarity
|
|
matched_key = None
|
|
added = set()
|
|
removed = set()
|
|
vehicle_diff = 0
|
|
|
|
for comp_key, comp in comps_data.items():
|
|
old_players = comp.get("Players", [])
|
|
old_uids = {p["UID"]: p for p in old_players}
|
|
|
|
added = set(new_uids) - set(old_uids)
|
|
removed = set(old_uids) - set(new_uids)
|
|
shared = set(new_uids) & set(old_uids)
|
|
vehicle_diff = sum(
|
|
1 for uid in shared
|
|
if new_uids[uid]["vehicle"] != old_uids[uid]["vehicle"]
|
|
)
|
|
|
|
# Treat as same comp if fewer than 2 added and fewer than 2 removed
|
|
if len(added) < 2 and len(removed) < 2:
|
|
matched_key = comp_key
|
|
break
|
|
|
|
if matched_key:
|
|
# Always update roster + timestamp when matched
|
|
comps_data[matched_key]["Players"] = players_list
|
|
comps_data[matched_key]["upd"] = endtime_raw
|
|
async with aiofiles.open(squad_file, "w", encoding="utf-8") as f:
|
|
await f.write(json.dumps(comps_data, ensure_ascii=False, indent=2))
|
|
logging.info(f"(COMP-WRITE) Updated {matched_key} for {squadron} | added={len(added)} removed={len(removed)} veh_diff={vehicle_diff} | upd={endtime_raw}")
|
|
continue
|
|
|
|
# No match => create a new COMP entry
|
|
new_comp_key = f"COMP{len(comps_data) + 1}"
|
|
comps_data[new_comp_key] = {
|
|
"reg": endtime_raw,
|
|
"upd": endtime_raw,
|
|
"Players": players_list
|
|
}
|
|
async with aiofiles.open(squad_file, "w", encoding="utf-8") as f:
|
|
await f.write(json.dumps(comps_data, ensure_ascii=False, indent=2))
|
|
logging.info(f"(COMP-WRITE) Created {new_comp_key} for {squadron} | {len(players_list)} players | reg={endtime_raw}")
|
|
|
|
|
|
def as_int(x, default=0):
|
|
"""Safely convert *x* to int, returning *default* on failure.
|
|
|
|
Args:
|
|
x: Value to convert.
|
|
default: Returned when conversion raises TypeError or ValueError.
|
|
|
|
Returns:
|
|
int: Converted value or *default*.
|
|
"""
|
|
try:
|
|
return int(x)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
async def process_stats(new_games):
|
|
"""Upsert per-player game statistics into the SQLite player_games_hist table.
|
|
|
|
Loads replay data for each new game, translates vehicle names, and inserts
|
|
one row per player per session (skipping AI bot backfills). Sessions already
|
|
present in the database are skipped for deduplication.
|
|
|
|
Args:
|
|
new_games: List of game dicts with ``sessionIdHex`` and ``endTime``.
|
|
"""
|
|
if not new_games:
|
|
return
|
|
|
|
async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as conn:
|
|
await init_players_db(conn)
|
|
|
|
# Collect session IDs once
|
|
session_ids = []
|
|
end_times = {}
|
|
for game in new_games:
|
|
sid = game.get("sessionIdHex")
|
|
if sid:
|
|
session_ids.append(sid)
|
|
end_times[sid] = int(game.get("endTime", 0) or 0)
|
|
|
|
if not session_ids:
|
|
return
|
|
|
|
# Skip sessions already in DB
|
|
placeholders = ",".join("?" for _ in session_ids)
|
|
existing = await conn.execute_fetchall(
|
|
f"SELECT DISTINCT session_id FROM player_games_hist WHERE session_id IN ({placeholders})",
|
|
session_ids
|
|
)
|
|
existing_sessions = {row[0] for row in existing}
|
|
sessions_to_process = [sid for sid in session_ids if sid not in existing_sessions]
|
|
|
|
if not sessions_to_process:
|
|
return
|
|
|
|
translate = get_translator()
|
|
|
|
all_rows = []
|
|
await conn.execute("BEGIN;")
|
|
|
|
for sid in sessions_to_process:
|
|
endtime_raw = end_times.get(sid, 0)
|
|
|
|
replay_path = replay_data_path(sid)
|
|
|
|
try:
|
|
raw = await asyncio.to_thread(replay_path.read_bytes)
|
|
replay_data = json.loads(gzip.decompress(raw))
|
|
except (FileNotFoundError, OSError, json.JSONDecodeError):
|
|
continue
|
|
|
|
winning_team = replay_data.get("winning_team_squadron")
|
|
teams = replay_data.get("teams") or []
|
|
|
|
for team in teams:
|
|
# WT replay JSON occasionally leaks decorative tag glyphs (box-drawing
|
|
# chars, brackets, "=" etc.) into the "squadron" field, producing
|
|
# duplicate squadron_name variants for the same clan. Strip to plain
|
|
# short-name alphanumerics; fall back to UNKNOWN if stripping empties.
|
|
squadron_name = re.sub(r"[^A-Za-z0-9_-]", "", team.get("squadron", "UNKNOWN")) or "UNKNOWN"
|
|
squadron_tagged = team.get("squadron_tagged", "UNKNOWN")
|
|
victor_text = "Win" if (winning_team and squadron_name == winning_team) else "Loss"
|
|
team_clan_id = _resolve_clan_id_for_pgh(squadron_name)
|
|
|
|
players = team.get("players") or []
|
|
for p in players:
|
|
uid = str(p.get("uid") or "").strip()
|
|
nick = str(p.get("nick") or "").strip()
|
|
if not (uid and nick and sid):
|
|
continue
|
|
# Skip AI bot backfills (e.g. "coop/Bot340") —
|
|
# these replace disconnected players but keep their UID,
|
|
# polluting the nick column for real players.
|
|
if nick.startswith("coop/"):
|
|
continue
|
|
|
|
veh_internal = p.get("vehicle") or "DISCONNECTED"
|
|
veh_display = p.get("vehicle_new")
|
|
|
|
if not veh_display:
|
|
if veh_internal != "DISCONNECTED":
|
|
veh_display = translate.get_translate(str(veh_internal))
|
|
else:
|
|
veh_display = "DISCONNECTED"
|
|
|
|
all_rows.append((
|
|
uid,
|
|
nick,
|
|
squadron_name,
|
|
squadron_tagged,
|
|
sid,
|
|
veh_display,
|
|
veh_internal,
|
|
as_int(p.get("ground_kills")),
|
|
as_int(p.get("air_kills")),
|
|
as_int(p.get("assists")),
|
|
as_int(p.get("captures")),
|
|
as_int(p.get("deaths")),
|
|
victor_text,
|
|
endtime_raw,
|
|
team_clan_id,
|
|
))
|
|
|
|
if not all_rows:
|
|
await conn.execute("ROLLBACK;")
|
|
return
|
|
|
|
try:
|
|
await conn.executemany(UPSERT_SQL, all_rows)
|
|
await conn.commit()
|
|
except Exception as e:
|
|
await conn.execute("ROLLBACK;")
|
|
logging.error(f"(STAT-MANAGER) DB upsert failed: {e}")
|
|
|
|
|
|
def _compact(obj: Any) -> str:
|
|
"""Serialize *obj* to a compact JSON string with no extra whitespace.
|
|
|
|
Args:
|
|
obj: JSON-serializable object.
|
|
|
|
Returns:
|
|
str: Minified JSON string.
|
|
"""
|
|
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
|
|
|
|
|
|
def _find_winner_loser(replay_data: Dict[str, Any]) -> Optional[Tuple[Dict, Dict]]:
|
|
"""Extract the winner and loser team dicts from replay data.
|
|
|
|
Args:
|
|
replay_data: Parsed replay JSON containing ``teams``,
|
|
``winning_team_squadron``, and ``losing_team_squadron`` keys.
|
|
|
|
Returns:
|
|
A ``(winner_team, loser_team)`` tuple of team dicts, or ``None`` if
|
|
fewer than two teams exist or either tag cannot be resolved.
|
|
"""
|
|
teams: List[Dict] = replay_data.get("teams") or []
|
|
if len(teams) < 2:
|
|
return None
|
|
|
|
winner_tag = replay_data.get("winning_team_squadron")
|
|
loser_tag = replay_data.get("losing_team_squadron")
|
|
|
|
# Map tags to team dicts
|
|
tag_to_team = {(t or {}).get("squadron"): (t or {}) for t in teams}
|
|
winner_team_dict = tag_to_team.get(winner_tag)
|
|
loser_team_dict = tag_to_team.get(loser_tag)
|
|
|
|
if winner_team_dict and loser_team_dict:
|
|
return winner_team_dict, loser_team_dict
|
|
|
|
|
|
async def process_match_summaries(new_games: List[Dict[str, Any]]) -> None:
|
|
"""Create or update match_summary records for processed games.
|
|
|
|
Stores session_id, map_name, endtime_unix, winning_sq, losing_sq,
|
|
winning_team_json, and losing_team_json. Runs schema migration for
|
|
any missing columns on first call.
|
|
|
|
Args:
|
|
new_games: List of replay dicts containing match details.
|
|
"""
|
|
async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as conn:
|
|
# Fresh DBs
|
|
await conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS match_summary (
|
|
session_id TEXT PRIMARY KEY,
|
|
map_name TEXT,
|
|
endtime_unix INTEGER,
|
|
winning_sq TEXT,
|
|
losing_sq TEXT,
|
|
winning_team_json TEXT,
|
|
losing_team_json TEXT
|
|
)
|
|
""")
|
|
# Migrate (add any missing columns)
|
|
cols = {r[1] for r in await conn.execute_fetchall("PRAGMA table_info(match_summary)")}
|
|
wanted = {
|
|
"session_id", "map_name", "endtime_unix", "winning_sq", "losing_sq",
|
|
"winning_team_json", "losing_team_json", "game_type", "mission_mode"
|
|
}
|
|
for col in (wanted - cols):
|
|
await conn.execute(f"ALTER TABLE match_summary ADD COLUMN {col} TEXT")
|
|
if "received_unix" not in cols:
|
|
await conn.execute("ALTER TABLE match_summary ADD COLUMN received_unix INTEGER")
|
|
if "winning_clan_id" not in cols:
|
|
await conn.execute("ALTER TABLE match_summary ADD COLUMN winning_clan_id INTEGER")
|
|
if "losing_clan_id" not in cols:
|
|
await conn.execute("ALTER TABLE match_summary ADD COLUMN losing_clan_id INTEGER")
|
|
await conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_ms_winning_clanid ON match_summary(winning_clan_id)"
|
|
)
|
|
await conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_ms_losing_clanid ON match_summary(losing_clan_id)"
|
|
)
|
|
await conn.commit()
|
|
|
|
# One-time backfill: older rows embed the gamemode in map_name as a
|
|
# "[Conquest #1] <map>" prefix (sometimes with a leading space). Split
|
|
# that into mission_mode and a clean map_name. Idempotent — only touches
|
|
# rows where mission_mode is unset and the prefix is still present.
|
|
await conn.execute("""
|
|
UPDATE match_summary
|
|
SET mission_mode = TRIM(SUBSTR(TRIM(map_name), 2, INSTR(TRIM(map_name), ']') - 2)),
|
|
map_name = LTRIM(SUBSTR(TRIM(map_name), INSTR(TRIM(map_name), ']') + 1))
|
|
WHERE TRIM(map_name) LIKE '[%]%'
|
|
AND (mission_mode IS NULL OR mission_mode = '')
|
|
""")
|
|
await conn.commit()
|
|
|
|
rows: List[Tuple[str, str, int, str, str, bytes, bytes, str, str, Optional[int], Optional[int], Optional[int]]] = []
|
|
|
|
for game in new_games:
|
|
session_id: str = game.get("sessionIdHex") or ""
|
|
|
|
if not session_id:
|
|
continue
|
|
|
|
received_raw = game.get("receivedTime")
|
|
received_unix: Optional[int] = int(received_raw) if received_raw else None
|
|
|
|
# Load replay JSON
|
|
replay_path = replay_data_path(session_id)
|
|
try:
|
|
raw = await asyncio.to_thread(replay_path.read_bytes)
|
|
replay_data = json.loads(gzip.decompress(raw))
|
|
except (FileNotFoundError, OSError, json.JSONDecodeError):
|
|
continue
|
|
|
|
# Translate vehicle names & sanitize
|
|
translate = LangTableReader("English")
|
|
|
|
for team in replay_data.get("teams", []):
|
|
for p in team.get("players", []):
|
|
veh = p.get("vehicle")
|
|
if veh:
|
|
p["vehicle_new"] = translate.get_translate(f"{veh}")
|
|
else:
|
|
p["vehicle"] = "DISCONNECTED"
|
|
p["vehicle_new"] = "DISCONNECTED"
|
|
|
|
# Map fields
|
|
map_name: str = str(game.get("missionName", "") or "")
|
|
endtime_unix: int = int(game.get("endTime", 0) or 0)
|
|
|
|
# Winner / loser
|
|
wl = _find_winner_loser(replay_data)
|
|
if not wl:
|
|
continue
|
|
winner_team, loser_team = wl
|
|
|
|
winning_sq = str(winner_team.get("squadron") or "")
|
|
losing_sq = str(loser_team.get("squadron") or "")
|
|
winning_clan_id = _resolve_clan_id_for_pgh(winning_sq)
|
|
losing_clan_id = _resolve_clan_id_for_pgh(losing_sq)
|
|
|
|
rows.append((
|
|
session_id,
|
|
str(map_name),
|
|
int(endtime_unix),
|
|
winning_sq,
|
|
losing_sq,
|
|
compress_json(winner_team, ensure_ascii=False, separators=(",", ":")),
|
|
compress_json(loser_team, ensure_ascii=False, separators=(",", ":")),
|
|
str(replay_data.get("type", "")),
|
|
str(replay_data.get("mode", "")),
|
|
received_unix,
|
|
winning_clan_id,
|
|
losing_clan_id,
|
|
))
|
|
|
|
if not rows:
|
|
return
|
|
|
|
# Upsert (one row per session)
|
|
try:
|
|
await conn.executemany("""
|
|
INSERT INTO match_summary
|
|
(session_id, map_name, endtime_unix, winning_sq, losing_sq,
|
|
winning_team_json, losing_team_json, game_type, mission_mode,
|
|
received_unix, winning_clan_id, losing_clan_id)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(session_id) DO UPDATE SET
|
|
map_name = excluded.map_name,
|
|
endtime_unix = excluded.endtime_unix,
|
|
winning_sq = excluded.winning_sq,
|
|
losing_sq = excluded.losing_sq,
|
|
winning_team_json = excluded.winning_team_json,
|
|
losing_team_json = excluded.losing_team_json,
|
|
game_type = excluded.game_type,
|
|
mission_mode = excluded.mission_mode,
|
|
received_unix = COALESCE(excluded.received_unix, match_summary.received_unix),
|
|
winning_clan_id = COALESCE(excluded.winning_clan_id, match_summary.winning_clan_id),
|
|
losing_clan_id = COALESCE(excluded.losing_clan_id, match_summary.losing_clan_id)
|
|
""", rows)
|
|
await conn.commit()
|
|
except Exception as e:
|
|
logging.error(f"(MATCH-SUMMARY) DB upsert failed: {e}")
|