4b75ce1533
* update game files * update files and capture raw spectra payload
2192 lines
84 KiB
Python
2192 lines
84 KiB
Python
"""
|
||
task_executors.py
|
||
|
||
Task execution logic for scheduled tasks. Contains the execute_* functions
|
||
for leaderboard alarms, points tracking, leave detection, squadron stats,
|
||
and database synchronization routines.
|
||
"""
|
||
|
||
# Standard Library Imports
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import os
|
||
import shutil
|
||
import time as T
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
# Third-Party Library Imports
|
||
import aiofiles
|
||
import aiosqlite
|
||
import discord
|
||
from discord import TextChannel, Thread
|
||
|
||
# Local Module Imports
|
||
from .game_api import obtain_clan_new_points, save_squadrons_to_db, ClanInfoError
|
||
from .health import record_task_run
|
||
from .utils import t, lang_from_features
|
||
from .wl import clean_WL as _clean_wl_db, get_standings
|
||
from .utils import (
|
||
STORAGE_DIR,
|
||
SQUADRONS_DB_PATH,
|
||
SQ_BATTLES_DB_PATH,
|
||
blacklisted_guilds,
|
||
DEFAULT_FOOTER_CAT,
|
||
compress_json,
|
||
discord_len,
|
||
pad_display_width,
|
||
get_bot,
|
||
load_features,
|
||
parse_channel_id,
|
||
remove_guild_pref_notification,
|
||
resolve_clan,
|
||
resolve_pref_key,
|
||
is_guild_entitled,
|
||
get_guild_tier,
|
||
refresh_entitled_guilds,
|
||
PREMIUM_ACTIVATION_TS,
|
||
tier_enforcement_active,
|
||
allowed_pref_keys_for,
|
||
enabled_pref_keys_for,
|
||
esc,
|
||
REPLAYS_DIR,
|
||
WILDCARD_KEYS,
|
||
get_most_recent_posted_timeslot_window,
|
||
)
|
||
from .autologging import send_over_cap_warning
|
||
from .weekly_br_elo import (
|
||
br_color_int,
|
||
player_scores as _wbr_player_scores,
|
||
squadron_report_for_variants_from_maps,
|
||
squadron_scores as _wbr_squadron_scores,
|
||
top_n_squadrons_with_top_k_players_from_maps,
|
||
)
|
||
|
||
# Snapshots directory for points alarm
|
||
SNAPSHOTS_DIR = STORAGE_DIR / "SNAPSHOTS"
|
||
SNAPSHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
# Marker file directory for the Weekly BR Report (idempotency)
|
||
WEEKLY_BR_DIR = STORAGE_DIR / "WEEKLY_BR"
|
||
WEEKLY_BR_DIR.mkdir(parents=True, exist_ok=True)
|
||
WEEKLY_BR_MARKER_PATH = WEEKLY_BR_DIR / "last_fired.json"
|
||
|
||
|
||
# ============================================================================
|
||
# SNAPSHOT FUNCTIONS (for points alarm)
|
||
# ============================================================================
|
||
|
||
ORDINAL_SUFFIXES = {
|
||
"en": lambda n: f"{n}th" if 11 <= n % 100 <= 13 else f"{n}{['th','st','nd','rd','th'][min(n%10,4)]}",
|
||
"de": lambda n: f"{n}.",
|
||
"fr": lambda n: f"{n}er" if n == 1 else f"{n}e",
|
||
"es": lambda n: f"{n}\u00ba",
|
||
"it": lambda n: f"{n}\u00ba",
|
||
"pt": lambda n: f"{n}\u00ba",
|
||
"pl": lambda n: f"{n}.",
|
||
"ru": lambda n: f"{n}-е",
|
||
"uk": lambda n: f"{n}-е",
|
||
"cs": lambda n: f"{n}.",
|
||
}
|
||
|
||
|
||
def ordinal(n: int, lang: str) -> str:
|
||
"""Format an integer as an ordinal string for the given language."""
|
||
fmt = ORDINAL_SUFFIXES.get(lang, ORDINAL_SUFFIXES["en"])
|
||
return fmt(n)
|
||
|
||
|
||
async def get_squadron_placement(squadron_name: str) -> int | None:
|
||
"""Return 1-based leaderboard placement for a squadron, or None if not ranked."""
|
||
try:
|
||
async with aiosqlite.connect(f"file:{SQUADRONS_DB_PATH}?mode=ro", uri=True) as con:
|
||
async with con.execute(
|
||
"""
|
||
SELECT long_name FROM squadrons_data
|
||
WHERE clanrating IS NOT NULL AND clanrating > 0
|
||
ORDER BY clanrating DESC
|
||
"""
|
||
) as cur:
|
||
rank = 1
|
||
async for row in cur:
|
||
if row[0] and row[0].lower() == squadron_name.lower():
|
||
return rank
|
||
rank += 1
|
||
except Exception as e:
|
||
logging.error("[PLACEMENT] Error fetching placement for %s: %s", squadron_name, e)
|
||
return None
|
||
|
||
|
||
async def take_snapshot(squadron_name: str, region: str) -> dict | None:
|
||
"""
|
||
Return { total_points, members, placement } or None if we hit ANY problem or get no members.
|
||
"""
|
||
try:
|
||
ratings, total = await obtain_clan_new_points(squadron_name)
|
||
except ClanInfoError:
|
||
return None
|
||
|
||
if not ratings:
|
||
return None
|
||
|
||
placement = await get_squadron_placement(squadron_name)
|
||
|
||
return {"total_points": total, "members": ratings, "placement": placement}
|
||
|
||
|
||
async def save_snapshot(snapshot: dict, squadron_name: str, region: str) -> None:
|
||
"""Save a squadron points snapshot to a JSON file in the snapshots directory.
|
||
|
||
Args:
|
||
snapshot: Dict containing total_points and members data.
|
||
squadron_name: Squadron identifier used in the filename.
|
||
region: Region string used in the filename.
|
||
"""
|
||
filename = f"{squadron_name}-{region}.json"
|
||
path = SNAPSHOTS_DIR / filename
|
||
async with aiofiles.open(path, "w", encoding="utf-8") as f:
|
||
await f.write(json.dumps(snapshot, ensure_ascii=False, indent=2))
|
||
|
||
|
||
async def load_snapshot(squadron_name: str, region: str) -> dict | None:
|
||
"""Load a previously saved squadron points snapshot from disk.
|
||
|
||
Args:
|
||
squadron_name: Squadron identifier used in the filename.
|
||
region: Region string used in the filename.
|
||
|
||
Returns:
|
||
The parsed snapshot dict, or None if not found or on error.
|
||
"""
|
||
filename = f"{squadron_name}-{region}.json"
|
||
path = SNAPSHOTS_DIR / filename
|
||
try:
|
||
async with aiofiles.open(path, "r", encoding="utf-8") as f:
|
||
return json.loads(await f.read())
|
||
except FileNotFoundError:
|
||
return None
|
||
except Exception as e:
|
||
logging.error(f"Error loading snapshot {filename}: {e}")
|
||
return None
|
||
|
||
|
||
def compare_points(old_snapshot: dict, new_snapshot: dict) -> tuple[dict, int]:
|
||
"""
|
||
Compare `old_snapshot` vs `new_snapshot`, returning:
|
||
- diffs: { uid: (delta_points:int, new_points:int), ... }
|
||
• delta_points: how much the player's points changed (positive/negative)
|
||
• new_points: the player's current total points (0 if they left)
|
||
- old_total: total points from the old snapshot
|
||
"""
|
||
if not old_snapshot or not new_snapshot:
|
||
return {}, 0
|
||
|
||
old_members = old_snapshot.get("members", {})
|
||
new_members = new_snapshot.get("members", {})
|
||
old_total = old_snapshot.get("total_points", 0)
|
||
|
||
diffs = {}
|
||
|
||
# Pass 1: check all UIDs that exist in the new snapshot
|
||
for uid, info in new_members.items():
|
||
new_pts = info.get("points", 0)
|
||
old_pts = old_members.get(uid, {}).get("points", 0)
|
||
delta = new_pts - old_pts
|
||
if delta != 0:
|
||
diffs[uid] = (delta, new_pts)
|
||
|
||
# Pass 2: check for UIDs that existed before but are missing now
|
||
for uid, info in old_members.items():
|
||
if uid not in new_members:
|
||
old_pts = info.get("points", 0)
|
||
if old_pts > 0:
|
||
diffs[uid] = (-old_pts, 0)
|
||
|
||
return diffs, old_total
|
||
|
||
|
||
async def _load_points_alarm_player_slot_stats(
|
||
uids: List[str], region: str
|
||
) -> Dict[str, Dict[str, float]]:
|
||
"""Return per-UID KDR/KPS for the most recently completed posted slot."""
|
||
slot = get_most_recent_posted_timeslot_window(region, end_grace_minutes=10)
|
||
if not slot or not uids:
|
||
return {}
|
||
|
||
start_ts, end_ts = slot
|
||
clean_uids = [str(uid) for uid in uids if str(uid)]
|
||
if not clean_uids:
|
||
return {}
|
||
|
||
placeholders = ",".join("?" for _ in clean_uids)
|
||
sql = f"""
|
||
WITH player_sessions AS (
|
||
SELECT
|
||
UID,
|
||
session_id,
|
||
SUM(ground_kills + air_kills) AS kills,
|
||
SUM(deaths) AS deaths
|
||
FROM player_games_hist
|
||
WHERE UID IN ({placeholders})
|
||
AND endtime_unix >= ?
|
||
AND endtime_unix <= ?
|
||
GROUP BY UID, session_id
|
||
)
|
||
SELECT
|
||
UID,
|
||
COUNT(*) AS games,
|
||
SUM(kills) AS total_kills,
|
||
SUM(deaths) AS total_deaths
|
||
FROM player_sessions
|
||
GROUP BY UID
|
||
"""
|
||
|
||
out: Dict[str, Dict[str, float]] = {}
|
||
try:
|
||
async with aiosqlite.connect(
|
||
f"file:{SQ_BATTLES_DB_PATH}?mode=ro", uri=True, timeout=30.0
|
||
) as db:
|
||
await db.execute("PRAGMA busy_timeout=30000;")
|
||
params = [*clean_uids, start_ts, end_ts]
|
||
async with db.execute(sql, params) as cursor:
|
||
async for uid, games, total_kills, total_deaths in cursor:
|
||
games_i = max(0, int(games or 0))
|
||
kills_i = max(0, int(total_kills or 0))
|
||
deaths_i = max(0, int(total_deaths or 0))
|
||
if games_i <= 0:
|
||
continue
|
||
out[str(uid)] = {
|
||
"kdr": (kills_i / deaths_i) if deaths_i > 0 else float(kills_i),
|
||
"kps": (kills_i / games_i),
|
||
}
|
||
except Exception as e:
|
||
logging.error("(POINTS) Failed loading slot-scoped KDR/KPS stats: %s", e)
|
||
|
||
return out
|
||
|
||
|
||
# ============================================================================
|
||
# CLEANUP FUNCTIONS
|
||
# ============================================================================
|
||
|
||
async def cleanup_WL() -> None:
|
||
"""
|
||
Clears WL tables in SQLite (events + standings).
|
||
Keeps the async signature for existing callers.
|
||
"""
|
||
loop = asyncio.get_running_loop()
|
||
# run the synchronous DB clear off the event loop
|
||
await loop.run_in_executor(None, _clean_wl_db)
|
||
|
||
|
||
async def cleanup_replays():
|
||
"""
|
||
Cleans up replay directories in STORAGE/REPLAYS/SRE/:
|
||
- After 12 hours: deletes regenerable files (PNGs, MP4s)
|
||
- After 48 hours: deletes entire directory (replay JSON included)
|
||
|
||
Age is determined from the mtime of replay_data.json (written
|
||
once at capture time), not the directory mtime — directory mtime is bumped
|
||
whenever files inside are added or removed (including by this cleanup), which
|
||
would otherwise keep dirs perpetually "fresh".
|
||
"""
|
||
KEEP_FILES = {"replay_data.json.gz", "RAW_replay_data.json.gz"}
|
||
|
||
def _sync_cleanup_replays():
|
||
"""Synchronous helper that walks replay dirs and deletes stale files."""
|
||
replay_file_path = REPLAYS_DIR
|
||
if not replay_file_path.exists():
|
||
return
|
||
|
||
current_time = T.time()
|
||
cutoff_regen = current_time - (12 * 60 * 60) # 12h — delete regenerable files
|
||
cutoff_full = current_time - (48 * 60 * 60) # 48h — delete entire directory
|
||
|
||
full_deleted: list[str] = []
|
||
regen_cleaned: list[tuple[str, int]] = []
|
||
|
||
for entry in os.listdir(replay_file_path):
|
||
entry_path = replay_file_path / entry
|
||
if not entry_path.is_dir():
|
||
continue
|
||
|
||
json_path = entry_path / "replay_data.json.gz"
|
||
if json_path.exists():
|
||
entry_mtime = json_path.stat().st_mtime
|
||
else:
|
||
entry_mtime = entry_path.stat().st_mtime
|
||
|
||
if entry_mtime < cutoff_full:
|
||
try:
|
||
shutil.rmtree(entry_path)
|
||
full_deleted.append(entry)
|
||
except OSError as e:
|
||
logging.warning(f"cleanup_replays: failed to rmtree {entry_path}: {e}")
|
||
elif entry_mtime < cutoff_regen:
|
||
removed_count = 0
|
||
for fname in os.listdir(entry_path):
|
||
if fname not in KEEP_FILES:
|
||
fpath = entry_path / fname
|
||
try:
|
||
os.remove(fpath)
|
||
removed_count += 1
|
||
except OSError:
|
||
pass
|
||
if removed_count:
|
||
regen_cleaned.append((entry, removed_count))
|
||
|
||
if full_deleted:
|
||
logging.info(
|
||
f"cleanup_replays: removed {len(full_deleted)} dirs >48h old: "
|
||
f"{', '.join(full_deleted)}"
|
||
)
|
||
if regen_cleaned:
|
||
total_files = sum(n for _, n in regen_cleaned)
|
||
preview = ", ".join(f"{name}({n})" for name, n in regen_cleaned[:10])
|
||
suffix = f" (+{len(regen_cleaned) - 10} more)" if len(regen_cleaned) > 10 else ""
|
||
logging.info(
|
||
f"cleanup_replays: cleaned regen files in {len(regen_cleaned)} dirs "
|
||
f"({total_files} files): {preview}{suffix}"
|
||
)
|
||
if not full_deleted and not regen_cleaned:
|
||
logging.info("cleanup_replays: nothing to clean")
|
||
|
||
await asyncio.to_thread(_sync_cleanup_replays)
|
||
|
||
|
||
async def cleanup_masterLog():
|
||
"""
|
||
Deletes all files in STORAGE_DIR / "PROFILES" / "MASTER_LOG".
|
||
"""
|
||
def _sync_cleanup_masterLog():
|
||
"""Synchronous helper that deletes all files in the MASTER_LOG directory."""
|
||
masterLog_dir = STORAGE_DIR / "PROFILES" / "MASTER_LOG"
|
||
if not masterLog_dir.exists():
|
||
logging.warning(f"Master log dir does not exist: {masterLog_dir}")
|
||
return
|
||
|
||
for entry in masterLog_dir.iterdir():
|
||
if entry.is_file():
|
||
try:
|
||
entry.unlink()
|
||
except Exception as e:
|
||
logging.error(f"Failed to delete {entry}: {e}")
|
||
|
||
await asyncio.to_thread(_sync_cleanup_masterLog)
|
||
|
||
|
||
async def cleanup_comps():
|
||
"""
|
||
Deletes all files in STORAGE_DIR / "COMPS".
|
||
"""
|
||
def _sync_cleanup_comps():
|
||
"""Synchronous helper that deletes all files in the COMPS directory."""
|
||
comps_dir = STORAGE_DIR / "COMPS"
|
||
if not comps_dir.exists():
|
||
logging.warning(f"Comps dir does not exist: {comps_dir}")
|
||
return
|
||
|
||
for entry in comps_dir.iterdir():
|
||
if entry.is_file():
|
||
try:
|
||
entry.unlink()
|
||
except Exception as e:
|
||
logging.error(f"Failed to delete {entry}: {e}")
|
||
|
||
await asyncio.to_thread(_sync_cleanup_comps)
|
||
|
||
|
||
# ============================================================================
|
||
# SQUADRON POINTS TABLE
|
||
# ============================================================================
|
||
|
||
async def init_squadrons_points_table():
|
||
"""Initialize the squadrons_points table if it doesn't exist."""
|
||
async with aiosqlite.connect(SQUADRONS_DB_PATH, timeout=30.0) as db:
|
||
await db.execute("PRAGMA busy_timeout=30000;")
|
||
await db.execute("""
|
||
CREATE TABLE IF NOT EXISTS squadrons_points (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
clan_id INTEGER NOT NULL,
|
||
long_name TEXT NOT NULL,
|
||
unix_time INTEGER NOT NULL,
|
||
clan_pts TEXT NOT NULL,
|
||
total_score INTEGER NOT NULL
|
||
)
|
||
""")
|
||
# Create index for efficient queries
|
||
await db.execute("""
|
||
CREATE INDEX IF NOT EXISTS idx_squadrons_points_longname_time
|
||
ON squadrons_points(long_name, unix_time)
|
||
""")
|
||
await db.commit()
|
||
|
||
|
||
# ============================================================================
|
||
# EXECUTE FUNCTIONS
|
||
# ============================================================================
|
||
|
||
async def execute_ldb_alarm_task():
|
||
"""Execute the leaderboard alarm task.
|
||
|
||
Refreshes squadrons.db, loads the top-50 leaderboard, computes
|
||
stat deltas (kills, deaths, win rate, play time) against the
|
||
previous snapshot, builds paginated Discord embeds (top-20 and
|
||
21-50), and sends them to all guilds with a configured leaderboard
|
||
channel.
|
||
"""
|
||
bot = get_bot()
|
||
|
||
# 0. Update squadrons.db with fresh data
|
||
try:
|
||
await execute_update_squadrons_db_task(count=100)
|
||
except Exception as e:
|
||
logging.error(f"(LDB) Error refreshing squadrons.db: {e}")
|
||
# Continue anyway with existing data
|
||
|
||
# 1. Load top 50 squadrons from squadrons.db (track more, display top 30)
|
||
try:
|
||
async with aiosqlite.connect(SQUADRONS_DB_PATH, timeout=30.0) as db:
|
||
await db.execute("PRAGMA busy_timeout=30000;")
|
||
async with db.execute("""
|
||
SELECT clan_id, position, short_name, tag_name, wins, battles,
|
||
a_kills, g_kills, deaths, playtime, clanrating, members
|
||
FROM squadrons_data
|
||
WHERE position IS NOT NULL AND position < 50
|
||
ORDER BY position ASC
|
||
""") as cursor:
|
||
rows = list(await cursor.fetchall())
|
||
except Exception as e:
|
||
logging.error(f"(LDB) Fatal error loading squadrons from DB: {e}")
|
||
return
|
||
|
||
if not rows:
|
||
logging.warning("(LDB) No squadron data available; skipping")
|
||
return
|
||
|
||
# 2. Build current leaderboard data
|
||
current_data = {}
|
||
for row in rows:
|
||
clan_id, position, short_name, tag_name, wins, battles, a_kills, g_kills, deaths, playtime, clanrating, members = row
|
||
|
||
# Calculate derived stats
|
||
wins_val = wins or 0
|
||
battles_val = battles or 0
|
||
a_kills_val = a_kills or 0
|
||
g_kills_val = g_kills or 0
|
||
deaths_val = deaths or 0
|
||
|
||
win_rate = (wins_val / battles_val * 100) if battles_val > 0 else 0.0
|
||
total_kills = a_kills_val + g_kills_val
|
||
kd_ratio = (total_kills / deaths_val) if deaths_val > 0 else 0.0
|
||
|
||
current_data[str(clan_id)] = {
|
||
"position": position,
|
||
"short_name": short_name,
|
||
"tag_name": tag_name,
|
||
"wins": wins_val,
|
||
"battles": battles_val,
|
||
"a_kills": a_kills_val,
|
||
"g_kills": g_kills_val,
|
||
"deaths": deaths_val,
|
||
"playtime": playtime or 0,
|
||
"clanrating": clanrating or 0,
|
||
"members": members or 0,
|
||
"win_rate": win_rate,
|
||
"total_kills": total_kills,
|
||
"kd_ratio": kd_ratio
|
||
}
|
||
|
||
# 3. Calculate session stats and add to current_data
|
||
prefs_dir = STORAGE_DIR / "PREFERENCES"
|
||
leaderboards_dir = STORAGE_DIR / "LEADERBOARDS"
|
||
leaderboards_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# Load previous data to calculate session stats
|
||
prev_data_all = {}
|
||
for clan_id_str in current_data.keys():
|
||
clan_file = leaderboards_dir / f"{clan_id_str}.json"
|
||
if clan_file.exists():
|
||
try:
|
||
async with aiofiles.open(clan_file, "r", encoding="utf-8") as fp:
|
||
prev_data_all[clan_id_str] = json.loads(await fp.read())
|
||
except Exception as e:
|
||
logging.error(f"(LDB) Error loading previous data for clan {clan_id_str}: {e}")
|
||
|
||
# Calculate and store session stats
|
||
for clan_id_str, data in current_data.items():
|
||
prev = prev_data_all.get(clan_id_str, {})
|
||
|
||
if prev:
|
||
session_kills = data["total_kills"] - prev.get("total_kills", 0)
|
||
session_deaths = data["deaths"] - prev.get("deaths", 0)
|
||
data["session_kd"] = (session_kills / session_deaths) if session_deaths > 0 else None
|
||
|
||
session_wins = data["wins"] - prev.get("wins", 0)
|
||
session_battles = data["battles"] - prev.get("battles", 0)
|
||
data["session_wr"] = (session_wins / session_battles * 100) if session_battles > 0 else None
|
||
else:
|
||
data["session_kd"] = None
|
||
data["session_wr"] = None
|
||
|
||
# 4. Helper function to build an embed for a group of squadrons
|
||
def build_squadron_embed(clans_to_display, sorted_clans, prev_data_all, title, description, color, lang="en"):
|
||
"""Build a Discord embed showing leaderboard stats for a group of squadrons.
|
||
|
||
Args:
|
||
clans_to_display: List of (clan_id_str, data) tuples to render.
|
||
sorted_clans: Full sorted list of all clans for position lookups.
|
||
prev_data_all: Dict mapping clan_id_str to previous snapshot data.
|
||
title: Embed title string.
|
||
description: Embed description string.
|
||
color: discord.Color for the embed sidebar.
|
||
lang: Locale code for field labels.
|
||
|
||
Returns:
|
||
A discord.Embed with one inline field per squadron.
|
||
"""
|
||
embed = discord.Embed(title=title, description=description, color=color)
|
||
embed.set_footer(text=DEFAULT_FOOTER_CAT)
|
||
|
||
# Create position lookup map for O(1) access instead of O(n) search
|
||
position_map = {data['position']: data for _, data in sorted_clans}
|
||
|
||
for clan_id_str, data in clans_to_display:
|
||
prev = prev_data_all.get(clan_id_str, {})
|
||
|
||
# Universal indicator: shows either change amount or previous value
|
||
def get_indicator(current_val, prev_val, precision=0, suffix="", show_diff_instead_of_prev=True):
|
||
"""Return a formatted up/down indicator string comparing current vs previous values."""
|
||
if prev_val is None:
|
||
return ""
|
||
|
||
diff = current_val - prev_val
|
||
|
||
# Choose what to display: difference or current value
|
||
display_val = abs(diff) if show_diff_instead_of_prev else current_val
|
||
|
||
# Format the value
|
||
if isinstance(current_val, float):
|
||
display_fmt = f"{display_val:.{precision}f}"
|
||
else:
|
||
display_fmt = f"{display_val}"
|
||
|
||
# Return with appropriate indicator
|
||
if current_val > prev_val:
|
||
return f"(🌲 {display_fmt}{suffix})"
|
||
elif current_val < prev_val:
|
||
return f"(🔻 {display_fmt}{suffix})"
|
||
|
||
return ""
|
||
|
||
# Build position indicator and details
|
||
def get_pos_indicator(data, prev):
|
||
"""Return a formatted position-change indicator and stat details line."""
|
||
# Position change indicator
|
||
if prev:
|
||
prev_pos = prev.get("position", 999)
|
||
pos_change = prev_pos - data["position"]
|
||
if pos_change > 0:
|
||
pos_indicator = f"(🌲 {pos_change})"
|
||
elif pos_change < 0:
|
||
pos_indicator = f"(🔻 {abs(pos_change)})"
|
||
else:
|
||
pos_indicator = ""
|
||
else:
|
||
pos_indicator = "🆕"
|
||
|
||
# Points behind next rank (for non-first places)
|
||
position_details = ""
|
||
if data['position'] > 0:
|
||
# Find squadron directly ahead using O(1) lookup
|
||
prev_position_data = position_map.get(data['position'] - 1)
|
||
|
||
if prev_position_data:
|
||
points_behind = prev_position_data['clanrating'] - data['clanrating']
|
||
position_details = f"({points_behind:,} Behind)"
|
||
|
||
return pos_indicator, position_details
|
||
|
||
pos_indicator, position_details = get_pos_indicator(data, prev)
|
||
|
||
# Get session stats from data (already calculated)
|
||
session_kd = data.get("session_kd")
|
||
session_wr = data.get("session_wr")
|
||
|
||
rating_indicator = get_indicator(data["clanrating"], prev.get("clanrating", 0))
|
||
battles_indicator = get_indicator(data["battles"], prev.get("battles", 0))
|
||
wins_indicator = get_indicator(data["wins"], prev.get("wins", 0))
|
||
|
||
# For WR and K/D: compare overall to current session performance
|
||
win_rate_indicator = get_indicator(session_wr, data["win_rate"], precision=1, suffix="%", show_diff_instead_of_prev=False) if session_wr is not None else ""
|
||
kd_indicator = get_indicator(session_kd, data["kd_ratio"], precision=2, show_diff_instead_of_prev=False) if session_kd is not None else ""
|
||
|
||
total_kills_indicator = get_indicator(data["total_kills"], prev.get("total_kills", 0))
|
||
deaths_indicator = get_indicator(data["deaths"], prev.get("deaths", 0))
|
||
members_indicator = get_indicator(data["members"], prev.get("members", 0))
|
||
|
||
field_name = f"**#{data['position'] + 1} {esc(data['short_name'])} {pos_indicator}\n{position_details}**"
|
||
field_value = (
|
||
f"**{t(lang, 'common.rating_field')}:** {data['clanrating']:,} {rating_indicator}\n"
|
||
f"**{t(lang, 'common.battles_field')}:** {data['battles']:,} {battles_indicator}\n"
|
||
f"**{t(lang, 'common.wins_field')}:** {data['wins']:,} {wins_indicator}\n"
|
||
f"**WR:** {data['win_rate']:.1f}% {win_rate_indicator}\n"
|
||
f"**{t(lang, 'common.kills_field')}:** {data['total_kills']:,} {total_kills_indicator}\n"
|
||
f"**{t(lang, 'common.deaths_field')}:** {data['deaths']:,} {deaths_indicator}\n"
|
||
f"**{t(lang, 'common.kd_field')}:** {data['kd_ratio']:.2f} {kd_indicator}\n"
|
||
f"**{t(lang, 'common.members_field')}:** {data['members']} {members_indicator}"
|
||
"\u200b" # Adds spacing
|
||
)
|
||
|
||
embed.add_field(name=field_name, value=field_value, inline=True)
|
||
|
||
return embed
|
||
|
||
# 5. Compute shared values used when building embeds per-guild
|
||
current_timestamp = int(datetime.now(timezone.utc).timestamp())
|
||
|
||
# Sort by position
|
||
sorted_clans = sorted(current_data.items(), key=lambda x: x[1]["position"])
|
||
|
||
# Split into groups
|
||
Zero_To_Fifteen = [(cid, data) for cid, data in sorted_clans if data["position"] < 15]
|
||
Sixteen_To_Thirty = [(cid, data) for cid, data in sorted_clans if 15 <= data["position"] < 30]
|
||
|
||
# 7. Loop through guilds and send all embeds
|
||
|
||
for guild in bot.guilds:
|
||
guild_id = guild.id
|
||
guild_name = guild.name
|
||
# Load this guild's preferences
|
||
pref_path = prefs_dir / f"{guild_id}-preferences.json"
|
||
try:
|
||
async with aiofiles.open(pref_path, "r", encoding="utf-8") as fp:
|
||
prefs = json.loads(await fp.read())
|
||
except FileNotFoundError:
|
||
continue
|
||
except Exception as e:
|
||
logging.error(f"(LDB) Error loading prefs for guild {guild_id}: {e}")
|
||
continue
|
||
|
||
# Find the first usable Leaderboard channel; stale entries are removed.
|
||
channel = None
|
||
for squadron_name, squad_prefs in prefs.items():
|
||
if not (isinstance(squad_prefs, dict) and "Leaderboard" in squad_prefs):
|
||
continue
|
||
channel_str = squad_prefs["Leaderboard"]
|
||
channel_id = parse_channel_id(str(channel_str))
|
||
if channel_id is None:
|
||
logging.warning(f"(LDB) Invalid channel ID '{channel_str}' for {squadron_name} in {guild_name}")
|
||
await remove_guild_pref_notification(guild_id, squadron_name, "Leaderboard", preferences=prefs)
|
||
continue
|
||
|
||
channel = bot.get_channel(channel_id)
|
||
if channel is None:
|
||
try:
|
||
channel = await bot.fetch_channel(channel_id)
|
||
except discord.NotFound:
|
||
logging.warning(f"(LDB) Removing stale Leaderboard channel {channel_id} for {squadron_name} in {guild_name}")
|
||
await remove_guild_pref_notification(guild_id, squadron_name, "Leaderboard", preferences=prefs)
|
||
channel = None
|
||
continue
|
||
except discord.Forbidden:
|
||
channel = None
|
||
continue
|
||
break
|
||
|
||
if channel is None:
|
||
continue
|
||
|
||
# Resolve guild language
|
||
guild_features = await load_features(guild_id)
|
||
lang = lang_from_features(guild_features)
|
||
|
||
# Premium gate — block non-entitled guilds when premium is active
|
||
if not await is_guild_entitled(guild_id):
|
||
try:
|
||
gate_embed = discord.Embed(
|
||
title=t(lang, "leaderboard_alarm.not_logged_title"),
|
||
description=t(lang, "leaderboard_alarm.not_logged_desc"),
|
||
color=discord.Color.red(),
|
||
)
|
||
await channel.send(embed=gate_embed) # type: ignore
|
||
except Exception:
|
||
pass
|
||
continue
|
||
|
||
# Build locale-aware embeds for this guild
|
||
guild_embeds = []
|
||
|
||
if Zero_To_Fifteen:
|
||
embed1 = build_squadron_embed(
|
||
clans_to_display=Zero_To_Fifteen,
|
||
sorted_clans=sorted_clans,
|
||
prev_data_all=prev_data_all,
|
||
title=t(lang, "leaderboard_alarm.title"),
|
||
description=t(lang, "leaderboard_alarm.top15_desc", timestamp=current_timestamp),
|
||
color=discord.Color.gold(),
|
||
lang=lang,
|
||
)
|
||
guild_embeds.append(embed1)
|
||
|
||
if Sixteen_To_Thirty:
|
||
embed2 = build_squadron_embed(
|
||
clans_to_display=Sixteen_To_Thirty,
|
||
sorted_clans=sorted_clans,
|
||
prev_data_all=prev_data_all,
|
||
title=t(lang, "leaderboard_alarm.title"),
|
||
description=t(lang, "leaderboard_alarm.top30_desc"),
|
||
color=discord.Color.gold(),
|
||
lang=lang,
|
||
)
|
||
guild_embeds.append(embed2)
|
||
|
||
# Send all locale-aware embeds to this guild's channel
|
||
try:
|
||
for embed in guild_embeds:
|
||
await channel.send(embed=embed) # type: ignore
|
||
except Exception as e:
|
||
logging.error(f"(LDB) Failed to send embeds to guild {guild_name}: {e}")
|
||
|
||
# Premium upsell for non-entitled guilds (pre-deadline)
|
||
if not await is_guild_entitled(guild_id):
|
||
warn_embed = discord.Embed(
|
||
title=t(lang, "leaderboard_alarm.server_not_upgraded_title"),
|
||
description=t(lang, "leaderboard_alarm.server_not_upgraded_desc", deadline=PREMIUM_ACTIVATION_TS),
|
||
color=discord.Color.orange(),
|
||
)
|
||
warn_embed.set_footer(text=DEFAULT_FOOTER_CAT)
|
||
try:
|
||
await channel.send(embed=warn_embed) # type: ignore
|
||
except Exception as e:
|
||
logging.error("(LDB) Error sending premium warning: %s", e)
|
||
|
||
# 8. Save current data for next comparison
|
||
for clan_id_str, data in current_data.items():
|
||
clan_file = leaderboards_dir / f"{clan_id_str}.json"
|
||
try:
|
||
async with aiofiles.open(clan_file, "w", encoding="utf-8") as fp:
|
||
await fp.write(json.dumps(data, indent=2))
|
||
except Exception as e:
|
||
logging.error(f"(LDB) Error saving data for clan {clan_id_str}: {e}")
|
||
|
||
|
||
async def _process_squadron_points(
|
||
squadron_name: str,
|
||
region: str,
|
||
opposite_region: str,
|
||
channels: list[tuple[int, str, str, str]],
|
||
) -> None:
|
||
"""
|
||
Process a single squadron's points update and fan out to all subscribed channels.
|
||
Each squadron is processed exactly once regardless of how many guilds track it.
|
||
"""
|
||
bot = get_bot()
|
||
old_snap = await load_snapshot(squadron_name, opposite_region)
|
||
new_snap = await take_snapshot(squadron_name, region)
|
||
|
||
if not new_snap or not new_snap.get("members"):
|
||
return
|
||
|
||
# First run for this squadron?
|
||
if old_snap is None:
|
||
await save_snapshot(new_snap, squadron_name, region)
|
||
return
|
||
|
||
# Compute diffs
|
||
sq_total, old_total = new_snap["total_points"], old_snap["total_points"]
|
||
points_changes, _ = compare_points(old_snap, new_snap)
|
||
|
||
# SEND UPDATE
|
||
if points_changes:
|
||
# Sort by biggest gains first
|
||
sorted_changes = sorted(
|
||
points_changes.items(),
|
||
key=lambda kv: kv[1][1],
|
||
reverse=True
|
||
)
|
||
|
||
slot_stats = await _load_points_alarm_player_slot_stats(
|
||
[uid for uid, _ in sorted_changes], region
|
||
)
|
||
|
||
# SAFE CHUNK BUILDER
|
||
max_len = 1024
|
||
chunks = []
|
||
buf = "```\nName Change Now KDR\n"
|
||
|
||
for uid, (delta, now) in sorted_changes:
|
||
name_raw = (
|
||
new_snap["members"].get(uid, {}).get("nick")
|
||
or old_snap["members"].get(uid, {}).get("nick")
|
||
or uid
|
||
)
|
||
stats = slot_stats.get(uid, {})
|
||
kdr_str = f"{float(stats['kdr']):.2f}" if "kdr" in stats else "-"
|
||
kps_str = f"{float(stats['kps']):.2f}" if "kps" in stats else "-"
|
||
|
||
# DO NOT CHANGE
|
||
arrow = "🌲" if delta > 0 else "🔻"
|
||
member_str = pad_display_width(name_raw, 16)
|
||
change_str = f"{arrow} {abs(delta):<5}"
|
||
current_points_str = f"{now:>6}"
|
||
# DO NOT CHANGE
|
||
|
||
line = f"{member_str}{change_str}{current_points_str} {kdr_str:>5}\n"
|
||
|
||
# Width-safe check
|
||
if discord_len(buf) + discord_len(line) > max_len:
|
||
buf += "```"
|
||
chunks.append(buf)
|
||
buf = "```\n" + line
|
||
else:
|
||
buf += line
|
||
|
||
# Close final chunk
|
||
buf += "```"
|
||
chunks.append(buf)
|
||
|
||
# Final clamp to guarantee <1024 even in absurd cases
|
||
safe_chunks = []
|
||
for c in chunks:
|
||
if discord_len(c) > max_len:
|
||
# Truncate by display width, not character count
|
||
budget = max_len - discord_len("\n```(truncated)```")
|
||
trimmed = ""
|
||
width = 0
|
||
for ch in c:
|
||
ch_w = discord_len(ch)
|
||
if width + ch_w > budget:
|
||
break
|
||
trimmed += ch
|
||
width += ch_w
|
||
trimmed += "\n```(truncated)```"
|
||
safe_chunks.append(trimmed)
|
||
else:
|
||
safe_chunks.append(c)
|
||
chunks = safe_chunks
|
||
|
||
# Chart arrow
|
||
if sq_total > old_total:
|
||
chart = "📈"
|
||
elif sq_total < old_total:
|
||
chart = "📉"
|
||
else:
|
||
chart = "ᓚᘏᗢ"
|
||
|
||
# WL record for this session
|
||
# Preferences are keyed by long_name but wl_standings stores short_name
|
||
# from replay data, so resolve via squadrons_data first.
|
||
wl_lookup_name = squadron_name
|
||
try:
|
||
clan = await resolve_clan(long=squadron_name)
|
||
if clan and clan.get("short_name") and clan["long_name"] != "<unresolved>":
|
||
wl_lookup_name = clan["short_name"]
|
||
except Exception as e:
|
||
logging.warning("(POINTS) Could not resolve short name for WL lookup: %s", e)
|
||
|
||
standings = get_standings([wl_lookup_name]).get(wl_lookup_name, {})
|
||
w, l = standings.get("wins", 0), standings.get("losses", 0)
|
||
|
||
# Placement change detection
|
||
old_placement = old_snap.get("placement")
|
||
new_placement = new_snap.get("placement")
|
||
|
||
# Fan out: send to all subscribed channels
|
||
for guild_id, raw_chan, flag, pref_key in channels:
|
||
channel_id = parse_channel_id(str(raw_chan))
|
||
if channel_id is None:
|
||
logging.error(
|
||
"(POINTS) Invalid channel ID '%s' for guild %s",
|
||
raw_chan, guild_id
|
||
)
|
||
await remove_guild_pref_notification(guild_id, pref_key, "Points")
|
||
continue
|
||
|
||
channel = bot.get_channel(channel_id)
|
||
if not channel:
|
||
try:
|
||
channel = await bot.fetch_channel(channel_id)
|
||
except discord.NotFound:
|
||
await remove_guild_pref_notification(guild_id, pref_key, "Points")
|
||
channel = None
|
||
except discord.Forbidden:
|
||
channel = None
|
||
if not channel:
|
||
logging.error(
|
||
"(POINTS) Channel %s not found in guild %s",
|
||
channel_id, guild_id
|
||
)
|
||
continue
|
||
|
||
# Safe sendable channel check
|
||
if isinstance(channel, (TextChannel, Thread)):
|
||
# Resolve guild language
|
||
guild_features = await load_features(guild_id)
|
||
lang = lang_from_features(guild_features)
|
||
|
||
# Premium gate — block non-entitled guilds when premium is active
|
||
if not await is_guild_entitled(guild_id):
|
||
try:
|
||
gate_embed = discord.Embed(
|
||
title=t(lang, "autolog.points_not_logged_title"),
|
||
description=t(lang, "autolog.points_not_logged_desc"),
|
||
color=discord.Color.red(),
|
||
)
|
||
await channel.send(embed=gate_embed)
|
||
except Exception:
|
||
pass
|
||
continue
|
||
|
||
# Tier cap — send orange upgrade warning and skip the update
|
||
if flag == "over_cap":
|
||
tier = await get_guild_tier(guild_id)
|
||
await send_over_cap_warning(
|
||
channel, lang, tier, "Points", squadron_name, reason="over_cap"
|
||
)
|
||
continue
|
||
|
||
# Build locale-aware WL line and main embed for this guild
|
||
wl_line = t(lang, "autolog.wl_line", squadron=esc(squadron_name), wins=w, losses=l) if (w or l) else ""
|
||
|
||
# Placement change line — only if placement actually changed
|
||
placement_line = ""
|
||
if (old_placement is not None and new_placement is not None
|
||
and old_placement != new_placement):
|
||
old_ord = ordinal(old_placement, lang)
|
||
new_ord = ordinal(new_placement, lang)
|
||
if new_placement < old_placement:
|
||
placement_line = t(lang, "autolog.placement_rose",
|
||
squadron=esc(squadron_name),
|
||
old_place=old_ord,
|
||
new_place=new_ord)
|
||
else:
|
||
placement_line = t(lang, "autolog.placement_fell",
|
||
squadron=esc(squadron_name),
|
||
old_place=old_ord,
|
||
new_place=new_ord)
|
||
|
||
embed = discord.Embed(
|
||
title=t(lang, "autolog.points_update_title", squadron=esc(squadron_name), region=region),
|
||
description=t(lang, "autolog.points_update_desc", old_total=old_total, new_total=sq_total, chart=chart, wl_line=wl_line, placement_line=placement_line),
|
||
color=discord.Color.blue()
|
||
)
|
||
for c in chunks:
|
||
embed.add_field(name="\u200A", value=c, inline=False)
|
||
embed.set_footer(text=DEFAULT_FOOTER_CAT)
|
||
|
||
try:
|
||
await channel.send(embed=embed)
|
||
except Exception as e:
|
||
logging.error("(POINTS) Error sending update: %s", e)
|
||
|
||
# Premium upsell for non-entitled guilds (pre-deadline)
|
||
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_points_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("(POINTS) Error sending premium warning: %s", e)
|
||
else:
|
||
logging.error(
|
||
"(POINTS) Channel %s in guild %s is not a sendable channel (type=%s)",
|
||
channel_id, guild_id, type(channel).__name__
|
||
)
|
||
|
||
# Overwrite the snapshot
|
||
await save_snapshot(new_snap, squadron_name, region)
|
||
|
||
|
||
async def execute_points_alarm_task(region: str):
|
||
"""
|
||
Once-a-minute task that:
|
||
1. Cleans old files
|
||
2. Collects all squadron -> channel mappings across guilds
|
||
3. Processes each unique squadron once (single API call + snapshot)
|
||
4. Fans out notifications to all subscribed channels
|
||
"""
|
||
bot = get_bot()
|
||
|
||
# CLEANUP (concurrent) — WL cleared after embeds so session record is available
|
||
cleanup_results = await asyncio.gather(
|
||
cleanup_replays(),
|
||
cleanup_masterLog(),
|
||
cleanup_comps(),
|
||
return_exceptions=True
|
||
)
|
||
for r in cleanup_results:
|
||
if isinstance(r, Exception):
|
||
logging.error(f"[TASK] cleanup sub-task failed: {r}")
|
||
|
||
PREFS_DIR = STORAGE_DIR / "PREFERENCES"
|
||
opposite_region = "EU" if region == "NA" else "NA"
|
||
|
||
# Refresh entitlement cache once up-front — tier lookups below rely on it
|
||
await refresh_entitled_guilds()
|
||
|
||
# Step 1: Collect squadron -> [(guild_id, channel_str, flag)] mappings
|
||
# flag: "ok" (dispatch normal) | "over_cap" (send orange upgrade warning)
|
||
squadron_channels: dict[str, list[tuple[int, str, str, str]]] = {}
|
||
blacklist = blacklisted_guilds()
|
||
|
||
for guild in bot.guilds:
|
||
guild_id = guild.id
|
||
|
||
if guild_id in blacklist:
|
||
continue
|
||
|
||
prefs_path = PREFS_DIR / f"{guild_id}-preferences.json"
|
||
try:
|
||
preferences = json.loads(prefs_path.read_text(encoding="utf-8"))
|
||
except FileNotFoundError:
|
||
continue
|
||
except Exception as e:
|
||
logging.error("(POINTS) Error loading prefs for guild %s: %s", guild_id, e)
|
||
continue
|
||
|
||
tier = await get_guild_tier(guild_id)
|
||
allowed_points = allowed_pref_keys_for(preferences, tier, "Points")
|
||
enabled_points = set(enabled_pref_keys_for(preferences, "Points"))
|
||
over_cap_points = enabled_points - allowed_points
|
||
enforcement = tier_enforcement_active()
|
||
|
||
for pref_key, squad_prefs in preferences.items():
|
||
if not isinstance(squad_prefs, dict):
|
||
continue
|
||
if "Points" not in squad_prefs:
|
||
continue
|
||
# Wildcard / Global keys aren't squadron points subjects.
|
||
if str(pref_key).lower() in WILDCARD_KEYS or str(pref_key).lower() == "global":
|
||
continue
|
||
if pref_key in allowed_points:
|
||
flag = "ok"
|
||
elif enforcement and pref_key in over_cap_points:
|
||
flag = "over_cap"
|
||
else:
|
||
continue # disabled entry or silent drop
|
||
# Resolve the key to the squadron's CURRENT long_name for the
|
||
# downstream WT API call. Post-rename, the prefs key is the stable
|
||
# clan_id while the long_name on squadrons_data tracks the rename.
|
||
resolved = await resolve_pref_key(pref_key, squad_prefs)
|
||
target_name = resolved["long_name"] if resolved and resolved.get("long_name") else pref_key
|
||
squadron_channels.setdefault(target_name, []).append(
|
||
(guild_id, squad_prefs["Points"], flag, pref_key)
|
||
)
|
||
|
||
if not squadron_channels:
|
||
await cleanup_WL()
|
||
return
|
||
|
||
# Step 2: Process each unique squadron once (concurrent)
|
||
squadron_tasks = [
|
||
_process_squadron_points(squadron_name, region, opposite_region, channels)
|
||
for squadron_name, channels in squadron_channels.items()
|
||
]
|
||
|
||
if squadron_tasks:
|
||
results = await asyncio.gather(*squadron_tasks, return_exceptions=True)
|
||
for r in results:
|
||
if isinstance(r, Exception):
|
||
logging.error(f"[TASK] points alarm sub-task failed: {r}")
|
||
|
||
# Clear WL after all embeds have been sent
|
||
await cleanup_WL()
|
||
|
||
|
||
async def execute_leave_alarm_task():
|
||
"""
|
||
Optimized leave-update alarm that:
|
||
- Loads each guild's squadron preferences
|
||
- Groups guilds by squadron
|
||
- Fetches all squadron snapshots in parallel
|
||
- Detects leavers for each squadron
|
||
- Sends all notifications in parallel with rate limiting
|
||
- Saves all snapshots
|
||
"""
|
||
bot = get_bot()
|
||
PREFS_DIR = STORAGE_DIR / "PREFERENCES"
|
||
|
||
# Refresh entitlement cache once — used to filter over-cap squadrons below
|
||
await refresh_entitled_guilds()
|
||
|
||
# Step 1: Collect all squadron -> [(guild_id, channel_id)] mappings.
|
||
# Over-cap squadrons are silently dropped here — the Points task already sends
|
||
# the orange upgrade warning on the shared Points channel (rate-limit prevents dupes).
|
||
squadron_channels: dict[str, list[tuple[int, int]]] = {}
|
||
|
||
for guild in bot.guilds:
|
||
guild_id = guild.id
|
||
guild_name = guild.name
|
||
prefs_path = PREFS_DIR / f"{guild_id}-preferences.json"
|
||
|
||
try:
|
||
preferences = json.loads(prefs_path.read_text(encoding="utf-8"))
|
||
except FileNotFoundError:
|
||
continue
|
||
except Exception as e:
|
||
logging.error("(LEAVE) Error loading prefs for guild %s: %s", guild_id, e)
|
||
continue
|
||
|
||
tier = await get_guild_tier(guild_id)
|
||
allowed_points = allowed_pref_keys_for(preferences, tier, "Points")
|
||
allowed_leave = allowed_pref_keys_for(preferences, tier, "Leave")
|
||
allowed_for_leave = allowed_points | allowed_leave
|
||
|
||
for pref_key, squad_prefs in preferences.items():
|
||
if not isinstance(squad_prefs, dict):
|
||
continue
|
||
if str(pref_key).lower() in WILDCARD_KEYS or str(pref_key).lower() == "global":
|
||
continue
|
||
leave_chan = squad_prefs.get("Leave")
|
||
points_chan = squad_prefs.get("Points")
|
||
if not leave_chan and not points_chan:
|
||
continue
|
||
# Respect the tier cap — squadrons must be within either the Points
|
||
# or Leave cap to receive leave notifications.
|
||
if tier_enforcement_active() and pref_key not in allowed_for_leave:
|
||
continue
|
||
resolved = await resolve_pref_key(pref_key, squad_prefs)
|
||
target_name = resolved["long_name"] if resolved and resolved.get("long_name") else pref_key
|
||
|
||
# Check Leave first, then fallback to Points. If both are present,
|
||
# send to both routes (helps staged migrations to the new pref key).
|
||
raw_channels = [leave_chan, points_chan]
|
||
for raw_chan in raw_channels:
|
||
if not raw_chan:
|
||
continue
|
||
try:
|
||
channel_id = int(raw_chan.strip("<#>"))
|
||
except Exception:
|
||
continue
|
||
targets = squadron_channels.setdefault(target_name, [])
|
||
target = (guild_id, channel_id)
|
||
if target not in targets:
|
||
targets.append(target)
|
||
|
||
if not squadron_channels:
|
||
return
|
||
|
||
# Step 2: Fetch all snapshots in parallel
|
||
squadron_names = list(squadron_channels.keys())
|
||
|
||
# Semaphore to limit concurrent API calls (avoid overwhelming the API)
|
||
api_semaphore = asyncio.Semaphore(5)
|
||
|
||
async def fetch_squadron_snapshots(sq_name):
|
||
"""Fetch old and new snapshots for a squadron, rate-limited by semaphore."""
|
||
async with api_semaphore:
|
||
old_snap = await load_snapshot(sq_name, "GLOBAL")
|
||
new_snap = await take_snapshot(sq_name, "GLOBAL")
|
||
return sq_name, old_snap, new_snap
|
||
|
||
# Fetch all snapshots concurrently
|
||
snapshot_results = await asyncio.gather(
|
||
*[fetch_squadron_snapshots(sq) for sq in squadron_names],
|
||
return_exceptions=True
|
||
)
|
||
|
||
# Step 3: Process snapshots and collect all notifications
|
||
all_notifications = [] # List of (channel, embed, metadata) tuples
|
||
squadrons_to_save = [] # List of (squadron_name, new_snap) tuples
|
||
|
||
for result in snapshot_results:
|
||
# Check if result is an exception (including BaseException)
|
||
if isinstance(result, BaseException):
|
||
logging.error(f"(LEAVE) Error fetching snapshot: {result}")
|
||
continue
|
||
|
||
squadron_name, old_snap, new_snap = result
|
||
guild_channels = squadron_channels[squadron_name]
|
||
|
||
# Validate new snapshot
|
||
if not new_snap or not new_snap.get("members"):
|
||
continue
|
||
|
||
# First run - save and skip
|
||
if not old_snap or not old_snap.get("members"):
|
||
squadrons_to_save.append((squadron_name, new_snap))
|
||
continue
|
||
|
||
# Detect leavers
|
||
old_members = old_snap.get("members", {})
|
||
new_members = new_snap.get("members", {})
|
||
leavers = {
|
||
uid: info.get("points", 0)
|
||
for uid, info in old_members.items()
|
||
if uid not in new_members and info.get("points", 0) > 0
|
||
}
|
||
|
||
if not leavers:
|
||
squadrons_to_save.append((squadron_name, new_snap))
|
||
continue
|
||
|
||
# Collect notifications for all leavers
|
||
for uid, old_pts in leavers.items():
|
||
nick = old_members.get(uid, {}).get("nick", uid)
|
||
|
||
for guild_id, channel_id in guild_channels:
|
||
channel = bot.get_channel(channel_id)
|
||
if not channel:
|
||
try:
|
||
channel = await bot.fetch_channel(channel_id)
|
||
except (discord.NotFound, discord.Forbidden):
|
||
channel = None
|
||
if not channel:
|
||
logging.error("(LEAVE) Channel %s not found in guild %s", channel_id, guild_id)
|
||
continue
|
||
|
||
# Resolve guild language and create locale-aware embed
|
||
guild_features_leave = await load_features(guild_id)
|
||
lang_leave = lang_from_features(guild_features_leave)
|
||
embed = discord.Embed(
|
||
title=t(lang_leave, "autolog.leave_title", squadron=esc(squadron_name)),
|
||
description=t(lang_leave, "autolog.leave_desc", nick=esc(nick), uid=uid, points=old_pts),
|
||
color=discord.Color.red()
|
||
)
|
||
embed.set_footer(text=DEFAULT_FOOTER_CAT)
|
||
|
||
all_notifications.append((channel, embed, squadron_name, nick, uid, guild_id))
|
||
|
||
squadrons_to_save.append((squadron_name, new_snap))
|
||
|
||
# Step 4: Send all notifications in parallel
|
||
if all_notifications:
|
||
# Semaphore to limit concurrent Discord sends (avoid rate limiting)
|
||
discord_semaphore = asyncio.Semaphore(10)
|
||
|
||
async def send_notification(channel, embed, sq_name, nick, uid, guild_id):
|
||
"""Send a leave-notice embed to a channel, rate-limited by semaphore."""
|
||
async with discord_semaphore:
|
||
try:
|
||
await channel.send(embed=embed) # type: ignore
|
||
except Exception as e:
|
||
logging.error("(LEAVE) Error sending leave notice: %s", e)
|
||
|
||
# Send all notifications concurrently
|
||
notif_results = await asyncio.gather(
|
||
*[send_notification(ch, em, sq, n, u, g) for ch, em, sq, n, u, g in all_notifications],
|
||
return_exceptions=True
|
||
)
|
||
for r in notif_results:
|
||
if isinstance(r, Exception):
|
||
logging.error(f"[TASK] leave notification sub-task failed: {r}")
|
||
|
||
# Step 5: Save all snapshots
|
||
for squadron_name, new_snap in squadrons_to_save:
|
||
await save_snapshot(new_snap, squadron_name, "GLOBAL")
|
||
|
||
|
||
async def execute_squadron_stats_tracker():
|
||
"""
|
||
Read all squadrons with clanrating > 0 from squadrons_data table,
|
||
fetch their current clan points, and store in squadrons_points table.
|
||
"""
|
||
|
||
# Ensure table exists
|
||
await init_squadrons_points_table()
|
||
|
||
current_time = int(T.time())
|
||
tracked_count = 0
|
||
error_count = 0
|
||
|
||
async with aiosqlite.connect(SQUADRONS_DB_PATH, timeout=30.0) as db:
|
||
await db.execute("PRAGMA busy_timeout=30000;")
|
||
# Get all squadrons with clanrating > 0
|
||
cursor = await db.execute("""
|
||
SELECT clan_id, long_name
|
||
FROM squadrons_data
|
||
WHERE clanrating > 0
|
||
ORDER BY clanrating DESC
|
||
""")
|
||
squadrons = list(await cursor.fetchall())
|
||
total_squadrons = len(squadrons)
|
||
|
||
|
||
# Track each squadron
|
||
for clan_id, long_name in squadrons:
|
||
try:
|
||
# Fetch current clan points using Game_API
|
||
clan_data = await obtain_clan_new_points(long_name)
|
||
|
||
if clan_data:
|
||
members_dict, total_score = clan_data
|
||
|
||
# Store as gzip-compressed JSON blob
|
||
clan_pts_json = compress_json([members_dict, total_score], ensure_ascii=False)
|
||
|
||
# Insert into database
|
||
await db.execute("""
|
||
INSERT INTO squadrons_points (clan_id, long_name, unix_time, clan_pts, total_score)
|
||
VALUES (?, ?, ?, ?, ?)
|
||
""", (clan_id, long_name, current_time, clan_pts_json, total_score))
|
||
|
||
tracked_count += 1
|
||
|
||
if tracked_count % 10 == 0:
|
||
await db.commit() # Commit periodically
|
||
|
||
# Small delay to avoid rate limiting
|
||
await asyncio.sleep(0.5)
|
||
|
||
except Exception as e:
|
||
error_count += 1
|
||
logging.error(f"Error tracking squadron {long_name}: {e}")
|
||
continue
|
||
|
||
# Final commit
|
||
await db.commit()
|
||
|
||
|
||
|
||
async def execute_update_squadrons_db_task(count: int = 1000):
|
||
"""Refresh squadrons.db by fetching the leaderboard from the Game API.
|
||
|
||
Args:
|
||
count: Number of top squadrons to fetch and upsert.
|
||
"""
|
||
try:
|
||
await save_squadrons_to_db(count)
|
||
except Exception as e:
|
||
logging.error(f"[SQ-DB] Error during squadrons.db update: {e}")
|
||
|
||
|
||
async def execute_sync_squadron_members_points_loop(count: int = 1000, delay: float = 3.0):
|
||
"""
|
||
Continuously update points for existing squadron members forever.
|
||
Does NOT add/remove members - only updates points for members already in DB.
|
||
Very slow, runs indefinitely in the background.
|
||
"""
|
||
cycle = 0
|
||
|
||
while True:
|
||
cycle += 1
|
||
|
||
try:
|
||
async with aiosqlite.connect(SQUADRONS_DB_PATH, timeout=30.0) as db:
|
||
await db.execute("PRAGMA busy_timeout=30000;")
|
||
# Only process clans that are currently active in the latest
|
||
# leaderboard snapshot. Stale rows may still exist in the DB.
|
||
async with db.execute("""
|
||
SELECT clan_id, long_name FROM squadrons_data
|
||
WHERE clanrating > 0
|
||
ORDER BY position ASC
|
||
LIMIT ?
|
||
""", (count,)) as cursor:
|
||
squadrons = list(await cursor.fetchall())
|
||
|
||
if not squadrons:
|
||
logging.warning("[SQ-POINTS] No squadrons found, sleeping 5 min...")
|
||
await asyncio.sleep(300)
|
||
continue
|
||
|
||
total_squadrons = len(squadrons)
|
||
updated_count = 0
|
||
failed_count = 0
|
||
members_updated = 0
|
||
|
||
for i, (clan_id, long_name) in enumerate(squadrons):
|
||
try:
|
||
members_data, total_score = await obtain_clan_new_points(long_name)
|
||
current_time = int(T.time())
|
||
|
||
if not members_data:
|
||
failed_count += 1
|
||
else:
|
||
async with aiosqlite.connect(SQUADRONS_DB_PATH, timeout=30.0) as db:
|
||
await db.execute("PRAGMA busy_timeout=30000;")
|
||
# Only update points for existing members (don't insert new ones)
|
||
for uid, info in members_data.items():
|
||
result = await db.execute("""
|
||
UPDATE squadron_members
|
||
SET points = ?, updated_at = ?
|
||
WHERE clan_id = ? AND uid = ?
|
||
""", (info.get('points', 0), current_time, clan_id, uid))
|
||
members_updated += result.rowcount
|
||
|
||
await db.commit()
|
||
|
||
updated_count += 1
|
||
except ClanInfoError as e:
|
||
logging.info(f"[SQ-POINTS] Skipped {long_name}: {e}")
|
||
failed_count += 1
|
||
except Exception as e:
|
||
logging.error(f"[SQ-POINTS] Error for {long_name}: {e}")
|
||
failed_count += 1
|
||
|
||
# Slow delay between clans (skip after the last one)
|
||
if i < total_squadrons - 1:
|
||
await asyncio.sleep(delay)
|
||
|
||
logging.info(
|
||
f"[SQ-POINTS] Cycle {cycle} done: "
|
||
f"{updated_count}/{total_squadrons} clans updated, "
|
||
f"{members_updated} member rows written, "
|
||
f"{failed_count} failures"
|
||
)
|
||
await record_task_run("squadron_points_loop", True)
|
||
|
||
except Exception as e:
|
||
logging.error(f"[SQ-POINTS] Cycle {cycle} error: {e}")
|
||
try:
|
||
await record_task_run("squadron_points_loop", False, str(e))
|
||
except Exception:
|
||
pass
|
||
await asyncio.sleep(60) # Wait a bit before retrying
|
||
|
||
|
||
async def execute_sync_squadron_members_bulk(count: int = 1000, delay: float = 1.0):
|
||
"""
|
||
Sync squadron member rosters using the War Thunder game API.
|
||
|
||
For each tracked squadron in squadrons_data, calls obtain_clan_new_points
|
||
to get the authoritative member list (including zero-point players), then:
|
||
- Removes players who left the clan
|
||
- Adds new members with their current points
|
||
- Updates nicks and points for existing members
|
||
"""
|
||
|
||
try:
|
||
async with aiosqlite.connect(SQUADRONS_DB_PATH, timeout=30.0) as db:
|
||
await db.execute("PRAGMA busy_timeout=30000;")
|
||
# Ensure table exists
|
||
await db.execute("""
|
||
CREATE TABLE IF NOT EXISTS squadron_members (
|
||
clan_id INTEGER,
|
||
uid TEXT,
|
||
nick TEXT,
|
||
points INTEGER DEFAULT 0,
|
||
updated_at INTEGER,
|
||
PRIMARY KEY (clan_id, uid)
|
||
)
|
||
""")
|
||
await db.execute("""
|
||
CREATE INDEX IF NOT EXISTS idx_squadron_members_clan
|
||
ON squadron_members(clan_id)
|
||
""")
|
||
await db.commit()
|
||
|
||
# Only process clans that are currently active in the latest
|
||
# leaderboard snapshot. Stale rows may still exist in the DB.
|
||
async with db.execute("""
|
||
SELECT clan_id, long_name FROM squadrons_data
|
||
WHERE clanrating > 0
|
||
ORDER BY position ASC
|
||
LIMIT ?
|
||
""", (count,)) as cursor:
|
||
squadrons = list(await cursor.fetchall())
|
||
|
||
if not squadrons:
|
||
logging.warning("[SQ-MEMBERS] No squadrons found in squadrons_data")
|
||
return 0
|
||
|
||
total_squadrons = len(squadrons)
|
||
logging.info("[SQ-MEMBERS] Starting sync for %d squadrons", total_squadrons)
|
||
updated_count = 0
|
||
failed_count = 0
|
||
total_members_added = 0
|
||
total_members_removed = 0
|
||
current_time = int(T.time())
|
||
|
||
for i, (clan_id, long_name) in enumerate(squadrons):
|
||
try:
|
||
members_data, _ = await obtain_clan_new_points(long_name)
|
||
|
||
if not members_data:
|
||
logging.info("[SQ-MEMBERS] No members returned for %s (clan %s), skipping", long_name, clan_id)
|
||
failed_count += 1
|
||
continue
|
||
|
||
#logging.info("[SQ-MEMBERS] Updated %s (clan %s) — %d members", long_name, clan_id, len(members_data))
|
||
|
||
async with aiosqlite.connect(SQUADRONS_DB_PATH, timeout=30.0) as db:
|
||
await db.execute("PRAGMA busy_timeout=30000;")
|
||
|
||
# Get current member UIDs from DB
|
||
async with db.execute(
|
||
"SELECT uid, points FROM squadron_members WHERE clan_id = ?",
|
||
(clan_id,)
|
||
) as cursor:
|
||
existing = {row[0]: row[1] for row in await cursor.fetchall()}
|
||
|
||
# Build new member set from game API
|
||
new_members = {uid: info for uid, info in members_data.items()}
|
||
|
||
# Remove players who left the clan
|
||
to_remove = set(existing.keys()) - set(new_members.keys())
|
||
if to_remove:
|
||
await db.execute(
|
||
f"DELETE FROM squadron_members WHERE clan_id = ? AND uid IN ({','.join('?' * len(to_remove))})",
|
||
(clan_id, *to_remove)
|
||
)
|
||
total_members_removed += len(to_remove)
|
||
|
||
# Upsert all current members with fresh points and nicks
|
||
new_uids = set(new_members.keys()) - set(existing.keys())
|
||
total_members_added += len(new_uids)
|
||
|
||
for uid, info in new_members.items():
|
||
await db.execute("""
|
||
INSERT INTO squadron_members (clan_id, uid, nick, points, updated_at)
|
||
VALUES (?, ?, ?, ?, ?)
|
||
ON CONFLICT(clan_id, uid) DO UPDATE SET
|
||
nick = excluded.nick,
|
||
points = excluded.points,
|
||
updated_at = excluded.updated_at
|
||
""", (clan_id, uid, info.get("nick", ""), info.get("points", 0), current_time))
|
||
|
||
await db.commit()
|
||
|
||
updated_count += 1
|
||
|
||
except ClanInfoError as e:
|
||
logging.warning("[SQ-MEMBERS] Failed to update %s (clan %s): %s", long_name, clan_id, e)
|
||
failed_count += 1
|
||
except Exception as e:
|
||
logging.error("[SQ-MEMBERS] Failed to update %s (clan %s): %s", long_name, clan_id, e)
|
||
failed_count += 1
|
||
|
||
# Throttle between clans
|
||
if i < total_squadrons - 1:
|
||
await asyncio.sleep(delay)
|
||
|
||
logging.info(
|
||
"[SQ-MEMBERS] Sync done: %d/%d clans updated, +%d members, -%d members, %d failures",
|
||
updated_count, total_squadrons, total_members_added, total_members_removed, failed_count
|
||
)
|
||
return updated_count
|
||
|
||
except Exception as e:
|
||
logging.error("[SQ-MEMBERS] Sync error: %s", e)
|
||
return 0
|
||
|
||
|
||
async def execute_cleanup_stale_squadrons() -> int:
|
||
"""Remove squadrons from squadrons.db that have never played SQB.
|
||
|
||
A squadron is stale if none of its members (by clan long_name matching
|
||
squadron_name in player_games_hist) have any recorded game data.
|
||
|
||
Returns the number of squadrons removed.
|
||
"""
|
||
try:
|
||
# Step 1: Get all squadron_names that have game data
|
||
active_names: set[str] = set()
|
||
async with aiosqlite.connect(
|
||
f"file:{SQ_BATTLES_DB_PATH}?mode=ro", uri=True
|
||
) as battles_db:
|
||
cursor = await battles_db.execute(
|
||
"SELECT DISTINCT squadron_name FROM player_games_hist "
|
||
"WHERE squadron_name IS NOT NULL AND squadron_name != 'UNKNOWN'"
|
||
)
|
||
rows = await cursor.fetchall()
|
||
active_names = {row[0] for row in rows}
|
||
|
||
logging.info(
|
||
"[SQ-CLEANUP] %d squadron names have game data", len(active_names)
|
||
)
|
||
|
||
# Step 2: Find and delete stale squadrons
|
||
async with aiosqlite.connect(SQUADRONS_DB_PATH, timeout=30.0) as db:
|
||
cursor = await db.execute(
|
||
"SELECT clan_id, long_name, short_name, tag_name "
|
||
"FROM squadrons_data"
|
||
)
|
||
all_squads = list(await cursor.fetchall())
|
||
|
||
stale_clan_ids: list[int] = []
|
||
for clan_id, long_name, short_name, tag_name in all_squads:
|
||
# A squadron is active if any of its names appear in game data
|
||
names_to_check = {
|
||
n for n in (long_name, short_name, tag_name) if n
|
||
}
|
||
if not names_to_check & active_names:
|
||
stale_clan_ids.append(clan_id)
|
||
|
||
if not stale_clan_ids:
|
||
logging.info("[SQ-CLEANUP] No stale squadrons found")
|
||
return 0
|
||
|
||
logging.info(
|
||
"[SQ-CLEANUP] Removing %d stale squadrons (of %d total)",
|
||
len(stale_clan_ids),
|
||
len(all_squads),
|
||
)
|
||
|
||
# Batch delete in chunks of 500
|
||
for i in range(0, len(stale_clan_ids), 500):
|
||
chunk = stale_clan_ids[i : i + 500]
|
||
placeholders = ",".join("?" * len(chunk))
|
||
await db.execute(
|
||
f"DELETE FROM squadron_members WHERE clan_id IN ({placeholders})",
|
||
chunk,
|
||
)
|
||
await db.execute(
|
||
f"DELETE FROM squadrons_points WHERE clan_id IN ({placeholders})",
|
||
chunk,
|
||
)
|
||
await db.execute(
|
||
f"DELETE FROM squadrons_data WHERE clan_id IN ({placeholders})",
|
||
chunk,
|
||
)
|
||
await db.commit()
|
||
|
||
logging.info(
|
||
"[SQ-CLEANUP] Purged %d stale squadrons", len(stale_clan_ids)
|
||
)
|
||
return len(stale_clan_ids)
|
||
|
||
except Exception as e:
|
||
logging.error("[SQ-CLEANUP] Cleanup error: %s", e)
|
||
return 0
|
||
|
||
|
||
# ============================================================================
|
||
# WEEKLY BR REPORT
|
||
# ============================================================================
|
||
|
||
|
||
async def _load_squadron_name_lookup() -> Dict[str, Dict[str, Any]]:
|
||
"""Build a name->squadron_data map keyed by long_name, tag_name, short_name.
|
||
|
||
Used to resolve `squadron_name` strings from player_games_hist back to the
|
||
canonical (clan_id, tag_name, long_name, short_name) for display.
|
||
"""
|
||
out: Dict[str, Dict[str, Any]] = {}
|
||
try:
|
||
async with aiosqlite.connect(
|
||
f"file:{SQUADRONS_DB_PATH}?mode=ro", uri=True, timeout=30.0
|
||
) as db:
|
||
await db.execute("PRAGMA busy_timeout=30000;")
|
||
async with db.execute(
|
||
"SELECT clan_id, long_name, tag_name, short_name FROM squadrons_data"
|
||
) as cur:
|
||
async for clan_id, long_name, tag_name, short_name in cur:
|
||
info = {
|
||
"clan_id": int(clan_id) if clan_id is not None else None,
|
||
"long_name": str(long_name or ""),
|
||
"tag_name": str(tag_name or ""),
|
||
"short_name": str(short_name or ""),
|
||
}
|
||
for n in (long_name, tag_name, short_name):
|
||
if n:
|
||
out.setdefault(str(n), info)
|
||
except Exception as e:
|
||
logging.error("(WBR) Failed to load squadron name lookup: %s", e)
|
||
return out
|
||
|
||
|
||
def _br_window_already_fired(window_start: int) -> bool:
|
||
"""Idempotency: returns True if the marker file already records this window."""
|
||
try:
|
||
if not WEEKLY_BR_MARKER_PATH.exists():
|
||
return False
|
||
with open(WEEKLY_BR_MARKER_PATH, "r", encoding="utf-8") as fp:
|
||
data = json.load(fp)
|
||
return int(data.get("br_window_start") or 0) == int(window_start)
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _mark_br_window_fired(window: Dict[str, Any]) -> None:
|
||
try:
|
||
payload = {
|
||
"br_window_start": int(window["start"]),
|
||
"br_window_end": int(window["end"]),
|
||
"max_br": float(window["max_br"]),
|
||
"fired_at": int(T.time()),
|
||
}
|
||
with open(WEEKLY_BR_MARKER_PATH, "w", encoding="utf-8") as fp:
|
||
json.dump(payload, fp)
|
||
except Exception as e:
|
||
logging.error("(WBR) Failed to write marker: %s", e)
|
||
|
||
|
||
def _strip_tag_decoration(tag: str) -> str:
|
||
"""Strip the decorative glyph chars surrounding a WT squadron tag.
|
||
|
||
Tags in WT often appear as `┤DCRSS├` or `[CLAN]` -- one decoration char on
|
||
each side. Slice both off only if neither end is alphanumeric, so plain
|
||
short tags like `AVR` are left alone.
|
||
"""
|
||
if len(tag) >= 3 and not tag[0].isalnum() and not tag[-1].isalnum():
|
||
return tag[1:-1]
|
||
return tag
|
||
|
||
|
||
_TROPHY = "\U0001F3C6"
|
||
|
||
|
||
def _format_player_line_short(
|
||
rank: int, nick: str, score: float, games: int, is_top: bool
|
||
) -> str:
|
||
"""Render one player row in the wildcard roster.
|
||
|
||
Bold-rank prefix (`**1.**`) dodges Discord's ordered-list detection so
|
||
items 2-5 don't render as continuation lines under item 1.
|
||
"""
|
||
safe_nick = discord.utils.escape_markdown(nick)
|
||
line = f"**{rank}.** {safe_nick} • {score:.2f} • {games}g"
|
||
if is_top:
|
||
line += f" {_TROPHY}"
|
||
return line
|
||
|
||
|
||
def _format_player_line_full(
|
||
rank: int, nick: str, score: float, games: int, kdr: float, is_top: bool
|
||
) -> str:
|
||
"""Render one player row in the per-squadron roster (top-15 listing)."""
|
||
safe_nick = discord.utils.escape_markdown(nick)
|
||
line = f"**{rank}.** {safe_nick} • {score:.2f} • {games}g • K/D {kdr:.2f}"
|
||
if is_top:
|
||
line += f" {_TROPHY}"
|
||
return line
|
||
|
||
|
||
def _build_wildcard_embeds(
|
||
window: Dict[str, Any],
|
||
payload: List[Dict[str, Any]],
|
||
name_lookup: Dict[str, Dict[str, Any]],
|
||
lang: str,
|
||
) -> List[discord.Embed]:
|
||
"""Build the two wildcard-mode embeds (ranks 1-10, 11-20)."""
|
||
color = discord.Color(br_color_int(float(window["max_br"])))
|
||
title = t(lang, "weekly_br.title_wildcard", br=window["max_br"])
|
||
window_line = t(
|
||
lang,
|
||
"weekly_br.window_label",
|
||
start=f"<t:{int(window['start'])}:D>",
|
||
end=f"<t:{int(window['end'])}:D>",
|
||
)
|
||
|
||
# The highest-scoring person for the entire week -- single trophy on the
|
||
# top-scoring player across all squadrons in the payload.
|
||
top_uid: Optional[str] = None
|
||
top_score = -1.0
|
||
for sq in payload:
|
||
for p in sq.get("top_players") or []:
|
||
score = float(p.get("score") or 0.0)
|
||
if score > top_score:
|
||
top_score = score
|
||
top_uid = str(p.get("uid") or "")
|
||
|
||
def _format_squadron_field(rank: int, sq: Dict[str, Any]) -> tuple[str, str]:
|
||
sq_name = str(sq.get("squadron_name") or "")
|
||
info = name_lookup.get(sq_name) or {}
|
||
raw_tag = info.get("tag_name") or info.get("short_name") or sq_name
|
||
tag = _strip_tag_decoration(raw_tag)
|
||
long_name = info.get("long_name") or sq_name
|
||
score = float(sq.get("score") or 0.0)
|
||
games = int(sq.get("games") or 0)
|
||
kdr = float(sq.get("kdr") or 0.0)
|
||
wr = float(sq.get("win_rate") or 0.0)
|
||
|
||
safe_tag = discord.utils.escape_markdown(tag)
|
||
safe_long = discord.utils.escape_markdown(long_name)
|
||
field_name = f"{rank}. [{safe_tag}] {safe_long} - {score:.2f}"
|
||
|
||
stats_line = t(
|
||
lang,
|
||
"weekly_br.squadron_stats_line",
|
||
games=games,
|
||
kdr=f"{kdr:.2f}",
|
||
wr=f"{wr:.0f}",
|
||
)
|
||
|
||
roster = sq.get("top_players") or []
|
||
roster_lines: List[str] = []
|
||
for i, p in enumerate(roster, start=1):
|
||
nick = str(p.get("nick") or "?")
|
||
p_score = float(p.get("score") or 0.0)
|
||
p_games = int(p.get("games") or 0)
|
||
is_top = top_uid is not None and str(p.get("uid") or "") == top_uid
|
||
roster_lines.append(
|
||
_format_player_line_short(i, nick, p_score, p_games, is_top)
|
||
)
|
||
|
||
if roster_lines:
|
||
value = stats_line + "\n" + "\n".join(roster_lines)
|
||
else:
|
||
value = stats_line
|
||
return field_name, value
|
||
|
||
half_n = (len(payload) + 1) // 2 # split point (eg. 20 -> 10)
|
||
first, second = payload[:half_n], payload[half_n:]
|
||
embeds: List[discord.Embed] = []
|
||
|
||
if first:
|
||
e1 = discord.Embed(
|
||
title=title,
|
||
description=window_line + "\n" + t(
|
||
lang,
|
||
"weekly_br.wildcard_desc_first",
|
||
count=len(payload),
|
||
low=1,
|
||
high=len(first),
|
||
),
|
||
color=color,
|
||
)
|
||
for i, sq in enumerate(first, start=1):
|
||
name, value = _format_squadron_field(i, sq)
|
||
e1.add_field(name=name, value=value, inline=False)
|
||
e1.set_footer(text=DEFAULT_FOOTER_CAT)
|
||
embeds.append(e1)
|
||
|
||
if second:
|
||
e2 = discord.Embed(
|
||
title=title,
|
||
description=t(
|
||
lang,
|
||
"weekly_br.wildcard_desc_second",
|
||
count=len(payload),
|
||
low=len(first) + 1,
|
||
high=len(payload),
|
||
),
|
||
color=color,
|
||
)
|
||
for i, sq in enumerate(second, start=len(first) + 1):
|
||
name, value = _format_squadron_field(i, sq)
|
||
e2.add_field(name=name, value=value, inline=False)
|
||
e2.set_footer(text=DEFAULT_FOOTER_CAT)
|
||
embeds.append(e2)
|
||
|
||
return embeds
|
||
|
||
|
||
def _build_squadron_embed(
|
||
window: Dict[str, Any],
|
||
sq_info: Dict[str, Any],
|
||
sq_row: Optional[Dict[str, Any]],
|
||
roster: List[Dict[str, Any]],
|
||
lang: str,
|
||
) -> discord.Embed:
|
||
"""Build the per-squadron embed (top-K players for one squadron)."""
|
||
color = discord.Color(br_color_int(float(window["max_br"])))
|
||
raw_tag = sq_info.get("tag_name") or sq_info.get("short_name") or "?"
|
||
tag = _strip_tag_decoration(raw_tag)
|
||
long_name = sq_info.get("long_name") or sq_info.get("short_name") or "?"
|
||
|
||
safe_tag = discord.utils.escape_markdown(tag)
|
||
safe_long = discord.utils.escape_markdown(long_name)
|
||
|
||
title = t(
|
||
lang,
|
||
"weekly_br.title_squadron",
|
||
tag=safe_tag,
|
||
long=safe_long,
|
||
br=window["max_br"],
|
||
)
|
||
window_line = t(
|
||
lang,
|
||
"weekly_br.window_label",
|
||
start=f"<t:{int(window['start'])}:D>",
|
||
end=f"<t:{int(window['end'])}:D>",
|
||
)
|
||
|
||
if sq_row is None and not roster:
|
||
embed = discord.Embed(
|
||
title=title,
|
||
description=window_line + "\n\n" + t(lang, "weekly_br.no_data", tag=safe_tag),
|
||
color=color,
|
||
)
|
||
embed.set_footer(text=DEFAULT_FOOTER_CAT)
|
||
return embed
|
||
|
||
if sq_row is not None:
|
||
sq_score = float(sq_row.get("score") or 0.0)
|
||
sq_games = int(sq_row.get("games") or 0)
|
||
sq_kdr = float(sq_row.get("kdr") or 0.0)
|
||
sq_wr = float(sq_row.get("win_rate") or 0.0)
|
||
header = t(
|
||
lang,
|
||
"weekly_br.squadron_header_line",
|
||
score=f"{sq_score:.2f}",
|
||
games=sq_games,
|
||
kdr=f"{sq_kdr:.2f}",
|
||
wr=f"{sq_wr:.0f}",
|
||
)
|
||
else:
|
||
header = t(lang, "weekly_br.squadron_header_no_aggregate")
|
||
|
||
parts = [window_line, header]
|
||
|
||
roster_lines: List[str] = []
|
||
for i, p in enumerate(roster, start=1):
|
||
nick = str(p.get("nick") or "?")
|
||
p_score = float(p.get("score") or 0.0)
|
||
p_games = int(p.get("games") or 0)
|
||
p_kdr = float(p.get("kdr") or 0.0)
|
||
roster_lines.append(
|
||
_format_player_line_full(i, nick, p_score, p_games, p_kdr, is_top=(i == 1))
|
||
)
|
||
|
||
if roster_lines:
|
||
parts.append("") # blank line before roster
|
||
parts.append("\n".join(roster_lines))
|
||
|
||
embed = discord.Embed(title=title, description="\n".join(parts), color=color)
|
||
embed.set_footer(text=DEFAULT_FOOTER_CAT)
|
||
return embed
|
||
|
||
|
||
async def execute_weekly_br_report_task(window: Dict[str, Any]) -> None:
|
||
"""Send the Weekly BR Report for a just-closed BR window to all subscribers.
|
||
|
||
`window` is a SCHEDULE.json entry (max_br, start, end). The function:
|
||
- is idempotent per `start` via a marker file
|
||
- precomputes the wildcard payload (top 20 squadrons + top 5 players each)
|
||
once and reuses it across all guilds
|
||
- iterates each guild's preferences for `everything.WeeklyBR` (wildcard)
|
||
and `<clan_id>.WeeklyBR` (per-squadron) channel slots
|
||
- dedupes per channel id within each guild (wildcard wins on collision)
|
||
- tolerates per-channel send failures
|
||
"""
|
||
bot = get_bot()
|
||
start_ts = int(window["start"])
|
||
end_ts = int(window["end"])
|
||
|
||
if _br_window_already_fired(start_ts):
|
||
logging.info("(WBR) Skip — marker says window %s already fired", start_ts)
|
||
return
|
||
|
||
# 1. Hit the heavy scoring pipeline ONCE per fire. The maps are reused below for
|
||
# both the wildcard payload and every per-squadron variant, so 30 guilds
|
||
# subscribing don't trigger 30 full score recomputes.
|
||
sq_map = await _wbr_squadron_scores(start_ts, end_ts)
|
||
pl_map = await _wbr_player_scores(start_ts, end_ts)
|
||
wildcard_payload = top_n_squadrons_with_top_k_players_from_maps(
|
||
sq_map, pl_map, n=20, k=5
|
||
)
|
||
name_lookup = await _load_squadron_name_lookup()
|
||
|
||
if not wildcard_payload:
|
||
logging.warning(
|
||
"(WBR) No squadron activity in window %s..%s — skipping all sends",
|
||
start_ts,
|
||
end_ts,
|
||
)
|
||
_mark_br_window_fired(window)
|
||
return
|
||
|
||
logging.info(
|
||
"(WBR) Window %.1f BR [%s..%s] — %d squadrons, %d players in cached maps",
|
||
float(window["max_br"]),
|
||
start_ts,
|
||
end_ts,
|
||
len(sq_map),
|
||
len(pl_map),
|
||
)
|
||
|
||
prefs_dir = STORAGE_DIR / "PREFERENCES"
|
||
sent_guilds = 0
|
||
sent_channels = 0
|
||
|
||
for guild in bot.guilds:
|
||
guild_id = guild.id
|
||
guild_name = guild.name
|
||
pref_path = prefs_dir / f"{guild_id}-preferences.json"
|
||
try:
|
||
async with aiofiles.open(pref_path, "r", encoding="utf-8") as fp:
|
||
prefs = json.loads(await fp.read())
|
||
except FileNotFoundError:
|
||
continue
|
||
except Exception as e:
|
||
logging.error("(WBR) Error loading prefs for guild %s: %s", guild_id, e)
|
||
continue
|
||
|
||
# Collect channels: wildcard + per-squadron, deduped (wildcard wins).
|
||
wildcard_channel_id: Optional[int] = None
|
||
wildcard_pref_key: Optional[str] = None
|
||
squadron_channels: List[Tuple[int, str, Dict[str, Any]]] = [] # (channel_id, pref_key, sq_info)
|
||
|
||
for pref_key, entry in prefs.items():
|
||
if not isinstance(entry, dict):
|
||
continue
|
||
chan_str = entry.get("WeeklyBR")
|
||
if not chan_str:
|
||
continue
|
||
channel_id = parse_channel_id(str(chan_str))
|
||
if channel_id is None:
|
||
logging.warning(
|
||
"(WBR) Invalid channel '%s' for key %s in %s",
|
||
chan_str,
|
||
pref_key,
|
||
guild_name,
|
||
)
|
||
await remove_guild_pref_notification(guild_id, pref_key, "WeeklyBR", preferences=prefs)
|
||
continue
|
||
|
||
if pref_key.lower() in WILDCARD_KEYS:
|
||
wildcard_channel_id = channel_id
|
||
wildcard_pref_key = pref_key
|
||
continue
|
||
|
||
# Per-squadron entry — resolve to name variants
|
||
resolved = await resolve_pref_key(pref_key, entry)
|
||
if resolved:
|
||
long_name = resolved.get("long_name") or entry.get("Long") or ""
|
||
tag_name = resolved.get("tag_name") or ""
|
||
short_name = resolved.get("short_name") or entry.get("Short") or ""
|
||
clan_id = resolved.get("clan_id")
|
||
else:
|
||
long_name = entry.get("Long") or ""
|
||
tag_name = ""
|
||
short_name = entry.get("Short") or ""
|
||
clan_id = None
|
||
|
||
squadron_channels.append(
|
||
(
|
||
channel_id,
|
||
pref_key,
|
||
{
|
||
"long_name": str(long_name or ""),
|
||
"tag_name": str(tag_name or ""),
|
||
"short_name": str(short_name or ""),
|
||
"clan_id": clan_id,
|
||
},
|
||
)
|
||
)
|
||
|
||
if wildcard_channel_id is None and not squadron_channels:
|
||
continue
|
||
|
||
# Per-squadron reports are distinct from the wildcard report and may
|
||
# legitimately share the same channel — both should be sent.
|
||
|
||
# Resolve guild language once per guild
|
||
guild_features = await load_features(guild_id)
|
||
lang = lang_from_features(guild_features)
|
||
|
||
guild_did_send = False
|
||
|
||
# Wildcard send
|
||
if wildcard_channel_id is not None:
|
||
channel = bot.get_channel(wildcard_channel_id)
|
||
if channel is None:
|
||
try:
|
||
channel = await bot.fetch_channel(wildcard_channel_id)
|
||
except discord.NotFound:
|
||
if wildcard_pref_key:
|
||
await remove_guild_pref_notification(
|
||
guild_id, wildcard_pref_key, "WeeklyBR", preferences=prefs
|
||
)
|
||
channel = None
|
||
except discord.Forbidden:
|
||
channel = None
|
||
if channel is None:
|
||
logging.warning(
|
||
"(WBR) Wildcard channel %s missing in guild %s",
|
||
wildcard_channel_id,
|
||
guild_name,
|
||
)
|
||
elif not isinstance(channel, (TextChannel, Thread)):
|
||
logging.warning(
|
||
"(WBR) Wildcard channel %s is not text-capable in guild %s",
|
||
wildcard_channel_id,
|
||
guild_name,
|
||
)
|
||
else:
|
||
try:
|
||
embeds = _build_wildcard_embeds(
|
||
window, wildcard_payload, name_lookup, lang
|
||
)
|
||
if embeds:
|
||
await channel.send(embeds=embeds)
|
||
sent_channels += 1
|
||
guild_did_send = True
|
||
except discord.Forbidden:
|
||
logging.warning(
|
||
"(WBR) Forbidden sending wildcard report to %s in %s",
|
||
wildcard_channel_id,
|
||
guild_name,
|
||
)
|
||
except discord.NotFound:
|
||
if wildcard_pref_key:
|
||
await remove_guild_pref_notification(
|
||
guild_id, wildcard_pref_key, "WeeklyBR", preferences=prefs
|
||
)
|
||
logging.warning(
|
||
"(WBR) Wildcard channel %s not found in %s",
|
||
wildcard_channel_id,
|
||
guild_name,
|
||
)
|
||
except Exception as e:
|
||
logging.error(
|
||
"(WBR) Wildcard send failed in %s: %s", guild_name, e
|
||
)
|
||
|
||
# Per-squadron sends
|
||
for channel_id, pref_key, sq_info in squadron_channels:
|
||
channel = bot.get_channel(channel_id)
|
||
if channel is None:
|
||
try:
|
||
channel = await bot.fetch_channel(channel_id)
|
||
except discord.NotFound:
|
||
await remove_guild_pref_notification(guild_id, pref_key, "WeeklyBR", preferences=prefs)
|
||
channel = None
|
||
except discord.Forbidden:
|
||
channel = None
|
||
if channel is None:
|
||
logging.warning(
|
||
"(WBR) Squadron channel %s missing in guild %s",
|
||
channel_id,
|
||
guild_name,
|
||
)
|
||
continue
|
||
if not isinstance(channel, (TextChannel, Thread)):
|
||
logging.warning(
|
||
"(WBR) Squadron channel %s not text-capable in guild %s",
|
||
channel_id,
|
||
guild_name,
|
||
)
|
||
continue
|
||
|
||
variants = [
|
||
sq_info.get("long_name") or "",
|
||
sq_info.get("tag_name") or "",
|
||
sq_info.get("short_name") or "",
|
||
]
|
||
try:
|
||
sq_row, roster = squadron_report_for_variants_from_maps(
|
||
sq_map, pl_map, variants, k=15
|
||
)
|
||
embed = _build_squadron_embed(window, sq_info, sq_row, roster, lang)
|
||
await channel.send(embed=embed)
|
||
sent_channels += 1
|
||
guild_did_send = True
|
||
except discord.Forbidden:
|
||
logging.warning(
|
||
"(WBR) Forbidden sending squadron report to %s in %s",
|
||
channel_id,
|
||
guild_name,
|
||
)
|
||
except discord.NotFound:
|
||
await remove_guild_pref_notification(guild_id, pref_key, "WeeklyBR", preferences=prefs)
|
||
logging.warning(
|
||
"(WBR) Squadron channel %s not found in %s",
|
||
channel_id,
|
||
guild_name,
|
||
)
|
||
except Exception as e:
|
||
logging.error(
|
||
"(WBR) Squadron send failed in %s: %s", guild_name, e
|
||
)
|
||
|
||
if guild_did_send:
|
||
sent_guilds += 1
|
||
|
||
logging.info(
|
||
"(WBR) Dispatched window %s..%s to %d guilds across %d channels",
|
||
start_ts,
|
||
end_ts,
|
||
sent_guilds,
|
||
sent_channels,
|
||
)
|
||
_mark_br_window_fired(window)
|