commit 2b399fdb81bfe61f4b2f8fc30adcb674c46eced5 Author: FURRO404 Date: Wed May 13 23:17:02 2026 -0700 add SREBOT, SHARED, TSSBOT contents (fixup for #1223) PR #1223 only staged the deletions of the old paths because the new top-level directories were still untracked when the commit was authored. This commit adds the actual restructured tree: SREBOT/ (existing bot), SHARED/ (vromfs, data_parser, ICONS/MAPS/FONTS, DAGOR_FILES, update_game_files), and TSSBOT/ (skeleton). Co-Authored-By: Claude Opus 4.7 (1M context) diff --git a/BOT/NEWS.json b/BOT/NEWS.json new file mode 100644 index 0000000..05af854 --- /dev/null +++ b/BOT/NEWS.json @@ -0,0 +1,22 @@ +[ + { + "title": "DISCORD OUTAGE - SEVERE", + "body": "Starting around (), Discord experienced a severe outage. The bot was unable to log in or post messages during this window, and a number of games were almost certainly missed. Service will resume automatically once Discord is fully back. Sorry for the inconvenience.", + "expires": 1778443012 + }, + { + "title": "Language Update", + "body": "We've updated our translations across the bot and website, and Chinese is now supported too. Use `/language` to select your preferred language.", + "expires": 1778529412 + }, + { + "title": "/comp Usage Limits", + "body": "Free servers now get **15 /comp lookups per timeslot** during SQB hours. Outside SQB hours, /comp remains unlimited for everyone. Premium subscribers get **unlimited /comp lookups** at all times — run `/unlock` to subscribe.", + "expires": 1778356612 + }, + { + "title": "Updated Terms of Service", + "body": "We've updated our [Terms of Service](https://srebot-meow.ing/terms) with new sections covering **Premium Subscription Terms** (billing, cancellation, refunds, price changes) and **Service Availability & Liability** (warranty, liability limits, service credits for outages). Please review the updated terms at your convenience.", + "expires": 1778716800 + } +] diff --git a/BOT/SCHEDULE.json b/BOT/SCHEDULE.json new file mode 100644 index 0000000..b424845 --- /dev/null +++ b/BOT/SCHEDULE.json @@ -0,0 +1,65 @@ +[ + { + "max_br": 14.3, + "start": 1777618800, + "end": 1778137200, + "start_discord": "", + "end_discord": "" + }, + { + "max_br": 12.0, + "start": 1778137200, + "end": 1778742000, + "start_discord": "", + "end_discord": "" + }, + { + "max_br": 11.0, + "start": 1778742000, + "end": 1779346800, + "start_discord": "", + "end_discord": "" + }, + { + "max_br": 10.0, + "start": 1779346800, + "end": 1779951600, + "start_discord": "", + "end_discord": "" + }, + { + "max_br": 9.0, + "start": 1779951600, + "end": 1780556400, + "start_discord": "", + "end_discord": "" + }, + { + "max_br": 8.0, + "start": 1780556400, + "end": 1781161200, + "start_discord": "", + "end_discord": "" + }, + { + "max_br": 7.0, + "start": 1781161200, + "end": 1781766000, + "start_discord": "", + "end_discord": "" + }, + { + "max_br": 6.0, + "start": 1781766000, + "end": 1782370800, + "start_discord": "", + "end_discord": "" + }, + { + "max_br": 5.0, + "start": 1782370800, + "end": 1782802800, + "start_discord": "", + "end_discord": "" + } +] \ No newline at end of file diff --git a/BOT/analytics.py b/BOT/analytics.py new file mode 100644 index 0000000..2f764fc --- /dev/null +++ b/BOT/analytics.py @@ -0,0 +1,273 @@ +""" +analytics.py + +SQB session analytics: map win rates, team composition analysis, +player consistency, time-of-day performance, and opponent difficulty. +""" + +# Standard Library Imports +import json +import logging +import math +import re +from collections import Counter, defaultdict +from datetime import datetime, timezone + +# Third-Party Library Imports +import aiosqlite + +# Local Module Imports +from .data_parser import get_unit_type_abbrev +from .utils import SQ_BATTLES_DB_PATH, decompress_json + + +async def get_map_stats(squadron_short: str, start_ts: int = 0) -> list[dict]: + """Win rate by map for a squadron. + + Returns list of dicts sorted by total games desc: + [{map_name, wins, losses, total, win_rate}, ...] + """ + async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=30.0) as db: + await db.execute("PRAGMA busy_timeout=30000;") + rows = await db.execute_fetchall( + """SELECT map_name, winning_sq, losing_sq + FROM match_summary + WHERE (winning_sq = ? OR losing_sq = ?) + AND endtime_unix >= ?""", + (squadron_short, squadron_short, start_ts), + ) + + seen: dict[str, str] = {} # lowercase key -> first clean spelling + + def _normalize_map(raw: str) -> tuple[str, str]: + clean = re.sub(r"^\s*\[[^\]]+\]\s*", "", raw).strip() or "Unknown" + key = clean.lower() + if key not in seen: + seen[key] = clean + return key, seen[key] + + stats: dict[str, dict] = {} + for raw_name, winning_sq, losing_sq in rows: + key, name = _normalize_map(raw_name or "Unknown") + entry = stats.setdefault(key, {"map_name": name, "wins": 0, "losses": 0}) + if winning_sq == squadron_short: + entry["wins"] += 1 + else: + entry["losses"] += 1 + + result = list(stats.values()) + for r in result: + r["total"] = r["wins"] + r["losses"] + r["win_rate"] = round(r["wins"] / r["total"] * 100, 1) if r["total"] else 0 + result.sort(key=lambda x: x["total"], reverse=True) + return result + + +async def get_comp_analysis(squadron_short: str, start_ts: int = 0) -> list[dict]: + """Vehicle type composition vs win rate. + + Parses winning_team_json/losing_team_json, classifies vehicles, + builds comp signatures (e.g. "3T 1H 1F"), correlates with outcomes. + + Returns list sorted by usage: + [{comp_signature, wins, losses, total, win_rate}, ...] + """ + async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=30.0) as db: + await db.execute("PRAGMA busy_timeout=30000;") + rows = await db.execute_fetchall( + """SELECT winning_sq, losing_sq, winning_team_json, losing_team_json + FROM match_summary + WHERE (winning_sq = ? OR losing_sq = ?) + AND endtime_unix >= ?""", + (squadron_short, squadron_short, start_ts), + ) + + stats: dict[str, dict] = {} + for winning_sq, losing_sq, w_json, l_json in rows: + is_win = winning_sq == squadron_short + team_json = w_json if is_win else l_json + if not team_json: + continue + + try: + team = decompress_json(team_json) + except (json.JSONDecodeError, TypeError, OSError): + continue + + # Classify vehicles + type_counts: Counter = Counter() + players = team.get("players", []) + for p in players: + veh_internal = p.get("vehicle", "") + if not veh_internal or veh_internal == "DISCONNECTED": + continue + vtype = get_unit_type_abbrev(veh_internal) + type_counts[vtype] += 1 + + if not type_counts: + continue + + # Build signature: sorted by count desc, then alpha + sig_parts = sorted(type_counts.items(), key=lambda x: (-x[1], x[0])) + signature = " ".join(f"{count}{abbr}" for abbr, count in sig_parts) + + entry = stats.setdefault(signature, {"comp_signature": signature, "wins": 0, "losses": 0}) + if is_win: + entry["wins"] += 1 + else: + entry["losses"] += 1 + + result = list(stats.values()) + for r in result: + r["total"] = r["wins"] + r["losses"] + r["win_rate"] = round(r["wins"] / r["total"] * 100, 1) if r["total"] else 0 + result.sort(key=lambda x: x["total"], reverse=True) + return result[:20] + + +async def get_player_consistency(squadron_short: str, min_games: int = 50) -> list[dict]: + """Per-player performance variance. + + Returns list sorted by most recently played: + [{uid, nick, avg_kills, std_kills, avg_deaths, std_deaths, games, consistency_score, last_played}, ...] + """ + async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=30.0) as db: + await db.execute("PRAGMA busy_timeout=30000;") + # squadron_name in player_games_hist stores the short name (e.g. "DSPL") + rows = await db.execute_fetchall( + """SELECT UID, nick, + SUM(ground_kills + air_kills) as total_kills, + SUM(deaths) as total_deaths, + session_id, + MAX(endtime_unix) as last_played + FROM player_games_hist + WHERE squadron_name = ? + GROUP BY UID, session_id""", + (squadron_short,), + ) + + # Aggregate per-player + player_data: dict[str, dict] = {} + for uid, nick, kills, deaths, session_id, last_played in rows: + entry = player_data.setdefault(uid, {"uid": uid, "nick": nick, "kills": [], "deaths": [], "last_played": 0}) + entry["nick"] = nick # keep latest nick + entry["kills"].append(kills or 0) + entry["deaths"].append(deaths or 0) + entry["last_played"] = max(entry["last_played"], last_played or 0) + + result = [] + for uid, data in player_data.items(): + games = len(data["kills"]) + if games < min_games: + continue + + avg_kills = sum(data["kills"]) / games + avg_deaths = sum(data["deaths"]) / games + + std_kills = math.sqrt(sum((k - avg_kills) ** 2 for k in data["kills"]) / games) + std_deaths = math.sqrt(sum((d - avg_deaths) ** 2 for d in data["deaths"]) / games) + + # Consistency score: lower is more consistent (normalized std dev) + consistency = (std_kills + std_deaths) / max(avg_kills + avg_deaths, 1) + + result.append({ + "uid": uid, + "nick": data["nick"], + "avg_kills": round(avg_kills, 2), + "std_kills": round(std_kills, 2), + "avg_deaths": round(avg_deaths, 2), + "std_deaths": round(std_deaths, 2), + "games": games, + "consistency_score": round(consistency, 3), + "last_played": data["last_played"], + }) + + result.sort(key=lambda x: x["avg_kills"] / max(x["avg_deaths"], 0.01), reverse=True) + return result[:128] + + +async def get_matchup_history(squadron_short: str, start_ts: int = 0) -> dict: + """Top opponents this squadron has won against and lost to. + + Returns: + { + "won_against": [{opponent, wins, losses, total, win_rate}, ...up to 10], + "lost_against": [{opponent, wins, losses, total, win_rate}, ...up to 10], + "total_opponents": int, + } + """ + async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=30.0) as db: + await db.execute("PRAGMA busy_timeout=30000;") + rows = await db.execute_fetchall( + """SELECT winning_sq, losing_sq + FROM match_summary + WHERE (winning_sq = ? OR losing_sq = ?) + AND endtime_unix >= ?""", + (squadron_short, squadron_short, start_ts), + ) + + stats: dict[str, dict] = {} + for winning_sq, losing_sq in rows: + if not winning_sq or not losing_sq or winning_sq == losing_sq: + continue + opponent = losing_sq if winning_sq == squadron_short else winning_sq + if not opponent: + continue + entry = stats.setdefault(opponent, {"opponent": opponent, "wins": 0, "losses": 0}) + if winning_sq == squadron_short: + entry["wins"] += 1 + else: + entry["losses"] += 1 + + enriched = [] + for r in stats.values(): + r["total"] = r["wins"] + r["losses"] + r["win_rate"] = round(r["wins"] / r["total"] * 100, 1) if r["total"] else 0 + enriched.append(r) + + won_against = sorted(enriched, key=lambda x: (x["wins"], x["total"]), reverse=True)[:10] + lost_against = sorted(enriched, key=lambda x: (x["losses"], x["total"]), reverse=True)[:10] + + return { + "won_against": won_against, + "lost_against": lost_against, + "total_opponents": len(enriched), + } + + +async def get_time_performance(squadron_short: str) -> dict: + """Win rate bucketed by hour of day (UTC). + + Returns dict keyed by hour (0-23): + {hour: {wins, losses, total, win_rate}, ...} + """ + async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=30.0) as db: + await db.execute("PRAGMA busy_timeout=30000;") + rows = await db.execute_fetchall( + """SELECT endtime_unix, winning_sq, losing_sq + FROM match_summary + WHERE (winning_sq = ? OR losing_sq = ?) + AND endtime_unix > 0""", + (squadron_short, squadron_short), + ) + + stats: dict[int, dict] = {} + for endtime_unix, winning_sq, losing_sq in rows: + hour = datetime.fromtimestamp(endtime_unix, tz=timezone.utc).hour + entry = stats.setdefault(hour, {"wins": 0, "losses": 0}) + if winning_sq == squadron_short: + entry["wins"] += 1 + else: + entry["losses"] += 1 + + result = {} + for hour in sorted(stats.keys()): + e = stats[hour] + total = e["wins"] + e["losses"] + result[hour] = { + "wins": e["wins"], + "losses": e["losses"], + "total": total, + "win_rate": round(e["wins"] / total * 100, 1) if total else 0, + } + return result diff --git a/BOT/autologging.py b/BOT/autologging.py new file mode 100644 index 0000000..fa65902 --- /dev/null +++ b/BOT/autologging.py @@ -0,0 +1,2164 @@ +""" +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 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 +import pygob + +# Local Module Imports +from . import utils +from .data_parser import LangTableReader +from .game_api import get_point_diff +from .gob import load_gob_file, render_gob +from .health import record_game_processed, record_ws_message +from .receiver_bridge import publish_gob_payload, publish_replay_batch +from .utils import t, lang_from_features +from .lux_apis import _gob_to_dict +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 from disk for a session.""" + path = replay_data_path(session_id) + if path.is_file(): + with open(path, "r", 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 (check for replay_data.json, not just dir — GOB ws may create the dir first) + replay_dir = replay_session_dir(hex_id) + if (replay_dir / "replay_data.json").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" + + try: + async with aiofiles.open(replay_file, 'w', encoding='utf-8') as f: + content = json.dumps(local_data, indent=4, ensure_ascii=False) + await f.write(content) + logging.info(f"[WSS] Saved {hex_id} ({len(content)} bytes)") + 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=replay.get('end_ts', now_ts), + ) + local_data["scoreboard_context"] = scoreboard_context + + forwarded_replays.append(local_data) + validated_games.append({ + "sessionIdHex": hex_id, + "endTime": replay.get('end_ts', 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: + async with aiofiles.open(replay_path, "r", encoding="utf-8") as fp: + replay_data = json.loads(await fp.read()) + 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"] != ""} + + 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 = base_dir / "replay_data.json" + try: + async with aiofiles.open(replay_path, "r", encoding="utf-8") as f: + replay_data = json.loads(await f.read()) + except FileNotFoundError: + logging.error(f"Replay file not found for session ID {session_id}") + return + except json.JSONDecodeError: + logging.error(f"Replay file for session ID {session_id} is invalid JSON") + 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. ````) — 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", "") + 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://srebot-meow.ing/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_gob_message(compressed: bytes, decompressed: bytes) -> None: + """Save a received GOB replay (zstd-compressed) to disk for on-demand video generation.""" + try: + replay = pygob.load(decompressed) + d = _gob_to_dict(replay) + session_id = d.get("SessionID") + if not session_id: + return + hex_id = format(session_id, 'x') + replay_dir = replay_session_dir(hex_id) + replay_dir.mkdir(parents=True, exist_ok=True) + gob_path = replay_dir / "replay.gob" + if not gob_path.exists(): + gob_path.write_bytes(compressed) + logging.info(f"[GOB] Saved {hex_id} ({len(compressed)} bytes compressed)") + await record_ws_message("sqb_gob") + try: + await publish_gob_payload({ + "session_id": hex_id, + "payload": d, + "compressed_size": len(compressed), + }) + except Exception as bridge_error: + logging.warning(f"[BRIDGE] Failed to forward GOB payload for {hex_id}: {bridge_error}") + except Exception as e: + logging.error(f"[GOB] Save error: {e}") + + +async def handle_view_video(interaction: discord.Interaction, session_id: str): + """Callback for 'View Video' - renders GOB replay 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) + gob_path = replay_dir / "replay.gob" + video_path = replay_dir / "replay_video.mp4" + + if not gob_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(gob_path) + render_gob(d, video_path) + + logging.info(f"GOB ({session_id}) RENDER START") + async with _video_render_sem: + await asyncio.get_event_loop().run_in_executor(None, _generate) + logging.info(f"GOB ({session_id}) RENDER END (Success)") + except Exception as e: + logging.info(f"GOB ({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://srebot-meow.ing/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]) + + squads: List[str] = [str(team["squadron"]) for team in teams if team.get("squadron")] + squads_tagged: List[str] = [str(team["squadron_tagged"]) for team in 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(teams, long_clans): + if team and long_name: + team["squadron_long"] = long_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') + + base_dir = replay_session_dir(session_id) + replay_path = base_dir / "replay_data.json" + + try: + async with aiofiles.open(replay_path, "r", encoding="utf-8") as f: + replay_data = json.loads(await f.read()) + except FileNotFoundError: + logging.warning(f"(COMP-WRITE) Replay file not found: {replay_path}") + continue + except json.JSONDecodeError: + logging.warning(f"(COMP-WRITE) Invalid JSON in replay: {replay_path}") + 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) + + base_dir = replay_session_dir(sid) + replay_path = base_dir / "replay_data.json" + + try: + async with aiofiles.open(replay_path, "r", encoding="utf-8") as f: + replay_data = json.loads(await f.read()) + except FileNotFoundError: + continue + except 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] " 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: + async with aiofiles.open(replay_path, "r", encoding="utf-8") as f: + replay_data = json.loads(await f.read()) + except FileNotFoundError: + continue + except 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}") diff --git a/BOT/botscript.py b/BOT/botscript.py new file mode 100644 index 0000000..a70bb81 --- /dev/null +++ b/BOT/botscript.py @@ -0,0 +1,9402 @@ +""" +botscript.py + +Main Discord bot module. Defines the MyBot class and all slash commands including +squadron comparison, player statistics, leaderboards, notifications, metadata management, +translations, and administrative functions with interactive UI components. +""" + +# Standard Library Imports +import asyncio +import json +import logging +import math +import os +import re +import time as time_module +import traceback +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Awaitable, Callable, Dict, List, Optional, cast + +# Third-Party Library Imports +import aiofiles +import aiosqlite +import deepl +import discord +import matplotlib +matplotlib.use('Agg') # Use non-interactive backend +import matplotlib.cm as cm # noqa: E402 +import matplotlib.dates as mdates # noqa: E402 +import matplotlib.pyplot as plt # noqa: E402 +import numpy as np +from discord import Color, Embed, app_commands +from discord.ext import commands +from discord.ui import Select, View, button +from dotenv import load_dotenv +import matplotlib.patheffects as path_effects # noqa: E402 +from matplotlib.collections import LineCollection +from matplotlib.colors import TwoSlopeNorm + +# Local Module Imports (relative imports within BOT package) +from . import utils +from .analytics import ( + get_map_stats, get_comp_analysis, get_player_consistency, + get_time_performance, get_matchup_history, +) +from .autologging import init_players_db, load_replay_data_from_disk, build_scoreboard_view +from .data_parser import LangTableReader, count_unit_types, get_unit_type_abbrev, normalize_name +from .game_api import ( + ClanInfoError, + obtain_clan_info_api, + obtain_clan_new_points, + obtain_clans_leaderboard, +) +from .health import init_health, get_health_snapshot +from .utils import t, guild_lang +from .lux_apis import fetch_replay_by_id +from .meta_manager import ( + add_player_to_guild_meta, + bulk_add_squadron_players_to_guild_meta, + create_or_update_guild, + get_guild_meta_players, + get_guild_settings, + get_squadron_owner, + init_meta_db, + refresh_guild_player_vehicles, + remove_player_from_guild_meta, + search_guild_meta_by_vehicle, + transfer_squadron_to_guild, + update_guild_settings, + update_squadron_password, + validate_squadron_password, +) +from .scoreboard import create_scoreboard +from .stack_manager import register_commands as register_stack_commands +from .tasks import start_all_tasks +from . import utils as _utils +from .utils import ( + STORAGE_DIR, + ICONS_DIR, + SQ_BATTLES_DB_PATH, + SQUADRONS_DB_PATH, + COMMAND_DATA_DB_PATH, + DISCORD_SKU_ID_STANDARD, + ENTITLEMENTS_DB_PATH, + TOKEN, + DEFAULT_FOOTER_CAT, + compress_json, + decompress_json, + init_game_cache, + init_vehicle_translation_cache, + invalidate_entitled_guilds_cache, + is_admin, + is_blacklisted, + is_guild_entitled, + get_guild_tier, + permission_fail, + refresh_entitled_guilds, + tier_cap, + tier_enforcement_active, + tier_allows_wildcard, + TIER_ORDER, + DISCORD_SKU_ID_PRO, + DISCORD_SKU_ID_MAX, + enabled_pref_keys_for, + enabled_non_wildcard_keys_for, + allowed_pref_keys_for, + is_notif_enabled, + WILDCARD_KEYS, + resolve_clan, + resolve_clan_id, + resolve_clans, + get_guild_squadron, + load_guild_preferences, + save_guild_preferences, + load_features, + save_features, + load_json, + write_json, + set_bot, + esc, + init_command_stats_db, + collect_command_stats, + command_locale, + is_dev_team, + LocaleJsonTranslator, + COMP_FREE_UNTIL_TS, + COMP_LIMIT_PER_TIMESLOT, + get_current_timeslot_start_ts, + get_comp_usage_in_timeslot, + SQB_SLOTS_POSTED, + RECAP_LANGS, + RecapError, + get_player_recap, + get_seasons, + get_squadron_recap, + replay_session_dir, +) +from .wl import wl_bootstrap + +# Load environment variables from .env file +load_dotenv() + +logging.basicConfig(level=logging.INFO) +logging.getLogger("httpx").setLevel(logging.WARNING) +logging.getLogger("hpack").setLevel(logging.WARNING) +logging.getLogger("deepl").setLevel(logging.WARNING) + +intents = discord.Intents.default() +intents.message_content = False +intents.reactions = True +intents.messages = True + +class MyBot(commands.Bot): + """Custom Discord bot subclass for SRE Bot. + + Extends commands.Bot with a sync guard to prevent duplicate + slash-command tree syncs on reconnect. + """ + + def __init__(self): + """Initialize the bot with default prefix and configured intents.""" + super().__init__(command_prefix='~', intents=intents) + self.synced = False + + async def setup_hook(self): + """Sync the slash-command tree with Discord on first startup.""" + await self.tree.set_translator(LocaleJsonTranslator()) + await self.tree.sync() + self.synced = True + +bot = MyBot() +set_bot(bot) # Register bot globally for other modules +register_stack_commands(bot) + + + +@bot.event +async def on_ready(): + """Handle bot startup: write guild report, refresh presence, init caches, and start tasks.""" + GUILD_TOTAL = len(bot.guilds) + + out_path = STORAGE_DIR / "GUILD_REPORT.txt" + with out_path.open("w", encoding="utf-8") as f: + f.write(f"We have logged in as {bot.user} in the following Guilds:\n\n") + for guild in bot.guilds: + created = guild.created_at.strftime("%Y-%m-%d") + f.write( + f" - {guild.name} (id: {guild.id}) | Members: {guild.member_count} | Created: {created}\n" + ) + + logging.info(f"Guild report written to {out_path.resolve()}") + logging.info(f' - Total Guilds: {GUILD_TOTAL}') + + await _refresh_presence() + + if not bot.synced: + await bot.tree.sync() + bot.synced = True + + try: + await init_game_cache() + logging.info("Initialized game cache . . .") + except Exception as e: + logging.error(f"Error initializing game cache in startup: {e}") + + try: + await init_vehicle_translation_cache() + except Exception as e: + logging.error(f"Error initializing vehicle translation cache in startup: {e}") + + try: + await refresh_entitled_guilds(force=True) + logging.info("Initialized entitlements cache") + except Exception as e: + logging.error(f"Error initializing entitlements cache: {e}") + + try: + await cast(Callable[[], Awaitable[None]], init_meta_db)() + logging.info("Initialized Meta.db . . .") + except Exception as e: + logging.error(f"Error initializing Meta.db in startup: {e}") + + try: + async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as conn: + await init_players_db(conn) + logging.info("Initialized sq_battles.db . . .") + except Exception as e: + logging.error(f"Error initializing sq_battles.db in startup: {e}") + + try: + await init_command_stats_db() + except Exception as e: + logging.error(f"Error initializing COMMAND_DATA.db in startup: {e}") + + try: + # Initialize health monitoring + init_health(started_at=time_module.time(), guild_count=GUILD_TOTAL) + + # Start all background tasks from tasks.py + await cast(Callable[[], Awaitable[None]], start_all_tasks)() + + # Start the W/L queue consumer + wl_bootstrap() + + logging.info("Engines 1-3 are a go . . .") + logging.info("We have liftoff ! ! !") + + except Exception as e: + logging.error(f"Error starting tasks in startup: {e}") + + +@bot.event +async def on_guild_join(guild): + """Handle joining a new guild: update presence and send setup hint. + + Args: + guild: The Discord guild the bot just joined. + """ + GUILD_TOTAL = len(bot.guilds) + + logging.info(f"Joined new guild: {guild.name} (id: {guild.id})") + logging.info(f"Updated total guilds: {GUILD_TOTAL}") + + try: + await _refresh_presence() + except Exception as e: + logging.error(f"Error changing bot status after guild join: {e}") + + # Send setup hint to system channel or first available text channel + try: + target = guild.system_channel + if not target: + for ch in guild.text_channels: + if ch.permissions_for(guild.me).send_messages: + target = ch + break + if target: + lang = 'en' + embed = Embed( + title=t(lang, "events.guild_join_title"), + description=t(lang, "events.guild_join_desc"), + color=Color.blue() + ) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + await target.send(embed=embed) + except Exception as e: + logging.error(f"Error sending setup hint to guild {guild.id}: {e}") + + +@bot.event +async def on_guild_remove(guild): + """Handle being removed from a guild: update presence. + + Args: + guild: The Discord guild the bot was removed from. + """ + GUILD_TOTAL = len(bot.guilds) + + logging.info(f"Removed from guild: {guild.name} (id: {guild.id})") + logging.info(f"Updated total guilds: {GUILD_TOTAL}") + + try: + await _refresh_presence() + except Exception as e: + logging.error(f"Error changing bot status after guild removal: {e}") + + +@bot.event +async def on_entitlement_create(entitlement): + """Invalidate the entitlement cache when a Discord SKU subscription is created.""" + invalidate_entitled_guilds_cache() + logging.info(f"[PREMIUM] Discord entitlement created for guild {entitlement.guild_id}, cache invalidated") + + +async def _refresh_presence(): + """Update the bot's Discord presence to show the current guild count.""" + logging.info("Refreshing status . . .") + GUILD_TOTAL = len(bot.guilds) + + await bot.change_presence( + activity=discord.Activity( + type=discord.ActivityType.playing, + name=f"Playing War Thunder in {GUILD_TOTAL} servers!" + ) + ) + +async def squadron_autocomplete( + interaction: discord.Interaction, + current: str, +) -> List[discord.app_commands.Choice[str]]: + """Autocomplete for squadron short names. Shared across all squadron-param commands.""" + try: + async with aiosqlite.connect(SQUADRONS_DB_PATH) as db: + await db.create_function("ulower", 1, str.lower) + if not current: + async with db.execute( + """SELECT short_name, long_name FROM squadrons_data + WHERE short_name IS NOT NULL + ORDER BY position ASC NULLS LAST + LIMIT 25""" + ) as cursor: + rows = await cursor.fetchall() + else: + search = f"%{current}%" + async with db.execute( + """SELECT short_name, long_name FROM squadrons_data + WHERE short_name IS NOT NULL + AND (ulower(short_name) LIKE ulower(?) + OR ulower(long_name) LIKE ulower(?) + OR ulower(tag_name) LIKE ulower(?)) + ORDER BY + CASE WHEN ulower(short_name) = ulower(?) THEN 0 + WHEN ulower(short_name) LIKE ulower(?) THEN 1 + ELSE 2 + END, + position ASC NULLS LAST + LIMIT 25""", + (search, search, search, current, f"{current}%") + ) as cursor: + rows = await cursor.fetchall() + + return [ + discord.app_commands.Choice( + name=row[0], + value=row[0] + ) + for row in rows + ] + except Exception: + return [] + + +@is_blacklisted() +@bot.tree.command(name='comp', description=command_locale('Find the last known comps for a given team', "commands.comp.description")) +@app_commands.describe(squadron_short=command_locale('The shortname of the enemy team', "commands.comp.squadron_short")) +@discord.app_commands.autocomplete(squadron_short=squadron_autocomplete) +async def comp(interaction: discord.Interaction, squadron_short: str): + """Show the last known team compositions for a squadron. + + Loads comp data from the COMPS JSON directory, filters to comps seen in the + last hour, sorts by recency, and displays each comp with player-vehicle + lists and unit-type notation (e.g. 2F / 1T / 1AA). + + Args: + interaction: The Discord interaction. + squadron_short: Short name of the enemy team. + """ + guild = interaction.guild + if guild is None: + return + + lang = await guild_lang(guild.id) + + await interaction.response.defer() + + # ── Comp usage limit (after free period) ────────────────────────── + comp_footer = DEFAULT_FOOTER_CAT + now_ts_check = int(time_module.time()) + if now_ts_check >= COMP_FREE_UNTIL_TS: + slot_start = get_current_timeslot_start_ts() + if slot_start is not None: + entitled = await is_guild_entitled(guild.id) + if not entitled: + used = await get_comp_usage_in_timeslot(guild.id, slot_start) + logging.info( + "[COMP-USAGE] guild=%s user=%s squadron=%s used=%s limit=%s slot_start=%s entitled=%s", + guild.id, + interaction.user.id, + squadron_short, + used, + COMP_LIMIT_PER_TIMESLOT, + slot_start, + entitled, + ) + if used >= COMP_LIMIT_PER_TIMESLOT: + logging.warning( + "[COMP-LIMIT] guild=%s user=%s squadron=%s used=%s limit=%s slot_start=%s action=blocked", + guild.id, + interaction.user.id, + squadron_short, + used, + COMP_LIMIT_PER_TIMESLOT, + slot_start, + ) + embed_limit = discord.Embed( + title=t(lang, "comp.limit_reached_title"), + description=t(lang, "comp.limit_reached_desc", limit=COMP_LIMIT_PER_TIMESLOT), + color=discord.Color.red() + ) + embed_limit.set_footer(text=t(lang, "comp.remaining_footer", remaining=0, limit=COMP_LIMIT_PER_TIMESLOT)) + return await interaction.followup.send(embed=embed_limit) + remaining = COMP_LIMIT_PER_TIMESLOT - used - 1 + logging.info( + "[COMP-REMAINING] guild=%s user=%s squadron=%s remaining=%s limit=%s slot_start=%s", + guild.id, + interaction.user.id, + squadron_short, + remaining, + COMP_LIMIT_PER_TIMESLOT, + slot_start, + ) + comp_footer = t(lang, "comp.remaining_footer", remaining=remaining, limit=COMP_LIMIT_PER_TIMESLOT) + + squadron_short = squadron_short.upper() + comp_dir = STORAGE_DIR / "COMPS" + squad_file = comp_dir / f"{squadron_short}.json" + if not squad_file.exists(): + return await interaction.followup.send( + embed=discord.Embed( + title=t(lang, "comp.not_found_title"), + description=t(lang, "comp.not_found_desc", squadron=squadron_short), + color=discord.Color.red() + ) + ) + + # Load all comps + try: + async with aiofiles.open(squad_file, "r", encoding="utf-8") as f: + comps_data = json.loads(await f.read()) + logging.info(f"(COMP-CMD) Loaded {len(comps_data)} comps for {squadron_short}") + except Exception as e: + logging.error(f"(COMP-CMD) Failed to load comp file for {squadron_short}: {e}") + return await interaction.followup.send( + embed=discord.Embed( + title=t(lang, "comp.error_loading_title"), + description=t(lang, "comp.error_loading_desc", error=e), + color=discord.Color.red() + ) + ) + + # Thresholds + threshold_seconds = 3600 # 1 hour + now_ts = int(time_module.time()) + + embed = discord.Embed( + title=t(lang, "comp.title", squadron=squadron_short), + description=t(lang, "comp.desc", minutes=threshold_seconds // 60), + color=discord.Color.blurple() + ) + embed.set_footer(text=comp_footer) + + added_any = False + comp_index = 1 # <- start numbering here + + # Sort by most recent (upd or reg) + for comp_key, comp in sorted( + comps_data.items(), + key=lambda kv: kv[1].get("upd") or kv[1].get("reg", 0), + reverse=True + ): + logging.info(f"(COMP-CMD) Processing {comp_key} for {squadron_short}") + reg_ts = comp.get("reg", 0) + upd_ts = comp.get("upd", 0) + last_seen_ts = upd_ts or reg_ts + age = now_ts - last_seen_ts + + # Skip if too old + if age > threshold_seconds: + continue + + # Discord‐style relative timestamps + reg_str = f"" + last_str = f"" + + # Build player list block with padding + players = comp.get("Players", []) + if players: + _type_order = {"F": 0, "B": 1, "H": 2, "L": 3, "T": 4, "AA": 5, "?": 6} + players = sorted(players, key=lambda p: _type_order.get(get_unit_type_abbrev(p.get("vehicle_internal")), 6)) + max_nick_len = max(len(p["nick"]) for p in players) + lines = [] + for p in players: + try: + vehicle_raw = p.get("vehicle") + vehicle = normalize_name(vehicle_raw) or "DISCONNECTED" + lines.append(f"{p['nick']:<{max_nick_len}} | {vehicle}") + except Exception as e: + logging.error(f"(COMP-CMD) Error processing player {p.get('nick', 'UNKNOWN')} in {comp_key}: {e}, player data: {p}") + lines.append(f"{p.get('nick', 'UNKNOWN'):<{max_nick_len}}: ERROR") + block = "```\n" + "\n".join(lines) + "\n```" + else: + block = t(lang, "comp.no_players_recorded") + + # Build comp notation + if players: + vehicles = [] + try: + for p in players: + v_internal = p.get("vehicle_internal") + if v_internal is None: + logging.warning(f"(COMP-CMD) Player {p.get('nick', 'UNKNOWN')} missing vehicle_internal in {comp_key}, player data: {p}") + vehicles.append(v_internal) + + notation_list = count_unit_types(vehicles) + except Exception as e: + logging.error(f"(COMP-CMD) Error building comp notation for {comp_key}: {e}, vehicles: {vehicles}") + notation_list = {} + comp_order = [ + ("F", "Fighters"), + ("B", "Bombers"), + ("H", "Helicopters"), + ("L", "Light"), + ("T", "Tanks"), + ("AA", "AA"), + ("?", "?") + ] + summary_parts = [ + f"{notation_list.get(code, 0)}{code}" + for code, _ in comp_order + if notation_list.get(code, 0) > 0 + ] + comp_notation = " / ".join(summary_parts) or "None" + else: + comp_notation = "None" + + comp_key_str = str(comp_key) if comp_key is not None else "" + comp_key_str = comp_key_str.replace("COMP", "") + squad_key = f"({comp_key_str})" + comp_title = t(lang, "comp.comp_title", index=comp_index) + comp_index += 1 + + embed.add_field( + name=comp_title, + value=( + #f"SQ Number {squad_key}\n" + t(lang, "comp.last_seen_label", timestamp=last_str, warning=' ⚠️' if age > 1200 else '') + "\n" + + t(lang, "comp.comp_label", notation=comp_notation) + "\n" + + f"{block}" + ), + inline=False + ) + added_any = True + + if not added_any: + no_embed = discord.Embed( + title=t(lang, "comp.no_recent_title"), + description=t(lang, "comp.no_recent_desc", minutes=threshold_seconds // 60), + color=discord.Color.red() + ) + no_embed.set_footer(text=comp_footer) + return await interaction.followup.send(embed=no_embed) + + await collect_command_stats(interaction) + await interaction.followup.send(embed=embed) + + +@comp.error +async def comp_perm_error(interaction, error): + await permission_fail(interaction, error) + + + +@is_blacklisted() +@is_admin() +@bot.tree.command( + name="quick-log", + description=command_locale("Quickly set an alarm for this squadron in this channel", "commands.quick_log.description") +) +@app_commands.describe( + squadron_name=command_locale("The SHORT name of the squadron to monitor", "commands.quick_log.squadron_name"), + type=command_locale("Choose Logs, Points, Leaderboard, Weekly BR, or Both", "commands.quick_log.type") +) +@app_commands.choices(type=[ + app_commands.Choice(name=command_locale("Logs", "commands.quick_log.choice_logs"), value="Logs"), + app_commands.Choice(name=command_locale("Points", "commands.quick_log.choice_points"), value="Points"), + app_commands.Choice(name=command_locale("Leaderboard", "commands.quick_log.choice_leaderboard"), value="Leaderboard"), + app_commands.Choice(name=command_locale("Weekly BR", "commands.quick_log.choice_weekly_br"), value="WeeklyBR"), + app_commands.Choice(name=command_locale("Both (Logs + Points)", "commands.quick_log.choice_both"), value="Both"), +]) +@discord.app_commands.autocomplete(squadron_name=squadron_autocomplete) +async def quick_log( + interaction: discord.Interaction, + squadron_name: str = "", + type: str = "Logs" +): + """Set a Logs, Points, Leaderboard, Weekly BR, or Both alarm for a squadron. + + Resolves the squadron name, validates the alarm type, writes the channel + preference to the guild's preferences JSON, and confirms with a premium + reminder if applicable. + + Args: + interaction: The Discord interaction. + squadron_name: Short name of the squadron to monitor (empty = wildcard + for Logs and Weekly BR). + type: Alarm type -- Logs, Points, Leaderboard, Weekly BR, or Both. + """ + await collect_command_stats(interaction) + + # Normalize and validate type + type_normalized = type.title() if type.lower() != "weeklybr" else "WeeklyBR" + type_lower = type_normalized.lower() + is_leaderboard = type_lower in ("leaderboard", "leaderboards") + is_both = type_lower in ("both", "all") + is_weekly_br = type_lower == "weeklybr" + + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + + if is_leaderboard: + alarm_type = "Leaderboard" + elif is_both: + alarm_type = "Both" + elif is_weekly_br: + alarm_type = "WeeklyBR" + else: + if type_normalized not in ("Logs", "Points"): + await interaction.response.send_message( + t(lang, "quick_log.invalid_type"), + ephemeral=True + ) + return + alarm_type = type_normalized + + # Defer response to allow I/O + await interaction.response.defer() + + guild_id = interaction.guild.id # type: ignore + guild_name = interaction.guild.name # type: ignore + + # Load existing preferences or initialize + preferences = await load_guild_preferences(guild_id) + logging.info(f"Loaded preferences for guild {guild_id}") + + + # At the top of quick_log (after loading prefs etc.) + guild_id = str(interaction.guild_id) + + # `pref_key` is the dict key written into preferences. Real squadrons key + # by str(clan_id) so a future rename doesn't orphan the entry; wildcard / + # leaderboard slots keep their literal special string. + # `display_name` is what we show to the user. + resolved_clan: Optional[Dict[str, Any]] = None + short_for_entry: Optional[str] = None + if is_leaderboard: + long_name = "Global" + pref_key = "Global" + display_name = "Global" + elif is_weekly_br and not squadron_name: + # Weekly BR allows empty squadron -> wildcard (top-20 mode) + long_name = "everything" + pref_key = "everything" + display_name = "everything" + else: + # Logs, Points, Both, and Weekly-BR-with-squadron require a name + if not squadron_name: + await interaction.followup.send( + t(lang, "quick_log.squadron_required"), + ephemeral=True + ) + return + + sq_lower = squadron_name + if sq_lower in ("*", "everything", "all"): + if alarm_type not in ("Logs", "WeeklyBR"): + await interaction.followup.send( + t(lang, "quick_log.wildcard_logs_only"), + ephemeral=True + ) + return + long_name = "everything" + pref_key = "everything" + display_name = "everything" + else: + clan = await resolve_clan(short=sq_lower) + + if not clan or clan.get("long_name") == "": + await interaction.followup.send( + t(lang, "quick_log.squadron_not_resolved", squadron=squadron_name), + ephemeral=True + ) + return + resolved_clan = clan + long_name = str(clan.get("long_name") or "") + short_for_entry = str(clan.get("short_name") or "") or None + clan_id_val = clan.get("clan_id") + pref_key = str(clan_id_val) if clan_id_val else long_name + display_name = long_name + + + # Determine channel mention + channel_id = interaction.channel.id # type: ignore + channel_mention = f"<#{channel_id}>" + + # Tier gating — blocks wildcards on Standard and rejects new additions past the per-notif cap. + # Channel swaps on already-enabled entries are allowed (no counted delta). + # Free/unentitled users are NOT blocked here — the autologging premium gate already + # stops dispatch for them, and letting them configure prefs up-front is better UX. + tier = await get_guild_tier(interaction.guild_id) if interaction.guild_id else None + if tier_enforcement_active() and tier is not None: + is_wildcard = long_name.lower() in WILDCARD_KEYS + + # Wildcard tier-gate doesn't apply to Leaderboard or Weekly BR + # (both are uncapped + free, like /schedule). + if is_wildcard and alarm_type not in ("Leaderboard", "WeeklyBR") \ + and not tier_allows_wildcard(tier): + wb_embed = discord.Embed( + title=t(lang, "autolog.wildcard_blocked_title"), + description=t(lang, "autolog.wildcard_blocked_desc", + tier=(tier or "none").title(), notif="Logs"), + color=discord.Color.orange(), + ) + wb_embed.set_footer(text=t(lang, "autolog.over_cap_footer")) + await interaction.followup.send(embed=wb_embed, ephemeral=True) + return + + if is_leaderboard or is_weekly_br: + types_to_check: list[str] = [] # Both are uncapped + elif is_both: + types_to_check = ["Logs", "Points"] + elif alarm_type in ("Logs", "Points"): + types_to_check = [alarm_type] + else: + types_to_check = [] + + for nt in types_to_check: + cap = tier_cap(tier, nt) + if cap is None: + continue # unlimited for this tier + already_enabled = is_notif_enabled(preferences.get(pref_key, {}), nt) + if already_enabled: + continue # channel swap on existing entry — no new slot consumed + # Wildcards (*, all, everything) don't count toward the cap + if long_name.lower() in WILDCARD_KEYS: + continue + enabled_now = set(enabled_non_wildcard_keys_for(preferences, nt)) - {pref_key} + if len(enabled_now) >= cap: + oc_embed = discord.Embed( + title=t(lang, "autolog.over_cap_title"), + description=t(lang, "autolog.over_cap_desc", + tier=(tier or "none").title(), notif=nt, + cap=cap, squadron=display_name), + color=discord.Color.orange(), + ) + oc_embed.set_footer(text=t(lang, "autolog.over_cap_footer")) + await interaction.followup.send(embed=oc_embed, ephemeral=True) + return + + # Set or overwrite the channel for this alarm type + entry = preferences.setdefault(pref_key, {}) + if isinstance(entry, dict): + # Cache display fields on the entry so a stale squadrons_data lookup + # later still has a name to show. + if resolved_clan and long_name: + entry["Long"] = long_name + if short_for_entry: + entry["Short"] = short_for_entry + if is_both: + entry["Logs"] = channel_mention + entry["Points"] = channel_mention + else: + entry[alarm_type] = channel_mention + + # Save back to preferences file + success = await save_guild_preferences(interaction.guild.id, preferences) # type: ignore + if not success: + await interaction.followup.send( + t(lang, "quick_log.save_failed"), + ephemeral=True + ) + return + logging.info(f"Saved preferences for guild {guild_id}") + + # Premium reminder for log types + logs_involved = is_both or alarm_type == "Logs" + premium_note = "" + if logs_involved and interaction.guild_id and not await is_guild_entitled(interaction.guild_id): + premium_note = t(lang, "quick_log.premium_warning") + + # Confirmation message + if is_leaderboard: + await interaction.followup.send( + t(lang, "quick_log.leaderboard_set"), + ephemeral=True + ) + elif is_weekly_br: + if long_name == "everything": + await interaction.followup.send( + t(lang, "quick_log.weekly_br_wildcard_set"), + ephemeral=True + ) + else: + await interaction.followup.send( + t(lang, "quick_log.weekly_br_squadron_set", squadron=long_name), + ephemeral=True + ) + elif is_both: + await interaction.followup.send( + t(lang, "quick_log.both_set", squadron=long_name, premium_note=premium_note), + ephemeral=True + ) + else: + await interaction.followup.send( + t(lang, "quick_log.alarm_set", alarm_type=alarm_type, squadron=long_name, premium_note=premium_note), + ephemeral=True + ) + + logging.info( + f"{guild_name} ({guild_id}) is now setting {alarm_type} alarm for " + f"{long_name} in channel ID {channel_id}" + ) + + +@quick_log.error +async def quick_log_error(interaction, error): + await permission_fail(interaction, error) + + +# ============================================================================ +# /test-weekly-br -- preview the Weekly BR Report for the current/most-recent +# BR window. Admin-only. Always sends the wildcard embeds (top-20 + top-5 +# players each); if a squadron is provided, also sends the per-squadron embed +# (top-15 players for that squadron). Useful for previewing the format +# without waiting for the scheduled fire time. +# ============================================================================ + + +def _pick_test_br_window() -> Optional[Dict[str, Any]]: + """Return the BR window enclosing now, else the most recently ended one.""" + schedule_path = Path(__file__).parent / "SCHEDULE.json" + try: + with open(schedule_path, "r", encoding="utf-8") as fp: + schedule = json.load(fp) + except Exception as e: + logging.error("[TEST-WBR] Failed to read SCHEDULE.json: %s", e) + return None + + now_ts = int(time_module.time()) + active = None + most_recent_past = None + for entry in schedule: + try: + s = int(entry["start"]) + e = int(entry["end"]) + except (KeyError, TypeError, ValueError): + continue + if s <= now_ts <= e: + active = entry + break + if e < now_ts and (most_recent_past is None or e > int(most_recent_past["end"])): + most_recent_past = entry + return active or most_recent_past + + +@is_blacklisted() +@is_admin() +@bot.tree.command( + name="test-weekly-br", + description="[Admin] Preview the Weekly BR Report for the current/most-recent BR window", +) +@app_commands.describe( + squadron_name="Optional: also send the per-squadron variant for this squadron", +) +@discord.app_commands.autocomplete(squadron_name=squadron_autocomplete) +async def test_weekly_br( + interaction: discord.Interaction, + squadron_name: str = "", +): + """Preview Weekly BR Report embeds in the current channel.""" + from .task_executors import ( + _build_squadron_embed, + _build_wildcard_embeds, + _load_squadron_name_lookup, + ) + from .weekly_br_elo import ( + squadron_report_for_variants, + top_n_squadrons_with_top_k_players, + ) + + await collect_command_stats(interaction) + await interaction.response.defer(ephemeral=False) + + lang = await guild_lang(interaction.guild.id) if interaction.guild else "en" + + window = _pick_test_br_window() + if window is None: + await interaction.followup.send( + "Could not pick a BR window from SCHEDULE.json.", + ephemeral=True, + ) + return + + start_ts = int(window["start"]) + end_ts = int(window["end"]) + + # Build wildcard payload (top 20 squadrons x top 5 players each) + wildcard_payload = await top_n_squadrons_with_top_k_players( + start_ts, end_ts, n=20, k=5 + ) + + if not wildcard_payload: + await interaction.followup.send( + f"No squadron activity in the {window['max_br']} BR window " + f"() yet — nothing to preview.", + ephemeral=True, + ) + return + + name_lookup = await _load_squadron_name_lookup() + channel = interaction.channel + if not isinstance(channel, (discord.TextChannel, discord.Thread)): + await interaction.followup.send( + "This command must be run in a regular text channel or thread.", + ephemeral=True, + ) + return + + # Wildcard preview (always) + wildcard_embeds = _build_wildcard_embeds(window, wildcard_payload, name_lookup, lang) + if wildcard_embeds: + await channel.send(embeds=wildcard_embeds) + + # Per-squadron preview (only if squadron_name provided) + sq_section_status = "" + if squadron_name: + clan = await resolve_clan(short=squadron_name) + if not clan or clan.get("long_name") == "": + sq_section_status = ( + f"⚠️ Squadron `{squadron_name}` could not be resolved — " + "skipped per-squadron preview." + ) + else: + sq_info = { + "long_name": str(clan.get("long_name") or ""), + "tag_name": str(clan.get("tag_name") or ""), + "short_name": str(clan.get("short_name") or ""), + "clan_id": clan.get("clan_id"), + } + variants = [sq_info["long_name"], sq_info["tag_name"], sq_info["short_name"]] + sq_row, roster = await squadron_report_for_variants( + start_ts, end_ts, variants, k=15 + ) + sq_embed = _build_squadron_embed(window, sq_info, sq_row, roster, lang) + await channel.send(embed=sq_embed) + + summary = ( + f"✅ Previewed Weekly BR Report for **{window['max_br']} BR** " + f"()\n" + f"• Wildcard: {len(wildcard_payload)} squadrons rendered\n" + f"• Per-squadron: " + + ("rendered above" if squadron_name and not sq_section_status + else (sq_section_status or "skipped (no squadron_name)")) + ) + await interaction.followup.send(summary, ephemeral=True) + + +@test_weekly_br.error +async def test_weekly_br_error(interaction, error): + await permission_fail(interaction, error) + + +async def _diagnose_perms_logic(interaction: discord.Interaction, target_channel=None): + """Core logic for autolog diagnostics. Called from the diagnose channel select.""" + await interaction.response.defer(ephemeral=True) + + guild = interaction.guild + if guild is None: + await interaction.followup.send(t('en', "common.must_use_in_server"), ephemeral=True) + return + + lang = await guild_lang(guild.id) + guild_id = str(guild.id) + channel = target_channel or interaction.channel + if channel is None or not isinstance(channel, discord.abc.GuildChannel): + await interaction.followup.send(t(lang, "common.could_not_resolve_channel"), ephemeral=True) + return + bot_member = guild.me + + lines: list[str] = [] + + # ── 1. Check bot permissions in this channel ── + perms = channel.permissions_for(bot_member) + perm_checks = { + "View Channel": perms.view_channel, + "Send Messages": perms.send_messages, + "Attach Files": perms.attach_files, + "Embed Links": perms.embed_links, + } + all_ok = all(perm_checks.values()) + lines.append(t(lang, "diagnostics.channel_permissions_header", channel_id=channel.id)) + for name, has in perm_checks.items(): + lines.append(f" {'\u2705' if has else '\u274c'} {name}") + if not all_ok: + lines.append(t(lang, "diagnostics.perms_needed")) + lines.append("") + + # ── 2. Check /set-squadron ── + squadrons_json = await load_json(STORAGE_DIR / "SQUADRONS.json", {}) + + guild_sq = squadrons_json.get(guild_id, {}) + if guild_sq: + lines.append(t(lang, "diagnostics.server_squadron_header")) + lines.append(t(lang, "diagnostics.server_squadron_short", short=guild_sq.get('SQ_ShortHand_Name', 'N/A'))) + lines.append(t(lang, "diagnostics.server_squadron_long", long=guild_sq.get('SQ_LongHandName', 'N/A'))) + else: + lines.append(t(lang, "diagnostics.server_squadron_header")) + lines.append(t(lang, "diagnostics.server_squadron_not_set")) + lines.append("") + + # ── 3. Check preferences (this is what actually controls autologging) ── + prefs = await load_guild_preferences(guild.id) + lines.append(t(lang, "diagnostics.autolog_prefs_header")) + + if not prefs: + lines.append(t(lang, "diagnostics.autolog_none_configured")) + lines.append(t(lang, "diagnostics.autolog_setup_hint")) + else: + has_any_logs = False + for key, cfg in prefs.items(): + logs_chan = cfg.get("Logs", "") + if not logs_chan or "DISABLED" in str(logs_chan).upper(): + continue + has_any_logs = True + chan_id_match = re.search(r"\b(\d{17,19})\b", str(logs_chan)) + chan_id = int(chan_id_match.group(1)) if chan_id_match else None + + # Check if channel is valid + if chan_id: + target_ch = bot.get_channel(chan_id) + if target_ch is None: + try: + target_ch = await bot.fetch_channel(chan_id) + except Exception: + target_ch = None + + if target_ch and isinstance(target_ch, discord.abc.GuildChannel): + target_perms = target_ch.permissions_for(bot_member) + ch_ok = target_perms.send_messages and target_perms.attach_files + icon = "\u2705" if ch_ok else "\u274c" + status = "" if ch_ok else t(lang, "diagnostics.missing_send_attach") + else: + icon = "\u274c" + status = t(lang, "diagnostics.channel_not_found") + else: + icon = "\u274c" + status = t(lang, "diagnostics.invalid_channel_id") + + is_selected_channel = chan_id == channel.id + here = t(lang, "diagnostics.selected_channel_tag") if is_selected_channel else "" + lines.append(f" {icon} `{key}` -> <#{chan_id}>{here}{status}") + + if not has_any_logs: + lines.append(t(lang, "diagnostics.autolog_no_logs_channels")) + lines.append(t(lang, "diagnostics.autolog_enable_hint")) + + lines.append("") + + # ── 4. Premium / entitlement check ── + lines.append(t(lang, "diagnostics.premium_status_header")) + if await is_guild_entitled(guild.id): + lines.append(t(lang, "diagnostics.premium_active")) + else: + lines.append(t(lang, "diagnostics.premium_not_subscribed")) + lines.append(t(lang, "diagnostics.premium_autolog_required")) + lines.append("") + + # ── Send ── + embed = discord.Embed( + title=t(lang, "diagnostics.title"), + description="\n".join(lines), + color=discord.Color.blue() + ) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + await interaction.followup.send(embed=embed, ephemeral=True) + + +def _extract_pref_channel_id(raw: Any) -> Optional[int]: + """Extract a Discord channel ID from a stored preference value.""" + if raw is None: + return None + match = re.search(r"\b(\d{17,19})\b", str(raw)) + return int(match.group(1)) if match else None + + +def _format_pref_target_name(pref_key: str, settings: dict[str, Any]) -> str: + """Prefer Short/Long squadron labels over raw preference keys.""" + if pref_key.lower() in WILDCARD_KEYS or pref_key == "Global": + return pref_key + + short_name = str(settings.get("Short") or "").strip() + long_name = str(settings.get("Long") or "").strip() + + if short_name and long_name and short_name.lower() != long_name.lower(): + return f"{short_name} - {long_name}" + if short_name: + return short_name + if long_name: + return long_name + return pref_key + + +def _configured_pref_targets(preferences: dict[str, Any]) -> list[dict[str, Any]]: + """Return enabled channel targets from autolog preferences.""" + targets: list[dict[str, Any]] = [] + seen: set[tuple[str, str, int]] = set() + for squadron, settings in preferences.items(): + if not isinstance(settings, dict): + continue + pref_key = str(squadron) + display_name = _format_pref_target_name(pref_key, settings) + for notif_type, raw in settings.items(): + if notif_type in ("Short", "Long"): + continue + raw_s = str(raw or "") + if not raw_s or "DISABLED" in raw_s.upper(): + continue + channel_id = _extract_pref_channel_id(raw_s) + if channel_id is None: + continue + dedupe_key = (pref_key, str(notif_type), channel_id) + if dedupe_key in seen: + continue + seen.add(dedupe_key) + targets.append({ + "squadron": pref_key, + "display": display_name, + "type": str(notif_type), + "raw": raw_s, + "channel_id": channel_id, + "short": str(settings.get("Short") or ""), + "long": str(settings.get("Long") or ""), + }) + return targets + + +REPORTS_NOTIF_TYPE = "Reports" +REPORTS_STORAGE_TYPES = ("Leaderboard", "WeeklyBR") + + +def _notif_type_label(notif_type: str) -> str: + if notif_type == REPORTS_NOTIF_TYPE: + return "Reports" + if notif_type == "WeeklyBR": + return "Weekly BR" + return str(notif_type) + + +def _notif_types_for_management(notif_type: str) -> tuple[str, ...]: + if notif_type == REPORTS_NOTIF_TYPE: + return REPORTS_STORAGE_TYPES + return (notif_type,) + + +def _encode_management_value(squadron: str, notif_type: str) -> str: + return f"{notif_type}::{squadron}" + + +def _decode_management_value(value: str) -> tuple[str, str]: + notif_type, squadron = value.split("::", 1) + return notif_type, squadron + + +def _management_pref_rows( + preferences: dict[str, Any], notif_type: str +) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for squadron, settings in preferences.items(): + if not isinstance(settings, dict): + continue + for storage_type in _notif_types_for_management(notif_type): + if storage_type in settings: + rows.append({ + "squadron": squadron, + "settings": settings, + "notif_type": storage_type, + }) + return rows + + +def _perm_diag_embed( + guild: discord.Guild, + target: dict[str, Any], + channel: Optional[discord.abc.GuildChannel], + entitled: bool, +) -> discord.Embed: + """Build a support-facing permission diagnostic embed for one pref target.""" + channel_id = int(target["channel_id"]) + embed = discord.Embed( + title="Configured Channel Permissions", + color=discord.Color.green() if channel and entitled else discord.Color.red(), + ) + embed.add_field(name="Server", value=f"{esc(guild.name)} (`{guild.id}`)", inline=False) + embed.add_field( + name="Preference", + value=( + f"**{esc(target['type'])}** for `{esc(target.get('display') or target['squadron'])}`\n" + f"Preference key: `{esc(target['squadron'])}`\n" + f"Stored value: `{esc(target['raw'])}`\n" + f"Channel ID: `{channel_id}`" + ), + inline=False, + ) + embed.add_field( + name="Entitlement Check", + value=( + "✅ Premium entitlement active for this server." + if entitled else + "❌ Premium entitlement is not active; autolog uploads are blocked." + ), + inline=False, + ) + + if channel is None: + embed.description = "The bot cannot resolve this configured channel in the target server." + result_lines = ["❌ Channel not found or the bot has no access to see it."] + if not entitled: + result_lines.append("❌ Autolog dispatch is also blocked by missing premium entitlement.") + embed.add_field( + name="Result", + value="\n".join(result_lines), + inline=False, + ) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + return embed + + perms = channel.permissions_for(guild.me) + checks = { + "View Channel": perms.view_channel, + "Send Messages": perms.send_messages, + "Attach Files": perms.attach_files, + "Embed Links": perms.embed_links, + "Read Message History": perms.read_message_history, + } + ok = all(checks.values()) + embed.color = discord.Color.green() if ok and entitled else discord.Color.red() + embed.add_field(name="Resolved Channel", value=f"{channel.mention} (`{channel.id}`)", inline=False) + embed.add_field( + name="Permission Check", + value="\n".join(f"{'✅' if allowed else '❌'} {name}" for name, allowed in checks.items()), + inline=False, + ) + embed.add_field( + name="Autolog Result", + value=( + "❌ Autolog uploads will not run until this server has an active premium entitlement." + if not entitled else + ( + "✅ Scoreboard uploads should work for this channel." + if ok else + "❌ Discord will reject scoreboard uploads until the missing channel permissions are fixed." + ) + ), + inline=False, + ) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + return embed + + +class GuildPermTargetSelect(discord.ui.Select): + """Dropdown of configured autolog channels for a target guild.""" + + def __init__(self, guild: discord.Guild, targets: list[dict[str, Any]], owner_id: int, page: int = 0): + self.guild = guild + self.targets = targets + self.owner_id = owner_id + self.page = page + start = page * 25 + page_targets = targets[start:start + 25] + options: list[discord.SelectOption] = [] + for idx, target in enumerate(page_targets, start=start): + desc_bits = [str(target["channel_id"])] + if target.get("squadron") and target.get("display") and target["squadron"] != target["display"]: + desc_bits.append(f"key {target['squadron']}") + options.append(discord.SelectOption( + label=f"{target['type']}: {target.get('display') or target['squadron']}"[:100], + description=" • ".join(desc_bits)[:100], + value=str(idx), + )) + super().__init__( + placeholder=f"Select configured channel ({page + 1}/{max(1, math.ceil(len(targets) / 25))})", + min_values=1, + max_values=1, + options=options, + ) + + async def callback(self, interaction: discord.Interaction): + if interaction.user.id != self.owner_id: + await interaction.response.defer() + return + target = self.targets[int(self.values[0])] + channel_id = int(target["channel_id"]) + channel = self.guild.get_channel(channel_id) + if channel is None: + try: + fetched = await bot.fetch_channel(channel_id) + channel = fetched if isinstance(fetched, discord.abc.GuildChannel) else None + except Exception: + channel = None + entitled = await is_guild_entitled(self.guild.id) + await interaction.response.send_message( + embed=_perm_diag_embed(self.guild, target, channel, entitled), + ephemeral=True, + ) + + +class GuildPermPageButton(discord.ui.Button): + """Page button for the /view-guild-perms target selector.""" + + def __init__(self, label: str, delta: int): + super().__init__(label=label, style=discord.ButtonStyle.secondary) + self.delta = delta + + async def callback(self, interaction: discord.Interaction): + view: GuildPermTargetView = self.view # type: ignore + if interaction.user.id != view.owner_id: + await interaction.response.defer() + return + view.page = max(0, min(view.max_page, view.page + self.delta)) + view.refresh_items() + await interaction.response.edit_message(view=view) + + +class GuildPermTargetView(discord.ui.View): + """Paginated configured-channel selector for /view-guild-perms.""" + + def __init__(self, guild: discord.Guild, targets: list[dict[str, Any]], owner_id: int, page: int = 0): + super().__init__(timeout=300) + self.guild = guild + self.targets = targets + self.owner_id = owner_id + self.page = page + self.max_page = max(0, math.ceil(len(targets) / 25) - 1) + self.refresh_items() + + def refresh_items(self): + self.clear_items() + self.add_item(GuildPermTargetSelect(self.guild, self.targets, self.owner_id, self.page)) + if self.max_page > 0: + self.add_item(GuildPermPageButton("Previous", -1)) + self.add_item(GuildPermPageButton("Next", 1)) + + +@is_blacklisted() +@bot.tree.command(name="view-guild-perms", description="[DEV] Diagnose configured autolog channel permissions for a server") +@app_commands.describe(server_id="Discord server ID to inspect") +async def view_guild_perms(interaction: discord.Interaction, server_id: str): + """Support command for diagnosing autolog channels from any server/ticket.""" + await collect_command_stats(interaction) + if not await is_dev_team(interaction): + await interaction.response.send_message(t("en", "dev.restricted_dev_team"), ephemeral=True) + return + + await interaction.response.defer(ephemeral=True) + if not server_id.isdigit() or not (17 <= len(server_id) <= 19): + await interaction.followup.send(t("en", "dev.invalid_server_id"), ephemeral=True) + return + + guild = bot.get_guild(int(server_id)) + if guild is None: + await interaction.followup.send(f"I am not in a server with ID `{esc(server_id)}`.", ephemeral=True) + return + + preferences = await load_guild_preferences(guild.id) + targets = _configured_pref_targets(preferences) + if not targets: + await interaction.followup.send( + f"No enabled configured notification channels found for **{esc(guild.name)}** (`{guild.id}`).", + ephemeral=True, + ) + return + + view = GuildPermTargetView(guild, targets, interaction.user.id) + embed = discord.Embed( + title="Select Configured Channel", + description=( + f"Server: **{esc(guild.name)}** (`{guild.id}`)\n" + f"Configured enabled targets: `{len(targets)}`\n\n" + "Select a channel from this server's stored preferences to diagnose the bot's effective permissions." + ), + color=discord.Color.blurple(), + ) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + await interaction.followup.send(embed=embed, view=view, ephemeral=True) + + +@is_blacklisted() +@bot.tree.command(name="sq-info", description=command_locale("Fetch information about a squadron", "commands.sq_info.description")) +@app_commands.describe(squadron=command_locale("The short name of the squadron", "commands.common.squadron_short")) +@discord.app_commands.autocomplete(squadron=squadron_autocomplete) +async def sq_info(interaction: discord.Interaction, squadron: str = ""): + """Fetch and display squadron info including placement, total points, and member list. + + Resolves the squadron, calls the game API for current point data, + looks up leaderboard placement, and builds a paginated embed with + all members and their individual points. + + Args: + interaction: The Discord interaction. + squadron: Short name of the squadron (falls back to guild default). + """ + await collect_command_stats(interaction) + await interaction.response.defer(ephemeral=False) + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + + try: + clan = await get_guild_squadron(interaction.guild_id, squadron) + except ValueError as e: + embed = discord.Embed(title=t(lang, "common.error_title"), description=str(e), color=discord.Color.red()) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + return await interaction.followup.send(embed=embed, ephemeral=True) + + squadron_name = clan["long_name"] + + # Fetch squadron info and turn into embed + sq_data = await obtain_clan_new_points(squadron_name) + if sq_data: + members: dict[str, dict[str, str | int]] = sq_data[0] + total_points: int = sq_data[1] + + placement, _ = await get_current_squadron_placement(squadron_name, squadron or "") + + embed = discord.Embed( + title=t(lang, "sq_info.title", squadron=squadron_name), + color=discord.Color.green() + ) + embed.add_field(name=t(lang, "sq_info.placement_field"), value=f"#{placement}" if placement else "N/A", inline=True) + embed.add_field(name=t(lang, "sq_info.total_points_field"), value=f"{total_points:,}", inline=True) + embed.add_field(name=t(lang, "sq_info.total_members_field"), value=str(len(members)), inline=True) + + # Build full member list (all 128 possible members) + lines: list[str] = [] + for uid, info in members.items(): + raw_nick: str = str(info.get("nick", "Unknown")) + nick: str = esc(raw_nick) + points: int = int(info.get("points", 0)) + lines.append(f"**{nick}** — {points:,} pts") + + # Split into chunks so no field exceeds 1024 characters + chunk = "" + first = True + for line in lines: + if len(chunk) + len(line) + 1 > 1024: + embed.add_field( + name=t(lang, "sq_info.members_field") if first else "\u00A0", + value=chunk.rstrip(), + inline=False + ) + first = False + chunk = "" + chunk += line + "\n" + + if chunk: + embed.add_field( + name=t(lang, "sq_info.members_field") if first else "\u00A0", + value=chunk.rstrip(), + inline=False + ) + + embed.set_footer(text=DEFAULT_FOOTER_CAT) + await interaction.followup.send(embed=embed) + else: + await interaction.followup.send(t(lang, "sq_info.fetch_failed"), ephemeral=True) + + +@sq_info.error +async def sq_info_error(interaction, error): + await permission_fail(interaction, error) + + +# Roster composition graph palette (separate from line-chart palette so it can +# be retuned independently if categories grow). +SQ_INFO_GRAPH_CATEGORY_COLORS = { + 'core': '#5cb85c', # green — high games + WR ≥ 50% + 'active': '#f0ad4e', # amber — high games + WR < 50% + 'weak': '#d9534f', # red — below median games / no games +} + + +@is_blacklisted() +@bot.tree.command( + name="sq-info-graph", + description=command_locale( + "Show a roster composition graph by activity and WR (current season)", + "commands.sq_info_graph.description", + ), +) +@app_commands.describe(squadron=command_locale("The short name of the squadron", "commands.common.squadron_short")) +@discord.app_commands.autocomplete(squadron=squadron_autocomplete) +async def sq_info_graph(interaction: discord.Interaction, squadron: str = ""): + """Render a per-member WR bar chart, grouped into core / active / weak blocks. + + Pulls the current squadron roster the same way /sq-info does, then aggregates + each member's games + wins from sq_battles.db within the current season's + timestamp window. Members are bucketed by games-vs-median and WR-vs-50%, then + drawn left-to-right in CORE → ACTIVE → WEAK order, sorted by games desc inside + each bucket. Bar height = WR%. + """ + await collect_command_stats(interaction) + await interaction.response.defer(ephemeral=False) + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + + try: + clan = await get_guild_squadron(interaction.guild_id, squadron) + except ValueError as e: + embed = discord.Embed(title=t(lang, "common.error_title"), description=str(e), color=discord.Color.red()) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + return await interaction.followup.send(embed=embed, ephemeral=True) + + squadron_name = clan["long_name"] + + # Resolve the current in-progress season (we only score games inside its window). + seasons = get_seasons() + current_season_name: Optional[str] = None + season_start = 0 + season_end = 0 + for name, rng in seasons.items(): + if rng["status"] == "in_progress": + current_season_name = name + season_start = int(rng["start"]) + season_end = int(rng["end"]) + break + + if current_season_name is None: + embed = discord.Embed( + title=t(lang, "common.error_title"), + description=t(lang, "sq_info_graph.no_active_season"), + color=discord.Color.red(), + ) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + return await interaction.followup.send(embed=embed, ephemeral=True) + + sq_data = await obtain_clan_new_points(squadron_name) + if not sq_data: + return await interaction.followup.send(t(lang, "sq_info.fetch_failed"), ephemeral=True) + + members: dict[str, dict[str, str | int]] = sq_data[0] + if not members: + embed = discord.Embed( + title=t(lang, "common.error_title"), + description=t(lang, "sq_info_graph.no_members", squadron=squadron_name), + color=discord.Color.orange(), + ) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + return await interaction.followup.send(embed=embed, ephemeral=True) + + uids = list(members.keys()) + + # Aggregate per-UID games + wins for the current season in a single query. + stats: dict[str, dict[str, int]] = {} + placeholders = ",".join("?" * len(uids)) + try: + async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=30.0) as db: + db.row_factory = aiosqlite.Row + async with db.execute( + f""" + SELECT UID, + COUNT(*) AS games, + SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END) AS wins + FROM player_games_hist + WHERE UID IN ({placeholders}) + AND endtime_unix BETWEEN ? AND ? + GROUP BY UID + """, + [*uids, season_start, season_end], + ) as cursor: + async for row in cursor: + stats[row["UID"]] = { + "games": int(row["games"] or 0), + "wins": int(row["wins"] or 0), + } + except Exception as e: + logging.error(f"sq_info_graph DB query failed: {e}") + return await interaction.followup.send( + t(lang, "common.database_error", error=str(e)[:1500]), + ephemeral=True, + ) + + # Build per-member records. + records: list[dict] = [] + for uid in uids: + s = stats.get(uid, {"games": 0, "wins": 0}) + games = s["games"] + wins = s["wins"] + wr = (wins / games * 100.0) if games > 0 else 0.0 + nick = str(members[uid].get("nick", "Unknown")) + records.append({"uid": uid, "nick": nick, "games": games, "wins": wins, "wr": wr}) + + # Median games threshold computed only over members with at least one game, + # so a roster of mostly-inactive members doesn't drop the bar to zero. + active_games = [r["games"] for r in records if r["games"] >= 1] + median_games = float(np.median(active_games)) if active_games else 0.0 + + # Percentile-based bucketing scales with the squadron's own WR distribution + # rather than absolute thresholds. CORE = top 30% by WR and active enough, + # ACTIVE = next slice up to top 45% with relaxed activity, WEAK = the rest. + sq_total_games = sum(r["games"] for r in records) + sq_total_wins = sum(r["wins"] for r in records) + squadron_wr = (sq_total_wins / sq_total_games * 100.0) if sq_total_games > 0 else 50.0 + + n_total = len(records) + core_rank_cutoff = max(1, int(n_total * 0.30)) if n_total > 0 else 0 + active_rank_cutoff = max(core_rank_cutoff, int(n_total * 0.45)) if n_total > 0 else 0 + + # "Games around median" — a slightly relaxed activity floor for ACTIVE so + # a top-WR member who plays a bit below median doesn't get dumped into WEAK. + games_threshold_active = max(1.0, median_games * 0.7) + + # Rank members by WR desc; tiebreak by games desc so heavier players win ties. + ranked = sorted(records, key=lambda r: (-r["wr"], -r["games"])) + + # Capture the WR boundary values for the chart's threshold lines. + core_wr_threshold = ranked[core_rank_cutoff - 1]["wr"] if 0 < core_rank_cutoff <= len(ranked) else 0.0 + active_wr_threshold = ranked[active_rank_cutoff - 1]["wr"] if 0 < active_rank_cutoff <= len(ranked) else 0.0 + + bucket_by_uid: dict[str, str] = {} + for i, r in enumerate(ranked): + if i < core_rank_cutoff and r["games"] >= median_games and r["games"] >= 1: + bucket_by_uid[r["uid"]] = "core" + elif i < active_rank_cutoff and r["games"] >= games_threshold_active and r["games"] >= 1: + bucket_by_uid[r["uid"]] = "active" + else: + bucket_by_uid[r["uid"]] = "weak" + + core: list[dict] = [] + active: list[dict] = [] + weak: list[dict] = [] + for r in records: + b = bucket_by_uid.get(r["uid"], "weak") + if b == "core": + core.append(r) + elif b == "active": + active.append(r) + else: + weak.append(r) + + # Within each block: most-played first (ties broken by WR desc). + for bucket in (core, active, weak): + bucket.sort(key=lambda r: (-r["games"], -r["wr"])) + + ordered = core + active + weak + n = len(ordered) + + # Render + fig, ax = plt.subplots(figsize=(16, 8), facecolor=SQ_STATS_GRAPH_COLORS['bg']) + ax.set_facecolor(SQ_STATS_GRAPH_COLORS['plot_bg']) + + x_positions = list(range(n)) + heights = [r["wr"] for r in ordered] + colors = ( + [SQ_INFO_GRAPH_CATEGORY_COLORS['core']] * len(core) + + [SQ_INFO_GRAPH_CATEGORY_COLORS['active']] * len(active) + + [SQ_INFO_GRAPH_CATEGORY_COLORS['weak']] * len(weak) + ) + + if n > 0: + # Faint ghost stubs first so 0-WR / 0-game members are still visible as + # an occupied slot rather than invisible empty space. Drawn under the + # real WR bars. + ghost_height = 2.5 + ax.bar( + x_positions, + [ghost_height] * n, + width=1.0, + color='#3a3a48', + edgecolor=SQ_STATS_GRAPH_COLORS['bg'], + linewidth=0.5, + align='center', + zorder=1, + ) + ax.bar( + x_positions, + heights, + width=1.0, + color=colors, + edgecolor=SQ_STATS_GRAPH_COLORS['bg'], + linewidth=0.5, + align='center', + zorder=2, + ) + + # Per-bar vertical labels: "{nick} · {games}g" rotated 90°, drawn inside + # the coloured portion when there's room, or above the bar otherwise. + # Black text with a thin white halo keeps it readable on both green and + # red fills. + for i, r in enumerate(ordered): + label = f"{r['nick']} · {r['games']}g" + # Truncate to keep extreme nicks from overflowing the plot top. + if len(label) > 28: + label = label[:27] + "…" + wr_val = r["wr"] + if wr_val >= 12: + y_text = wr_val / 2.0 + color = '#0b0b0b' + va = 'center' + else: + # Stack label above bar (or above the ghost stub for 0-game members). + y_text = max(wr_val, ghost_height) + 1.5 + color = SQ_STATS_GRAPH_COLORS['text'] + va = 'bottom' + txt = ax.text( + i, y_text, label, + rotation=90, ha='center', va=va, + fontsize=6, color=color, alpha=0.95, + clip_on=True, + zorder=3, + ) + txt.set_path_effects([ + path_effects.withStroke(linewidth=1.2, foreground='#ffffff' if color == '#0b0b0b' else '#000000'), + ]) + + # 50% WR reference line (absolute "winning vs losing" baseline). + ax.axhline(50, color=SQ_STATS_GRAPH_COLORS['text'], linestyle=':', linewidth=1, alpha=0.3) + + # CORE WR boundary — the lowest WR in the top-30% rank slice. Anyone at or + # above this WR is a CORE candidate (still gated on games ≥ median). + if core_wr_threshold > 0: + ax.axhline( + core_wr_threshold, + color=SQ_INFO_GRAPH_CATEGORY_COLORS['core'], + linestyle='--', linewidth=1.2, alpha=0.75, zorder=4, + ) + ax.text( + n - 0.5 if n > 0 else 0.5, core_wr_threshold + 1.2, + t(lang, "sq_info_graph.core_threshold_line", wr=f"{core_wr_threshold:.1f}"), + ha='right', va='bottom', + color=SQ_INFO_GRAPH_CATEGORY_COLORS['core'], + fontsize=9, fontweight='bold', alpha=0.9, zorder=5, + ) + + # WEAK WR boundary — the lowest WR in the top-45% rank slice. Below this + # by WR alone puts a member in WEAK. + if active_wr_threshold > 0 and active_wr_threshold < core_wr_threshold: + ax.axhline( + active_wr_threshold, + color=SQ_INFO_GRAPH_CATEGORY_COLORS['weak'], + linestyle='--', linewidth=1.2, alpha=0.75, zorder=4, + ) + ax.text( + n - 0.5 if n > 0 else 0.5, active_wr_threshold + 1.2, + t(lang, "sq_info_graph.weak_threshold_line", wr=f"{active_wr_threshold:.1f}"), + ha='right', va='bottom', + color=SQ_INFO_GRAPH_CATEGORY_COLORS['weak'], + fontsize=9, fontweight='bold', alpha=0.9, zorder=5, + ) + + # Vertical dividers between blocks. + if core and (active or weak): + ax.axvline(len(core) - 0.5, color=SQ_STATS_GRAPH_COLORS['text'], linewidth=1, alpha=0.7) + if (core or active) and weak: + ax.axvline(len(core) + len(active) - 0.5, color=SQ_STATS_GRAPH_COLORS['text'], linewidth=1, alpha=0.7) + + def _block_avg_wr(bucket: list[dict]) -> float: + total_games = sum(b["games"] for b in bucket) + total_wins = sum(b["wins"] for b in bucket) + return (total_wins / total_games * 100.0) if total_games > 0 else 0.0 + + if n > 0: + if core: + cx = (len(core) - 1) / 2.0 + ax.text( + cx, 105, + t(lang, "sq_info_graph.core_header", count=len(core), avg=f"{_block_avg_wr(core):.1f}"), + ha='center', va='bottom', + color=SQ_INFO_GRAPH_CATEGORY_COLORS['core'], fontweight='bold', fontsize=11, + ) + if active: + ax_x = len(core) + (len(active) - 1) / 2.0 + ax.text( + ax_x, 105, + t(lang, "sq_info_graph.active_header", count=len(active), avg=f"{_block_avg_wr(active):.1f}"), + ha='center', va='bottom', + color=SQ_INFO_GRAPH_CATEGORY_COLORS['active'], fontweight='bold', fontsize=11, + ) + if weak: + wx = len(core) + len(active) + (len(weak) - 1) / 2.0 + ax.text( + wx, 105, + t(lang, "sq_info_graph.weak_header", count=len(weak), avg=f"{_block_avg_wr(weak):.1f}"), + ha='center', va='bottom', + color=SQ_INFO_GRAPH_CATEGORY_COLORS['weak'], fontweight='bold', fontsize=11, + ) + + ax.set_ylim(0, 118) + ax.set_xlim(-0.5, max(n - 0.5, 0.5)) + ax.set_ylabel(t(lang, "sq_info_graph.y_label"), fontsize=12, color=SQ_STATS_GRAPH_COLORS['text']) + ax.set_title( + t(lang, "sq_info_graph.title", squadron=squadron_name, season=current_season_name), + fontsize=14, fontweight='bold', color=SQ_STATS_GRAPH_COLORS['text'], + ) + ax.grid(True, alpha=0.2, color=SQ_STATS_GRAPH_COLORS['grid'], axis='y') + ax.tick_params(colors=SQ_STATS_GRAPH_COLORS['text']) + for spine in ax.spines.values(): + spine.set_color(SQ_STATS_GRAPH_COLORS['text']) + ax.set_xticks([]) + ax.set_yticks([0, 25, 50, 75, 100]) + ax.set_yticklabels(['0%', '25%', '50%', '75%', '100%']) + + plt.tight_layout() + + safe_squadron = re.sub(r'[^A-Za-z0-9_-]+', '_', squadron_name) or 'squadron' + temp_path = Path(f"/tmp/sq_info_graph_{safe_squadron}_{int(time_module.time())}.png") + plt.savefig(temp_path, dpi=150, bbox_inches='tight', facecolor=SQ_STATS_GRAPH_COLORS['bg']) + plt.close(fig) + + embed = discord.Embed( + title=t(lang, "sq_info_graph.embed_title", squadron=squadron_name), + description=t( + lang, "sq_info_graph.embed_desc", + season=current_season_name, + core=len(core), active=len(active), weak=len(weak), + median=int(round(median_games)), + ), + color=discord.Color.green(), + ) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + embed.set_image(url=f"attachment://{temp_path.name}") + + try: + await interaction.followup.send(embed=embed, file=discord.File(temp_path)) + finally: + try: + temp_path.unlink() + except Exception: + pass + + +@sq_info_graph.error +async def sq_info_graph_error(interaction, error): + await permission_fail(interaction, error) + + +# ═══════════════════════════════════════════════════════════════════════════ +# SEASON RECAP CARDS (/sq-card, /card) +# ═══════════════════════════════════════════════════════════════════════════ + +# Cached pre-formatted season choices (refreshed periodically so status transitions +# from in_progress → completed get picked up during long bot uptimes). +_SEASONS_CHOICES_CACHE: list[tuple[str, str]] = [] # (name, display_label) +_SEASONS_CHOICES_CACHE_TS: float = 0.0 +_SEASONS_CHOICES_TTL_SECONDS = 60.0 + + +def _build_seasons_choices() -> list[tuple[str, str]]: + """Return (value, display_label) pairs, most recent first.""" + seasons = get_seasons() + entries = sorted(seasons.items(), key=lambda kv: kv[1]["start"], reverse=True) + return [ + (name, f"{name} (in progress)" if rng["status"] == "in_progress" else name) + for name, rng in entries + ] + + +def _refresh_seasons_choices_cache() -> list[tuple[str, str]]: + global _SEASONS_CHOICES_CACHE, _SEASONS_CHOICES_CACHE_TS + _SEASONS_CHOICES_CACHE = _build_seasons_choices() + _SEASONS_CHOICES_CACHE_TS = time_module.time() + return _SEASONS_CHOICES_CACHE + + +# Pre-warm at import so the first autocomplete call doesn't pay the parse cost. +try: + _refresh_seasons_choices_cache() +except Exception as _e: + print(f"[WARN] Failed to pre-warm seasons cache: {_e}", flush=True) + + +async def seasons_autocomplete( + interaction: discord.Interaction, + current: str, +) -> List[discord.app_commands.Choice[str]]: + """Autocomplete for season names from constants/seasons. Most recent first. + + Kept deliberately minimal — Discord invalidates the interaction after 3s, + so every op here must be O(1)-ish on cached data. + """ + try: + now = time_module.time() + if now - _SEASONS_CHOICES_CACHE_TS > _SEASONS_CHOICES_TTL_SECONDS: + choices = _refresh_seasons_choices_cache() + else: + choices = _SEASONS_CHOICES_CACHE + current_l = (current or "").lower() + if current_l: + choices = [c for c in choices if current_l in c[0].lower()] + return [ + discord.app_commands.Choice(name=label, value=name) + for name, label in choices[:25] + ] + except Exception as e: + print(f"[AUTOCOMPLETE ERROR] seasons_autocomplete failed: {e}", flush=True) + return [] + + +def _recap_lang(guild_lang_code: str) -> str: + """Map a guild lang to a lang the recap renderer supports (falls back to 'en').""" + return guild_lang_code if guild_lang_code in RECAP_LANGS else 'en' + + +RECAP_THEME_CHOICES = [ + app_commands.Choice(name=command_locale("Dark", "commands.common.choice_dark"), value="dark"), + app_commands.Choice(name=command_locale("Light", "commands.common.choice_light"), value="light"), +] + + +@is_blacklisted() +@bot.tree.command(name="sq-card", description=command_locale("Generate a season recap card for a squadron", "commands.sq_card.description")) +@app_commands.describe( + season=command_locale("The season to generate the card for", "commands.common.season"), + squadron=command_locale("The short name of the squadron", "commands.sq_card.squadron"), + theme=command_locale("Card color theme", "commands.common.theme"), +) +@app_commands.choices(theme=RECAP_THEME_CHOICES) +@discord.app_commands.autocomplete( + season=seasons_autocomplete, + squadron=squadron_autocomplete, +) +async def sq_card( + interaction: discord.Interaction, + season: str, + squadron: str = "", + theme: app_commands.Choice[str] | None = None, +): + """Generate and send a season recap card PNG for a squadron. + + Args: + interaction: The Discord interaction. + season: Season identifier (e.g. "2026-II"). + squadron: Short name of the squadron (falls back to guild default). + theme: "dark" (default) or "light". + """ + await collect_command_stats(interaction) + await interaction.response.defer(ephemeral=False) + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + theme_value = theme.value if theme else 'dark' + + # Validate season up-front so bad input fails fast without rendering. + seasons = get_seasons() + if season not in seasons: + embed = discord.Embed( + title=t(lang, "common.error_title"), + description=t(lang, "recap_card.unknown_season", season=season), + color=discord.Color.red(), + ) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + return await interaction.followup.send(embed=embed, ephemeral=True) + + try: + clan = await get_guild_squadron(interaction.guild_id, squadron) + except ValueError as e: + embed = discord.Embed(title=t(lang, "common.error_title"), description=str(e), color=discord.Color.red()) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + return await interaction.followup.send(embed=embed, ephemeral=True) + + squadron_name = clan["long_name"] + short_name = clan.get("short_name") or squadron_name + + clan_id = await resolve_clan_id(squadron_name) + if clan_id is None: + embed = discord.Embed( + title=t(lang, "common.error_title"), + description=t(lang, "recap_card.no_clan_id", squadron=squadron_name), + color=discord.Color.red(), + ) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + return await interaction.followup.send(embed=embed, ephemeral=True) + + try: + path = await get_squadron_recap(clan_id, season, theme_value, _recap_lang(lang)) + except RecapError: + embed = discord.Embed( + title=t(lang, "common.error_title"), + description=t(lang, "recap_card.render_failed"), + color=discord.Color.red(), + ) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + return await interaction.followup.send(embed=embed, ephemeral=True) + + safe_name = re.sub(r'[^A-Za-z0-9_-]+', '_', short_name) or str(clan_id) + filename = f"{safe_name}-{season}.png" + await interaction.followup.send(file=discord.File(path, filename=filename)) + + +@sq_card.error +async def sq_card_error(interaction, error): + await permission_fail(interaction, error) + + +# Dark mode color scheme for graphs +SQ_STATS_GRAPH_COLORS = { + 'bg': '#1e1e2e', # Background color + 'plot_bg': '#2b2b3c', # Plot area background + 'text': "#16c52e", # Text color + 'grid': "#6d6d6d", # Grid line color + 'squadron_line': "#12ed2f", # Squadron total line +} + + +class PlayerSelect(discord.ui.Select): + """Paginated dropdown for selecting players from a squadron's point history. + + Each page shows up to 25 players (Discord select limit). Selections are + tracked in the parent PlayerRefineView so they persist across page changes. + """ + + def __init__(self, squadron_name: str, timestamps: list, player_histories: dict, + dates_numeric: list, filtered_ticks: list, filtered_labels: list, page: int = 0, parent_view=None, lang: str = "en"): + self.squadron_name = squadron_name + self.timestamps = timestamps + self.all_player_histories = player_histories + self.dates_numeric = dates_numeric + self.filtered_ticks = filtered_ticks + self.filtered_labels = filtered_labels + self.page = page + self.parent_view = parent_view + self.lang = lang + + # Pagination: 25 players per page (Discord limit) + players_per_page = 25 + all_players = list(player_histories.items()) + start_idx = page * players_per_page + end_idx = start_idx + players_per_page + page_players = all_players[start_idx:end_idx] + + # Create options with player name and UID + options = [] + for uid, player_data in page_players: + clean_nick = re.sub(r'[^a-zA-Z0-9\u0400-\u04FF\u4E00-\u9FFF\s_-]', '', player_data["nick"]) + # Truncate name if too long (Discord limit is 100 chars for label, 100 for description) + label = clean_nick[:80] if len(clean_nick) > 80 else clean_nick + description = f"UID: {uid}" + # Mark as default if already selected + is_default = parent_view and uid in parent_view.selected_uids if parent_view else False + options.append(discord.SelectOption(label=label, description=description, value=uid, default=is_default)) + + super().__init__( + placeholder=t(lang, "sq_stats.select_players_placeholder", page=page + 1), + min_values=0, + max_values=len(options), + options=options + ) + + async def callback(self, interaction: discord.Interaction): + # Update parent view's selected UIDs + if self.parent_view: + # Remove previous selections from this page + players_per_page = 25 + all_players = list(self.all_player_histories.keys()) + start_idx = self.page * players_per_page + end_idx = start_idx + players_per_page + page_player_uids = all_players[start_idx:end_idx] + + # Remove old selections from this page + self.parent_view.selected_uids = {uid for uid in self.parent_view.selected_uids + if uid not in page_player_uids} + + # Add new selections + self.parent_view.selected_uids.update(self.values) + + # Acknowledge interaction silently (message stays for further interaction) + await interaction.response.defer() + + +class PlayerRefineView(discord.ui.View): + """Interactive view for refining player selections and generating filtered charts. + + Contains a PlayerSelect dropdown, pagination buttons, and a "Generate Chart" + button. Selected player UIDs persist across page changes so users can pick + players from multiple pages before charting. + """ + + def __init__(self, squadron_name: str, timestamps: list, player_histories: dict, + dates_numeric: list, filtered_ticks: list, filtered_labels: list, page: int = 0, selected_uids: Optional[set] = None, lang: str = "en"): + super().__init__(timeout=7200) # 2 hours + self.message: Optional[discord.Message] = None + self.squadron_name = squadron_name + self.timestamps = timestamps + self.player_histories = player_histories + self.dates_numeric = dates_numeric + self.filtered_ticks = filtered_ticks + self.filtered_labels = filtered_labels + self.page = page + self.selected_uids = selected_uids if selected_uids is not None else set() + self.lang = lang + + # Add player select dropdown + self.select = PlayerSelect(squadron_name, timestamps, player_histories, dates_numeric, filtered_ticks, filtered_labels, page, parent_view=self, lang=lang) + self.add_item(self.select) + + # Add pagination buttons if needed + players_per_page = 25 + total_pages = (len(player_histories) + players_per_page - 1) // players_per_page + + if total_pages > 1: + # Previous button + prev_button = discord.ui.Button( + label=t(lang, "buttons.prev_arrow"), + style=discord.ButtonStyle.secondary, + disabled=(page == 0) + ) + prev_button.callback = self.previous_page + self.add_item(prev_button) + + # Next button + next_button = discord.ui.Button( + label=t(lang, "buttons.next_arrow"), + style=discord.ButtonStyle.secondary, + disabled=(page >= total_pages - 1) + ) + next_button.callback = self.next_page + self.add_item(next_button) + + # Add "Generate Chart" button + generate_button = discord.ui.Button( + label=t(lang, "buttons.generate_chart"), + style=discord.ButtonStyle.success, + row=2 + ) + generate_button.callback = self.generate_chart + self.add_item(generate_button) + + async def on_timeout(self): + for item in self.children: + item.disabled = True # type: ignore[attr-defined] + try: + if self.message: + await self.message.edit(view=self) + except Exception: + pass + + async def generate_chart(self, interaction: discord.Interaction): + """Generate a player points chart filtered to the currently selected UIDs.""" + await interaction.response.defer(ephemeral=False) + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + + if not self.selected_uids: + await interaction.followup.send(t(lang, "common.no_players_selected"), ephemeral=True) + return + + # Filter player_histories to only selected UIDs + filtered_histories = {uid: self.player_histories[uid] for uid in self.selected_uids + if uid in self.player_histories} + + # Generate chart with filtered players + view_helper = SquadronStatsView(self.squadron_name, self.timestamps, self.player_histories, + self.dates_numeric, self.filtered_ticks, self.filtered_labels, lang=lang) + embed, temp_path = await view_helper.generate_player_chart(filtered_histories, lang=lang) + + # Create new refine view for further refinement (preserve selections) + refine_view = PlayerRefineView(self.squadron_name, self.timestamps, self.player_histories, + self.dates_numeric, self.filtered_ticks, self.filtered_labels, 0, self.selected_uids, lang=lang) + + refine_view.message = await interaction.followup.send(embed=embed, file=discord.File(temp_path), view=refine_view, wait=True) + + # Clean up temporary file + try: + temp_path.unlink() + except Exception: + pass + + async def previous_page(self, interaction: discord.Interaction): + new_page = self.page - 1 + await self.update_page(interaction, new_page) + + async def next_page(self, interaction: discord.Interaction): + new_page = self.page + 1 + await self.update_page(interaction, new_page) + + async def update_page(self, interaction: discord.Interaction, new_page: int): + # Create new view with updated page, preserving selected UIDs + new_view = PlayerRefineView(self.squadron_name, self.timestamps, self.player_histories, + self.dates_numeric, self.filtered_ticks, self.filtered_labels, new_page, self.selected_uids, lang=self.lang) + + # Update the message with new view + await interaction.response.edit_message(view=new_view) + + +class SquadronStatsView(discord.ui.View): + """View attached to /sq-stats with buttons for player breakdown and leaderboard comparison. + + Holds all the squadron's historical data needed to generate individual player + charts and nearby-squadron comparison charts on demand. + """ + + def __init__(self, squadron_name: str, timestamps: list, player_histories: dict, dates_numeric: list, filtered_ticks: list, filtered_labels: list, lang: str = "en"): + super().__init__(timeout=21600) + self.message: Optional[discord.Message] = None + self.squadron_name = squadron_name + self.timestamps = timestamps + self.player_histories = player_histories + self.dates_numeric = dates_numeric + self.filtered_ticks = filtered_ticks + self.filtered_labels = filtered_labels + self.lang = lang + self.view_player_stats.label = t(lang, "buttons.view_player_stats") + self.compare_nearby_squadrons.label = t(lang, "buttons.compare_nearby") + + async def on_timeout(self): + for item in self.children: + item.disabled = True # type: ignore[attr-defined] + try: + if self.message: + await self.message.edit(view=self) + except Exception: + pass + + async def generate_player_chart(self, filtered_player_histories: Optional[dict] = None, lang: str = "en"): + """Generate player stats chart. If filtered_player_histories is provided, use that, otherwise use all players.""" + player_data = filtered_player_histories if filtered_player_histories else self.player_histories + + # Create player stats chart with dark mode + fig, ax = plt.subplots(figsize=(16, 8), facecolor=SQ_STATS_GRAPH_COLORS['bg']) + ax.set_facecolor(SQ_STATS_GRAPH_COLORS['plot_bg']) + + # Collect all point changes to determine color scale range + all_changes = [] + for uid, p_data in player_data.items(): + points = p_data["points"] + if len(points) == len(self.dates_numeric) and len(points) > 1: + for i in range(1, len(points)): + change = points[i] - points[i-1] + all_changes.append(change) + + # Determine color scale bounds using percentiles to avoid extreme outliers + if all_changes: + # Use 5th and 95th percentiles to avoid outliers dominating the scale + p5 = float(np.percentile(all_changes, 5)) + p95 = float(np.percentile(all_changes, 95)) + + # Ensure we have negative and positive bounds for diverging colormap + # But don't force them to be symmetric - use actual data distribution + vmin = float(min(p5, -abs(p95) * 0.1)) if p5 >= 0 else p5 # Allow negative even if all gains + vmax = float(max(p95, abs(p5) * 0.1)) if p95 <= 0 else p95 # Allow positive even if all losses + + # TwoSlopeNorm requires vmin < vcenter(0) < vmax strictly + # When all changes are 0, both vmin and vmax become 0, causing ValueError + if vmin >= 0: + vmin = -1.0 + if vmax <= 0: + vmax = 1.0 + else: + vmin, vmax = -1.0, 1.0 + + # Create colormap: Red for decreases, Yellow for neutral, Green for increases + # Use TwoSlopeNorm to ensure 0 is always at the center (yellow) + cmap = cm.get_cmap('RdYlGn') + norm = TwoSlopeNorm(vmin=vmin, vcenter=0.0, vmax=vmax) + + # Collect endpoint positions for smart labeling + endpoints = [] + for uid, p_data in player_data.items(): + points = p_data["points"] + if len(points) == len(self.dates_numeric) and len(points) > 1: + # Create line segments with colors based on point change + segments = [] + colors = [] + + for i in range(len(points) - 1): + # Create segment from point i to point i+1 + segment = [(self.dates_numeric[i], points[i]), + (self.dates_numeric[i+1], points[i+1])] + segments.append(segment) + + # Calculate change and map to color + change = points[i+1] - points[i] + color = cmap(norm(change)) + colors.append(color) + + # Create and add LineCollection + lc = LineCollection(segments, colors=colors, linewidths=1.5, alpha=0.7) + ax.add_collection(cast(Any, lc)) + + # Clean special characters from nickname (keep ASCII, Cyrillic, Chinese/CJK, spaces, underscores, hyphens) + clean_nick = re.sub(r'[^a-zA-Z0-9\u0400-\u04FF\u4E00-\u9FFF\s_-]', '', p_data["nick"]) + # Use average color for endpoint label (based on overall trend) + avg_change = (points[-1] - points[0]) / (len(points) - 1) if len(points) > 1 else 0 + avg_color = cmap(norm(avg_change)) + endpoints.append((points[-1], clean_nick, avg_color)) + + # Sort endpoints by Y position + endpoints.sort(key=lambda x: x[0]) + + # Set axis limits based on data (LineCollection doesn't auto-scale) + if self.dates_numeric and endpoints: + all_y_values = [] + for uid, p_data in player_data.items(): + if len(p_data["points"]) == len(self.dates_numeric): + all_y_values.extend(p_data["points"]) + + if all_y_values: + ax.set_xlim(min(self.dates_numeric), max(self.dates_numeric)) + ax.set_ylim(min(all_y_values) - 50, max(all_y_values) + 50) + + # Extend x-axis to make room for labels on the right + x_min, x_max = ax.get_xlim() + x_range = x_max - x_min + ax.set_xlim(x_min, x_max + x_range * 0.08) # Add 8% padding to right + + # Set minimum separation based on whether this is a refined view or all players + # Non-refined (all players): 50 points minimum separation + # Refined (filtered players): 25 points minimum separation + min_separation = 25 if filtered_player_histories is not None else 50 + + # Only label players with enough vertical separation + last_labeled_y = None + for y_pos, nick, color in endpoints: + if last_labeled_y is None or abs(y_pos - last_labeled_y) >= min_separation: + ax.annotate(nick, + xy=(self.dates_numeric[-1], y_pos), + xytext=(5, 0), + textcoords='offset points', + fontsize=7, + color=SQ_STATS_GRAPH_COLORS['text'], + va='center', + alpha=0.9) + last_labeled_y = y_pos + + # Formatting with dark mode colors + ax.set_xlabel('Time', fontsize=12, color=SQ_STATS_GRAPH_COLORS['text']) + ax.set_ylabel('Player Points', fontsize=12, color=SQ_STATS_GRAPH_COLORS['text']) + ax.set_title(f'{self.squadron_name}', fontsize=14, fontweight='bold', color=SQ_STATS_GRAPH_COLORS['text']) + ax.grid(True, alpha=0.2, color=SQ_STATS_GRAPH_COLORS['grid']) + ax.tick_params(colors=SQ_STATS_GRAPH_COLORS['text']) + ax.spines['bottom'].set_color(SQ_STATS_GRAPH_COLORS['text']) + ax.spines['top'].set_color(SQ_STATS_GRAPH_COLORS['text']) + ax.spines['left'].set_color(SQ_STATS_GRAPH_COLORS['text']) + ax.spines['right'].set_color(SQ_STATS_GRAPH_COLORS['text']) + + # Set x-axis ticks to only show where changes occurred + ax.set_xticks(self.filtered_ticks) + ax.set_xticklabels(self.filtered_labels, rotation=45, ha='right', color=SQ_STATS_GRAPH_COLORS['text']) + + plt.tight_layout() + + # Save to temporary file + temp_path = Path(f"/tmp/sq_stats_players_{self.squadron_name.replace(' ', '_')}_{int(time_module.time())}.png") + plt.savefig(temp_path, dpi=150, bbox_inches='tight', facecolor=SQ_STATS_GRAPH_COLORS['bg']) + plt.close(fig) + + # Return the embed and temp_path + embed = discord.Embed( + title=t(lang, "sq_stats.player_title", squadron=self.squadron_name), + description=t(lang, "sq_stats.player_desc"), + color=discord.Color.green() + ) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + embed.set_image(url=f"attachment://{temp_path.name}") + + return embed, temp_path + + async def generate_squadron_comparison_chart(self, lang: str = "en"): + """Generate comparison chart showing this squadron vs 10 above and 10 below in leaderboard.""" + # Get position of current squadron from squadrons_data + async with aiosqlite.connect(SQUADRONS_DB_PATH) as db: + async with db.execute(""" + SELECT position FROM squadrons_data + WHERE LOWER(long_name) = ? + LIMIT 1 + """, (self.squadron_name.lower(),)) as cursor: + row = await cursor.fetchone() + if not row: + return None, None, t(lang, "sq_stats.squadron_not_found_error") + current_position = row[0] + + # Get squadrons 5 above and 5 below + min_pos = max(0, current_position - 5) + max_pos = current_position + 5 + + async with db.execute(""" + SELECT long_name, short_name, position + FROM squadrons_data + WHERE position >= ? AND position <= ? AND position IS NOT NULL + ORDER BY position ASC + """, (min_pos, max_pos)) as cursor: + nearby_squadrons = await cursor.fetchall() + + if not nearby_squadrons: + return None, None, t(lang, "sq_stats.no_nearby_error") + + # Fetch historical data for each squadron + squadron_histories = {} + async with aiosqlite.connect(SQUADRONS_DB_PATH) as db: + for long_name, short_name, position in nearby_squadrons: + async with db.execute(""" + SELECT unix_time, total_score + FROM squadrons_points + WHERE long_name = ? + ORDER BY unix_time ASC + """, (long_name,)) as cursor: + rows = await cursor.fetchall() + if rows: + # Limit to same timeframe as current squadron + if len(self.timestamps) > 0: + min_time = min(self.timestamps) + max_time = max(self.timestamps) + filtered_rows = [(t, s) for t, s in rows if min_time <= t <= max_time] + if filtered_rows: + squadron_histories[long_name] = { + "short_name": short_name, + "position": position, + "timestamps": [r[0] for r in filtered_rows], + "scores": [r[1] for r in filtered_rows] + } + + if not squadron_histories: + return None, None, t(lang, "sq_stats.no_historical_error") + + # Create comparison chart with dark mode + fig, ax = plt.subplots(figsize=(16, 10), facecolor=SQ_STATS_GRAPH_COLORS['bg']) + ax.set_facecolor(SQ_STATS_GRAPH_COLORS['plot_bg']) + + # Convert timestamps to datetime for formatting + + # Find squadrons that have overtaken ours (their score crossed above ours at some point) + overtaking_squadrons = set() + our_data_pre = next((data for ln, data in squadron_histories.items() + if ln.lower() == self.squadron_name.lower()), None) + if our_data_pre: + our_dates_numeric_pre = [mdates.date2num(datetime.fromtimestamp(ts)) for ts in our_data_pre["timestamps"]] + our_scores_pre = our_data_pre["scores"] + for long_name, data in squadron_histories.items(): + if long_name.lower() == self.squadron_name.lower(): + continue + other_dates = [mdates.date2num(datetime.fromtimestamp(ts)) for ts in data["timestamps"]] + other_scores = data["scores"] + common_times = sorted(set(our_dates_numeric_pre) & set(other_dates)) + if len(common_times) < 2: + continue + our_at_common = [our_scores_pre[our_dates_numeric_pre.index(t)] for t in common_times] + other_at_common = [other_scores[other_dates.index(t)] for t in common_times] + for i in range(1, len(common_times)): + diff_prev = our_at_common[i-1] - other_at_common[i-1] + diff_curr = our_at_common[i] - other_at_common[i] + if diff_prev * diff_curr < 0 and diff_curr < 0: # other crossed above us + overtaking_squadrons.add(long_name) + break + + # Assign colors: our squadron = green, overtakers = red, rest = grey + grey_shades = ['#888888', '#999999', '#aaaaaa', '#777777', '#bbbbbb', + '#666666', '#cccccc', '#555555', '#dddddd', '#444444'] + grey_idx = 0 + + # Store our squadron's data for crossover detection + our_dates_numeric = None + our_scores = None + + endpoints = [] + for long_name, data in squadron_histories.items(): + dates = [datetime.fromtimestamp(ts) for ts in data["timestamps"]] + dates_numeric = [mdates.date2num(d) for d in dates] + scores = data["scores"] + + is_ours = long_name.lower() == self.squadron_name.lower() + has_overtaken = long_name in overtaking_squadrons + + if is_ours: + color = SQ_STATS_GRAPH_COLORS['squadron_line'] + linewidth = 3.5 + alpha = 1.0 + zorder = 100 + marker = 'o' + our_dates_numeric = dates_numeric + our_scores = scores + elif has_overtaken: + color = '#e84040' + linewidth = 2.5 + alpha = 0.9 + zorder = 50 + marker = None + else: + color = grey_shades[grey_idx % len(grey_shades)] + grey_idx += 1 + linewidth = 1.2 + alpha = 0.4 + zorder = 1 + marker = None + + ax.plot(dates_numeric, scores, + marker=marker, + linestyle='-', + linewidth=linewidth, + markersize=4, + color=color, + alpha=alpha, + zorder=zorder) + + # Store endpoint for labeling + display_name = data["short_name"] if data["short_name"] else long_name[:15] + if is_ours: + display_name = f"★ {display_name}" + endpoints.append((scores[-1], display_name, color, data["position"], is_ours, has_overtaken)) + + # Detect and highlight crossover points (where our squadron crosses another) + if our_dates_numeric and our_scores: + for long_name, data in squadron_histories.items(): + if long_name.lower() == self.squadron_name.lower(): + continue + other_dates = [mdates.date2num(datetime.fromtimestamp(ts)) for ts in data["timestamps"]] + other_scores = data["scores"] + + # Interpolate both to common timestamps for comparison + common_times = sorted(set(our_dates_numeric) & set(other_dates)) + if len(common_times) < 2: + continue + + our_at_common = [our_scores[our_dates_numeric.index(t)] for t in common_times] + other_at_common = [other_scores[other_dates.index(t)] for t in common_times] + + # Find crossover points (sign change in difference) + for i in range(1, len(common_times)): + diff_prev = our_at_common[i-1] - other_at_common[i-1] + diff_curr = our_at_common[i] - other_at_common[i] + if diff_prev * diff_curr < 0: # sign changed = crossover + cross_x = common_times[i] + cross_y = our_at_common[i] + # Green if we crossed above them, red if we fell below + cross_color = SQ_STATS_GRAPH_COLORS['squadron_line'] if diff_curr > 0 else '#e84040' + ax.plot(cross_x, cross_y, marker='X', markersize=14, + color=cross_color, zorder=200, markeredgecolor='#000000', + markeredgewidth=1.5) + + # Sort endpoints by Y position for labeling + endpoints.sort(key=lambda x: x[0]) + + # Extend x-axis to make room for labels + if endpoints: + x_min, x_max = ax.get_xlim() + x_range = x_max - x_min + ax.set_xlim(x_min, x_max + x_range * 0.12) + + # Label squadrons with smart spacing + last_labeled_y = None + min_separation = (max(e[0] for e in endpoints) - min(e[0] for e in endpoints)) / 30 + for y_pos, name, color, position, is_ours, has_overtaken in endpoints: + if last_labeled_y is None or abs(y_pos - last_labeled_y) >= min_separation: + label_color = color if (is_ours or has_overtaken) else SQ_STATS_GRAPH_COLORS['text'] + ax.annotate(f"#{position+1} {name}", + xy=(x_max, y_pos), + xytext=(5, 0), + textcoords='offset points', + fontsize=9, + color=label_color, + va='center', + alpha=1.0 if (is_ours or has_overtaken) else 0.6, + fontweight='bold' if (is_ours or has_overtaken) else 'normal') + last_labeled_y = y_pos + + # Formatting with dark mode colors + ax.set_xlabel('Time', fontsize=12, color=SQ_STATS_GRAPH_COLORS['text']) + ax.set_ylabel('Total Squadron Score', fontsize=12, color=SQ_STATS_GRAPH_COLORS['text']) + ax.set_title(f'Leaderboard Comparison (±5 positions)', fontsize=14, fontweight='bold', color=SQ_STATS_GRAPH_COLORS['text']) + ax.grid(True, alpha=0.2, color=SQ_STATS_GRAPH_COLORS['grid']) + ax.tick_params(colors=SQ_STATS_GRAPH_COLORS['text']) + ax.spines['bottom'].set_color(SQ_STATS_GRAPH_COLORS['text']) + ax.spines['top'].set_color(SQ_STATS_GRAPH_COLORS['text']) + ax.spines['left'].set_color(SQ_STATS_GRAPH_COLORS['text']) + ax.spines['right'].set_color(SQ_STATS_GRAPH_COLORS['text']) + + # Format x-axis with dates + ax.xaxis.set_major_formatter(mdates.DateFormatter('%m/%d')) + ax.xaxis.set_major_locator(mdates.AutoDateLocator()) + plt.setp(ax.xaxis.get_majorticklabels(), rotation=45, ha='right', color=SQ_STATS_GRAPH_COLORS['text']) + + plt.tight_layout() + + # Save to temporary file + temp_path = Path(f"/tmp/sq_comparison_{self.squadron_name.replace(' ', '_')}_{int(time_module.time())}.png") + plt.savefig(temp_path, dpi=150, bbox_inches='tight', facecolor=SQ_STATS_GRAPH_COLORS['bg']) + plt.close(fig) + + # Determine actual range of positions shown (convert to 1-indexed for display) + if squadron_histories: + positions = [data["position"] for data in squadron_histories.values()] + min_shown_pos = min(positions) + 1 # Convert to 1-indexed + max_shown_pos = max(positions) + 1 # Convert to 1-indexed + position_range = f"#{min_shown_pos} to #{max_shown_pos}" + else: + position_range = "N/A" + + # Create embed + embed = discord.Embed( + title=t(lang, "sq_stats.comparison_title", squadron=self.squadron_name), + description=t(lang, "sq_stats.comparison_desc", range=position_range), + color=discord.Color.purple() + ) + embed.add_field(name=t(lang, "sq_stats.current_position_field"), value=f"#{current_position+1}", inline=True) + embed.add_field(name=t(lang, "sq_stats.squadrons_shown_field"), value=str(len(squadron_histories)), inline=True) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + embed.set_image(url=f"attachment://{temp_path.name}") + + return embed, temp_path, None + + @discord.ui.button(label="📊 View Player Stats", style=discord.ButtonStyle.primary) + async def view_player_stats(self, interaction: discord.Interaction, button: discord.ui.Button): + """Generate and send the individual player points chart with a refine view.""" + await interaction.response.defer(ephemeral=False) + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + + # Generate chart for all players + embed, temp_path = await self.generate_player_chart(lang=lang) + + # Create view with refine button + refine_view = PlayerRefineView(self.squadron_name, self.timestamps, self.player_histories, + self.dates_numeric, self.filtered_ticks, self.filtered_labels, lang=lang) + + refine_view.message = await interaction.followup.send(embed=embed, file=discord.File(temp_path), view=refine_view, wait=True) + + # Clean up temporary file + try: + temp_path.unlink() + except Exception: + pass + + @discord.ui.button(label="📈 Compare Nearby Squadrons", style=discord.ButtonStyle.secondary) + async def compare_nearby_squadrons(self, interaction: discord.Interaction, button: discord.ui.Button): + """Generate and send a leaderboard comparison chart with 5 squadrons above and below.""" + await interaction.response.defer(ephemeral=False) + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + + # Generate comparison chart + embed, temp_path, error = await self.generate_squadron_comparison_chart(lang=lang) + + if error or not embed or not temp_path: + error_embed = discord.Embed( + title=t(lang, "common.error_title"), + description=error or t(lang, "sq_stats.comparison_chart_failed"), + color=discord.Color.red() + ) + error_embed.set_footer(text=DEFAULT_FOOTER_CAT) + await interaction.followup.send(embed=error_embed, ephemeral=True) + return + + await interaction.followup.send(embed=embed, file=discord.File(temp_path)) + + # Clean up temporary file + try: + temp_path.unlink() + except Exception: + pass + + + +@is_blacklisted() +@bot.tree.command( + name="sq-stats", + description=command_locale("Display a squadron's points over time", "commands.sq_stats.description"), + guild=None +) +@discord.app_commands.autocomplete(squadron=squadron_autocomplete) +async def sq_stats(interaction: discord.Interaction, squadron: str = "", data_points: int = 150): + """Display a squadron's total score trend over time as a line chart. + + Reads historical point snapshots from squadrons_points in SQLite, plots + the total score with timeslot-aware x-axis labels (NA/EU), and attaches + a SquadronStatsView with buttons for player breakdown and leaderboard + comparison. + + Args: + interaction: The Discord interaction. + squadron: Short name of the squadron (falls back to guild default). + data_points: Number of recent data points to plot (clamped to 2-500). + """ + await collect_command_stats(interaction) + await interaction.response.defer(ephemeral=False) + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + + # Clamp data_points to minimum of 2, so its not just a single dot lol + if data_points < 1: + data_points = 2 + if data_points > 500: + data_points = 500 + + try: + clan = await get_guild_squadron(interaction.guild_id, squadron) + except ValueError as e: + embed = discord.Embed(title=t(lang, "common.error_title"), description=str(e), color=discord.Color.red()) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + return await interaction.followup.send(embed=embed, ephemeral=True) + + squadron_name = clan["long_name"] + + # Read historical data from squadrons_points table + timestamps = [] + total_scores = [] + clan_pts_data = [] + + async with aiosqlite.connect(SQUADRONS_DB_PATH, timeout=30.0) as db: + async with db.execute(""" + SELECT unix_time, total_score, clan_pts + FROM squadrons_points + WHERE long_name = ? + ORDER BY unix_time ASC + """, (squadron_name,)) as cursor: + rows = await cursor.fetchall() + for row in rows: + timestamps.append(row[0]) + total_scores.append(row[1]) + clan_pts_data.append(row[2]) + + if not timestamps or not total_scores: + embed = discord.Embed( + title=t(lang, "sq_stats.no_data_title"), + description=t(lang, "sq_stats.no_data_desc", squadron=squadron_name), + color=discord.Color.orange() + ) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + await interaction.followup.send(embed=embed, ephemeral=True) + return + + # Limit to last N data points + if len(timestamps) > data_points: + timestamps = timestamps[-data_points:] + total_scores = total_scores[-data_points:] + clan_pts_data = clan_pts_data[-data_points:] + + # Parse player data from clan_pts JSON + # Format: [members_dict, total_score] where members_dict = {uid: {"nick": str, "points": int}} + player_histories = {} # {uid: {"nick": str, "points": [list of points over time]}} + + for clan_pts_json in clan_pts_data: + try: + members_dict, _ = decompress_json(clan_pts_json) + for uid, player_data in members_dict.items(): + if uid not in player_histories: + player_histories[uid] = { + "nick": player_data["nick"], + "points": [] + } + player_histories[uid]["points"].append(player_data["points"]) + except (json.JSONDecodeError, KeyError, ValueError, TypeError, OSError): + # If parsing fails, skip this data point + continue + + # Create line chart with dark mode + fig, ax = plt.subplots(figsize=(12, 6), facecolor=SQ_STATS_GRAPH_COLORS['bg']) + ax.set_facecolor(SQ_STATS_GRAPH_COLORS['plot_bg']) + + # Convert timestamps to datetime objects for better formatting + dates = [datetime.fromtimestamp(ts) for ts in timestamps] + # Convert to matplotlib numeric format for type compatibility + dates_numeric = [mdates.date2num(d) for d in dates] + + # Plot squadron total with dark mode color + ax.plot(dates_numeric, total_scores, marker='o', linestyle='-', linewidth=2.5, markersize=5, color=SQ_STATS_GRAPH_COLORS['squadron_line']) + + # Formatting with dark mode colors + ax.set_xlabel('Time', fontsize=12, color=SQ_STATS_GRAPH_COLORS['text']) + ax.set_ylabel('Total Squadron Score', fontsize=12, color=SQ_STATS_GRAPH_COLORS['text']) + ax.set_title(f'{squadron_name}', fontsize=14, fontweight='bold', color=SQ_STATS_GRAPH_COLORS['text']) + ax.grid(True, alpha=0.2, color=SQ_STATS_GRAPH_COLORS['grid']) + ax.tick_params(colors=SQ_STATS_GRAPH_COLORS['text']) + ax.spines['bottom'].set_color(SQ_STATS_GRAPH_COLORS['text']) + ax.spines['top'].set_color(SQ_STATS_GRAPH_COLORS['text']) + ax.spines['left'].set_color(SQ_STATS_GRAPH_COLORS['text']) + ax.spines['right'].set_color(SQ_STATS_GRAPH_COLORS['text']) + + # Add padding to y-axis to prevent annotation clipping + y_min, y_max = ax.get_ylim() + y_range = y_max - y_min + ax.set_ylim(y_min, y_max + y_range * 0.15) # Add 15% padding to top for arrow annotations + + # Determine timeslot region by hour: NA ~01:00-07:00, EU ~14:00-22:00 + def get_timeslot_region(dt): + """Return 'NA' or 'EU' region label based on UTC hour, or None if outside SQB hours.""" + if 0 <= dt.hour <= 9: + return 'NA' + elif dt.hour >= 13: + return 'EU' + return None + + # Only show ticks where the score changed, with one label per timeslot + # A timeslot instance is identified by (date, region) e.g. ("02/15", "EU") + # The label goes on the first change within that timeslot + filtered_ticks = [] # date_num values for tick placement + filtered_labels = [] # label strings ('' for unlabeled ticks) + filtered_scores = [] # scores at each filtered tick (for annotations) + labeled_timeslots = set() + + for i, (dt, date_num, score) in enumerate(zip(dates, dates_numeric, total_scores)): + # Always include the first data point as a tick + if i == 0: + region = get_timeslot_region(dt) + if region: + date_str = dt.strftime('%m/%d') + filtered_ticks.append(date_num) + filtered_labels.append(f'{date_str} {region}') + filtered_scores.append(score) + labeled_timeslots.add((dt.strftime('%Y-%m-%d'), region)) + else: + filtered_ticks.append(date_num) + filtered_labels.append('') + filtered_scores.append(score) + continue + + # Skip points where the score didn't change + if score == total_scores[i - 1]: + continue + + filtered_ticks.append(date_num) + filtered_scores.append(score) + + # Check if this timeslot instance needs a label + region = get_timeslot_region(dt) + if region: + timeslot_key = (dt.strftime('%Y-%m-%d'), region) + if timeslot_key not in labeled_timeslots: + date_str = dt.strftime('%m/%d') + filtered_labels.append(f'{date_str} {region}') + labeled_timeslots.add(timeslot_key) + else: + filtered_labels.append('') + else: + filtered_labels.append('') + + # Set x-axis ticks to only show where changes occurred + ax.set_xticks(filtered_ticks) + ax.set_xticklabels(filtered_labels, rotation=45, ha='right', color=SQ_STATS_GRAPH_COLORS['text']) + + # Add score annotations on labeled tick points (first change per timeslot) + # Skip annotations that would overlap with any previously placed annotation + labeled_annotations = [(tick, score) for tick, label, score in + zip(filtered_ticks, filtered_labels, filtered_scores) + if label] # Only annotate points that have a visible label + + if labeled_annotations: + # Convert axis limits to figure out data-per-pixel ratios + x_range = ax.get_xlim()[1] - ax.get_xlim()[0] + y_range_axis = ax.get_ylim()[1] - ax.get_ylim()[0] + fig_width, fig_height = fig.get_size_inches() + dpi = fig.dpi + # Approximate text box size in data coords (font 11 ~ 15px height, ~60px width) + text_h = y_range_axis * 40 / (fig_height * dpi) + text_w = x_range * 80 / (fig_width * dpi) + + placed = [] # list of (text_x, text_y) in data coords for placed annotations + + for ann_idx, (date_num, score) in enumerate(labeled_annotations): + # First annotation goes upper-right to avoid clipping against y-axis + if ann_idx == 0: + x_offset, y_offset, h_align = 20, 30, 'left' + else: + x_offset, y_offset, h_align = -20, 30, 'right' + + # Approximate where the text will land in data coords + text_x = date_num + (x_range * x_offset / (fig_width * dpi)) + text_y = score + (y_range_axis * y_offset / (fig_height * dpi)) + + # Check if this would overlap with any previously placed annotation + too_close = False + for px, py in placed: + if abs(text_x - px) < text_w and abs(text_y - py) < text_h: + too_close = True + break + + if too_close: + continue + + ax.annotate(f'{score:,}', + xy=(float(date_num), float(score)), + xytext=(x_offset, y_offset), + textcoords='offset points', + ha=h_align, + fontsize=11, + fontweight='bold', + color=SQ_STATS_GRAPH_COLORS['text'], + alpha=1.0, + zorder=10, + arrowprops=dict( + arrowstyle='->', + color=SQ_STATS_GRAPH_COLORS['text'], + alpha=0.9, + lw=1.5 + )) + placed.append((text_x, text_y)) + + plt.tight_layout() + + # Save to temporary file + temp_path = Path(f"/tmp/sq_stats_{squadron_name.replace(' ', '_')}_{int(time_module.time())}.png") + plt.savefig(temp_path, dpi=150, bbox_inches='tight', facecolor=SQ_STATS_GRAPH_COLORS['bg']) + plt.close(fig) + + # Send the chart + embed = discord.Embed( + title=t(lang, "sq_stats.title", squadron=squadron_name), + description=t(lang, "sq_stats.desc", count=len(timestamps)), + color=discord.Color.blue() + ) + embed.add_field(name=t(lang, "sq_stats.previous_score_field"), value=f"{total_scores[0]:,}", inline=True) + embed.add_field(name=t(lang, "sq_stats.current_score_field"), value=f"{total_scores[-1]:,}", inline=True) + change = total_scores[-1] - total_scores[0] + embed.add_field(name=t(lang, "sq_stats.change_field"), value=f"{change:+,}", inline=True) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + embed.set_image(url=f"attachment://{temp_path.name}") + + # Create view with button for player stats + view = SquadronStatsView(squadron_name, timestamps, player_histories, dates_numeric, filtered_ticks, filtered_labels, lang=lang) + + view.message = await interaction.followup.send(embed=embed, file=discord.File(temp_path), view=view, wait=True) + + # Clean up temporary file + try: + temp_path.unlink() + except Exception: + pass + + +@sq_stats.error +async def sq_stats_error(interaction, error): + await permission_fail(interaction, error) + + + +async def load_leaderboard_readonly(db_path: Path) -> tuple[list[tuple[str, str, float]], list[float]]: + """ + Async read-only snapshot of the leaderboard from squadrons.db. + + Returns: + rows: [(long_name, short_name, clanrating), ...] sorted DESC by clanrating + ratings_desc: [clanrating, ...] sorted DESC + """ + rows: list[tuple[str, str, float]] = [] + async with aiosqlite.connect(f"file:{db_path}?mode=ro", uri=True) as con: + con.row_factory = aiosqlite.Row + async with con.execute( + """ + SELECT long_name, short_name, clanrating + FROM squadrons_data + WHERE clanrating IS NOT NULL AND clanrating > 0 + ORDER BY clanrating DESC + """ + ) as cur: + async for r in cur: + rows.append((r["long_name"], r["short_name"], float(r["clanrating"]))) + ratings_desc = [r[2] for r in rows] + return rows, ratings_desc + + +def find_current_rank( + rows: list[tuple[str, str, float]], + squadron_long: str, + squadron_short: str, +) -> tuple[Optional[float], Optional[int]]: + """Find this squadron's current rating and 1-based rank in the rows snapshot.""" + long_lower = (squadron_long or "").lower() + short_lower = (squadron_short or "").lower() + for idx, (long_name, short_name, rating) in enumerate(rows, start=1): + if ((long_name and long_name.lower() == long_lower) or + (short_name and short_name.lower() == short_lower)): + return float(rating), idx + return None, None + + +async def get_current_squadron_placement( + squadron_long: str, squadron_short: str +) -> tuple[Optional[int], Optional[float]]: + """Return (1-based rank, rating) for a squadron, or (None, None) if not found.""" + rows, _ = await load_leaderboard_readonly(SQUADRONS_DB_PATH) + rating, rank = find_current_rank(rows, squadron_long, squadron_short) + return rank, rating + + +def project_rank(ratings_desc: list[float], projected_rating: float) -> int: + """Return 1-based projected rank for a DESC list of ratings.""" + for idx, rating in enumerate(ratings_desc, start=1): + if projected_rating >= rating: + return idx + return len(ratings_desc) + 1 # below all current entries + + + + +async def _sq_player_autocomplete( + interaction: discord.Interaction, + current: str, +) -> List[discord.app_commands.Choice[str]]: + """Autocomplete player names from the player_games_hist table, scoped to the selected squadron.""" + sq_short = getattr(interaction.namespace, "squadron_short", "") or "" + + # Resolve squadron to clan_id + clan_id: Optional[int] = None + try: + if sq_short: + async with aiosqlite.connect(SQUADRONS_DB_PATH) as db: + async with db.execute( + "SELECT clan_id FROM squadrons_data WHERE LOWER(short_name) = ? LIMIT 1", + (sq_short.lower(),), + ) as cur: + row = await cur.fetchone() + if row: + clan_id = row[0] + else: + # Try guild default + guild_id = str(interaction.guild_id) if interaction.guild_id else "" + try: + sq_cfg = await load_json(STORAGE_DIR / "SQUADRONS.json", {}) + sq_short_default = sq_cfg.get(guild_id, {}).get("SQ_ShortHand_Name", "") + if sq_short_default: + async with aiosqlite.connect(SQUADRONS_DB_PATH) as db: + async with db.execute( + "SELECT clan_id FROM squadrons_data WHERE LOWER(short_name) = ? LIMIT 1", + (sq_short_default.lower(),), + ) as cur: + row = await cur.fetchone() + if row: + clan_id = row[0] + except Exception: + pass + + if clan_id is None: + return [] + + async with aiosqlite.connect(SQUADRONS_DB_PATH) as db: + await db.create_function("ulower", 1, str.lower) + if not current or len(current) < 1: + # Show top members by points + async with db.execute( + "SELECT nick FROM squadron_members WHERE clan_id = ? ORDER BY points DESC LIMIT 25", + (clan_id,), + ) as cur: + rows = await cur.fetchall() + else: + async with db.execute( + """ + SELECT nick FROM squadron_members + WHERE clan_id = ? AND ulower(nick) LIKE ulower(?) + ORDER BY + CASE WHEN ulower(nick) = ulower(?) THEN 0 + WHEN ulower(nick) LIKE ulower(?) THEN 1 + ELSE 2 + END, + points DESC + LIMIT 25 + """, + (clan_id, f"%{current}%", current, f"{current}%"), + ) as cur: + rows = await cur.fetchall() + + return [ + discord.app_commands.Choice(name=row[0][:100], value=row[0][:100]) + for row in rows + ] + except Exception: + return [] + + +# ============================= +# /loss-calculator COMMAND +# ============================= +@is_blacklisted() +@bot.tree.command( + name="loss-calculator", + description=command_locale("Calculate the point loss if players leave a squadron", "commands.loss_calculator.description") +) +@app_commands.describe( + squadron_short=command_locale("The short name of the squadron", "commands.common.squadron_short"), + player1=command_locale("Player leaving", "commands.loss_calculator.player1"), + player2=command_locale("Player leaving (optional)", "commands.loss_calculator.player_optional"), + player3=command_locale("Player leaving (optional)", "commands.loss_calculator.player_optional"), + player4=command_locale("Player leaving (optional)", "commands.loss_calculator.player_optional"), + player5=command_locale("Player leaving (optional)", "commands.loss_calculator.player_optional"), + player6=command_locale("Player leaving (optional)", "commands.loss_calculator.player_optional"), + player7=command_locale("Player leaving (optional)", "commands.loss_calculator.player_optional"), +) +@discord.app_commands.autocomplete( + squadron_short=squadron_autocomplete, + player1=_sq_player_autocomplete, player2=_sq_player_autocomplete, + player3=_sq_player_autocomplete, player4=_sq_player_autocomplete, + player5=_sq_player_autocomplete, player6=_sq_player_autocomplete, + player7=_sq_player_autocomplete, +) +async def loss_calculator( + interaction: discord.Interaction, + player1: str, + squadron_short: str = "", + player2: str = "", player3: str = "", + player4: str = "", player5: str = "", + player6: str = "", player7: str = "", +): + """Calculate projected point loss and rank change if players leave a squadron. + + Fetches live squad data, matches player names to UIDs, computes + effective point contributions using the top-20 rate multiplier, + and projects the new leaderboard rank after removal. + + Args: + interaction: The Discord interaction. + player1: Required player name leaving the squadron. + squadron_short: Short name of the squadron (falls back to guild default). + player2-player7: Optional additional player names leaving. + """ + await collect_command_stats(interaction) + await interaction.response.defer(ephemeral=True) + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + + try: + clan = await get_guild_squadron(interaction.guild_id, squadron_short) + except ValueError as e: + return await interaction.followup.send(str(e), ephemeral=True) + + squadron_name = clan["long_name"] + squadron_short_key = clan["short_name"] + + # --- Fetch live squad data --- + try: + sq_data, sq_total = await obtain_clan_new_points(squadron_name) + except Exception as e: + await interaction.followup.send(t(lang, "loss_calc.fetch_failed", error=str(e)), ephemeral=True) + return + + if not sq_data or sq_total <= 0: + await interaction.followup.send(t(lang, "loss_calc.no_point_data"), ephemeral=True) + return + + sorted_players = sorted(sq_data.items(), key=lambda kv: kv[1]["points"], reverse=True) + + # --- Match provided player names to UIDs in sq_data --- + player_names = [p for p in [player1, player2, player3, player4, player5, player6, player7] if p] + selected_ids: list[str] = [] + not_found: list[str] = [] + + nick_to_uid = {d["nick"].lower(): uid for uid, d in sq_data.items()} + for name in player_names: + uid = nick_to_uid.get(name.lower()) + if uid and uid not in selected_ids: + selected_ids.append(uid) + else: + # Fuzzy fallback: partial match + matches = [u for nick, u in nick_to_uid.items() if name.lower() in nick] + if len(matches) == 1 and matches[0] not in selected_ids: + selected_ids.append(matches[0]) + elif not uid: + not_found.append(name) + + if not selected_ids: + await interaction.followup.send( + t(lang, "loss_calc.no_matching_players", squadron=squadron_name), ephemeral=True) + return + + # --- Compute rate_x and loss --- + TOP_N = 20 + top20_sum = sum(d["points"] for _, d in sorted_players[:TOP_N]) + other_sum = sum(d["points"] for _, d in sorted_players[TOP_N:]) + rate_x = (sq_total - top20_sum) / other_sum if other_sum > 0 else 0.0 + + total_eff = 0.0 + for pid in selected_ids: + raw = sq_data[pid]["points"] + idx = next(i for i, (p, _) in enumerate(sorted_players) if p == pid) + eff = raw if idx < TOP_N else raw * rate_x + total_eff += eff + + remaining = [(pid, d) for pid, d in sorted_players if pid not in selected_ids] + new_top20 = sum(d["points"] for _, d in remaining[:TOP_N]) + new_other = sum(d["points"] for _, d in remaining[TOP_N:]) + new_total = new_top20 + rate_x * new_other + real_loss = sq_total - new_total + + # --- Leaderboard projection --- + current_rank, current_rating = await get_current_squadron_placement(squadron_name, squadron_short_key) + _, ratings_desc = await load_leaderboard_readonly(SQUADRONS_DB_PATH) + + # --- Build embed --- + e = discord.Embed( + title=t(lang, "loss_calc.title", squadron=esc(squadron_name)), + color=discord.Color.blurple(), + ) + e.add_field( + name=t(lang, "loss_calc.players_leaving_field"), + value=", ".join( + f"{esc(sq_data[p]['nick'])} ({sq_data[p]['points']} pts)" for p in selected_ids + ), + inline=False, + ) + e.add_field(name=t(lang, "loss_calc.share_of_total_field"), value=f"{(total_eff / sq_total * 100):.3f}%", inline=True) + e.add_field(name=t(lang, "loss_calc.points_lost_real_field"), value=f"{real_loss:.1f}", inline=True) + e.add_field(name=t(lang, "loss_calc.points_lost_raw_field"), value=f"{total_eff:.1f}", inline=True) + + if current_rating is not None and current_rank is not None: + projected_rating = max(0.0, current_rating - real_loss) + # Exclude our own rating so we rank against other squadrons only + other_ratings = [r for i, r in enumerate(ratings_desc) if i != current_rank - 1] + projected_rank = project_rank(other_ratings, projected_rating) + positions_lost = max(0, projected_rank - current_rank) + e.add_field(name=t(lang, "loss_calc.squadron_rating_field"), value=f"{current_rating:.0f} -> {projected_rating:.0f}", inline=True) + e.add_field(name=t(lang, "loss_calc.squadron_position_field"), value=f"#{current_rank} -> #{projected_rank}", inline=True) + e.add_field(name=t(lang, "loss_calc.positions_lost_field"), value=str(positions_lost), inline=True) + + if not_found: + e.set_footer(text=t(lang, "loss_calc.not_found_footer", players=", ".join(not_found))) + + await interaction.followup.send(embed=e, ephemeral=True) + + +@loss_calculator.error +async def loss_calculator_error(interaction, error): + await permission_fail(interaction, error) + + + + +@is_blacklisted() +@bot.tree.command(name="website", description=command_locale("Get a link to the SRE Bot website", "commands.website.description")) +async def website(interaction: discord.Interaction): + """Send the SRE Bot website URL.""" + await collect_command_stats(interaction) + await interaction.response.send_message("https://srebot-meow.ing/") + + +@website.error +async def website_perm_error(interaction, error): + await permission_fail(interaction, error) + + +# ═══════════════════════════════════════════════════════════════════════════ +# PLAYER AUTOCOMPLETE (shared by /player-stats and /compare) +# ═══════════════════════════════════════════════════════════════════════════ + +async def player_autocomplete( + interaction: discord.Interaction, + current: str, +) -> List[discord.app_commands.Choice[str]]: + """Autocomplete for player nicknames from the battle history DB.""" + if not current or len(current) < 2: + return [] + try: + async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db: + await db.create_function("ulower", 1, str.lower) + async with db.execute( + """ + SELECT nick, UID FROM ( + SELECT nick, UID, MAX(session_id) as last_seen + FROM player_games_hist + WHERE nick LIKE ? COLLATE NOCASE + GROUP BY UID + ORDER BY + CASE WHEN ulower(nick) = ulower(?) THEN 0 + WHEN ulower(nick) LIKE ulower(?) THEN 1 + ELSE 2 + END, + last_seen DESC + LIMIT 25 + ) + """, + (f"{current}%", current, f"{current}%"), + ) as cursor: + rows = await cursor.fetchall() + return [ + discord.app_commands.Choice(name=row[0][:100], value=row[0][:100]) + for row in rows + ] + except Exception as e: + print(f"[AUTOCOMPLETE ERROR] player_autocomplete failed for '{current}': {e}\n{traceback.format_exc()}", flush=True) + return [] + + +# ═══════════════════════════════════════════════════════════════════════════ +# PLAYER SEASON RECAP CARD (/card) +# ═══════════════════════════════════════════════════════════════════════════ + +async def _send_player_card( + interaction: discord.Interaction, + uid: int, + nick: str, + season: str, + theme: str, + lang: str, + *, + followup: bool, +): + """Render and send a player card; shared by /card and the disambiguation view.""" + try: + path = await get_player_recap(uid, season, theme, _recap_lang(lang)) + except RecapError: + embed = discord.Embed( + title=t(lang, "common.error_title"), + description=t(lang, "recap_card.render_failed"), + color=discord.Color.red(), + ) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + if followup: + await interaction.followup.send(embed=embed, ephemeral=True) + else: + await interaction.edit_original_response(embed=embed, view=None) + return + + safe_name = re.sub(r'[^A-Za-z0-9_-]+', '_', nick) or str(uid) + filename = f"{safe_name}-{season}.png" + file = discord.File(path, filename=filename) + if followup: + await interaction.followup.send(file=file) + else: + await interaction.edit_original_response( + content=None, attachments=[file], view=None + ) + + +class CardPlayerSelectView(View): + """Disambiguation dropdown when a nick resolves to multiple UIDs.""" + + def __init__(self, results, author: discord.abc.User, season: str, theme: str, lang: str = "en"): + super().__init__(timeout=60) + self.author = author + self.results = results + self.season = season + self.theme = theme + self.lang = lang + + options = [ + discord.SelectOption( + label=row["nick"][:100], + description=f"UID: {row['UID']}"[:100], + value=str(row["UID"])[:100], + ) + for row in results[:25] + ] + self.select = Select( + placeholder=t(lang, "player.select_player_placeholder"), + options=options, + min_values=1, + max_values=1, + ) + self.select.callback = self.select_callback + self.add_item(self.select) + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + return interaction.user.id == self.author.id + + async def select_callback(self, interaction: discord.Interaction): + await interaction.response.defer() + uid = int(self.select.values[0]) + nick = next( + (r["nick"] for r in self.results if str(r["UID"]) == self.select.values[0]), + str(uid), + ) + await _send_player_card( + interaction, uid, nick, self.season, self.theme, self.lang, followup=False + ) + + +@is_blacklisted() +@bot.tree.command(name="card", description=command_locale("Generate a season recap card for a player", "commands.card.description")) +@app_commands.describe( + season=command_locale("The season to generate the card for", "commands.common.season"), + player=command_locale("The player's username", "commands.common.player_username"), + theme=command_locale("Card color theme", "commands.common.theme"), +) +@app_commands.choices(theme=RECAP_THEME_CHOICES) +@discord.app_commands.autocomplete( + season=seasons_autocomplete, + player=player_autocomplete, +) +async def card( + interaction: discord.Interaction, + season: str, + player: str, + theme: app_commands.Choice[str] | None = None, +): + """Generate and send a season recap card PNG for a player. + + If multiple players share the nick, shows a disambiguation dropdown. + + Args: + interaction: The Discord interaction. + season: Season identifier (e.g. "2026-II"). + player: Player's War Thunder username. + theme: "dark" (default) or "light". + """ + await collect_command_stats(interaction) + await interaction.response.defer(ephemeral=False) + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + theme_value = theme.value if theme else 'dark' + + seasons = get_seasons() + if season not in seasons: + embed = discord.Embed( + title=t(lang, "common.error_title"), + description=t(lang, "recap_card.unknown_season", season=season), + color=discord.Color.red(), + ) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + return await interaction.followup.send(embed=embed, ephemeral=True) + + try: + async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db: + db.row_factory = aiosqlite.Row + await db.create_function("ulower", 1, str.lower) + async with db.execute( + """ + SELECT UID, MIN(nick) AS nick + FROM player_games_hist + WHERE ulower(nick) LIKE ulower(?) + GROUP BY UID + ORDER BY nick + LIMIT 25 + """, + (f"%{player}%",), + ) as cursor: + results = list(await cursor.fetchall()) + except Exception as e: + error_str = str(e)[:1800] + return await interaction.followup.send( + t(lang, "common.database_error", error=error_str), ephemeral=True + ) + + if not results: + return await interaction.followup.send( + t(lang, "player.no_players_found", username=player), ephemeral=True + ) + + if len(results) > 1: + return await interaction.followup.send( + t(lang, "player.multiple_matches"), + view=CardPlayerSelectView( + results, interaction.user, season, theme_value, lang=lang + ), + ) + + await _send_player_card( + interaction, int(results[0]["UID"]), results[0]["nick"], + season, theme_value, lang, followup=True, + ) + + +@card.error +async def card_error(interaction, error): + await permission_fail(interaction, error) + + +# ═══════════════════════════════════════════════════════════════════════════ +# PLAYER STATS WITH VEHICLE BREAKDOWN +# ═══════════════════════════════════════════════════════════════════════════ + +class PlayerSelectViewForStats(View): + """View for selecting a player when multiple matches are found""" + def __init__(self, results, author: discord.abc.User, lang: str = "en"): + super().__init__(timeout=30) + self.author = author + self.results = results + self.lang = lang + + options = [ + discord.SelectOption( + label=row["nick"][:100], + description=f"UID: {row['UID']}"[:100], + value=str(row["UID"])[:100] + ) + for row in results[:25] # Discord limit of 25 options + ] + + self.select = Select( + placeholder=t(lang, "player.select_player_placeholder"), + options=options, + min_values=1, + max_values=1 + ) + self.select.callback = self.select_callback + self.add_item(self.select) + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + return interaction.user.id == self.author.id + + async def select_callback(self, interaction: discord.Interaction): + """Fetch aggregated vehicle stats for the selected player and show VehicleStatsView.""" + uid = self.select.values[0] + + # Defer to show thinking state + await interaction.response.defer() + + # Fetch vehicle stats for selected player + try: + async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db: + db.row_factory = aiosqlite.Row + + # Get player info + async with db.execute( + "SELECT nick, squadron_name FROM player_games_hist WHERE UID = ? ORDER BY session_id DESC LIMIT 1", + (uid,) + ) as cursor: + player_row = await cursor.fetchone() + + if not player_row: + await interaction.followup.send(t(self.lang, "player.no_stats_found", uid=uid), ephemeral=True) + return + + # Get aggregated vehicle stats + async with db.execute( + """ + SELECT + vehicle_internal, + vehicle, + SUM(ground_kills) as total_ground_kills, + SUM(air_kills) as total_air_kills, + SUM(assists) as total_assists, + SUM(captures) as total_captures, + SUM(deaths) as total_deaths, + SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END) as wins, + SUM(CASE WHEN UPPER(victor_bool) = 'LOSS' THEN 1 ELSE 0 END) as losses, + COUNT(*) as total_battles + FROM player_games_hist + WHERE UID = ? + GROUP BY vehicle_internal + ORDER BY total_battles DESC + """, + (uid,) + ) as cursor: + vehicle_rows = await cursor.fetchall() + except Exception as e: + error_str = str(e)[:1800] if len(str(e)) > 1800 else str(e) + await interaction.followup.send(t(self.lang, "common.database_error", error=error_str), ephemeral=True) + return + + if not vehicle_rows: + await interaction.followup.send(t(self.lang, "player.no_vehicle_stats"), ephemeral=True) + return + + # Convert rows to dicts with calculated win rate + vehicle_stats = [] + for row in vehicle_rows: + wins = row['wins'] or 0 + losses = row['losses'] or 0 + total_battles = row['total_battles'] or 0 + + win_rate = '0.0' + if total_battles > 0 and wins >= 0: + win_rate = f"{(wins / total_battles * 100):.1f}" + + vehicle_name = utils.apply_vehicle_name_filters(row['vehicle']) if row['vehicle'] else "" + + vehicle_stats.append({ + 'vehicle_internal': row['vehicle_internal'], + 'vehicle': vehicle_name or row['vehicle_internal'], + 'total_ground_kills': row['total_ground_kills'] or 0, + 'total_air_kills': row['total_air_kills'] or 0, + 'total_assists': row['total_assists'] or 0, + 'total_captures': row['total_captures'] or 0, + 'total_deaths': row['total_deaths'] or 0, + 'wins': wins, + 'losses': losses, + 'total_battles': total_battles, + 'win_rate': win_rate + }) + + player_info = { + 'nick': player_row['nick'], + 'squadron': player_row['squadron_name'], + 'uid': uid + } + + # Update message with vehicle dropdown + await interaction.edit_original_response( + content=t(self.lang, "player.vehicles_found", count=len(vehicle_stats), nick=esc(player_info['nick'])), + view=VehicleStatsView(vehicle_stats, player_info, self.author, lang=self.lang) + ) + + +class VehicleStatsView(View): + """View for selecting a vehicle to see detailed stats with pagination""" + def __init__(self, vehicle_stats: list, player_info: dict, author: discord.abc.User, page: int = 0, lang: str = "en"): + super().__init__(timeout=60) + self.author = author + self.vehicle_stats = vehicle_stats + self.player_info = player_info + self.page = page + self.lang = lang + self.total_pages = (len(vehicle_stats) + 24) // 25 # Ceiling division + + # Get current page of vehicles + start_idx = page * 25 + end_idx = min(start_idx + 25, len(vehicle_stats)) + current_vehicles = vehicle_stats[start_idx:end_idx] + + # Create dropdown options from vehicle stats + options = [] + for idx, vehicle in enumerate(current_vehicles): + actual_idx = start_idx + idx + + # Calculate K/D ratio + total_kills = vehicle['total_ground_kills'] + vehicle['total_air_kills'] + deaths = vehicle['total_deaths'] if vehicle['total_deaths'] > 0 else 1 + kd = round(total_kills / deaths, 2) + + # Create label with vehicle name and basic stats + label = vehicle['vehicle'][:100] if vehicle['vehicle'] else f"Vehicle {actual_idx+1}" + description = f"Battles: {vehicle['total_battles']} | K/D: {kd} | WR: {vehicle['win_rate']}%" + + options.append(discord.SelectOption( + label=label[:100], + description=description[:100], + value=str(actual_idx) # Use actual index in full list + )) + + self.select = Select( + placeholder=t(lang, "player.vehicle_select_placeholder", page=page + 1, total=self.total_pages), + options=options, + min_values=1, + max_values=1 + ) + self.select.callback = self.select_callback + self.add_item(self.select) + + # Add pagination buttons if needed + if self.total_pages > 1: + prev_button = discord.ui.Button( + label=t(lang, "buttons.prev_arrow"), + style=discord.ButtonStyle.secondary, + disabled=(page == 0) + ) + next_button = discord.ui.Button( + label=t(lang, "buttons.next_arrow"), + style=discord.ButtonStyle.secondary, + disabled=(page >= self.total_pages - 1) + ) + + async def prev_callback(interaction: discord.Interaction): + await interaction.response.edit_message( + content=t(self.lang, "player.vehicles_found", count=len(self.vehicle_stats), nick=esc(self.player_info['nick'])), + view=VehicleStatsView(self.vehicle_stats, self.player_info, self.author, self.page - 1, lang=self.lang) + ) + + async def next_callback(interaction: discord.Interaction): + await interaction.response.edit_message( + content=t(self.lang, "player.vehicles_found", count=len(self.vehicle_stats), nick=esc(self.player_info['nick'])), + view=VehicleStatsView(self.vehicle_stats, self.player_info, self.author, self.page + 1, lang=self.lang) + ) + + prev_button.callback = prev_callback + next_button.callback = next_callback + + self.add_item(prev_button) + self.add_item(next_button) + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + return interaction.user.id == self.author.id + + async def select_callback(self, interaction: discord.Interaction): + """Build a detailed stats embed for the selected vehicle and refresh the same message.""" + # Get selected vehicle stats + vehicle_idx = int(self.select.values[0]) + vehicle = self.vehicle_stats[vehicle_idx] + + # Calculate additional stats + total_kills = vehicle['total_ground_kills'] + vehicle['total_air_kills'] + deaths = vehicle['total_deaths'] if vehicle['total_deaths'] > 0 else 1 + kd_ratio = round(total_kills / deaths, 2) + win_rate = vehicle['win_rate'] + + # Find vehicle icon + vehicle_internal = vehicle['vehicle_internal'] + icon_filename = f"{vehicle_internal}.png" + icon_path = ICONS_DIR / "VEHICLES" / icon_filename + + # Use not_found.png if vehicle icon doesn't exist + if not icon_path.exists(): + icon_path = ICONS_DIR / "not_found.png" + icon_filename = "not_found.png" + + # Create embed with vehicle stats + embed = discord.Embed( + title=f"{vehicle['vehicle']}", + description=t(self.lang, "player.stats_desc", nick=esc(self.player_info['nick']), squadron=self.player_info['squadron'], uid=self.player_info['uid']), + color=discord.Color.blue() + ) + + # Set vehicle icon as thumbnail + embed.set_thumbnail(url=f"attachment://{icon_filename}") + + # Combat stats + gk = f"{vehicle['total_ground_kills']:,}" + ak = f"{vehicle['total_air_kills']:,}" + tk = f"{total_kills:,}" + ast = f"{vehicle['total_assists']:,}" + dth = f"{vehicle['total_deaths']:,}" + cap = f"{vehicle['total_captures']:,}" + embed.add_field( + name="\u200b", + value=( + f"{t(self.lang, 'player.combat_stats_header')}\n" + f"{t(self.lang, 'player.ground_kills_label', value=gk)}\n" + f"{t(self.lang, 'player.air_kills_label', value=ak)}\n" + f"{t(self.lang, 'player.total_kills_label', value=tk)}\n" + f"{t(self.lang, 'player.assists_label', value=ast)}\n" + f"{t(self.lang, 'player.deaths_label', value=dth)}\n" + f"{t(self.lang, 'player.kd_label', value=kd_ratio)}\n" + f"{t(self.lang, 'player.captures_label', value=cap)}\n" + ), + inline=False + ) + + # Battle record + tb = f"{vehicle['total_battles']:,}" + wn = f"{vehicle['wins']:,}" + ls = f"{vehicle['losses']:,}" + embed.add_field( + name="\u200b", + value=( + f"{t(self.lang, 'player.battle_record_header')}\n" + f"{t(self.lang, 'player.total_battles_label', value=tb)}\n" + f"{t(self.lang, 'player.wins_label', value=wn)}\n" + f"{t(self.lang, 'player.losses_label', value=ls)}\n" + f"{t(self.lang, 'player.win_rate_label', value=win_rate)}" + ), + inline=False + ) + + embed.set_footer(text=DEFAULT_FOOTER_CAT) + + # Create file object for the icon + file = discord.File(fp=icon_path, filename=icon_filename) + + await interaction.response.edit_message( + content=t(self.lang, "player.vehicles_found", count=len(self.vehicle_stats), nick=esc(self.player_info['nick'])), + embed=embed, + attachments=[file], + view=self, + ) + + +@is_blacklisted() +@bot.tree.command( + name="player-stats", + description=command_locale("View detailed vehicle statistics for a player", "commands.player_stats.description") +) +@app_commands.describe( + username=command_locale("The WT username for stats request", "commands.player_stats.username"), + uid=command_locale("The WT UID for stats request", "commands.player_stats.uid") +) +@discord.app_commands.autocomplete(username=player_autocomplete) +async def player_stats(interaction: discord.Interaction, username: str = "", uid: str = ""): + """View per-vehicle battle statistics for a player. + + Resolves the player by UID or username search. If multiple username + matches are found, shows a PlayerSelectViewForStats dropdown. Otherwise + fetches aggregated vehicle stats and presents a VehicleStatsView with + paginated vehicle dropdown. + + Args: + interaction: The Discord interaction. + username: War Thunder username to search for. + uid: War Thunder UID for direct lookup. + """ + await collect_command_stats(interaction) + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + # Handle UID lookup + if uid: + await interaction.response.defer(thinking=True) + target_uid = uid + elif username: + await interaction.response.defer(thinking=True) + + # Search for player by username + try: + async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db: + db.row_factory = aiosqlite.Row + await db.create_function("ulower", 1, str.lower) + async with db.execute( + """ + SELECT UID, MIN(nick) AS nick + FROM player_games_hist + WHERE ulower(nick) LIKE ulower(?) + GROUP BY UID + ORDER BY nick + LIMIT 25 + """, + (f"%{username}%",), + ) as cursor: + results = list(await cursor.fetchall()) + except Exception as e: + error_str = str(e)[:1800] if len(str(e)) > 1800 else str(e) + await interaction.followup.send(t(lang, "common.database_error", error=error_str), ephemeral=True) + return + + if not results: + await interaction.followup.send( + t(lang, "player.no_players_found", username=username), + ephemeral=True + ) + return + elif len(results) > 1: + # Multiple matches found - show dropdown + await interaction.followup.send( + t(lang, "player.multiple_matches"), + view=PlayerSelectViewForStats(results, interaction.user, lang=lang) + ) + return + + target_uid = results[0]["UID"] + else: + await interaction.response.send_message( + t(lang, "player.must_provide_input"), + ephemeral=True + ) + return + + # Get vehicle stats for the player + try: + async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db: + db.row_factory = aiosqlite.Row + + # Get player info + async with db.execute( + "SELECT nick, squadron_name FROM player_games_hist WHERE UID = ? ORDER BY session_id DESC LIMIT 1", + (target_uid,) + ) as cursor: + player_row = await cursor.fetchone() + + if not player_row: + await interaction.followup.send(t(lang, "player.no_stats_found", uid=target_uid), ephemeral=True) + return + + # Get aggregated vehicle stats + async with db.execute( + """ + SELECT + vehicle_internal, + vehicle, + SUM(ground_kills) as total_ground_kills, + SUM(air_kills) as total_air_kills, + SUM(assists) as total_assists, + SUM(captures) as total_captures, + SUM(deaths) as total_deaths, + SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END) as wins, + SUM(CASE WHEN UPPER(victor_bool) = 'LOSS' THEN 1 ELSE 0 END) as losses, + COUNT(*) as total_battles + FROM player_games_hist + WHERE UID = ? + GROUP BY vehicle_internal + ORDER BY total_battles DESC + """, + (target_uid,) + ) as cursor: + vehicle_rows = await cursor.fetchall() + except Exception as e: + error_str = str(e)[:1800] if len(str(e)) > 1800 else str(e) + await interaction.followup.send(t(lang, "common.database_error", error=error_str), ephemeral=True) + return + + if not vehicle_rows: + await interaction.followup.send(t(lang, "player.no_vehicle_stats"), ephemeral=True) + return + + # Convert rows to dicts with calculated win rate + vehicle_stats = [] + for row in vehicle_rows: + wins = row['wins'] or 0 + losses = row['losses'] or 0 + total_battles = row['total_battles'] or 0 + + win_rate = '0.0' + if total_battles > 0 and wins >= 0: + win_rate = f"{(wins / total_battles * 100):.1f}" + + vehicle_name = utils.apply_vehicle_name_filters(row['vehicle']) if row['vehicle'] else "" + + vehicle_stats.append({ + 'vehicle_internal': row['vehicle_internal'], + 'vehicle': vehicle_name or row['vehicle_internal'], + 'total_ground_kills': row['total_ground_kills'] or 0, + 'total_air_kills': row['total_air_kills'] or 0, + 'total_assists': row['total_assists'] or 0, + 'total_captures': row['total_captures'] or 0, + 'total_deaths': row['total_deaths'] or 0, + 'wins': wins, + 'losses': losses, + 'total_battles': total_battles, + 'win_rate': win_rate + }) + + player_info = { + 'nick': player_row['nick'], + 'squadron': player_row['squadron_name'], + 'uid': target_uid + } + + # Send initial message with dropdown + await interaction.followup.send( + t(lang, "player.vehicles_found", count=len(vehicle_stats), nick=esc(player_info['nick'])), + view=VehicleStatsView(vehicle_stats, player_info, interaction.user, lang=lang) + ) + + +@player_stats.error +async def player_stats_perm_error(interaction, error): + await permission_fail(interaction, error) + + +# ═══════════════════════════════════════════════════════════════════════════ +# /view-player-games — Last 20 games for a player +# ═══════════════════════════════════════════════════════════════════════════ + +class FindPlayerView(discord.ui.View): + """Paginated embed view for displaying a player's recent game history. + + Each page contains embed fields for a subset of sessions, with + previous/next buttons for navigation. + """ + + def __init__(self, pages: list[list[tuple[str, str]]], summary_desc: str, player_nick: str, lang: str = "en"): + super().__init__(timeout=120) + self.message: Optional[discord.Message] = None + self.pages = pages + self.summary_desc = summary_desc + self.player_nick = player_nick + self.lang = lang + self.page = 0 + self._update_buttons() + self.prev_btn.label = t(lang, "buttons.prev_arrow_only") + self.next_btn.label = t(lang, "buttons.next_arrow_only") + + def _update_buttons(self) -> None: + """Enable or disable prev/next buttons based on the current page index.""" + self.prev_btn.disabled = self.page == 0 + self.next_btn.disabled = self.page >= len(self.pages) - 1 + + def build_embed(self) -> discord.Embed: + """Build the embed for the current page of game history fields. + + Returns: + Discord Embed with session fields for the current page. + """ + embed = discord.Embed( + title=self.player_nick, + description=self.summary_desc, + color=discord.Color.blurple(), + ) + for name, value in self.pages[self.page]: + embed.add_field(name=name, value=value, inline=False) + footer = f"Page {self.page + 1}/{len(self.pages)} • {DEFAULT_FOOTER_CAT}" if len(self.pages) > 1 else DEFAULT_FOOTER_CAT + embed.set_footer(text=footer) + return embed + + async def on_timeout(self): + for item in self.children: + item.disabled = True # type: ignore[attr-defined] + try: + if self.message: + await self.message.edit(view=self) + except Exception: + pass + + @discord.ui.button(label="◀", style=discord.ButtonStyle.secondary) + async def prev_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + self.page -= 1 + self._update_buttons() + await interaction.response.edit_message(embed=self.build_embed(), view=self) + + @discord.ui.button(label="▶", style=discord.ButtonStyle.secondary) + async def next_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + self.page += 1 + self._update_buttons() + await interaction.response.edit_message(embed=self.build_embed(), view=self) + +@is_blacklisted() +@bot.tree.command( + name="view-player-games", + description=command_locale("View the last 20 games for a player", "commands.view_player_games.description") +) +@app_commands.describe(player=command_locale("The player's username", "commands.common.player_username")) +@discord.app_commands.autocomplete(player=player_autocomplete) +async def view_player_games(interaction: discord.Interaction, player: str): + """Display a player's recent squadron battle sessions with win/loss, comps, and opponents. + + Resolves the player nickname to a UID, queries sessions from the last 8 hours + in sq_battles.db, enriches each session with opponent squadron and match summary + data, and presents the results in a paginated FindPlayerView. + + Args: + interaction: The Discord interaction. + player: The player's username (supports autocomplete). + """ + await collect_command_stats(interaction) + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + await interaction.response.defer(ephemeral=False) + + # Resolve player nick → most-recently-seen UID + try: + async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db: + db.row_factory = aiosqlite.Row + await db.create_function("ulower", 1, str.lower) + async with db.execute( + """ + SELECT UID, nick, MAX(endtime_unix) AS last_seen + FROM player_games_hist + WHERE ulower(nick) = ulower(?) + GROUP BY UID + ORDER BY last_seen DESC + LIMIT 1 + """, + (player,), + ) as cursor: + uid_row = await cursor.fetchone() + except Exception as e: + return await interaction.followup.send(t(lang, "common.database_error", error=str(e)[:1800]), ephemeral=True) + + if not uid_row: + return await interaction.followup.send( + embed=discord.Embed( + title=t(lang, "player.not_found_title"), + description=t(lang, "player.not_found_desc", player=esc(player)), + color=discord.Color.red(), + ).set_footer(text=DEFAULT_FOOTER_CAT) + ) + + target_uid = uid_row["UID"] + player_nick = esc(uid_row["nick"]) + + # Query last 20 sessions + try: + async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db: + db.row_factory = aiosqlite.Row + + async with db.execute( + "SELECT squadron_tagged FROM player_games_hist WHERE UID = ? ORDER BY endtime_unix DESC LIMIT 1", + (target_uid,), + ) as cursor: + sq_row = await cursor.fetchone() + sq_tag = sq_row["squadron_tagged"] if sq_row else "" + + cutoff = int(time_module.time()) - 8 * 3600 + async with db.execute( + """ + SELECT + session_id, + MAX(endtime_unix) AS endtime_unix, + MAX(victor_bool) AS victor_bool, + GROUP_CONCAT(vehicle_internal, '||') AS vehicles + FROM player_games_hist + WHERE UID = ? AND endtime_unix >= ? + GROUP BY session_id + ORDER BY MAX(endtime_unix) DESC + """, + (target_uid, cutoff), + ) as cursor: + sessions = list(await cursor.fetchall()) + except Exception as e: + return await interaction.followup.send(t(lang, "common.database_error", error=str(e)[:1800]), ephemeral=True) + + if not sessions: + return await interaction.followup.send( + embed=discord.Embed( + title=t(lang, "player_games.no_recent_title"), + description=t(lang, "player_games.no_recent_desc", player=player_nick), + color=discord.Color.red(), + ).set_footer(text=DEFAULT_FOOTER_CAT) + ) + + # Fetch match_summary for opponent, map, and full team JSON + session_ids = [s["session_id"] for s in sessions] + placeholders = ",".join("?" * len(session_ids)) + try: + async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db: + db.row_factory = aiosqlite.Row + async with db.execute( + f"SELECT session_id, map_name, winning_sq, losing_sq, winning_team_json, losing_team_json " + f"FROM match_summary WHERE session_id IN ({placeholders})", + session_ids, + ) as cursor: + ms_rows = await cursor.fetchall() + except Exception: + ms_rows = [] + ms_map = {r["session_id"]: r for r in ms_rows} + + _type_order = {"F": 0, "B": 1, "H": 2, "L": 3, "T": 4, "AA": 5, "?": 6} + _comp_order = [("F",), ("B",), ("H",), ("L",), ("T",), ("AA",), ("?",)] + + def _comp_notation(veh_internals: list[str]) -> str: + """Build a compact composition notation string (e.g. '2F / 1T / 1AA') from vehicle internals. + + Args: + veh_internals: List of internal vehicle name strings. + + Returns: + Formatted composition string, or '—' if empty. + """ + nd = count_unit_types(veh_internals) + parts = [f"{nd[code]}{code}" for (code,) in _comp_order if nd.get(code, 0) > 0] + return " / ".join(parts) or "—" + + def _team_block(players: list[dict]) -> str: + """Format a team's player list into a fixed-width code block sorted by vehicle type. + + Args: + players: List of player dicts with 'nick', 'vehicle', and 'vehicle_new' keys. + + Returns: + Discord code block string with aligned nick | vehicle rows. + """ + sorted_players = sorted( + players, + key=lambda p: _type_order.get(get_unit_type_abbrev(p.get("vehicle")), 6) + ) + max_nick = max((len(p.get("nick", "?")) for p in sorted_players), default=1) + lines = [ + f"{p.get('nick', '?'):<{max_nick}} | {p.get('vehicle_new') or normalize_name(p.get('vehicle') or '') or '?'}" + for p in sorted_players + ] + return "```\n" + "\n".join(lines) + "\n```" + + # Tally wins/losses and build a list of game groups + # Each game group is 1–2 (field_name, field_value) tuples + wins = 0 + losses = 0 + comp_counter: dict[str, int] = {} + game_groups: list[list[tuple[str, str]]] = [] + + for s in sessions: + is_win = (s["victor_bool"] or "").upper() == "WIN" + wins += is_win + losses += not is_win + + ms = ms_map.get(s["session_id"]) + if ms: + opponent = esc((ms["losing_sq"] if is_win else ms["winning_sq"]) or "?") + raw_map = re.sub(r'\[.*?\]', '', ms["map_name"] or "").strip() + map_name = esc(raw_map or "Unknown") + my_json = ms["winning_team_json"] if is_win else ms["losing_team_json"] + opp_json = ms["losing_team_json"] if is_win else ms["winning_team_json"] + try: + my_players = (decompress_json(my_json) if my_json else {}).get("players", []) + except (json.JSONDecodeError, TypeError): + my_players = [] + try: + opp_players = (decompress_json(opp_json) if opp_json else {}).get("players", []) + except (json.JSONDecodeError, TypeError): + opp_players = [] + else: + opponent = "?" + map_name = "Unknown" + my_players = [] + opp_players = [] + + my_veh = [p.get("vehicle") for p in my_players if p.get("vehicle")] or \ + [v for v in (s["vehicles"] or "").split("||") if v] + my_comp = _comp_notation(my_veh) + comp_counter[my_comp] = comp_counter.get(my_comp, 0) + 1 + + ts = f"" if s["endtime_unix"] else "" + icon = "👑" if is_win else "💔" + my_sq = esc(sq_tag[1:-1] if len(sq_tag) > 2 else sq_tag) if sq_tag else "?" + header = f"{icon} {my_sq} vs {opponent} · {map_name} · {ts}" + + if my_players or opp_players: + my_val = _team_block(my_players) if my_players else f"`{my_comp}`" + opp_val = _team_block(opp_players) if opp_players else "`no data`" + # Field names appear right above each block — use sq names + game_groups.append([(header, my_val), (opponent, opp_val)]) + else: + game_groups.append([(header, f"`{my_comp}`")]) + + total = wins + losses + wr = f"{wins / total * 100:.1f}" if total > 0 else "0.0" + + if comp_counter: + sorted_comps = sorted(comp_counter.items(), key=lambda x: -x[1]) + comps_text = t(lang, "player_games.comps_played_header") + "\n" + "\n".join(f"**×{c}** `{k}`" for k, c in sorted_comps) + else: + comps_text = "" + + sq_display = esc(sq_tag[1:-1] if len(sq_tag) > 2 else sq_tag) if sq_tag else 'N/A' + summary_desc = ( + t(lang, "player_games.squadron_label", squadron=sq_display) + "\n" + + t(lang, "player_games.record_label", wins=wins, losses=losses, wr=wr) + + comps_text + ) + + # Pack game groups into pages: max 5 games per page, budget 5500 - desc chars + field_budget = 5500 - len(summary_desc) + pages: list[list[tuple[str, str]]] = [] + current_page: list[tuple[str, str]] = [] + current_games = 0 + current_chars = 0 + for group in game_groups: + group_chars = sum(len(n) + len(v) for n, v in group) + if current_page and (current_games >= 5 or current_chars + group_chars > field_budget): + pages.append(current_page) + current_page = [] + current_games = 0 + current_chars = 0 + current_page.extend(group) + current_games += 1 + current_chars += group_chars + if current_page: + pages.append(current_page) + + view = FindPlayerView(pages, summary_desc, player_nick, lang=lang) + view.message = await interaction.followup.send(embed=view.build_embed(), view=view, wait=True) + + +@view_player_games.error # type: ignore[attr-defined] +async def view_player_games_perm_error(interaction, error): + await permission_fail(interaction, error) + + +# ═══════════════════════════════════════════════════════════════════════════ +# VIEW MATCH +# ═══════════════════════════════════════════════════════════════════════════ + +class ViewMatchSelectView(discord.ui.View): + """Dropdown of a player's recent games; selecting one generates the scoreboard.""" + + def __init__(self, games: list[dict], author: discord.abc.User, lang: str = "en"): + super().__init__(timeout=120) + self.message: Optional[discord.Message] = None + self.author = author + self.lang = lang + + options = [] + for g in games[:25]: # Discord select max 25 + ts_label = f"" if g["endtime_unix"] else "" + icon = "\U0001f451" if (g["victor_bool"] or "").upper() == "WIN" else "\U0001f494" + opponent = g.get("opponent") or "?" + map_name = re.sub(r'\[.*?\]', '', g.get("map_name") or "").strip() or "Unknown" + label = f"{icon} vs {opponent} \u00b7 {map_name}"[:100] + options.append(discord.SelectOption( + label=label, + value=g["session_id"], + description=f"ID: {g['session_id']}"[:100], + )) + + select = Select(placeholder=t(lang, "match.select_match_placeholder"), options=options) + select.callback = self._on_select + self.add_item(select) + + async def on_timeout(self): + for item in self.children: + item.disabled = True # type: ignore[attr-defined] + try: + if self.message: + await self.message.edit(view=self) + except Exception: + pass + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + return interaction.user.id == self.author.id + + async def _on_select(self, interaction: discord.Interaction): + """Handle game selection from the dropdown and generate its scoreboard.""" + values = (interaction.data or {}).get("values") or [] # type: ignore[union-attr] + if not values: + return await interaction.response.send_message(t(self.lang, "common.no_selection_received"), ephemeral=True) + session_id: str = values[0] + await interaction.response.defer(thinking=True) + await _send_view_match_scoreboard(interaction, session_id, lang=self.lang) + + +async def _send_view_match_scoreboard(interaction: discord.Interaction, session_id: str, lang: str = "en"): + """Fetch a replay by ID (disk first, then API), build a scoreboard, and send it.""" + + session_id = session_id.lower() + + # 1. Try loading from disk + replay_data = load_replay_data_from_disk(session_id) + + # 2. If not on disk, fetch from API (needs decimal ID, not hex) + if not replay_data: + try: + decimal_id = str(int(session_id, 16)) + except ValueError: + decimal_id = session_id # Already decimal or invalid — let API handle it + raw = await fetch_replay_by_id(decimal_id) + if not raw: + return await interaction.followup.send( + embed=discord.Embed( + title=t(lang, "match.not_found_title"), + description=t(lang, "match.not_found_desc", match_id=esc(session_id)), + color=discord.Color.red(), + ).set_footer(text=DEFAULT_FOOTER_CAT), + ephemeral=True, + ) + + # The API may return {"completed": [...]} directly, {"data": {...}} wrapper, or just the replay dict + if "completed" in raw: + wrapped = raw + elif "data" in raw and isinstance(raw["data"], dict): + wrapped = {"completed": [raw["data"]]} + else: + wrapped = {"completed": [raw]} + + replay_data = utils.transform_to_local_format(wrapped) + if not replay_data: + return await interaction.followup.send( + embed=discord.Embed( + title=t(lang, "match.invalid_data_title"), + description=t(lang, "match.invalid_data_desc"), + color=discord.Color.red(), + ).set_footer(text=DEFAULT_FOOTER_CAT), + ephemeral=True, + ) + + # Save to disk for buttons (Chat Log, Battle Log read from disk) + replay_dir = replay_session_dir(session_id) + replay_dir.mkdir(parents=True, exist_ok=True) + async with aiofiles.open(replay_dir / "replay_data.json", "w", encoding="utf-8") as f: + await f.write(json.dumps(replay_data, indent=4, ensure_ascii=False)) + + # 3. Translate vehicles + translate = LangTableReader("English") + 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" + + # 4. Resolve clan long names + squads = [t.get("squadron") for t in replay_data.get("teams", []) if t.get("squadron")] + squads_tagged = [t.get("squadron_tagged") for t in replay_data.get("teams", []) if t.get("squadron_tagged")] + resolved = await resolve_clans(shorts=squads, tags=squads_tagged) + for team, clan_info in zip(replay_data.get("teams", []), resolved): + if team and clan_info.get("long_name"): + team["squadron_long"] = clan_info["long_name"] + + # 5. Build scoreboard + winner = replay_data.get("winning_team_squadron", "") + is_draw = replay_data.get("draw", False) + teams = replay_data.get("teams", []) + timestamp = replay_data.get("timestamp", 0) + map_name = replay_data.get("map", "") + + match_details: dict = {"utc_timestamp": str(timestamp), "session_id": session_id} + 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 + + output_dir = replay_session_dir(session_id) + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / "game_result-not_involved-English.png" + + if not output_path.exists(): + await create_scoreboard( + match_details, + winner, + teams[0] if len(teams) > 0 else {}, + teams[1] if len(teams) > 1 else {}, + map_name, + str(output_path), + bar_color="not_involved", + is_draw=is_draw, + ) + + if not output_path.exists(): + return await interaction.followup.send( + embed=discord.Embed( + title=t(lang, "match.scoreboard_error_title"), + description=t(lang, "match.scoreboard_error_desc"), + color=discord.Color.red(), + ).set_footer(text=DEFAULT_FOOTER_CAT), + ephemeral=True, + ) + + # 6. Send with buttons + view = build_scoreboard_view(interaction.guild_id or 0, session_id, lang=lang) + with open(output_path, "rb") as f: + await interaction.followup.send( + file=discord.File(f, filename="game_result.png"), + view=view, + ) + + +@is_blacklisted() +@bot.tree.command( + name="view-match", + description=command_locale("View a match scoreboard by ID or player", "commands.view_match.description") +) +@app_commands.describe( + match_id=command_locale("The session hex ID of the match to view", "commands.view_match.match_id"), + player_name=command_locale("A player's username to browse recent matches", "commands.view_match.player_name"), +) +@discord.app_commands.autocomplete(player_name=player_autocomplete) +async def view_match( + interaction: discord.Interaction, + match_id: Optional[str] = None, + player_name: Optional[str] = None, +): + """View a match scoreboard by direct session ID or by browsing a player's recent games. + + If match_id is provided, defers and renders the scoreboard directly via + _send_view_match_scoreboard. If player_name is provided, resolves the player's + UID, fetches their last 100 sessions with opponent/map info, and presents a + ViewMatchSelectView dropdown for the user to pick a match. + """ + await collect_command_stats(interaction) + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + if not match_id and not player_name: + return await interaction.response.send_message( + embed=discord.Embed( + title=t(lang, "match.missing_input_title"), + description=t(lang, "match.missing_input_desc"), + color=discord.Color.red(), + ).set_footer(text=DEFAULT_FOOTER_CAT), + ephemeral=True, + ) + + # Direct ID lookup + if match_id: + await interaction.response.defer(thinking=True) + # Normalise: strip leading "0" prefix if provided, strip whitespace + match_id = match_id.strip().lstrip("0") or match_id.strip() + await _send_view_match_scoreboard(interaction, match_id, lang=lang) + return + + # Player lookup → show dropdown of last 100 games + await interaction.response.defer(thinking=True, ephemeral=True) + + try: + async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db: + db.row_factory = aiosqlite.Row + await db.create_function("ulower", 1, str.lower) + + # Resolve nick → UID + async with db.execute( + """ + SELECT UID, nick, MAX(endtime_unix) AS last_seen + FROM player_games_hist + WHERE ulower(nick) = ulower(?) + GROUP BY UID + ORDER BY last_seen DESC + LIMIT 1 + """, + (player_name,), + ) as cursor: + uid_row = await cursor.fetchone() + + if not uid_row: + return await interaction.followup.send( + embed=discord.Embed( + title=t(lang, "player.not_found_title"), + description=t(lang, "player.not_found_desc", player=esc(player_name or '')), + color=discord.Color.red(), + ).set_footer(text=DEFAULT_FOOTER_CAT), + ephemeral=True, + ) + + target_uid = uid_row["UID"] + player_nick = uid_row["nick"] + + # Fetch last 100 sessions with opponent info + async with db.execute( + """ + SELECT + p.session_id, + MAX(p.endtime_unix) AS endtime_unix, + MAX(p.victor_bool) AS victor_bool, + ms.map_name, + CASE + WHEN UPPER(MAX(p.victor_bool)) = 'WIN' THEN ms.losing_sq + ELSE ms.winning_sq + END AS opponent + FROM player_games_hist p + LEFT JOIN match_summary ms ON ms.session_id = p.session_id + WHERE p.UID = ? + GROUP BY p.session_id + ORDER BY MAX(p.endtime_unix) DESC + LIMIT 100 + """, + (target_uid,), + ) as cursor: + sessions = [dict(row) for row in await cursor.fetchall()] + + except Exception as e: + return await interaction.followup.send( + t(lang, "common.database_error", error=str(e)[:1800]), + ephemeral=True, + ) + + if not sessions: + return await interaction.followup.send( + embed=discord.Embed( + title=t(lang, "match.no_games_title"), + description=t(lang, "match.no_games_desc", player=esc(player_nick)), + color=discord.Color.red(), + ).set_footer(text=DEFAULT_FOOTER_CAT), + ephemeral=True, + ) + + view = ViewMatchSelectView(sessions, interaction.user, lang=lang) + view.message = await interaction.followup.send( + embed=discord.Embed( + title=t(lang, "match.recent_matches_title", player=esc(player_nick)), + description=t(lang, "match.recent_matches_desc", count=len(sessions)), + color=discord.Color.blurple(), + ).set_footer(text=DEFAULT_FOOTER_CAT), + view=view, + ephemeral=True, + wait=True, + ) + + +@view_match.error +async def view_match_perm_error(interaction, error): + await permission_fail(interaction, error) + + +# ═══════════════════════════════════════════════════════════════════════════ +# PLAYER COMPARISON +# ═══════════════════════════════════════════════════════════════════════════ + +_COMPARE_STATS_ORDER = [ + ("total_battles", "compare.battles_label"), + ("wins", "compare.wins_label"), + ("losses", "compare.losses_label"), + ("win_rate", "compare.win_rate_label"), + ("ground_kills", "compare.ground_kills_label"), + ("air_kills", "compare.air_kills_label"), + ("total_kills", "compare.total_kills_label"), + ("assists", "compare.assists_label"), + ("deaths", "compare.deaths_label"), + ("kd", "compare.kd_label"), + ("captures", "compare.captures_label"), +] + +# Stats where lower is better +_LOWER_IS_BETTER = {"deaths", "losses"} + + +async def _resolve_player_uids_batch(db, usernames: List[str], lang: str = "en"): + """Resolve multiple usernames in one pass. Returns (uid_list, error_msg) or (None, error_msg).""" + uids: List[str] = [] + for name in usernames: + async with db.execute( + """ + SELECT UID, nick FROM ( + SELECT UID, nick, ROW_NUMBER() OVER (PARTITION BY UID ORDER BY endtime_unix DESC) as rn + FROM player_games_hist + WHERE ulower(nick) LIKE ulower(?) + ) WHERE rn = 1 + ORDER BY nick LIMIT 25 + """, + (f"%{name}%",), + ) as cursor: + results = list(await cursor.fetchall()) + if not results: + return None, t(lang, "compare.no_players_found", name=name) + if len(results) > 1: + matches = ", ".join(esc(r["nick"]) for r in results[:10]) + return None, t(lang, "compare.multiple_matches", name=name, matches=matches) + uids.append(results[0]["UID"]) + return uids, None + + +async def _fetch_players_aggregate_batch(db, uids: List[str]) -> List[Optional[dict]]: + """Fetch aggregate stats for multiple UIDs in two batched queries.""" + placeholders = ",".join("?" for _ in uids) + + # Batch 1: latest nick + squadron for each UID + info_map: dict = {} + async with db.execute( + f""" + SELECT UID, nick, squadron_name FROM ( + SELECT UID, nick, squadron_name, + ROW_NUMBER() OVER (PARTITION BY UID ORDER BY endtime_unix DESC) as rn + FROM player_games_hist + WHERE UID IN ({placeholders}) + ) WHERE rn = 1 + """, + uids, + ) as cursor: + async for row in cursor: + info_map[row["UID"]] = row + + # Batch 2: aggregate stats for all UIDs at once + stats_map: dict = {} + async with db.execute( + f""" + SELECT + UID, + SUM(ground_kills) as ground_kills, + SUM(air_kills) as air_kills, + SUM(assists) as assists, + SUM(captures) as captures, + SUM(deaths) as deaths, + SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END) as wins, + SUM(CASE WHEN UPPER(victor_bool) = 'LOSS' THEN 1 ELSE 0 END) as losses, + COUNT(*) as total_battles + FROM player_games_hist + WHERE UID IN ({placeholders}) + GROUP BY UID + """, + uids, + ) as cursor: + async for row in cursor: + stats_map[row["UID"]] = row + + # Assemble results in UID order + results: List[Optional[dict]] = [] + for uid in uids: + info = info_map.get(uid) + row = stats_map.get(uid) + if not info or not row or not row["total_battles"]: + results.append(None) + continue + + total_kills = (row["ground_kills"] or 0) + (row["air_kills"] or 0) + deaths = row["deaths"] or 0 + total_battles = row["total_battles"] or 0 + wins = row["wins"] or 0 + losses = row["losses"] or 0 + + results.append({ + "nick": info["nick"], + "squadron": info["squadron_name"] or "None", + "uid": uid, + "ground_kills": row["ground_kills"] or 0, + "air_kills": row["air_kills"] or 0, + "total_kills": total_kills, + "assists": row["assists"] or 0, + "captures": row["captures"] or 0, + "deaths": deaths, + "kd": round(total_kills / deaths, 2) if deaths > 0 else float(total_kills), + "wins": wins, + "losses": losses, + "total_battles": total_battles, + "win_rate": f"{(wins / total_battles * 100):.1f}" if total_battles > 0 else "0.0", + }) + return results + + +def _build_compare_embed(players: List[dict], lang: str = "en") -> Embed: + """Build a side-by-side comparison embed for multiple players.""" + + # Pre-compute best value per stat + best: dict = {} + all_tied: dict = {} + for key, _ in _COMPARE_STATS_ORDER: + nums = [float(p[key]) for p in players] + if key in _LOWER_IS_BETTER: + best[key] = min(nums) + else: + best[key] = max(nums) + all_tied[key] = len(set(nums)) == 1 + + # Count how many stats each player "wins" to find the overall best + win_counts = [] + for p in players: + count = 0 + for key, _ in _COMPARE_STATS_ORDER: + if not all_tied[key] and float(p[key]) == best[key]: + count += 1 + win_counts.append(count) + max_wins = max(win_counts) + + title = " vs ".join(esc(p["nick"]) for p in players) + embed = Embed(title=title, color=Color.blue()) + + for i, p in enumerate(players): + star = " \u2b50" if win_counts[i] == max_wins and max_wins > 0 else "" + lines = [] + for key, label_key in _COMPARE_STATS_ORDER: + val = p[key] + formatted = f"{val:,}" if isinstance(val, int) else str(val) + suffix = "%" if key == "win_rate" else "" + is_best = not all_tied[key] and float(p[key]) == best[key] + label = t(lang, label_key) + line = f"{label}: {formatted}{suffix}" + if is_best: + line = f"**{line}**" + lines.append(line) + embed.add_field( + name=f"{esc(p['nick'])} [{esc(p['squadron'])}]{star}", + value="\n".join(lines), + inline=True, + ) + + embed.set_footer(text=DEFAULT_FOOTER_CAT) + return embed + + +async def _generate_compare_graph(players: List[dict], lang: str = "en") -> tuple[Optional[Path], List[str]]: + """Generate a points-over-time graph for the given players over the last 90 days. + + Reads per-player points from the squadrons_points table (same source as /sq-stats). + Returns (path, missing_nicks) where missing_nicks lists players with no graph data. + """ + cutoff = int(time_module.time()) - (90 * 86400) + uid_set = {p["uid"] for p in players} + uid_to_nick = {p["uid"]: p["nick"] for p in players} + + # player_points: {uid: [(unix_time, points), ...]} + player_points: dict = {p["uid"]: [] for p in players} + + # --- Phase 1: fast indexed lookup via known squadron names --- + squadrons_needed: set[str] = set() + + async with aiosqlite.connect(SQUADRONS_DB_PATH, timeout=30.0) as db: + await db.execute("PRAGMA busy_timeout=30000;") + + # Current membership: squadron_members → squadrons_data + uid_ph = ",".join("?" for _ in uid_set) + async with db.execute( + f""" + SELECT DISTINCT sd.long_name + FROM squadron_members sm + JOIN squadrons_data sd ON sm.clan_id = sd.clan_id + WHERE sm.uid IN ({uid_ph}) + """, + list(uid_set), + ) as cursor: + async for row in cursor: + squadrons_needed.add(row[0]) + + # Also include the squadron from battle history (already in player dict) + for p in players: + if p["squadron"] and p["squadron"] != "None": + squadrons_needed.add(p["squadron"]) + + # Fast per-squadron queries (uses idx_squadrons_points_longname_time) + for sq_name in squadrons_needed: + async with db.execute( + """ + SELECT unix_time, clan_pts + FROM squadrons_points + WHERE long_name = ? AND unix_time > ? + ORDER BY unix_time ASC + """, + (sq_name, cutoff), + ) as cursor: + async for row in cursor: + try: + members_dict, _ = decompress_json(row[1]) + for uid_str, pdata in members_dict.items(): + if uid_str in uid_set: + player_points[uid_str].append( + (row[0], pdata["points"]) + ) + except (json.JSONDecodeError, KeyError, ValueError, TypeError, OSError): + continue + + # --- Phase 2: lookup historical squadrons from battle records --- + missing_uids = {p["uid"] for p in players if not player_points[p["uid"]]} + if missing_uids: + # Find squadrons these players played for via sq_battles.db + extra_squads: set[str] = set() + uid_ph2 = ",".join("?" for _ in missing_uids) + async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=30.0) as sqdb: + async with sqdb.execute( + f"SELECT DISTINCT squadron_name FROM player_games_hist WHERE UID IN ({uid_ph2})", + list(missing_uids), + ) as cursor: + async for row in cursor: + if row[0] and row[0] not in squadrons_needed: + extra_squads.add(row[0]) + + # Query those squadrons using the same indexed path as Phase 1 + for sq_name in extra_squads: + async with db.execute( + """ + SELECT unix_time, clan_pts + FROM squadrons_points + WHERE long_name = ? AND unix_time > ? + ORDER BY unix_time ASC + """, + (sq_name, cutoff), + ) as cursor: + async for row in cursor: + try: + members_dict, _ = decompress_json(row[1]) + for uid_str, pdata in members_dict.items(): + if uid_str in missing_uids: + player_points[uid_str].append( + (row[0], pdata["points"]) + ) + except (json.JSONDecodeError, KeyError, ValueError, TypeError, OSError): + continue + + # Sort each player's points by timestamp (data may come from multiple squadrons) + for uid in player_points: + player_points[uid].sort(key=lambda x: x[0]) + + # Identify players with no data + missing_nicks = [uid_to_nick[p["uid"]] for p in players if not player_points[p["uid"]]] + + # Check if we got any data at all + if not any(pts for pts in player_points.values()): + return None, missing_nicks + + # Plot + fig, ax = plt.subplots(figsize=(12, 6), facecolor=SQ_STATS_GRAPH_COLORS['bg']) + ax.set_facecolor(SQ_STATS_GRAPH_COLORS['plot_bg']) + + colors = ['#12ed2f', '#ed4012', '#127eed', '#edd012', '#ed12c7', '#12edd0', '#ed8a12'] + for idx, p in enumerate(players): + pts = player_points[p["uid"]] + if pts: + times = [mdates.date2num(datetime.fromtimestamp(t)) for t, _ in pts] + values = [v for _, v in pts] + color = colors[idx % len(colors)] + ax.plot(times, values, linewidth=2, color=color, label=uid_to_nick[p["uid"]]) + + ax.set_xlabel('Date', fontsize=12, color=SQ_STATS_GRAPH_COLORS['text']) + ax.set_ylabel('Points', fontsize=12, color=SQ_STATS_GRAPH_COLORS['text']) + ax.set_title(t(lang, "compare.graph_title"), fontsize=14, fontweight='bold', + color=SQ_STATS_GRAPH_COLORS['text']) + ax.grid(True, alpha=0.2, color=SQ_STATS_GRAPH_COLORS['grid']) + ax.tick_params(colors=SQ_STATS_GRAPH_COLORS['text']) + for spine in ax.spines.values(): + spine.set_color(SQ_STATS_GRAPH_COLORS['text']) + ax.legend(facecolor=SQ_STATS_GRAPH_COLORS['plot_bg'], + edgecolor=SQ_STATS_GRAPH_COLORS['grid'], + labelcolor=SQ_STATS_GRAPH_COLORS['text']) + ax.xaxis.set_major_formatter(mdates.DateFormatter('%m/%d')) + fig.autofmt_xdate() + plt.tight_layout() + + temp_path = Path(f"/tmp/compare_{'_'.join(p['uid'] for p in players[:3])}_{int(time_module.time())}.png") + plt.savefig(temp_path, dpi=150, bbox_inches='tight', facecolor=SQ_STATS_GRAPH_COLORS['bg']) + plt.close(fig) + return temp_path, missing_nicks + + +class CompareGraphView(View): + """View with a graph button and per-player website link buttons.""" + + def __init__(self, players: List[dict], lang: str = "en"): + super().__init__(timeout=120) + self.players = players + self.lang = lang + for i, p in enumerate(players): + self.add_item(discord.ui.Button( + label=p["nick"][:80], + style=discord.ButtonStyle.link, + url=f"https://srebot-meow.ing/players/{p['uid']}", + row=0 if i < 4 else 1, + )) + self.show_graph.label = t(lang, "buttons.show_graph") + + @discord.ui.button(label="Show Graph", style=discord.ButtonStyle.blurple, row=2) + async def show_graph(self, interaction: discord.Interaction, button: discord.ui.Button): + """Fetch 90-day points graph for compared players and attach it to the message.""" + await interaction.response.defer() + temp_path, missing_nicks = await _generate_compare_graph(self.players, lang=self.lang) + if not temp_path: + await interaction.followup.send(t(self.lang, "compare.no_graph_data"), ephemeral=True) + return + button.disabled = True + await interaction.edit_original_response(view=self) + if missing_nicks: + names = ", ".join(f"**{n}**" for n in missing_nicks) + await interaction.followup.send( + content=t(self.lang, "compare.no_squadron_points_data", names=names), + file=discord.File(temp_path), + ) + else: + await interaction.followup.send(file=discord.File(temp_path)) + try: + temp_path.unlink() + except Exception: + pass + + +@is_blacklisted() +@bot.tree.command(name="compare", description=command_locale("Compare aggregate SQB stats between players", "commands.compare.description")) +@app_commands.describe( + player1=command_locale("First player username", "commands.compare.player1"), + player2=command_locale("Second player username", "commands.compare.player2"), + player3=command_locale("Additional player username (optional)", "commands.compare.player_optional"), + player4=command_locale("Additional player username (optional)", "commands.compare.player_optional"), + player5=command_locale("Additional player username (optional)", "commands.compare.player_optional"), + player6=command_locale("Additional player username (optional)", "commands.compare.player_optional"), + player7=command_locale("Additional player username (optional)", "commands.compare.player_optional"), +) +@discord.app_commands.autocomplete( + player1=player_autocomplete, player2=player_autocomplete, + player3=player_autocomplete, player4=player_autocomplete, + player5=player_autocomplete, player6=player_autocomplete, + player7=player_autocomplete, +) +async def compare( + interaction: discord.Interaction, + player1: str, player2: str, + player3: str = "", player4: str = "", + player5: str = "", player6: str = "", + player7: str = "", +): + """Compare aggregate squadron battle stats between 2-7 players. + + Resolves each username to a UID, fetches aggregated battle stats (kills, deaths, + win rate, etc.) from sq_battles.db, builds a side-by-side comparison embed, and + attaches a CompareGraphView with a graph button and per-player website links. + """ + await collect_command_stats(interaction) + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + await interaction.response.defer(thinking=True) + + usernames = [p for p in [player1, player2, player3, player4, player5, player6, player7] if p] + + try: + async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=30.0) as db: + await db.execute("PRAGMA busy_timeout=30000;") + await db.create_function("ulower", 1, str.lower) + db.row_factory = aiosqlite.Row + + uids, err = await _resolve_player_uids_batch(db, usernames, lang=lang) + if err or uids is None: + await interaction.followup.send(err or t(lang, "compare.could_not_resolve"), ephemeral=True) + return + + player_stats = await _fetch_players_aggregate_batch(db, uids) + + except Exception as e: + error_str = str(e)[:1800] + await interaction.followup.send(t(lang, "common.database_error", error=error_str), ephemeral=True) + return + + # Check for any failed lookups + players = [] + for i, p in enumerate(player_stats): + if not p: + await interaction.followup.send( + t(lang, "compare.could_not_fetch", name=usernames[i]), ephemeral=True) + return + players.append(p) + + view = CompareGraphView(players, lang=lang) + await interaction.followup.send(embed=_build_compare_embed(players, lang=lang), view=view) + + +@compare.error +async def compare_perm_error(interaction, error): + await permission_fail(interaction, error) + + +@is_blacklisted() +@bot.tree.command(name="leaderboard", description=command_locale("Get the SRE Bot global leaderboard", "commands.leaderboard.description")) +async def leaderboard(interaction: discord.Interaction): + """Send the SRE Bot global leaderboard URL.""" + await collect_command_stats(interaction) + await interaction.response.send_message( + "https://srebot-meow.ing/leaderboard/players" + ) + +@leaderboard.error +async def leaderboard_perm_error(interaction, error): + await permission_fail(interaction, error) + + + +@is_blacklisted() +@is_admin() +@bot.tree.command(name='set-squadron', description=command_locale('Set the squadron tag for this server', "commands.set_squadron.description")) +@app_commands.describe(abbreviated_name=command_locale('The short name of the squadron to set', "commands.set_squadron.abbreviated_name")) +@discord.app_commands.autocomplete(abbreviated_name=squadron_autocomplete) +async def set_squadron(interaction: discord.Interaction, abbreviated_name: str): + """Set or swap the squadron tag for this server. + + Resolves the abbreviated squadron name via resolve_clan, then either sets it + directly in SQUADRONS.json or shows a ConfirmSwapView if a different squadron + is already configured for this guild. + """ + await collect_command_stats(interaction) + await interaction.response.defer(ephemeral=True) + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + + squadrons_path = STORAGE_DIR / "SQUADRONS.json" + squadrons = await load_json(squadrons_path, {}) + + guild_id = str(interaction.guild_id) + + # Clean input + new_short = re.sub(r'\W+', '', abbreviated_name) + + # Resolve using short name only + try: + clan = await resolve_clan(short=new_short) + except Exception as e: + logging.error(f"Error resolving squadron {new_short}: {e}") + clan = None + + if not clan or clan["long_name"] == "": + embed = Embed( + title=t(lang, "common.error_title"), + description=t(lang, "squadron.not_found_desc", squadron=new_short), + color=Color.red() + ) + return await interaction.followup.send(embed=embed, ephemeral=True) + + new_long = clan["long_name"] + + # Same swap/confirmation logic as before + if (guild_id in squadrons + and squadrons[guild_id]['SQ_ShortHand_Name'] != new_short): + old_long = squadrons[guild_id]['SQ_LongHandName'] + + class ConfirmSwapView(View): + """Confirmation prompt to swap the server's squadron assignment.""" + + def __init__(self, lang): + super().__init__(timeout=60) + self.lang = lang + self.confirm.label = t(lang, "buttons.confirm_swap") + self.cancel.label = t(lang, "buttons.cancel_swap") + + @button(label="Yes, swap it", style=discord.ButtonStyle.green) + async def confirm(self, button_interaction: discord.Interaction, button: discord.ui.Button): + squadrons[guild_id] = { + "SQ_ShortHand_Name": new_short, + "SQ_LongHandName": new_long + } + await write_json(squadrons_path, squadrons) + logging.info(f"Swapped squadron for guild {guild_id}: {old_long} → {new_long}") + + embed_swapped = Embed( + title=t(self.lang, "squadron.swap_title"), + description=t(self.lang, "squadron.swap_desc", old=old_long, new=new_long), + color=Color.green() + ) + await button_interaction.response.edit_message(embed=embed_swapped, view=None) + self.stop() + + @button(label="No, keep the old one", style=discord.ButtonStyle.red) + async def cancel(self, button_interaction: discord.Interaction, button: discord.ui.Button): + await button_interaction.response.edit_message( + content=t(self.lang, "squadron.swap_cancelled"), embed=None, view=None + ) + self.stop() + + view = ConfirmSwapView(lang) + embed_confirm = Embed( + title=t(lang, "squadron.already_set_title"), + description=t(lang, "squadron.already_set_desc", old=old_long, new=new_long), + color=Color.gold() + ) + return await interaction.followup.send(embed=embed_confirm, view=view, ephemeral=True) + + # Otherwise set it fresh + squadrons[guild_id] = { + "SQ_ShortHand_Name": new_short, + "SQ_LongHandName": new_long + } + await write_json(squadrons_path, squadrons) + logging.info(f"Set squadron for guild {guild_id} → {new_long}") + + embed_set = Embed( + title=t(lang, "squadron.set_title"), + description=t(lang, "squadron.set_desc", squadron=new_long), + color=Color.green() + ) + embed_set.add_field(name=t(lang, "squadron.short_name_field"), value=new_short, inline=True) + embed_set.add_field(name=t(lang, "squadron.long_name_field"), value=new_long, inline=True) + embed_set.set_footer(text=DEFAULT_FOOTER_CAT) + await interaction.followup.send(embed=embed_set, ephemeral=False) + + +@set_squadron.error +async def set_squadron_error(interaction, error): + await permission_fail(interaction, error) + + +# ============================================================================ +# SETUP WIZARD +# ============================================================================ + +@dataclass +class SetupState: + """State bag passed through all setup wizard steps.""" + guild_id: str + squadron_short: Optional[str] = None + squadron_long: Optional[str] = None + squadron_clan_id: Optional[int] = None + logs_channel_id: Optional[int] = None + points_channel_id: Optional[int] = None + + +def _step1_embed(state: SetupState, lang: str = "en") -> Embed: + """Build the Step 1 welcome embed.""" + desc = t(lang, "setup.step1_desc") + if state.squadron_short: + desc += t(lang, "setup.step1_current_sq", short=state.squadron_short, long=state.squadron_long) + embed = Embed(title=t(lang, "setup.step1_title"), description=desc, color=Color.blue()) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + return embed + + +def _step2_embed(state: SetupState, lang: str = "en") -> Embed: + """Build the Step 2 logs channel embed.""" + desc = t(lang, "setup.step2_desc", short=state.squadron_short, long=state.squadron_long) + embed = Embed(title=t(lang, "setup.step2_title"), description=desc, color=Color.blue()) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + return embed + + +def _step3_embed(state: SetupState, lang: str = "en") -> Embed: + """Build the Step 3 points channel embed.""" + desc = t(lang, "setup.step3_desc") + if state.logs_channel_id: + desc += t(lang, "setup.step3_same_as_logs") + embed = Embed(title=t(lang, "setup.step3_title"), description=desc, color=Color.blue()) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + return embed + + +async def _summary_embed(state: SetupState, lang: str = "en") -> Embed: + """Build the summary embed.""" + sq = f"[{state.squadron_short}] {state.squadron_long}" if state.squadron_short else t(lang, "common.not_configured") + logs = f"<#{state.logs_channel_id}>" if state.logs_channel_id else t(lang, "common.not_configured") + points = f"<#{state.points_channel_id}>" if state.points_channel_id else t(lang, "common.not_configured") + + embed = Embed( + title=t(lang, "setup.summary_title"), + description=t(lang, "setup.summary_desc"), + color=Color.green(), + ) + embed.add_field(name=t(lang, "setup.squadron_field"), value=sq, inline=False) + embed.add_field(name=t(lang, "setup.logs_channel_field"), value=logs, inline=True) + embed.add_field(name=t(lang, "setup.points_channel_field"), value=points, inline=True) + + if state.logs_channel_id and not await is_guild_entitled(int(state.guild_id)): + embed.add_field( + name=t(lang, "setup.premium_required_field"), + value=t(lang, "setup.premium_required_value"), + inline=False, + ) + + embed.set_footer(text=DEFAULT_FOOTER_CAT) + return embed + + +class SetupSquadronModal(discord.ui.Modal): + """Modal for entering the squadron short name.""" + + def __init__(self, state: SetupState, lang: str = "en"): + super().__init__(title=t(lang, "setup.modal_title")) + self.state = state + self.lang = lang + self.squadron_input = discord.ui.TextInput( + label=t(lang, "setup.modal_label"), + placeholder=t(lang, "setup.modal_placeholder"), + required=True, + max_length=50, + ) + self.add_item(self.squadron_input) + + async def on_submit(self, interaction: discord.Interaction): + raw = self.squadron_input.value.strip() + short = re.sub(r'\W+', '', raw) + + try: + clan = await resolve_clan(short=short) + except Exception as e: + logging.error(f"Error resolving squadron {short} in setup wizard: {e}") + clan = None + + if not clan or clan["long_name"] == "": + await interaction.response.send_message( + t(self.lang, "setup.squadron_not_found", squadron=raw), + ephemeral=True, + ) + return + + new_long = clan["long_name"] + new_short = clan["short_name"] + + # Save to SQUADRONS.json (same pattern as /set-squadron) + squadrons_path = STORAGE_DIR / "SQUADRONS.json" + squadrons = await load_json(squadrons_path, {}) + + # Only write if value actually changed + current = squadrons.get(self.state.guild_id) + if not current or current.get("SQ_ShortHand_Name") != new_short: + squadrons[self.state.guild_id] = { + "SQ_ShortHand_Name": new_short, + "SQ_LongHandName": new_long, + } + await write_json(squadrons_path, squadrons) + logging.info(f"Setup wizard: set squadron for guild {self.state.guild_id} → {new_long}") + + self.state.squadron_short = new_short + self.state.squadron_long = new_long + clan_id_val = clan.get("clan_id") if isinstance(clan, dict) else None + self.state.squadron_clan_id = int(clan_id_val) if clan_id_val else None + + # Advance to Step 2 + view = SetupLogsView(self.state, self.lang) + await interaction.response.edit_message(embed=_step2_embed(self.state, self.lang), view=view) + + +class SetupWelcomeView(discord.ui.View): + """Step 1 — Welcome + Set Squadron.""" + + def __init__(self, state: SetupState, lang: str = "en"): + super().__init__(timeout=300) + self.state = state + self.lang = lang + self.set_squadron_btn.label = t(lang, "buttons.set_squadron") + self.skip_step.label = t(lang, "buttons.skip") + # Only show Skip if squadron is already configured + if not state.squadron_short: + self.skip_step.disabled = True + self.remove_item(self.skip_step) + + @discord.ui.button(label="Set Squadron", style=discord.ButtonStyle.green) + async def set_squadron_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + modal = SetupSquadronModal(self.state, self.lang) + await interaction.response.send_modal(modal) + + @discord.ui.button(label="Skip", style=discord.ButtonStyle.grey) + async def skip_step(self, interaction: discord.Interaction, button: discord.ui.Button): + view = SetupLogsView(self.state, self.lang) + await interaction.response.edit_message(embed=_step2_embed(self.state, self.lang), view=view) + + +class SetupLogsChannelSelect(discord.ui.ChannelSelect): + """Channel select for the logs channel (Step 2).""" + + def __init__(self, state: SetupState, lang: str = "en"): + super().__init__( + placeholder=t(lang, "setup.logs_channel_placeholder"), + channel_types=[discord.ChannelType.text, discord.ChannelType.news], + min_values=1, + max_values=1, + ) + self.state = state + self.lang = lang + + async def callback(self, interaction: discord.Interaction): + channel = self.values[0] + self.state.logs_channel_id = channel.id + + # Save to preferences + await _save_channel_pref(self.state, "Logs", channel.id) + + # Advance to Step 3 + view = SetupPointsView(self.state, self.lang) + await interaction.response.edit_message(embed=_step3_embed(self.state, self.lang), view=view) + + +class SetupLogsView(discord.ui.View): + """Step 2 — Set Logs Channel.""" + + def __init__(self, state: SetupState, lang: str = "en"): + super().__init__(timeout=300) + self.state = state + self.lang = lang + self.add_item(SetupLogsChannelSelect(state, lang)) + self.skip_step.label = t(lang, "buttons.skip") + + @discord.ui.button(label="Skip", style=discord.ButtonStyle.grey) + async def skip_step(self, interaction: discord.Interaction, button: discord.ui.Button): + view = SetupPointsView(self.state, self.lang) + await interaction.response.edit_message(embed=_step3_embed(self.state, self.lang), view=view) + + +class SetupPointsChannelSelect(discord.ui.ChannelSelect): + """Channel select for the points channel (Step 3).""" + + def __init__(self, state: SetupState, lang: str = "en"): + super().__init__( + placeholder=t(lang, "setup.points_channel_placeholder"), + channel_types=[discord.ChannelType.text, discord.ChannelType.news], + min_values=1, + max_values=1, + ) + self.state = state + self.lang = lang + + async def callback(self, interaction: discord.Interaction): + channel = self.values[0] + self.state.points_channel_id = channel.id + + # Save to preferences + await _save_channel_pref(self.state, "Points", channel.id) + + # Advance to Summary + await interaction.response.edit_message(embed=await _summary_embed(self.state, self.lang), view=None) + + +class SetupPointsView(discord.ui.View): + """Step 3 — Set Points Channel.""" + + def __init__(self, state: SetupState, lang: str = "en"): + super().__init__(timeout=300) + self.state = state + self.lang = lang + self.add_item(SetupPointsChannelSelect(state, lang)) + self.same_as_logs_btn.label = t(lang, "buttons.same_as_logs") + self.skip_step.label = t(lang, "buttons.skip") + # Only show "Same as Logs" if logs channel was set + if not state.logs_channel_id: + self.remove_item(self.same_as_logs_btn) + + @discord.ui.button(label="Same as Logs", style=discord.ButtonStyle.blurple) + async def same_as_logs_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + assert self.state.logs_channel_id is not None # button only shown when logs is set + self.state.points_channel_id = self.state.logs_channel_id + + # Save to preferences + await _save_channel_pref(self.state, "Points", self.state.logs_channel_id) + + await interaction.response.edit_message(embed=await _summary_embed(self.state, self.lang), view=None) + + @discord.ui.button(label="Skip", style=discord.ButtonStyle.grey) + async def skip_step(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.response.edit_message(embed=await _summary_embed(self.state, self.lang), view=None) + + +async def _save_channel_pref(state: SetupState, alarm_type: str, channel_id: int): + """Save a Logs or Points channel preference (same format as quick-log).""" + if not state.squadron_long: + return + guild_id = int(state.guild_id) + prefs = await load_guild_preferences(guild_id) + # Prefer the stable clan_id as the prefs key (matches quick-log writes). + pref_key = str(state.squadron_clan_id) if state.squadron_clan_id else state.squadron_long + entry = prefs.setdefault(pref_key, {}) + entry[alarm_type] = f"<#{channel_id}>" + if state.squadron_long: + entry["Long"] = state.squadron_long + if state.squadron_short: + entry["Short"] = state.squadron_short + await save_guild_preferences(guild_id, prefs) + logging.info( + f"Setup wizard: saved {alarm_type} channel {channel_id} for " + f"{state.squadron_long} (key={pref_key}) in guild {state.guild_id}" + ) + + +@is_blacklisted() +@is_admin() +@bot.tree.command(name='setup', description=command_locale('Set up the bot for this server', "commands.setup.description")) +async def setup(interaction: discord.Interaction): + """Launch the server setup wizard. + + Pre-fills state from existing SQUADRONS.json config, then presents a + three-step wizard: (1) set squadron, (2) choose logs channel, (3) choose + points channel. Each step is a Discord UI view with skip/continue buttons. + """ + await collect_command_stats(interaction) + await interaction.response.defer(ephemeral=True) + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + + guild_id = str(interaction.guild_id) + + # Pre-fill state from existing config + state = SetupState(guild_id=guild_id) + + squadrons_path = STORAGE_DIR / "SQUADRONS.json" + squadrons = await load_json(squadrons_path, {}) + if guild_id in squadrons: + state.squadron_short = squadrons[guild_id].get("SQ_ShortHand_Name") + state.squadron_long = squadrons[guild_id].get("SQ_LongHandName") + # Resolve clan_id so subsequent prefs writes use the canonical key. + if state.squadron_short: + try: + clan = await resolve_clan(short=state.squadron_short) + if clan and clan.get("clan_id"): + state.squadron_clan_id = int(clan["clan_id"]) + except Exception: + pass + + view = SetupWelcomeView(state, lang) + await interaction.followup.send(embed=_step1_embed(state, lang), view=view, ephemeral=True) + + +@setup.error +async def setup_error(interaction, error): + await permission_fail(interaction, error) + + +class PasswordModal(discord.ui.Modal): + """Modal for entering a squadron's access password to authenticate or claim ownership.""" + + def __init__(self, squadron_clanID: str, squadron_name: str, guild_id: str, + is_owner: bool = False, password_hint: str = "", lang: str = "en"): + self.lang = lang + super().__init__(title=t(lang, "meta_management.password_modal_title")) + self.squadron_clanID = squadron_clanID + self.squadron_name = squadron_name + self.guild_id = guild_id + self.is_owner = is_owner + placeholder = f"Hint: {password_hint}" if password_hint else t(lang, "meta_management.password_modal_placeholder") + self.password_input = discord.ui.TextInput( + label=t(lang, "meta_management.password_modal_label"), + placeholder=placeholder, + required=True, + max_length=50 + ) + self.add_item(self.password_input) + + async def on_submit(self, interaction: discord.Interaction): + """Validate the password, optionally transfer the squadron, and show MetaManagementView. + + If the submitting server already owns the squadron, re-authenticates without + transfer. Otherwise checks the squadron data lock before transferring + ownership to the new guild. On success, displays the management settings panel. + """ + # Validate password + password_provided = self.password_input.value.strip() + logging.info(f"[PASSWORD CHECK] squadron_clanID: {self.squadron_clanID}, is_owner: {self.is_owner}") + + is_valid = await validate_squadron_password(self.squadron_clanID, password_provided) + + if not is_valid: + logging.warning(f"[PASSWORD CHECK] FAILED for squadron {self.squadron_clanID}") + embed = discord.Embed( + title=t(self.lang, "meta_management.access_denied_title"), + description=t(self.lang, "meta_management.access_denied_desc"), + color=discord.Color.red() + ) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + logging.info(f"[PASSWORD CHECK] SUCCESS for squadron {self.squadron_clanID}") + + # Get squadron owner settings + squadron_owner = await get_squadron_owner(self.squadron_clanID) + + if self.is_owner: + # This server owns the squadron, they're just re-authenticating + # No transfer needed, just show settings + guild_settings = await get_guild_settings(self.guild_id) + else: + # Different server trying to claim the squadron + # Check if squadron data is locked + if squadron_owner and squadron_owner.get("lock_squadron_data", False): + logging.warning(f"[PASSWORD CHECK] Transfer BLOCKED - squadron {self.squadron_clanID} is locked") + embed = discord.Embed( + title=t(self.lang, "meta_management.data_locked_title"), + description=t(self.lang, "meta_management.data_locked_desc", squadron=self.squadron_name), + color=discord.Color.orange() + ) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + # Password correct and not locked - transfer squadron to this server + await transfer_squadron_to_guild(self.squadron_clanID, self.guild_id, self.squadron_name) + + # Get updated settings after transfer + guild_settings = await get_guild_settings(self.guild_id) + + if not guild_settings: + await interaction.response.send_message( + t(self.lang, "meta_management.error_retrieving_settings"), + ephemeral=True + ) + return + + # Show success message with management view + if self.is_owner: + embed = discord.Embed( + title=t(self.lang, "meta_management.authenticated_title"), + description=t(self.lang, "meta_management.authenticated_desc", squadron=self.squadron_name), + color=discord.Color.green() + ) + else: + embed = discord.Embed( + title=t(self.lang, "meta_management.claimed_title"), + description=t(self.lang, "meta_management.claimed_desc", squadron=self.squadron_name), + color=discord.Color.green() + ) + embed.add_field( + name=t(self.lang, "meta_management.password_requirement_field"), + value=t(self.lang, "meta_management.enabled_value") if guild_settings["require_password"] else t(self.lang, "meta_management.disabled_value"), + inline=True + ) + embed.add_field( + name=t(self.lang, "meta_management.data_lock_field"), + value=t(self.lang, "meta_management.enabled_value") if guild_settings["lock_squadron_data"] else t(self.lang, "meta_management.disabled_value"), + inline=True + ) + embed.add_field( + name=t(self.lang, "meta_management.public_meta_field"), + value=t(self.lang, "meta_management.enabled_value") if guild_settings["allow_public_meta"] else t(self.lang, "meta_management.disabled_value"), + inline=True + ) + if interaction.guild and interaction.user.id == interaction.guild.owner_id: + embed.add_field( + name=t(self.lang, "meta_management.access_password_field"), + value=f"`{guild_settings['access_password']}`", + inline=False + ) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + + # Create management view + view = MetaManagementView( + guild_id=self.guild_id, + squadron_name=self.squadron_name, + squadron_clanID=self.squadron_clanID, + require_password=guild_settings["require_password"], + lock_squadron_data=guild_settings["lock_squadron_data"], + allow_public_meta=guild_settings["allow_public_meta"], + lang=self.lang + ) + + # Update button styles based on current state + if guild_settings["require_password"]: + button = view.children[0] + if isinstance(button, discord.ui.Button): + button.style = discord.ButtonStyle.success + button.label = t(self.lang, "buttons.password_required") + + if guild_settings["lock_squadron_data"]: + button = view.children[1] + if isinstance(button, discord.ui.Button): + button.style = discord.ButtonStyle.success + button.label = t(self.lang, "buttons.data_locked") + + if guild_settings["allow_public_meta"]: + button = view.children[2] + if isinstance(button, discord.ui.Button): + button.style = discord.ButtonStyle.success + button.label = t(self.lang, "buttons.public_enabled") + + await interaction.response.send_message(embed=embed, view=view, ephemeral=True) + view.message = await interaction.original_response() + + +class ChangePasswordModal(discord.ui.Modal): + """Modal for changing a squadron's access password (server owner only).""" + + def __init__(self, squadron_clanID: str, squadron_name: str, guild_id: str, lang: str = "en"): + self.lang = lang + super().__init__(title=t(lang, "meta_management.change_pw_modal_title")) + self.squadron_clanID = squadron_clanID + self.squadron_name = squadron_name + self.guild_id = guild_id + self.current_password = discord.ui.TextInput( + label=t(lang, "meta_management.current_password_label"), + placeholder=t(lang, "meta_management.current_password_placeholder"), + required=True, + max_length=50 + ) + self.new_password = discord.ui.TextInput( + label=t(lang, "meta_management.new_password_label"), + placeholder=t(lang, "meta_management.new_password_placeholder"), + required=True, + max_length=50 + ) + self.confirm_password = discord.ui.TextInput( + label=t(lang, "meta_management.confirm_password_label"), + placeholder=t(lang, "meta_management.confirm_password_placeholder"), + required=True, + max_length=50 + ) + self.hint_input = discord.ui.TextInput( + label=t(lang, "meta_management.hint_label"), + placeholder=t(lang, "meta_management.hint_placeholder"), + required=False, + max_length=100 + ) + self.add_item(self.current_password) + self.add_item(self.new_password) + self.add_item(self.confirm_password) + self.add_item(self.hint_input) + + async def on_submit(self, interaction: discord.Interaction): + """Validate the current password, check new passwords match, then update. + + Sends a confirmation message with the new password on success. + """ + await interaction.response.defer(ephemeral=True) + + current = self.current_password.value.strip() + new_pw = self.new_password.value.strip() + confirm = self.confirm_password.value.strip() + hint = self.hint_input.value.strip() + + # Validate current password + is_valid = await validate_squadron_password(self.squadron_clanID, current) + if not is_valid: + await interaction.followup.send( + t(self.lang, "meta_management.pw_incorrect"), ephemeral=True + ) + return + + # Validate new passwords match + if new_pw != confirm: + await interaction.followup.send( + t(self.lang, "meta_management.pw_mismatch"), ephemeral=True + ) + return + + # Validate new password isn't empty after strip + if not new_pw: + await interaction.followup.send( + t(self.lang, "meta_management.pw_empty"), ephemeral=True + ) + return + + # Update password and hint + await update_squadron_password( + self.squadron_clanID, new_pw, + password_hint=hint if hint else "" + ) + + logging.info(f"[PASSWORD CHANGE] Password changed for squadron {self.squadron_clanID} by guild {self.guild_id}") + + hint_suffix = t(self.lang, "meta_management.pw_changed_hint", hint=hint) if hint else "" + await interaction.followup.send( + t(self.lang, "meta_management.pw_changed", squadron=self.squadron_name, password=new_pw) + hint_suffix, + ephemeral=True + ) + + +class PlayerAddModal(discord.ui.Modal): + """Modal for adding a single player to the guild's meta roster by UID or nickname.""" + + def __init__(self, guild_id: str, squadron_clanID: str, parent_view, lang: str = "en"): + self.lang = lang + super().__init__(title=t(lang, "meta_management.player_add_modal_title")) + self.guild_id = guild_id + self.squadron_clanID = squadron_clanID + self.parent_view = parent_view + self.player_input = discord.ui.TextInput( + label=t(lang, "meta_management.player_add_label"), + placeholder=t(lang, "meta_management.player_add_placeholder"), + required=True, + max_length=100 + ) + self.add_item(self.player_input) + + async def on_submit(self, interaction: discord.Interaction): + """Look up the player in Players_Global by UID or nick, then add to guild meta. + + Refreshes the parent PlayerManagementView on success. + """ + await interaction.response.defer(ephemeral=True) + + player_input = self.player_input.value.strip() + + # Try to find player by UID or nickname in Players_Global + meta_db_path = STORAGE_DIR / "Meta.db" + async with aiosqlite.connect(meta_db_path) as db: + # Try by UID first + cursor = await db.execute(""" + SELECT DISTINCT userID, nick, clanTag, clanName, clanID + FROM Players_Global + WHERE userID = ? + LIMIT 1 + """, (player_input,)) + row = await cursor.fetchone() + + # If not found, try by nickname + if not row: + cursor = await db.execute(""" + SELECT DISTINCT userID, nick, clanTag, clanName, clanID + FROM Players_Global + WHERE LOWER(nick) = ? + LIMIT 1 + """, (player_input.lower(),)) + row = await cursor.fetchone() + + if not row: + await interaction.followup.send(t(self.lang, "meta_management.player_not_found", player=player_input), ephemeral=True) + return + + user_id = str(row[0]) + nick = str(row[1]) + + # Add player to guild meta + success, message = await add_player_to_guild_meta(self.guild_id, user_id, self.squadron_clanID) + + if success: + await interaction.followup.send(f"✅ {message}", ephemeral=True) + # Refresh the parent view + await self.parent_view.refresh_display(interaction) + else: + await interaction.followup.send(f"❌ {message}", ephemeral=True) + + +class PlayerManagementView(discord.ui.View): + """Paginated view for managing a squadron's meta roster (add/remove players).""" + + def __init__(self, guild_id: str, squadron_name: str, squadron_clanID: str, lang: str = "en"): + super().__init__(timeout=300) # 5 minutes + self.guild_id = guild_id + self.squadron_name = squadron_name + self.squadron_clanID = squadron_clanID + self.lang = lang + self.current_page = 0 + self.players_per_page = 10 + + async def refresh_display(self, interaction: discord.Interaction): + """Rebuild and re-render the player roster embed with pagination and action buttons. + + Fetches the current player list from the meta database, paginates it, and + updates the message with add/remove controls and navigation buttons. + + Args: + interaction: The Discord interaction to edit the message on. + """ + # Get all players in guild meta + players = await get_guild_meta_players(self.guild_id) + + # Create embed + embed = discord.Embed( + title=t(self.lang, "meta_management.roster_title", squadron=self.squadron_name), + description=t(self.lang, "meta_management.roster_desc", clan_id=self.squadron_clanID, count=len(players)), + color=discord.Color.blue() + ) + + if players: + # Paginate players + start_idx = self.current_page * self.players_per_page + end_idx = start_idx + self.players_per_page + page_players = players[start_idx:end_idx] + + player_list = [] + for i, player in enumerate(page_players, start=start_idx + 1): + player_list.append( + f"{i}. **{esc(player['nick'])}** (`{player['userID']}`)" + ) + + total_pages = (len(players) - 1) // self.players_per_page + 1 + embed.add_field( + name=t(self.lang, "meta_management.roster_page_field", page=self.current_page + 1, total=total_pages), + value="\n".join(player_list), + inline=False + ) + else: + embed.add_field( + name=t(self.lang, "meta_management.no_players_field"), + value=t(self.lang, "meta_management.no_players_hint"), + inline=False + ) + + embed.set_footer(text=DEFAULT_FOOTER_CAT) + + # Update buttons + self.clear_items() + self.add_item(AddPlayerButton(self.guild_id, self.squadron_clanID, self, self.lang)) + self.add_item(AddAllSquadronButton(self.guild_id, self.squadron_name, self.squadron_clanID, self, self.lang)) + + if players: + # Calculate indices for current page + start_idx = self.current_page * self.players_per_page + end_idx = start_idx + self.players_per_page + self.add_item(RemovePlayerSelect(self.guild_id, players[start_idx:end_idx], self, self.lang)) + + # Add pagination buttons if needed + total_pages = (len(players) - 1) // self.players_per_page + 1 + if total_pages > 1: + if self.current_page > 0: + self.add_item(PlayerPrevPageButton(self, self.lang)) + if self.current_page < total_pages - 1: + self.add_item(PlayerNextPageButton(self, self.lang)) + + self.add_item(BackToSettingsButton(self.guild_id, self.squadron_name, self.squadron_clanID, self.lang)) + + # Edit or send message + try: + await interaction.response.edit_message(embed=embed, view=self) + except discord.errors.InteractionResponded: + await interaction.edit_original_response(embed=embed, view=self) + + +class AddPlayerButton(discord.ui.Button): + """Button that opens the PlayerAddModal to add a single player to the meta roster.""" + + def __init__(self, guild_id: str, squadron_clanID: str, parent_view, lang: str = "en"): + super().__init__(label=t(lang, "buttons.add_player"), style=discord.ButtonStyle.success, row=0) + self.guild_id = guild_id + self.squadron_clanID = squadron_clanID + self.parent_view = parent_view + self.lang = lang + + async def callback(self, interaction: discord.Interaction): + modal = PlayerAddModal(self.guild_id, self.squadron_clanID, self.parent_view, self.lang) + await interaction.response.send_modal(modal) + + +class AddAllSquadronButton(discord.ui.Button): + """Button that syncs the entire squadron roster into the guild's meta player list.""" + + def __init__(self, guild_id: str, squadron_name: str, squadron_clanID: str, parent_view, lang: str = "en"): + super().__init__(label=t(lang, "buttons.update_all"), style=discord.ButtonStyle.primary, row=0) + self.guild_id = guild_id + self.squadron_name = squadron_name + self.squadron_clanID = squadron_clanID + self.parent_view = parent_view + self.lang = lang + + async def callback(self, interaction: discord.Interaction): + """Fetch all squadron members via API, bulk-sync the roster, and refresh vehicles. + + Resolves the clan tag from squadrons_data, fetches current members via + obtain_clan_new_points, bulk-adds new / removes departed members, then + kicks off a background task to refresh vehicle data for the guild. + """ + await interaction.response.defer(ephemeral=True) + + # Get squadron tag from squadrons_data + clan_tag = "" + try: + async with aiosqlite.connect(SQUADRONS_DB_PATH) as db: + cursor = await db.execute( + "SELECT tag_name FROM squadrons_data WHERE clan_id = ? LIMIT 1", + (self.squadron_clanID,) + ) + row = await cursor.fetchone() + if row: + clan_tag = row[0] or "" + except Exception: + pass + + # Fetch all squadron members from API + try: + members, _total_points = await obtain_clan_new_points(self.squadron_name) + except Exception as e: + await interaction.followup.send( + t(self.lang, "meta_management.fetch_members_failed", error=e), + ephemeral=True + ) + return + + if not members: + await interaction.followup.send( + t(self.lang, "meta_management.no_members_found"), + ephemeral=True + ) + return + + # Sync all members (add new, remove departed) + added, removed, skipped = await bulk_add_squadron_players_to_guild_meta( + self.guild_id, self.squadron_clanID, self.squadron_name, clan_tag, members + ) + + parts = [t(self.lang, "meta_management.roster_synced")] + if added: + parts.append(t(self.lang, "meta_management.roster_added", count=added)) + if removed: + parts.append(t(self.lang, "meta_management.roster_removed", count=removed)) + if skipped: + parts.append(t(self.lang, "meta_management.roster_up_to_date", count=skipped)) + parts.append(t(self.lang, "meta_management.refreshing_vehicles")) + await interaction.followup.send(" · ".join(parts), ephemeral=True) + + # Refresh vehicle data for all guild members in the background + asyncio.create_task(refresh_guild_player_vehicles(self.guild_id)) + + # Refresh parent view + await self.parent_view.refresh_display(interaction) + + +class RemovePlayerSelect(discord.ui.Select): + """Dropdown select to remove a player from the guild's meta roster.""" + + def __init__(self, guild_id: str, players: list, parent_view, lang: str = "en"): + options = [ + discord.SelectOption( + label=f"{player['nick'][:50]}", # Truncate if too long + description=f"UID: {player['userID']}", + value=player['userID'] + ) + for player in players[:25] # Discord limit + ] + super().__init__(placeholder=t(lang, "meta_management.remove_player_placeholder"), options=options, row=1) + self.guild_id = guild_id + self.parent_view = parent_view + self.lang = lang + + async def callback(self, interaction: discord.Interaction): + await interaction.response.defer(ephemeral=True) + + user_id = self.values[0] + success, message = await remove_player_from_guild_meta(self.guild_id, user_id) + + if success: + await interaction.followup.send(f"✅ {message}", ephemeral=True) + # Refresh the parent view + await self.parent_view.refresh_display(interaction) + else: + await interaction.followup.send(f"❌ {message}", ephemeral=True) + + +class PlayerPrevPageButton(discord.ui.Button): + """Previous-page button for PlayerManagementView pagination.""" + + def __init__(self, parent_view, lang: str = "en"): + super().__init__(label=t(lang, "buttons.prev_arrow"), style=discord.ButtonStyle.secondary, row=2) + self.parent_view = parent_view + + async def callback(self, interaction: discord.Interaction): + self.parent_view.current_page -= 1 + await self.parent_view.refresh_display(interaction) + + +class PlayerNextPageButton(discord.ui.Button): + """Next-page button for PlayerManagementView pagination.""" + + def __init__(self, parent_view, lang: str = "en"): + super().__init__(label=t(lang, "buttons.next_arrow"), style=discord.ButtonStyle.secondary, row=2) + self.parent_view = parent_view + + async def callback(self, interaction: discord.Interaction): + self.parent_view.current_page += 1 + await self.parent_view.refresh_display(interaction) + + +class BackToSettingsButton(discord.ui.Button): + """Button that navigates back from player management to the MetaManagementView settings panel.""" + + def __init__(self, guild_id: str, squadron_name: str, squadron_clanID: str, lang: str = "en"): + super().__init__(label=t(lang, "buttons.back_to_settings"), style=discord.ButtonStyle.secondary, row=3) + self.guild_id = guild_id + self.squadron_name = squadron_name + self.squadron_clanID = squadron_clanID + self.lang = lang + + async def callback(self, interaction: discord.Interaction): + """Reload guild settings and rebuild the MetaManagementView with current toggle states.""" + await interaction.response.defer(ephemeral=True) + + # Get current settings + guild_settings = await get_guild_settings(self.guild_id) + + if not guild_settings: + await interaction.followup.send( + t(self.lang, "meta_management.error_retrieving_settings_retry"), + ephemeral=True + ) + return + + # Show settings embed + embed = discord.Embed( + title=t(self.lang, "meta_management.settings_title"), + description=t(self.lang, "meta_management.settings_desc", squadron=self.squadron_name, clan_id=self.squadron_clanID), + color=discord.Color.blue() + ) + embed.add_field( + name=t(self.lang, "meta_management.password_requirement_field"), + value=t(self.lang, "meta_management.enabled_value") if guild_settings["require_password"] else t(self.lang, "meta_management.disabled_value"), + inline=True + ) + embed.add_field( + name=t(self.lang, "meta_management.data_lock_field"), + value=t(self.lang, "meta_management.enabled_value") if guild_settings["lock_squadron_data"] else t(self.lang, "meta_management.disabled_value"), + inline=True + ) + embed.add_field( + name=t(self.lang, "meta_management.public_meta_field"), + value=t(self.lang, "meta_management.enabled_value") if guild_settings["allow_public_meta"] else t(self.lang, "meta_management.disabled_value"), + inline=True + ) + if interaction.guild and interaction.user.id == interaction.guild.owner_id: + embed.add_field( + name=t(self.lang, "meta_management.access_password_field"), + value=f"`{guild_settings['access_password']}`", + inline=False + ) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + + # Create view with buttons + view = MetaManagementView( + guild_id=self.guild_id, + squadron_name=self.squadron_name, + squadron_clanID=self.squadron_clanID, + require_password=guild_settings["require_password"], + lock_squadron_data=guild_settings["lock_squadron_data"], + allow_public_meta=guild_settings["allow_public_meta"], + lang=self.lang + ) + + # Update button styles based on current state + if guild_settings["require_password"]: + button = view.children[0] + if isinstance(button, discord.ui.Button): + button.style = discord.ButtonStyle.success + button.label = t(self.lang, "buttons.password_required") + + if guild_settings["lock_squadron_data"]: + button = view.children[1] + if isinstance(button, discord.ui.Button): + button.style = discord.ButtonStyle.success + button.label = t(self.lang, "buttons.data_locked") + + if guild_settings["allow_public_meta"]: + button = view.children[2] + if isinstance(button, discord.ui.Button): + button.style = discord.ButtonStyle.success + button.label = t(self.lang, "buttons.public_enabled") + + view.message = await interaction.edit_original_response(embed=embed, view=view) + + +class MetaManagementView(discord.ui.View): + """Settings panel for managing squadron meta configuration. + + Provides toggle buttons for password requirement, data lock, and public meta + access, plus navigation to player roster management and password change. + """ + + def __init__(self, guild_id: str, squadron_name: str, squadron_clanID: str, + require_password: bool, lock_squadron_data: bool, allow_public_meta: bool, + lang: str = "en"): + super().__init__(timeout=300) # 5 minutes + self.message: Optional[discord.Message] = None + self.guild_id = guild_id + self.squadron_name = squadron_name + self.squadron_clanID = squadron_clanID + self.require_password = require_password + self.lock_squadron_data = lock_squadron_data + self.allow_public_meta = allow_public_meta + self.lang = lang + self.toggle_password.label = t(lang, "buttons.require_password") + self.toggle_lock.label = t(lang, "buttons.lock_data") + self.toggle_public_meta.label = t(lang, "buttons.allow_public") + self.update_meta_accounts.label = t(lang, "buttons.update_accounts") + self.change_password.label = t(lang, "buttons.change_password") + self.show_help.label = t(lang, "buttons.help") + + async def on_timeout(self): + for item in self.children: + item.disabled = True # type: ignore[attr-defined] + try: + if self.message: + await self.message.edit(view=self) + except Exception: + pass + + @discord.ui.button(label="🔒 Require Password", style=discord.ButtonStyle.secondary, row=0) + async def toggle_password(self, interaction: discord.Interaction, button: discord.ui.Button): + """Toggle whether a password is required to access meta management on this server.""" + # Toggle the setting + new_value = not self.require_password + success = await update_guild_settings(self.guild_id, require_password=new_value) + + if success: + self.require_password = new_value + # Update button style + button.style = discord.ButtonStyle.success if new_value else discord.ButtonStyle.secondary + button.label = t(self.lang, "buttons.password_required") if new_value else t(self.lang, "buttons.require_password") + + await interaction.response.edit_message(view=self) + state_str = t(self.lang, "common.enabled") if new_value else t(self.lang, "common.disabled") + await interaction.followup.send( + t(self.lang, "meta_management.password_toggled", state=state_str), + ephemeral=True + ) + else: + await interaction.response.send_message(t(self.lang, "common.failed_update_setting"), ephemeral=True) + + @discord.ui.button(label="🔐 Lock Squadron Data", style=discord.ButtonStyle.secondary, row=0) + async def toggle_lock(self, interaction: discord.Interaction, button: discord.ui.Button): + """Toggle whether squadron data is locked to this server (prevents transfers).""" + # Toggle the setting + new_value = not self.lock_squadron_data + success = await update_guild_settings(self.guild_id, lock_squadron_data=new_value) + + if success: + self.lock_squadron_data = new_value + # Update button style + button.style = discord.ButtonStyle.success if new_value else discord.ButtonStyle.secondary + button.label = t(self.lang, "buttons.data_locked") if new_value else t(self.lang, "buttons.lock_data") + + await interaction.response.edit_message(view=self) + state_str = t(self.lang, "common.enabled") if new_value else t(self.lang, "common.disabled") + await interaction.followup.send( + t(self.lang, "meta_management.lock_toggled", state=state_str), + ephemeral=True + ) + else: + await interaction.response.send_message(t(self.lang, "common.failed_update_setting"), ephemeral=True) + + @discord.ui.button(label="👥 Allow Public Meta", style=discord.ButtonStyle.secondary, row=1) + async def toggle_public_meta(self, interaction: discord.Interaction, button: discord.ui.Button): + """Toggle whether non-admin members can use the /meta command.""" + # Toggle the setting + new_value = not self.allow_public_meta + success = await update_guild_settings(self.guild_id, allow_public_meta=new_value) + + if success: + self.allow_public_meta = new_value + # Update button style + button.style = discord.ButtonStyle.success if new_value else discord.ButtonStyle.secondary + button.label = t(self.lang, "buttons.public_enabled") if new_value else t(self.lang, "buttons.allow_public") + + await interaction.response.edit_message(view=self) + state_str = t(self.lang, "common.enabled") if new_value else t(self.lang, "common.disabled") + detail = t(self.lang, "meta_management.public_meta_enabled_detail") if new_value else t(self.lang, "meta_management.public_meta_disabled_detail") + await interaction.followup.send( + t(self.lang, "meta_management.public_meta_toggled", state=state_str, detail=detail), + ephemeral=True + ) + else: + await interaction.response.send_message(t(self.lang, "common.failed_update_setting"), ephemeral=True) + + @discord.ui.button(label="📋 Update Meta Accounts", style=discord.ButtonStyle.primary, row=1) + async def update_meta_accounts(self, interaction: discord.Interaction, button: discord.ui.Button): + """Open the PlayerManagementView for adding/removing players from the meta roster.""" + # Show player management view + view = PlayerManagementView(self.guild_id, self.squadron_name, self.squadron_clanID, self.lang) + await view.refresh_display(interaction) + + @discord.ui.button(label="🔑 Change Password", style=discord.ButtonStyle.secondary, row=2) + async def change_password(self, interaction: discord.Interaction, button: discord.ui.Button): + """Open the ChangePasswordModal (restricted to the server owner).""" + if not interaction.guild or interaction.user.id != interaction.guild.owner_id: + await interaction.response.send_message( + t(self.lang, "meta_management.owner_only_password"), + ephemeral=True + ) + return + modal = ChangePasswordModal(self.squadron_clanID, self.squadron_name, self.guild_id, self.lang) + await interaction.response.send_modal(modal) + + @discord.ui.button(label="❓ Help", style=discord.ButtonStyle.secondary, row=3) + async def show_help(self, interaction: discord.Interaction, button: discord.ui.Button): + """Display an ephemeral embed explaining each meta management setting.""" + embed = discord.Embed( + title=t(self.lang, "meta_management.help_title"), + description=t(self.lang, "meta_management.help_desc"), + color=discord.Color.blue() + ) + + embed.add_field( + name=t(self.lang, "meta_management.help_password_field"), + value=t(self.lang, "meta_management.help_password_value"), + inline=False + ) + + embed.add_field( + name=t(self.lang, "meta_management.help_require_field"), + value=t(self.lang, "meta_management.help_require_value"), + inline=False + ) + + embed.add_field( + name=t(self.lang, "meta_management.help_lock_field"), + value=t(self.lang, "meta_management.help_lock_value"), + inline=False + ) + + embed.add_field( + name=t(self.lang, "meta_management.help_public_field"), + value=t(self.lang, "meta_management.help_public_value"), + inline=False + ) + + embed.add_field( + name=t(self.lang, "meta_management.help_accounts_field"), + value=t(self.lang, "meta_management.help_accounts_value"), + inline=False + ) + + embed.add_field( + name=t(self.lang, "meta_management.help_change_pw_field"), + value=t(self.lang, "meta_management.help_change_pw_value"), + inline=False + ) + + embed.set_footer(text=DEFAULT_FOOTER_CAT) + + await interaction.response.send_message(embed=embed, ephemeral=True) + + +@is_blacklisted() +@is_admin() +@bot.tree.command(name='meta-management', description=command_locale('Manage meta data access settings for this server', "commands.meta_management.description")) +async def meta_management(interaction: discord.Interaction): + """Manage meta data access settings for the guild's squadron. + + Resolves the guild's squadron and clan ID, then determines the setup state: + first-time setup (generates password and creates guild entry), owner access + (bypasses or prompts password based on require_password setting), or + foreign-server access (always requires password). Displays a settings panel + with toggle buttons for password requirement, data lock, and public meta access. + """ + await collect_command_stats(interaction) + + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + guild_id = str(interaction.guild_id) + + try: + clan = await get_guild_squadron(interaction.guild_id) + except ValueError as e: + await interaction.response.defer(ephemeral=True) + embed = discord.Embed(title=t(lang, "common.error_title"), description=str(e), color=discord.Color.red()) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + return await interaction.followup.send(embed=embed, ephemeral=True) + + squadron_name = clan["long_name"] + + # Get clanID from squadrons.db + async with aiosqlite.connect(SQUADRONS_DB_PATH) as db: + cursor = await db.execute(""" + SELECT clan_id FROM squadrons_data + WHERE LOWER(long_name) = ? + LIMIT 1 + """, (squadron_name.lower(),)) + row = await cursor.fetchone() + + if not row: + # Try Players_Global table from Meta.db + meta_db_path = STORAGE_DIR / "Meta.db" + async with aiosqlite.connect(meta_db_path) as meta_db: + cursor = await meta_db.execute(""" + SELECT DISTINCT clanID FROM Players_Global + WHERE LOWER(clanName) = ? + LIMIT 1 + """, (squadron_name.lower(),)) + row = await cursor.fetchone() + + if not row or not row[0]: + await interaction.response.defer(ephemeral=True) + embed = discord.Embed( + title=t(lang, "meta_management.squadron_not_found_title"), + description=t(lang, "meta_management.squadron_not_found_desc", squadron=squadron_name), + color=discord.Color.red() + ) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + await interaction.followup.send(embed=embed, ephemeral=True) + return + + squadron_clanID = str(row[0]) + + # Check if squadron is claimed by ANY server + squadron_owner = await get_squadron_owner(squadron_clanID) + + # Check if THIS server has settings for this squadron + guild_settings = await get_guild_settings(guild_id) + + # Determine state: + # 1. Squadron not claimed by anyone → First time setup + # 2. Squadron claimed by THIS server → Check require_password setting + # - If require_password enabled → Show password modal + # - If require_password disabled → Show settings directly + # 3. Squadron claimed by DIFFERENT server → Require password + + if squadron_owner is None: + # First time setup - no one has claimed this squadron yet + is_first_time = True + elif squadron_owner["guild_id"] == guild_id: + # This server owns this squadron + # Check if require_password is enabled + if squadron_owner.get("require_password", False): + # Password required even for owner + hint = squadron_owner.get("password_hint", "") + modal = PasswordModal(squadron_clanID, squadron_name, guild_id, is_owner=True, password_hint=hint, lang=lang) + await interaction.response.send_modal(modal) + return + else: + # No password required, proceed to settings + is_first_time = False + else: + # Squadron claimed by a different server - require password + # Show password modal (can't defer before modal) + hint = squadron_owner.get("password_hint", "") if squadron_owner else "" + modal = PasswordModal(squadron_clanID, squadron_name, guild_id, is_owner=False, password_hint=hint, lang=lang) + await interaction.response.send_modal(modal) + return + + # Now we can defer for the rest + await interaction.response.defer(ephemeral=True) + + if is_first_time: + # First time setup - create guild entry + password = await create_or_update_guild( + guild_id=guild_id, + squadron_clanID=squadron_clanID, + squadron_name=squadron_name, + require_password=False, + lock_squadron_data=False + ) + + # Show password to user (only server owner sees the password) + is_guild_owner = interaction.guild and interaction.user.id == interaction.guild.owner_id + if is_guild_owner: + description = t(lang, "meta_management.first_time_owner_desc", + squadron=squadron_name, clan_id=squadron_clanID, password=password) + else: + description = t(lang, "meta_management.first_time_non_owner_desc", + squadron=squadron_name, clan_id=squadron_clanID) + embed = discord.Embed( + title=t(lang, "meta_management.first_time_title"), + description=description, + color=discord.Color.blue() + ) + embed.add_field( + name=t(lang, "meta_management.settings_field"), + value=t(lang, "meta_management.settings_hint"), + inline=False + ) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + + # Create view with buttons + view = MetaManagementView( + guild_id=guild_id, + squadron_name=squadron_name, + squadron_clanID=squadron_clanID, + require_password=False, + lock_squadron_data=False, + allow_public_meta=False, + lang=lang + ) + + view.message = await interaction.followup.send(embed=embed, view=view, ephemeral=True, wait=True) + + else: + # Already set up - show current settings + if not guild_settings: + await interaction.followup.send( + t(lang, "meta_management.error_retrieving_settings_retry"), + ephemeral=True + ) + return + + embed = discord.Embed( + title=t(lang, "meta_management.settings_title"), + description=t(lang, "meta_management.settings_desc", squadron=squadron_name, clan_id=squadron_clanID), + color=discord.Color.blue() + ) + embed.add_field( + name=t(lang, "meta_management.password_requirement_field"), + value=t(lang, "meta_management.enabled_value") if guild_settings["require_password"] else t(lang, "meta_management.disabled_value"), + inline=True + ) + embed.add_field( + name=t(lang, "meta_management.data_lock_field"), + value=t(lang, "meta_management.enabled_value") if guild_settings["lock_squadron_data"] else t(lang, "meta_management.disabled_value"), + inline=True + ) + embed.add_field( + name=t(lang, "meta_management.public_meta_field"), + value=t(lang, "meta_management.enabled_value") if guild_settings["allow_public_meta"] else t(lang, "meta_management.disabled_value"), + inline=True + ) + if interaction.guild and interaction.user.id == interaction.guild.owner_id: + embed.add_field( + name=t(lang, "meta_management.access_password_field"), + value=f"`{guild_settings['access_password']}`", + inline=False + ) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + + # Create view with buttons (set initial states) + view = MetaManagementView( + guild_id=guild_id, + squadron_name=squadron_name, + squadron_clanID=squadron_clanID, + require_password=guild_settings["require_password"], + lock_squadron_data=guild_settings["lock_squadron_data"], + allow_public_meta=guild_settings["allow_public_meta"], + lang=lang + ) + + # Update button styles based on current state + if guild_settings["require_password"]: + button = view.children[0] + if isinstance(button, discord.ui.Button): + button.style = discord.ButtonStyle.success + button.label = t(lang, "buttons.password_required") + + if guild_settings["lock_squadron_data"]: + button = view.children[1] + if isinstance(button, discord.ui.Button): + button.style = discord.ButtonStyle.success + button.label = t(lang, "buttons.data_locked") + + if guild_settings["allow_public_meta"]: + button = view.children[2] + if isinstance(button, discord.ui.Button): + button.style = discord.ButtonStyle.success + button.label = t(lang, "buttons.public_enabled") + + view.message = await interaction.followup.send(embed=embed, view=view, ephemeral=True, wait=True) + + +@meta_management.error +async def meta_management_error(interaction, error): + await permission_fail(interaction, error) + + +async def meta_vehicle_autocomplete( + interaction: discord.Interaction, + current: str, +) -> List[discord.app_commands.Choice[str]]: + """Autocomplete for vehicle names in /meta command. Shows all game vehicles (minus naval).""" + cache = _utils.game_data_cache + if not cache: + return [] + + # game_data_cache entries: [cdk, human, icon, misc_params] (only vehicles with icons) + # Filter out naval vehicles + vehicles = [ + {"vehicle_name": entry[0], "vehicle_human": entry[1]} + for entry in cache + if not entry[3].get("ship", False) and not entry[3].get("boat", False) + ] + + if not current: + # Return first 25 vehicles alphabetically by human name + sorted_vehicles = sorted(vehicles, key=lambda v: (v.get("vehicle_human") or v.get("vehicle_name", "")).lower()) + return [ + discord.app_commands.Choice( + name=v.get("vehicle_human") or v.get("vehicle_name", "Unknown")[:100], + value=v.get("vehicle_name", "") + ) + for v in sorted_vehicles[:25] + ] + + # Filter vehicles matching the current input + current_lower = current.lower() + search_terms = [t for t in current_lower.split() if t] + + matches = [] + for v in vehicles: + internal = v.get("vehicle_name", "") + human = v.get("vehicle_human", "") or internal + combined = f"{internal} {human}".lower() + + # Score the match + score = 0 + + # Check if all search terms appear + if all(term in combined for term in search_terms): + score = 10 + + # Bonus for exact substring match + if current_lower in combined: + score += 15 + + # Bonus for starting with search term + if human.lower().startswith(current_lower) or internal.lower().startswith(current_lower): + score += 20 + + if score > 0: + matches.append((score, human, internal)) + + # Sort by score (descending), then alphabetically + matches.sort(key=lambda x: (-x[0], x[1].lower())) + + return [ + discord.app_commands.Choice(name=human[:100], value=internal) + for score, human, internal in matches[:25] + ] + + +class MetaResultsView(discord.ui.View): + """Paginated view for displaying meta vehicle search results across multiple embeds.""" + + def __init__(self, pages: list, title: str, total_players: int, lang: str = "en"): + super().__init__(timeout=300) + self.message: Optional[discord.Message] = None + self.pages = pages + self.title = title + self.total_players = total_players + self.current_page = 0 + self.lang = lang + self.prev_page.label = t(lang, "buttons.prev_arrow") + self.next_page.label = t(lang, "buttons.next_arrow") + + async def on_timeout(self): + for item in self.children: + item.disabled = True # type: ignore[attr-defined] + try: + if self.message: + await self.message.edit(view=self) + except Exception: + pass + + def build_embed(self) -> discord.Embed: + """Build the embed for the current page of meta search results. + + Returns: + discord.Embed: Embed containing player-vehicle matches for the current page. + """ + embed = discord.Embed( + title=self.title, + description=t(self.lang, "meta.matches_found", count=self.total_players) + "\n\n" + + "\n".join(self.pages[self.current_page]), + color=discord.Color.green() + ) + embed.set_footer(text=f"Page {self.current_page + 1}/{len(self.pages)} • {DEFAULT_FOOTER_CAT}") + return embed + + @discord.ui.button(label="◀ Previous", style=discord.ButtonStyle.secondary) + async def prev_page(self, interaction: discord.Interaction, button: discord.ui.Button): + if self.current_page > 0: + self.current_page -= 1 + await interaction.response.edit_message(embed=self.build_embed(), view=self) + + @discord.ui.button(label="Next ▶", style=discord.ButtonStyle.secondary) + async def next_page(self, interaction: discord.Interaction, button: discord.ui.Button): + if self.current_page < len(self.pages) - 1: + self.current_page += 1 + await interaction.response.edit_message(embed=self.build_embed(), view=self) + + +@is_blacklisted() +@bot.tree.command(name='meta', description=command_locale('Search squadron meta roster by vehicle name', "commands.meta.description")) +@discord.app_commands.describe(vehicle=command_locale("Vehicle name to search for", "commands.meta.vehicle")) +@discord.app_commands.autocomplete(vehicle=meta_vehicle_autocomplete) +async def meta(interaction: discord.Interaction, vehicle: str): + """Search the squadron's meta roster for players who own a given vehicle. + + Checks guild settings and permissions (admin or public meta access), resolves + the vehicle name via autocomplete or partial match, queries the meta database, + aggregates stats across game modes and vehicle variants, then displays results + in a paginated embed showing spawns, deaths, and kills per player. + """ + await collect_command_stats(interaction) + + guild_id = str(interaction.guild_id) + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + + # Get guild settings + guild_settings = await get_guild_settings(guild_id) + + if not guild_settings: + await interaction.response.send_message( + t(lang, "meta.not_configured"), + ephemeral=True + ) + return + + # Check permissions: must be admin OR allow_public_meta must be enabled + is_admin = isinstance(interaction.user, discord.Member) and interaction.user.guild_permissions.administrator + allow_public = guild_settings.get("allow_public_meta", False) + + if not is_admin and not allow_public: + await interaction.response.send_message( + t(lang, "meta.no_permission"), + ephemeral=True + ) + return + + try: + clan = await get_guild_squadron(interaction.guild_id) + except ValueError as e: + return await interaction.response.send_message(str(e), ephemeral=True) + + squadron_name = clan["long_name"] + # The meta roster is bound to whichever squadron was registered via + # /meta-management (stored in Guilds.squadron_name). SQUADRONS.json (used by + # get_guild_squadron) can drift from that, which would make the points API + # call return members of the wrong squadron and zero uid overlap. Prefer + # the meta roster's own squadron name for the points lookup. + points_squadron_name = guild_settings.get("squadron_name") or squadron_name + + await interaction.response.defer(ephemeral=True) + + vehicle_search = vehicle.strip() + + # Translate internal name to human-readable for display + # If translation succeeds, input is a known internal name (from autocomplete) + translator = LangTableReader("English") + vehicle_display = translator.get_translate(vehicle_search) + is_exact = vehicle_display is not None + if vehicle_display: + vehicle_display = normalize_name(vehicle_display) + else: + vehicle_display = vehicle_search + + # Search for vehicles matching the input + results = await search_guild_meta_by_vehicle(guild_id, vehicle_search, exact_only=is_exact) + + if not results: + msg = t(lang, "meta.no_results", vehicle=vehicle_display) + if is_admin: + msg += t(lang, "meta.no_results_admin_hint") + await interaction.followup.send(msg, ephemeral=True) + return + + # Group results by player, aggregate by human-readable name to combine variants + # (e.g., germ_leopard_2a4m and germ_leopard_2a4m_can both display as "Leopard 2A4M") + translator = LangTableReader("English") + player_vehicles = {} + for result in results: + user_id = result['userID'] + if user_id not in player_vehicles: + clan_tag = result['clanTag'] or '' + if len(clan_tag) > 2: + clan_tag = clan_tag[1:-1] + player_vehicles[user_id] = { + 'nick': result['nick'], + 'clanTag': clan_tag, + 'vehicles': {} # Dict keyed by human name to aggregate modes AND variants + } + + intname = result['intname'] + # Translate to human-readable name for aggregation key + readable_name = translator.get_translate(intname) + if readable_name: + readable_name = normalize_name(readable_name) + else: + readable_name = intname + + if readable_name not in player_vehicles[user_id]['vehicles']: + player_vehicles[user_id]['vehicles'][readable_name] = { + 'readable_name': readable_name, + 'flyouts': 0, + 'deaths': 0, + 'ground_kills': 0, + 'air_kills': 0, + 'games': 0 + } + # Aggregate stats across modes AND vehicle variants with same display name + player_vehicles[user_id]['vehicles'][readable_name]['flyouts'] += result['flyouts'] + player_vehicles[user_id]['vehicles'][readable_name]['deaths'] += result['deaths'] + player_vehicles[user_id]['vehicles'][readable_name]['ground_kills'] += result['ground_kills'] + player_vehicles[user_id]['vehicles'][readable_name]['air_kills'] += result['air_kills'] + player_vehicles[user_id]['vehicles'][readable_name]['games'] += result.get('was_in_session', 0) + + # Fetch live squadron points so we can show each player's current rating. + # Players whose current squadron differs from the guild's squadron won't appear + # in this map and will fall back to a placeholder. + # Retry once: obtain_clan_new_points returns ({}, 0) silently on JWT refresh, + # so a second call after the refresh succeeds. + points_map: dict[str, int] = {} + for _ in range(2): + try: + sq_members, _total = await obtain_clan_new_points(points_squadron_name) + except (ClanInfoError, OSError, ValueError) as e: + logging.warning("meta: obtain_clan_new_points failed for %s: %s", points_squadron_name, e) + break + if sq_members: + points_map = {str(uid): int(info.get("points", 0)) for uid, info in sq_members.items()} + break + if not points_map: + logging.warning("meta: empty points_map for squadron '%s'", points_squadron_name) + + # Build player entries as text lines + points_lbl = t(lang, "meta.points_label") + kdr_lbl = t(lang, "meta.kdr_label") + games_lbl = t(lang, "meta.games_label") + no_points = t(lang, "meta.no_points") + + all_lines = [] + for user_id, data in player_vehicles.items(): + player_name = esc(str(data['nick'])) + pts = points_map.get(str(user_id)) + pts_str = f"{pts:,}" if pts is not None else no_points + + for veh in data['vehicles'].values(): + readable_name = veh['readable_name'] + kills = veh['ground_kills'] + veh['air_kills'] + deaths = veh['deaths'] + if deaths > 0: + kdr_str = f"{kills / deaths:.2f}" + elif kills > 0: + kdr_str = "∞" + else: + kdr_str = "0.00" + all_lines.append( + f"**{player_name}** — {readable_name}\n" + f"└─ {points_lbl}: {pts_str} | {kdr_lbl}: {kdr_str} | " + f"{games_lbl}: {veh['games']}" + ) + + # Paginate: group lines into pages that fit within embed limits + pages = [] + current_page_lines = [] + # title + description + footer overhead ~200 chars, keep page content under 5000 + current_size = 0 + for line in all_lines: + line_size = len(line) + 1 # +1 for newline + if current_page_lines and current_size + line_size > 4000: + pages.append(current_page_lines) + current_page_lines = [] + current_size = 0 + current_page_lines.append(line) + current_size += line_size + if current_page_lines: + pages.append(current_page_lines) + + total_players = len(player_vehicles) + + search_title = t(lang, "meta.search_title", vehicle=vehicle_display) + + if len(pages) == 1: + # Single page, no pagination needed + embed = discord.Embed( + title=search_title, + description=t(lang, "meta.matches_found", count=total_players) + "\n\n" + "\n".join(pages[0]), + color=discord.Color.green() + ) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + await interaction.followup.send(embed=embed, ephemeral=True) + else: + # Multiple pages, use pagination view + view = MetaResultsView(pages, search_title, total_players, lang) + embed = view.build_embed() + view.message = await interaction.followup.send(embed=embed, view=view, ephemeral=True, wait=True) + + +@meta.error +async def meta_error(interaction, error): + await permission_fail(interaction, error) + + +@is_blacklisted() +@bot.tree.command(name='top', description=command_locale('Get the top 20 squadrons with detailed stats', "commands.top.description")) +async def top(interaction: discord.Interaction): + """Display the top 20 squadrons with rating, kills, K/D, win rate, and playtime.""" + await collect_command_stats(interaction) + await interaction.response.defer() + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + + squadron_data = await obtain_clans_leaderboard(start=0, count=20) + if not squadron_data: + await interaction.followup.send(t(lang, "top.fetch_failed"), ephemeral=True) + return + + embed = discord.Embed(title=t(lang, "top.title"), color=discord.Color.purple()) + + for idx, squadron in enumerate(squadron_data, start=1): + ground_kills = squadron.get("g_kills", 0) + air_kills = squadron.get("a_kills", 0) + total_kills = ground_kills + air_kills + deaths = squadron.get("deaths", 1) # Avoid division by zero + kd_ratio = round(total_kills / deaths, 2) if deaths else "N/A" + + # Calculate win rate (ensure battles is not zero to avoid division errors) + wins = squadron.get("wins", 0) + battles = squadron.get("battles", 0) + win_rate = round((wins / battles) * 100, 2) if battles else "N/A" + + playtime_minutes = squadron.get("playtime", 0) + days = playtime_minutes // 1440 + hours = (playtime_minutes % 1440) // 60 + minutes = playtime_minutes % 60 + formatted_playtime = f"{days}d {hours}h {minutes}m" + + short_name = squadron['short_name'] + win_rate_display = "N/A" if win_rate == "N/A" else str(win_rate) + "%" + + embed.add_field( + name=f"**{idx} - {short_name}**", + value=( + f"{t(lang, 'top.rating_label', value=squadron.get('clanrating', 'N/A'))}\n" + f"{t(lang, 'top.air_kills_label', value=air_kills)}\n" + f"{t(lang, 'top.ground_kills_label', value=ground_kills)}\n" + f"{t(lang, 'top.deaths_label', value=deaths)}\n" + f"{t(lang, 'top.kd_label', value=kd_ratio)}\n" + f"{t(lang, 'top.win_rate_label', value=win_rate_display)}\n" + f"{t(lang, 'top.playtime_label', value=formatted_playtime)}\n" + "\u200b" # Adds spacing + ), + inline=True # Each squadron appears on a new line + ) + + embed.set_footer(text=DEFAULT_FOOTER_CAT) + await interaction.followup.send(embed=embed, ephemeral=False) + +@top.error +async def top_perm_error(interaction, error): + await permission_fail(interaction, error) + + + +class LanguageListSelect(discord.ui.Select): + """Dropdown select for choosing the bot's display language for a server.""" + + def __init__(self, lang: str = 'en'): + self.lang = lang + # Mapping of displayed language names to canonical stored values + self.language_mapping = { + "English": "", + "Français": "", + "Italiano": "", + "Deutsch": "", + "Español": "", + "Русский": "", + "Polski": "", + "Čeština": "", + "简体中文": "", + "Português": "", + "Українська": "", + } + + options = [ + discord.SelectOption(label=label, value=label) + for label in self.language_mapping + ] + + super().__init__( + placeholder=t(lang, "language.select_placeholder"), + min_values=1, + max_values=1, + options=options + ) + + async def callback(self, interaction: discord.Interaction): + guild_id = interaction.guild.id # type: ignore + guild_name = interaction.guild.name # type: ignore + features = await load_features(guild_id) + + selected_display = self.values[0] + canonical_value = self.language_mapping.get(selected_display, f"<{selected_display}>") + + features["Language"] = canonical_value + await save_features(guild_id, features) + + await interaction.response.send_message(t(self.lang, "language.language_set", language=selected_display), ephemeral=True) + logging.info(f"Guild {guild_name} ({guild_id}) set their language to {canonical_value}") + +class LanguageListView(discord.ui.View): + """View wrapper containing the LanguageListSelect dropdown for /language.""" + + def __init__(self, lang: str = 'en'): + super().__init__() + self.add_item(LanguageListSelect(lang)) + + +@is_blacklisted() +@is_admin() +@bot.tree.command(name="language",description=command_locale("Change the bot's language.", "commands.language.description")) +async def language(interaction: discord.Interaction): + """Present a dropdown to change the bot's display language for this server.""" + await collect_command_stats(interaction) + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + view = LanguageListView(lang) + await interaction.response.send_message(t(lang, "language.prompt"), view=view, ephemeral=True) + + +@language.error +async def language_error(interaction, error): + await permission_fail(interaction, error) + + + +# Map target‐codes → (native name, English name) +LANGUAGE_OPTIONS = { + "RU": ("Русский", "Russian"), + #"EN-US": ("English (US)", "English (US)"), + "EN-GB": ("English", "English"), + "UK": ("Українська", "Ukrainian"), + "ES": ("Español", "Spanish"), + "FR": ("Français", "French"), + "DE": ("Deutsch", "German"), + "ZH-HANS": ("简体中文", "Chinese"), + "JA": ("日本語", "Japanese"), + "KO": ("한국어", "Korean"), + "IT": ("Italiano", "Italian"), + "PT-PT": ("Português", "Portuguese"), + #"PT-BR": ("Português (Brasil)","Portuguese (Brazil)"), + "PL": ("Polski", "Polish"), + "LT": ("Lietuvių", "Lithuanian"), + "LV": ("Latviešu", "Latvian"), + "ET": ("Eesti", "Estonian"), + #"DA": ("Dansk", "Danish"), + "FI": ("Suomi", "Finnish"), + #"ID": ("Bahasa Indonesia", "Indonesian"), + #"NB": ("Norsk bokmål", "Norwegian"), + "NL": ("Nederlands", "Dutch"), + "SV": ("Svenska", "Swedish"), + "CS": ("Čeština", "Czech"), + #"SK": ("Slovenčina", "Slovak"), + #"SL": ("Slovenščina", "Slovenian"), + "RO": ("Română", "Romanian"), + "BG": ("Български", "Bulgarian"), + "EL": ("Ελληνικά", "Greek"), + "HU": ("Magyar", "Hungarian"), + "AR": ("العربية", "Arabic"), + "TR": ("Türkçe", "Turkish"), +} + +DEEPL_API_KEY = os.environ.get("DEEPL_KEY") +translator = deepl.Translator(DEEPL_API_KEY) if DEEPL_API_KEY else None + +def perform_translation(text: str, target_language: str) -> str: + """Translate text to the target language using the DeepL API. + + Args: + text: The text to translate. + target_language: DeepL target language code (e.g. "DE", "FR"). + + Returns: + Translated text, or an error message if translation fails. + """ + if not translator: + return "Translation unavailable (DeepL not configured)" + try: + result = translator.translate_text(text, target_lang=target_language.upper()) + return result.text # type: ignore + except Exception as e: + logging.error(f"Translation failed: {e}") + return "Translation failed" + +# ── UI Components ───────────────────────────────────────────────────────────── + +class LanguageSelect(discord.ui.Select): + """Dropdown for selecting a target language to translate a message via DeepL.""" + + def __init__(self, original_message: discord.Message, lang: str = "en"): + options = [ + discord.SelectOption( + label=f"{native} ({english})", + value=code + ) + for code, (native, english) in LANGUAGE_OPTIONS.items() + ] + super().__init__( + placeholder=t(lang, "language.translate_placeholder"), + min_values=1, max_values=1, + options=options + ) + self._msg = original_message + + async def callback(self, interaction: discord.Interaction): + target = self.values[0] + text = self._msg.content + translated = await asyncio.to_thread(perform_translation, text, target) + await interaction.response.send_message( + f"**{self._msg.author.display_name} → {LANGUAGE_OPTIONS[target][1]}:**\n{translated}", + ephemeral=True + ) + self.view.stop() # type: ignore + + +class LanguageView(discord.ui.View): + """View wrapper containing the LanguageSelect dropdown for the Translate Message context menu.""" + + def __init__(self, message: discord.Message, lang: str = "en"): + super().__init__(timeout=1200) + self.add_item(LanguageSelect(message, lang=lang)) + + +# ── Replace your TranslatorCog/slash-command with this context menu ───────── + +@bot.tree.context_menu(name=command_locale("Translate Message", "commands.translate_message.name")) +async def translate_message( + interaction: discord.Interaction, + message: discord.Message +): + """Right-click any message → Apps → Translate Message.""" + lang = await guild_lang(interaction.guild.id) if interaction.guild else "en" + view = LanguageView(message, lang=lang) + await interaction.response.send_message( + t(lang, "language.translate_prompt"), + view=view, + ephemeral=True + ) + + +@is_blacklisted() +@bot.tree.command(name="sq-track", description=command_locale("Track a squadron and compare stats against the last check", "commands.sq_track.description")) +@app_commands.describe( + squadron_short_name=command_locale("Short name of the squadron to track", "commands.sq_track.squadron_short_name") +) +@discord.app_commands.autocomplete(squadron_short_name=squadron_autocomplete) +async def track_squadron( + interaction: discord.Interaction, + squadron_short_name: str +): + """Track a squadron's stats and show deltas since the last check. + + Fetches current squadron stats (rating, kills, wins, K/D, etc.), loads + the previous snapshot from disk, computes and displays diffs for each stat, + then saves the current snapshot for the next comparison. + """ + await collect_command_stats(interaction) + await interaction.response.defer() + logging.info("Running /sq-track") + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + + clan = await resolve_clan(short=squadron_short_name.lower()) + if not clan or clan["long_name"] == "": + await interaction.followup.send(t(lang, "track.squadron_not_found"), ephemeral=True) + return + + squadron_long = clan["long_name"] + + # Now fetch full clan info from your API/db using the long name + clan_data = await obtain_clan_info_api(squadron_long, "clanName") + if not clan_data: + await interaction.followup.send(t(lang, "track.fetch_failed"), ephemeral=True) + return + + # Extract stats + clan_tag = clan_data.get("tag", squadron_short_name) + clan_tag = clan_tag[1:-1] + + clan_long = clan_data.get("tag", squadron_short_name.upper()) + astat = clan_data.get("astat", {}) + points = int(astat.get("dr_era5_hist", 0)) + ground_kills = int(astat.get("gkills_hist", 0)) + air_kills = int(astat.get("akills_hist", 0)) + deaths = int(astat.get("deaths_hist", 0)) + + battles = int(astat.get("battles_hist", 0)) + wins = int(astat.get("wins_hist", 0)) + members = clan_data.get("members", []) + member_count = len(members) if isinstance(members, (list, dict)) else int(members or 0) + + total_kills = ground_kills + air_kills + kd_ratio = total_kills / deaths if deaths > 0 else total_kills + kd_ratio_percentage = f"{kd_ratio:.2f}" + + losses = battles - wins + win_rate = (wins / battles) * 100 if battles > 0 else 0 + win_rate_percentage = f"{win_rate:.2f}%" + + # --- Placement --- + placement, _ = await get_current_squadron_placement(squadron_long, squadron_short_name) + + # --- Snapshot tracking --- + guild_id = str(interaction.guild_id if interaction.guild_id else interaction.user.id) + sq_key = clan_tag.upper() + tracks_dir = STORAGE_DIR / "TRACKS" / guild_id + tracks_dir.mkdir(parents=True, exist_ok=True) + snapshot_path = tracks_dir / f"{sq_key}.json" + + current_snapshot = { + "placement": placement, + "points": points, + "members": member_count, + "battles": battles, + "wins": wins, + "losses": losses, + "total_kills": total_kills, + "ground_kills": ground_kills, + "air_kills": air_kills, + "deaths": deaths, + "kd_ratio": kd_ratio, + "win_rate": win_rate, + } + + # Load previous snapshot if it exists + prev = await load_json(snapshot_path, None) + + # Save current snapshot for next time + await write_json(snapshot_path, current_snapshot) + + # Helper to format a value with its diff + def _diff(label, cur, old_val, fmt="d", suffix=""): + """Return display string. fmt='d' for int, 'f2' for 2-decimal float.""" + if fmt == "f2": + display = f"{cur:.2f}{suffix}" + else: + display = f"{cur:,}{suffix}" + + if old_val is None: + return display + delta = cur - old_val + if delta == 0: + return display + if fmt == "f2": + sign = "+" if delta > 0 else "" + return f"{display} ({sign}{delta:.2f})" + else: + sign = "+" if delta > 0 else "" + return f"{display} ({sign}{delta:,})" + + p = prev or {} + + # Format placement diff (lower rank = better, so invert sign display) + if placement is not None: + placement_str = f"#{placement}" + old_placement = p.get("placement") + if old_placement is not None: + delta = placement - old_placement + if delta != 0: + # negative delta = moved up = good, show as positive + sign = "+" if delta < 0 else "" + placement_str += f" ({sign}{-delta:,})" + else: + placement_str = "N/A" + + # Build embed + embed = discord.Embed(title=f"**{clan_tag}**", color=discord.Color.green()) + embed.add_field(name=t(lang, "common.placement_field"), value=placement_str, inline=True) + embed.add_field(name=t(lang, "common.points_field"), value=_diff("Points", points, p.get("points")), inline=True) + embed.add_field(name=t(lang, "common.members_field"), value=_diff("Members", member_count, p.get("members")), inline=True) + embed.add_field(name=t(lang, "common.win_rate_field"), value=_diff("Win Rate", win_rate, p.get("win_rate"), fmt="f2", suffix="%"), inline=True) + + embed.add_field(name=t(lang, "common.battles_field"), value=_diff("Battles", battles, p.get("battles")), inline=True) + embed.add_field(name=t(lang, "common.wins_field"), value=_diff("Wins", wins, p.get("wins")), inline=True) + embed.add_field(name=t(lang, "common.losses_field"), value=_diff("Losses", losses, p.get("losses")), inline=True) + + embed.add_field(name=t(lang, "common.total_kills_field"), value=_diff("Total Kills", total_kills, p.get("total_kills")), inline=True) + embed.add_field(name=t(lang, "common.ground_kills_field"), value=_diff("Ground Kills", ground_kills, p.get("ground_kills")), inline=True) + embed.add_field(name=t(lang, "common.air_kills_field"), value=_diff("Air Kills", air_kills, p.get("air_kills")), inline=True) + + embed.add_field(name=t(lang, "common.deaths_field"), value=_diff("Deaths", deaths, p.get("deaths")), inline=True) + embed.add_field(name=t(lang, "common.kd_field"), value=_diff("KD Ratio", kd_ratio, p.get("kd_ratio"), fmt="f2"), inline=True) + + embed.set_footer(text=DEFAULT_FOOTER_CAT) + await interaction.followup.send(embed=embed, ephemeral=False) + + +@track_squadron.error +async def track_perm_error(interaction, error): + await permission_fail(interaction, error) + + +# ============================================================================ +# ============================================================================ +# /analytics — Advanced SQB analytics +# ============================================================================ + +CONSISTENCY_PAGE_SIZE = 10 +MAP_PAGE_SIZE = 15 + + +def _build_map_embed(data: list[dict], sq_short: str, page: int, lang: str = 'en') -> Embed: + """Build one page of the map win-rates embed with visual bar charts. + + Args: + data: List of map stat dicts with 'map_name', 'wins', 'losses', 'win_rate'. + sq_short: Squadron short name for the embed title. + page: Zero-indexed page number. + lang: Locale code for translations. + + Returns: + Embed displaying map win rates for the requested page. + """ + total_pages = math.ceil(len(data) / MAP_PAGE_SIZE) + start = page * MAP_PAGE_SIZE + page_data = data[start:start + MAP_PAGE_SIZE] + lines = [] + for r in page_data: + filled = max(1, round(r["win_rate"] / 10)) + bar = "\u2588" * filled + "\u2591" * (10 - filled) + lines.append(f"**{esc(r['map_name'])}** — {r['wins']}W / {r['losses']}L ({r['win_rate']}%)\n> {bar}") + embed = Embed(title=t(lang, "analytics.map_title", squadron=sq_short), description="\n".join(lines), color=Color.green()) + embed.set_footer(text=f"Page {page + 1}/{total_pages} • {len(data)} maps • {DEFAULT_FOOTER_CAT}") + return embed + + +class MapStatsPaginatorView(discord.ui.View): + """Paginated view for browsing map win-rate statistics with Prev/Next buttons.""" + + def __init__(self, data: list[dict], sq_short: str, page: int = 0, lang: str = 'en'): + super().__init__(timeout=300) + self.data = data + self.sq_short = sq_short + self.page = page + self.lang = lang + self.total_pages = math.ceil(len(data) / MAP_PAGE_SIZE) + self._update_buttons() + self.prev_btn.label = t(lang, "buttons.prev") + self.next_btn.label = t(lang, "buttons.next") + + def _update_buttons(self): + self.prev_btn.disabled = self.page <= 0 + self.next_btn.disabled = self.page >= self.total_pages - 1 + + @discord.ui.button(label="Prev", style=discord.ButtonStyle.grey) + async def prev_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + self.page = max(0, self.page - 1) + self._update_buttons() + await interaction.response.edit_message(embed=_build_map_embed(self.data, self.sq_short, self.page, self.lang), view=self) + + @discord.ui.button(label="Next", style=discord.ButtonStyle.grey) + async def next_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + self.page = min(self.total_pages - 1, self.page + 1) + self._update_buttons() + await interaction.response.edit_message(embed=_build_map_embed(self.data, self.sq_short, self.page, self.lang), view=self) + + +def _build_consistency_embed(data: list[dict], sq_short: str, page: int, lang: str = 'en') -> Embed: + """Build one page of the consistency embed.""" + total_pages = math.ceil(len(data) / CONSISTENCY_PAGE_SIZE) + start = page * CONSISTENCY_PAGE_SIZE + end = start + CONSISTENCY_PAGE_SIZE + page_data = data[start:end] + + # Scale bars relative to the full dataset range + all_scores = [r["consistency_score"] for r in data] + lo, hi = min(all_scores), max(all_scores) + spread = hi - lo if hi > lo else 1 + + lines = [] + for i, r in enumerate(page_data, start + 1): + nick = esc(r["nick"]) + normalized = 1 - (r["consistency_score"] - lo) / spread + filled = max(1, round(normalized * 10)) + bar = "\u2588" * filled + "\u2591" * (10 - filled) + lines.append( + f"**{i}.** {nick} — {r['avg_kills']} avg kills / {r['avg_deaths']} avg deaths " + f"({r['games']} matches)\n> {bar}" + ) + + embed = Embed( + title=t(lang, "analytics.consistency_title", squadron=sq_short), + description=t(lang, "analytics.consistency_desc") + "\n\n" + "\n".join(lines), + color=Color.green(), + ) + embed.set_footer(text=f"Page {page + 1}/{total_pages} • {len(data)} players • {DEFAULT_FOOTER_CAT}") + return embed + + +class ConsistencyPaginatorView(discord.ui.View): + """Paginated view for browsing player consistency rankings with Prev/Next buttons.""" + + def __init__(self, data: list[dict], sq_short: str, page: int = 0, lang: str = 'en'): + super().__init__(timeout=300) + self.data = data + self.sq_short = sq_short + self.page = page + self.lang = lang + self.total_pages = math.ceil(len(data) / CONSISTENCY_PAGE_SIZE) + self._update_buttons() + self.prev_btn.label = t(lang, "buttons.prev") + self.next_btn.label = t(lang, "buttons.next") + + def _update_buttons(self): + self.prev_btn.disabled = self.page <= 0 + self.next_btn.disabled = self.page >= self.total_pages - 1 + + @discord.ui.button(label="Prev", style=discord.ButtonStyle.grey) + async def prev_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + self.page = max(0, self.page - 1) + self._update_buttons() + await interaction.response.edit_message( + embed=_build_consistency_embed(self.data, self.sq_short, self.page, self.lang), + view=self, + ) + + @discord.ui.button(label="Next", style=discord.ButtonStyle.grey) + async def next_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + self.page = min(self.total_pages - 1, self.page + 1) + self._update_buttons() + await interaction.response.edit_message( + embed=_build_consistency_embed(self.data, self.sq_short, self.page, self.lang), + view=self, + ) + + +@is_blacklisted() +@bot.tree.command(name="analytics", description=command_locale("View advanced SQB analytics for a squadron", "commands.analytics.description")) +@app_commands.describe( + squadron=command_locale("Squadron short name", "commands.common.squadron_short"), + view=command_locale("Which analytics view to show", "commands.analytics.view") +) +@app_commands.choices(view=[ + app_commands.Choice(name=command_locale("Map Win Rates", "commands.analytics.choice_maps"), value="maps"), + app_commands.Choice(name=command_locale("Team Compositions", "commands.analytics.choice_comps"), value="comps"), + app_commands.Choice(name=command_locale("Player Consistency", "commands.analytics.choice_consistency"), value="consistency"), + app_commands.Choice(name=command_locale("Time of Day", "commands.analytics.choice_time"), value="time"), + app_commands.Choice(name=command_locale("Matchup History", "commands.analytics.choice_matchups"), value="matchups"), +]) +@discord.app_commands.autocomplete(squadron=squadron_autocomplete) +async def analytics_cmd(interaction: discord.Interaction, view: str = "maps", squadron: str = ""): + """Display advanced SQB analytics for a squadron. + + Supports four views: map win rates (paginated), team compositions (top 10), + player consistency (paginated, sorted by K/D), and time-of-day performance + with EU/NA timeslot dividers and Discord-localized timestamps. + """ + await collect_command_stats(interaction) + await interaction.response.defer(ephemeral=False) + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + + try: + clan = await get_guild_squadron(interaction.guild_id, squadron) + except ValueError as e: + return await interaction.followup.send( + embed=Embed(title=t(lang, "common.error_title"), description=str(e), color=Color.red()) + ) + sq_short = clan["short_name"] + sq_long = clan["long_name"] + + if view == "maps": + data = await get_map_stats(sq_short) + if not data: + return await interaction.followup.send( + embed=Embed(title=t(lang, "analytics.no_data_title"), description=t(lang, "analytics.no_matches_desc"), color=Color.blue()) + ) + embed = _build_map_embed(data, sq_short, 0, lang) + paginator = MapStatsPaginatorView(data, sq_short, lang=lang) + return await interaction.followup.send(embed=embed, view=paginator) + + elif view == "comps": + data = await get_comp_analysis(sq_short) + if not data: + return await interaction.followup.send( + embed=Embed(title=t(lang, "analytics.no_data_title"), description=t(lang, "analytics.no_comp_desc"), color=Color.blue()) + ) + lines = [] + for r in data[:10]: + filled = max(1, round(r["win_rate"] / 10)) + bar = "\u2588" * filled + "\u2591" * (10 - filled) + lines.append(f"`{r['comp_signature']}` — {r['wins']}W / {r['losses']}L ({r['win_rate']}%) [{r['total']} games]\n> {bar}") + embed = Embed(title=t(lang, "analytics.comp_title", squadron=sq_short), description="\n".join(lines), color=Color.green()) + + elif view == "consistency": + data = await get_player_consistency(sq_short) + if not data: + return await interaction.followup.send( + embed=Embed(title=t(lang, "analytics.no_data_title"), description=t(lang, "analytics.no_consistency_desc"), color=Color.blue()) + ) + embed = _build_consistency_embed(data, sq_short, 0, lang) + paginator = ConsistencyPaginatorView(data, sq_short, lang=lang) + return await interaction.followup.send(embed=embed, view=paginator) + + elif view == "time": + data = await get_time_performance(sq_short) + if not data: + return await interaction.followup.send( + embed=Embed(title=t(lang, "analytics.no_data_title"), description=t(lang, "analytics.no_time_desc"), color=Color.blue()) + ) + # Use a reference date (epoch day 0) to build Discord timestamps for each hour + # Discord renders in the viewer's local timezone + ref = datetime(2025, 1, 1, tzinfo=timezone.utc) + + # SQB timeslot regions (from tasks.py: EU 13:55-22:10, NA 00:55-07:10 UTC) + eu_hours = set(range(14, 23)) # 14:00-22:00 UTC + na_hours = set(range(1, 8)) # 01:00-07:00 UTC + + lines = [] + prev_region = None + for hour in range(24): + if hour not in data: + continue + stats = data[hour] + + # Insert region dividers + if hour in eu_hours: + region = "eu" + elif hour in na_hours: + region = "na" + else: + region = "off" + + if region != prev_region: + if region == "eu": + lines.append(t(lang, "analytics.eu_timeslot")) + elif region == "na": + lines.append(t(lang, "analytics.na_timeslot")) + elif prev_region is not None: + lines.append(t(lang, "analytics.off_peak")) + prev_region = region + + ts = int(ref.replace(hour=hour).timestamp()) + filled = max(1, round(stats["win_rate"] / 10)) + bar = "\u2588" * filled + "\u2591" * (10 - filled) + lines.append(f" {stats['wins']}W / {stats['losses']}L ({stats['win_rate']}%)\n> {bar}") + embed = Embed(title=t(lang, "analytics.time_title", squadron=sq_short), description="\n".join(lines), color=Color.green()) + + elif view == "matchups": + data = await get_matchup_history(sq_short) + if not data["won_against"] and not data["lost_against"]: + return await interaction.followup.send( + embed=Embed(title=t(lang, "analytics.no_data_title"), description=t(lang, "analytics.no_matchups_desc"), color=Color.blue()) + ) + + def _fmt(entries: list[dict]) -> str: + if not entries: + return "—" + return "\n".join( + f"**{esc(e['opponent'])}** — {e['wins']}W / {e['losses']}L ({e['total']} games)" + for e in entries + ) + + embed = Embed( + title=t(lang, "analytics.matchups_title", squadron=sq_short), + color=Color.green(), + ) + embed.add_field(name=t(lang, "analytics.matchups_won_field"), value=_fmt(data["won_against"]), inline=True) + embed.add_field(name=t(lang, "analytics.matchups_lost_field"), value=_fmt(data["lost_against"]), inline=True) + embed.set_footer(text=f"{data['total_opponents']} unique opponents • {DEFAULT_FOOTER_CAT}") + return await interaction.followup.send(embed=embed) + + else: + return await interaction.followup.send(t(lang, "analytics.unknown_view"), ephemeral=True) + + embed.set_footer(text=DEFAULT_FOOTER_CAT) + await interaction.followup.send(embed=embed) + + +# /recent — Last 5 matches for a squadron +# ============================================================================ + +@is_blacklisted() +@bot.tree.command( + name="recent", + description=command_locale("Show recent squadron battles for a squadron", "commands.recent.description") +) +@app_commands.describe( + squadron=command_locale("Short name of the squadron", "commands.common.squadron_short"), + length=command_locale("Number of matches to show", "commands.recent.length") +) +@discord.app_commands.autocomplete(squadron=squadron_autocomplete) +async def recent(interaction: discord.Interaction, squadron: str = "", length: int = 10): + """Show recent SQB match history for a squadron. + + Queries match_summary from SQLite for the squadron's recent wins/losses, + formats each match with opponent, map, and relative timestamp, then splits + results across multiple embeds if the content exceeds Discord's character limit. + """ + await collect_command_stats(interaction) + await interaction.response.defer(ephemeral=False) + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + length = max(1, min(length, 100)) + + # Resolve squadron + try: + clan = await get_guild_squadron(interaction.guild_id, squadron) + except ValueError as e: + return await interaction.followup.send( + embed=Embed(title=t(lang, "common.error_title"), description=str(e), color=Color.red()) + ) + squadron_long = clan["long_name"] + squadron_short = clan["short_name"] + clan_id_val = clan.get("clan_id") if isinstance(clan, dict) else None + + # Prefer clan_id so post-rename history stays attached. Fall back to + # short_name lookup for any rows that didn't backfill (orphans). + async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=30.0) as db: + await db.execute("PRAGMA busy_timeout=30000;") + if clan_id_val: + async with db.execute( + """SELECT session_id, map_name, endtime_unix, winning_sq, losing_sq + FROM match_summary + WHERE winning_clan_id = ? OR losing_clan_id = ? + OR ((winning_clan_id IS NULL OR losing_clan_id IS NULL) + AND (winning_sq = ? OR losing_sq = ?)) + ORDER BY endtime_unix DESC + LIMIT ?""", + (clan_id_val, clan_id_val, squadron_short, squadron_short, length) + ) as cursor: + rows = await cursor.fetchall() + else: + async with db.execute( + """SELECT session_id, map_name, endtime_unix, winning_sq, losing_sq + FROM match_summary + WHERE winning_sq = ? OR losing_sq = ? + ORDER BY endtime_unix DESC + LIMIT ?""", + (squadron_short, squadron_short, length) + ) as cursor: + rows = await cursor.fetchall() + + if not rows: + embed = Embed( + title=t(lang, "recent.title", squadron=squadron_short), + description=t(lang, "recent.no_matches_desc"), + color=Color.blue() + ) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + return await interaction.followup.send(embed=embed) + + lines = [] + for session_id, map_name, endtime_unix, winning_sq, losing_sq in rows: + is_win = (winning_sq == squadron_short) + opponent = losing_sq if is_win else winning_sq + if is_win: + entry = f"\U0001f451 **{squadron_short}** vs {opponent} \U0001f494" + else: + entry = f"\U0001f494 **{squadron_short}** vs {opponent} \U0001f451" + ts = f"" if endtime_unix else "Unknown" + entry += f"\n> **Map:** {map_name or 'Unknown'} | {ts}" + lines.append(entry) + + # Split into multiple embeds if description exceeds 4096 chars + embeds = [] + current_lines: list[str] = [] + current_len = 0 + for line in lines: + if current_len + len(line) + 1 > 4000 and current_lines: + embed = Embed( + title=t(lang, "recent.title", squadron=squadron_short) if not embeds else "", + description="\n".join(current_lines), + color=Color.blue() + ) + embeds.append(embed) + current_lines = [] + current_len = 0 + current_lines.append(line) + current_len += len(line) + 1 + + if current_lines: + embed = Embed( + title=t(lang, "recent.title", squadron=squadron_short) if not embeds else "", + description="\n".join(current_lines), + color=Color.blue() + ) + embeds.append(embed) + + embeds[-1].set_footer(text=DEFAULT_FOOTER_CAT) + await interaction.followup.send(embeds=embeds) + + +@recent.error +async def recent_error(interaction, error): + await permission_fail(interaction, error) + + +# ============================================================================ +# /vs — Head-to-head record against another squadron +# ============================================================================ + +@is_blacklisted() +@bot.tree.command( + name="vs", + description=command_locale("Head-to-head record between two squadrons", "commands.vs.description") +) +@app_commands.describe( + squadron_a=command_locale("First squadron", "commands.vs.squadron_a"), + squadron_b=command_locale("Second squadron", "commands.vs.squadron_b") +) +@discord.app_commands.autocomplete(squadron_a=squadron_autocomplete, squadron_b=squadron_autocomplete) +async def vs(interaction: discord.Interaction, squadron_a: str = "", squadron_b: str = ""): + """Display the head-to-head SQB record between two squadrons. + + Resolves both squadron names (falling back to the server default), queries + match_summary for all games between them, computes the win/loss record, and + shows the most recent 5 encounters with map and timestamp details. + """ + await collect_command_stats(interaction) + await interaction.response.defer(ephemeral=False) + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + + # Fill in blanks from server default + if not squadron_a or not squadron_b: + if not squadron_a and not squadron_b: + return await interaction.followup.send( + embed=Embed( + title=t(lang, "h2h.two_required_title"), + description=t(lang, "h2h.two_required_desc"), + color=Color.red() + ) + ) + try: + default_clan = await get_guild_squadron(interaction.guild_id) + default_short = default_clan["short_name"] + except ValueError: + default_short = "" + + if not squadron_a: + if not default_short: + return await interaction.followup.send( + embed=Embed(title=t(lang, "h2h.two_required_title"), + description=t(lang, "h2h.provide_a_desc"), + color=Color.red()) + ) + squadron_a = default_short + elif not squadron_b: + if not default_short: + return await interaction.followup.send( + embed=Embed(title=t(lang, "h2h.two_required_title"), + description=t(lang, "h2h.provide_b_desc"), + color=Color.red()) + ) + squadron_b = default_short + + # Resolve squadron A + try: + clan_a = await get_guild_squadron(interaction.guild_id, squadron_a) + except ValueError as e: + return await interaction.followup.send( + embed=Embed(title=t(lang, "h2h.squadron_not_found_title"), description=str(e), color=Color.red()) + ) + a_long = clan_a["long_name"] + a_short = clan_a["short_name"] + + # Resolve squadron B + try: + clan_b = await get_guild_squadron(interaction.guild_id, squadron_b) + except ValueError as e: + return await interaction.followup.send( + embed=Embed(title=t(lang, "h2h.squadron_not_found_title"), description=str(e), color=Color.red()) + ) + b_long = clan_b["long_name"] + b_short = clan_b["short_name"] + + if a_short == b_short: + return await interaction.followup.send( + embed=Embed( + title=t(lang, "h2h.same_squadron_title"), + description=t(lang, "h2h.same_squadron_desc"), + color=Color.orange() + ) + ) + + # Query head-to-head matches (match_summary stores short squadron tags) + async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=30.0) as db: + await db.execute("PRAGMA busy_timeout=30000;") + async with db.execute( + """SELECT session_id, map_name, endtime_unix, winning_sq, losing_sq + FROM match_summary + WHERE (winning_sq = ? AND losing_sq = ?) + OR (winning_sq = ? AND losing_sq = ?) + ORDER BY endtime_unix DESC""", + (a_short, b_short, b_short, a_short) + ) as cursor: + all_matches = await cursor.fetchall() + + all_matches = list(all_matches) + a_wins = sum(1 for r in all_matches if r[3] == a_short) + b_wins = sum(1 for r in all_matches if r[3] == b_short) + total = len(all_matches) + + if a_wins > b_wins: + color = Color.green() + elif b_wins > a_wins: + color = Color.red() + else: + color = Color.gold() + + embed = Embed( + title=f"{a_short} vs {b_short}", + description=t(lang, "h2h.record_desc", a_wins=a_wins, b_wins=b_wins, total=total), + color=color + ) + + if not all_matches: + embed.description = t(lang, "h2h.no_matches_desc", a=a_short, b=b_short) + else: + for session_id, map_name, endtime_unix, winning_sq, losing_sq in all_matches[:5]: + a_won = (winning_sq == a_short) + if a_won: + title = f"\U0001f451 {a_short} vs {b_short} \U0001f494" + else: + title = f"\U0001f494 {a_short} vs {b_short} \U0001f451" + ts = f"" if endtime_unix else "Unknown" + + embed.add_field( + name=title, + value=f"**Map:** {map_name or 'Unknown'} | {ts}", + inline=False + ) + + embed.set_footer(text=DEFAULT_FOOTER_CAT) + await interaction.followup.send(embed=embed) + + +@vs.error +async def vs_error(interaction, error): + await permission_fail(interaction, error) + + +class NotificationTypeSelect(discord.ui.Select): + """Dropdown to choose a notification management category.""" + + def __init__(self, lang: str = 'en'): + self.lang = lang + options = [ + discord.SelectOption(label=t(lang, "autolog.logs_option"), description=t(lang, "autolog.logs_option_desc")), + discord.SelectOption(label=t(lang, "autolog.points_option"), description=t(lang, "autolog.points_option_desc")), + discord.SelectOption(label="Reports", description="Manage leaderboard and Weekly BR report routes."), + ] + super().__init__( + placeholder=t(lang, "autolog.select_notif_placeholder"), + min_values=1, + max_values=1, + options=options + ) + + async def callback(self, interaction: discord.Interaction): + notif_type = self.values[0] + guild_id = interaction.guild.id # type: ignore + + # Proceed to Step 2: Squadron selection. + view = await create_squadron_select_view(guild_id, notif_type, self.lang) + + header = t(self.lang, "autolog.selected_type", type=_notif_type_label(notif_type)) + # Append a tier-cap usage header so the user knows their limits upfront. + # Skip for Free users (no tier) and for uncapped categories. + if tier_enforcement_active() and notif_type not in ("Leaderboard", REPORTS_NOTIF_TYPE): + tier = await get_guild_tier(guild_id) + if tier is not None: + prefs = await load_guild_preferences(guild_id) + used = len(enabled_non_wildcard_keys_for(prefs, notif_type)) + cap = tier_cap(tier, notif_type) + cap_str = "∞" if cap is None else str(cap) + header += "\n" + t(self.lang, "autolog.cap_header", + used=used, cap=cap_str, notif=_notif_type_label(notif_type), tier=tier.title()) + + await interaction.response.send_message(header, view=view, ephemeral=True) + + +class NotificationManagementView(discord.ui.View): + """Top-level view containing the notification type selector.""" + + def __init__(self, lang: str = 'en'): + super().__init__(timeout=1200) + self.message: Optional[discord.Message] = None + self.add_item(NotificationTypeSelect(lang)) + + async def on_timeout(self): + for item in self.children: + item.disabled = True # type: ignore[attr-defined] + try: + if self.message: + await self.message.edit(view=self) + except Exception: + pass + + +# Basic (non-paginated) squadron dropdown (if 25 or fewer squadrons) +class SquadronSelect(discord.ui.Select): + """Dropdown listing squadrons with a specific notification type configured (<=25).""" + + def __init__(self, guild_id, notif_type, option_rows, lang: str = 'en', + over_cap_keys: Optional[set[str]] = None, + wildcard_blocked: bool = False): + self.guild_id = guild_id + self.notif_type = notif_type + self.option_rows = option_rows + self.lang = lang + enabled_label = t(lang, "common.enabled") + disabled_label = t(lang, "common.disabled") + over_cap_keys = over_cap_keys or set() + options = [] + for row in option_rows: + squadron = row["squadron"] + settings = row["settings"] + actual_notif_type = row["notif_type"] + channel_val = settings[actual_notif_type] + is_disabled = channel_val.startswith("<#DISABLED-") + if is_disabled: + prefix = "❌" + state = disabled_label + elif wildcard_blocked and squadron.lower() in WILDCARD_KEYS: + prefix = "🚫" + state = enabled_label + elif squadron in over_cap_keys: + prefix = "⚠️" + state = enabled_label + else: + prefix = "✅" + state = enabled_label + display_label = settings.get("Long") if isinstance(settings, dict) else None + display_label = display_label or squadron + type_suffix = f" [{_notif_type_label(actual_notif_type)}]" if notif_type == REPORTS_NOTIF_TYPE else "" + options.append( + discord.SelectOption( + label=f"{prefix} {display_label}{type_suffix}", + value=_encode_management_value(squadron, actual_notif_type), + description=f"{_notif_type_label(actual_notif_type)} • {state}: {channel_val}")) + if not options: + options.append( + discord.SelectOption(label=t(lang, "common.none_option"), + description=t(lang, "autolog.no_squadrons_desc"), + value="none")) + super().__init__(placeholder=t(lang, "autolog.select_squadron_placeholder"), + min_values=1, + max_values=1, + options=options) + + async def callback(self, interaction: discord.Interaction): + selected_value = self.values[0] + if selected_value == "none": + await interaction.response.send_message( + t(self.lang, "autolog.no_squadrons_available"), + ephemeral=True) + return + selected_notif_type, selected_squadron = _decode_management_value(selected_value) + + # Retrieve the current channel value for the selected notification type. + preferences = await load_guild_preferences(self.guild_id) + squadron_settings = preferences.get(selected_squadron, {}) + channel_value = squadron_settings.get(selected_notif_type, "Not configured") + if channel_value != "Not configured": + # Check if the value is wrapped in <#...> + if channel_value.startswith("<#") and channel_value.endswith(">"): + channel_id_str = channel_value[2:-1] + else: + channel_id_str = channel_value + # Remove "DISABLED-" if present. + if channel_id_str.startswith("DISABLED-"): + channel_id_str = channel_id_str[len("DISABLED-"):] + try: + channel_id = int(channel_id_str) + channel = interaction.guild.get_channel(channel_id) # type: ignore + channel_name = channel.name if channel else t(self.lang, "common.unknown") + except ValueError: + channel_name = t(self.lang, "common.unknown") + else: + channel_name = t(self.lang, "common.not_configured") + + # Proceed to Step 3: Display toggle and change channel buttons. + view = ToggleView(self.guild_id, selected_notif_type, selected_squadron, channel_value, self.lang) + # Display name comes from the entry's cached "Long" (set by /quick_log + # and refreshed by the autolog Short/Long sync). Falls back to the key + # itself for orphan / pre-migration entries. + display_squadron = ( + squadron_settings.get("Long") + if isinstance(squadron_settings, dict) else None + ) or selected_squadron + # Special messaging for Global (Leaderboard) + if selected_squadron == "Global": + message = t(self.lang, "autolog.managing_global", type=_notif_type_label(selected_notif_type), channel=channel_name) + else: + message = t(self.lang, "autolog.managing_squadron", type=_notif_type_label(selected_notif_type), squadron=display_squadron, channel=channel_name) + await interaction.response.send_message( + message, + view=view, + ephemeral=True) + + +# New classes for paginated squadron selection (> 25 squadrons, somehow.) +class PaginatedSquadronSelect(discord.ui.Select): + """Paginated squadron dropdown for servers tracking more than 25 squadrons.""" + + def __init__(self, guild_id, notif_type, option_rows, page=0, lang: str = 'en', + over_cap_keys: Optional[set[str]] = None, wildcard_blocked: bool = False): + self.guild_id = guild_id + self.notif_type = notif_type + self.option_rows = option_rows + self.page = page + self.lang = lang + self.over_cap_keys = over_cap_keys or set() + self.wildcard_blocked = wildcard_blocked + options = self.get_options(page) + super().__init__(placeholder=t(lang, "autolog.select_squadron_page_placeholder", page=page+1), + min_values=1, + max_values=1, + options=options) + + def get_options(self, page): + start = page * 25 + end = start + 25 + options = [] + enabled_label = t(self.lang, "common.enabled") + disabled_label = t(self.lang, "common.disabled") + for row in self.option_rows[start:end]: + squadron = row["squadron"] + settings = row["settings"] + actual_notif_type = row["notif_type"] + channel_val = settings[actual_notif_type] + is_disabled = channel_val.startswith("<#DISABLED-") + if is_disabled: + prefix = "❌" + state = disabled_label + elif self.wildcard_blocked and squadron.lower() in WILDCARD_KEYS: + prefix = "🚫" + state = enabled_label + elif squadron in self.over_cap_keys: + prefix = "⚠️" + state = enabled_label + else: + prefix = "✅" + state = enabled_label + display_label = settings.get("Long") if isinstance(settings, dict) else None + display_label = display_label or squadron + type_suffix = f" [{_notif_type_label(actual_notif_type)}]" if self.notif_type == REPORTS_NOTIF_TYPE else "" + options.append( + discord.SelectOption(label=f"{prefix} {display_label}{type_suffix}", + value=_encode_management_value(squadron, actual_notif_type), + description=f"{_notif_type_label(actual_notif_type)} • {state}: {channel_val}")) + if not options: + options.append( + discord.SelectOption(label=t(self.lang, "common.none_option"), + description=t(self.lang, "autolog.no_squadrons_desc"), + value="none")) + return options + + async def callback(self, interaction: discord.Interaction): + selected_value = self.values[0] + if selected_value == "none": + await interaction.response.send_message( + t(self.lang, "autolog.no_squadrons_available"), + ephemeral=True) + return + selected_notif_type, selected_squadron = _decode_management_value(selected_value) + + preferences = await load_guild_preferences(self.guild_id) + squadron_settings = preferences.get(selected_squadron, {}) + channel_value = squadron_settings.get(selected_notif_type, "Not configured") + if channel_value != "Not configured": + # Check if the value is wrapped in <#...> + if channel_value.startswith("<#") and channel_value.endswith(">"): + channel_id_str = channel_value[2:-1] + else: + channel_id_str = channel_value + # Remove "DISABLED-" if present. + if channel_id_str.startswith("DISABLED-"): + channel_id_str = channel_id_str[len("DISABLED-"):] + try: + channel_id = int(channel_id_str) + channel = interaction.guild.get_channel(channel_id) # type: ignore + channel_name = channel.name if channel else t(self.lang, "common.unknown") + except ValueError: + channel_name = t(self.lang, "common.unknown") + else: + channel_name = t(self.lang, "common.not_configured") + + view = ToggleView(self.guild_id, selected_notif_type, selected_squadron, channel_value, self.lang) + display_squadron = ( + squadron_settings.get("Long") + if isinstance(squadron_settings, dict) else None + ) or selected_squadron + # Special messaging for Global (Leaderboard) + if selected_squadron == "Global": + message = t(self.lang, "autolog.managing_global", type=_notif_type_label(selected_notif_type), channel=channel_name) + else: + message = t(self.lang, "autolog.managing_squadron", type=_notif_type_label(selected_notif_type), squadron=display_squadron, channel=channel_name) + await interaction.response.send_message( + message, + view=view, + ephemeral=True) + + +class PrevPageButton(discord.ui.Button): + """Previous-page button for PaginatedSquadronSelectView.""" + + def __init__(self, lang: str = 'en'): + super().__init__(label=t(lang, "buttons.previous"), style=discord.ButtonStyle.secondary) + self.lang = lang + + async def callback(self, interaction: discord.Interaction): + view: PaginatedSquadronSelectView = self.view # type: ignore + if view.page > 0: + view.page -= 1 + view.select.page = view.page + view.select.options = view.select.get_options(view.page) + view.select.placeholder = t(self.lang, "autolog.select_squadron_page_placeholder", page=view.page+1) + await interaction.response.edit_message(view=view) + + +class NextPageButton(discord.ui.Button): + """Next-page button for PaginatedSquadronSelectView.""" + + def __init__(self, lang: str = 'en'): + super().__init__(label=t(lang, "buttons.next"), style=discord.ButtonStyle.secondary) + self.lang = lang + + async def callback(self, interaction: discord.Interaction): + view: PaginatedSquadronSelectView = self.view # type: ignore + if view.page < view.total_pages - 1: + view.page += 1 + view.select.page = view.page + view.select.options = view.select.get_options(view.page) + view.select.placeholder = t(self.lang, "autolog.select_squadron_page_placeholder", page=view.page+1) + await interaction.response.edit_message(view=view) + + +class PaginatedSquadronSelectView(discord.ui.View): + """View combining a paginated squadron select with prev/next navigation buttons.""" + + def __init__(self, guild_id, notif_type, option_rows, page=0, lang: str = 'en', + over_cap_keys: Optional[set[str]] = None, wildcard_blocked: bool = False): + super().__init__(timeout=1200) + self.guild_id = guild_id + self.notif_type = notif_type + self.option_rows = option_rows + self.page = page + self.lang = lang + self.total_pages = math.ceil(len(option_rows) / 25) + self.select = PaginatedSquadronSelect(guild_id, notif_type, + option_rows, page, lang, + over_cap_keys=over_cap_keys, + wildcard_blocked=wildcard_blocked) + self.add_item(self.select) + self.add_item(PrevPageButton(lang)) + self.add_item(NextPageButton(lang)) + + +async def create_squadron_select_view(guild_id, notif_type, lang: str = 'en'): + """Return a View with either a paginated select or a basic select, based on number of squadrons. + + When tier enforcement is active, adds ⚠️ badges to squadrons enabled beyond the + tier cap and 🚫 to wildcard entries on tiers that don't allow them. + """ + preferences = await load_guild_preferences(guild_id) + option_rows = _management_pref_rows(preferences, notif_type) + + # Compute tier-related badge sets for the chosen notif type. + # Free/unentitled (tier=None) skips badges — premium gate handles them upstream. + over_cap_keys: set[str] = set() + wildcard_blocked = False + if tier_enforcement_active() and notif_type not in ("Leaderboard", REPORTS_NOTIF_TYPE): + tier = await get_guild_tier(guild_id) + if tier is not None: + wildcard_blocked = not tier_allows_wildcard(tier) + enabled = set(enabled_pref_keys_for(preferences, notif_type)) + allowed = allowed_pref_keys_for(preferences, tier, notif_type) + over_cap_keys = enabled - allowed + + if len(option_rows) > 25: + return PaginatedSquadronSelectView( + guild_id, notif_type, option_rows, lang=lang, + over_cap_keys=over_cap_keys, wildcard_blocked=wildcard_blocked, + ) + else: + view = discord.ui.View(timeout=180) + view.add_item(SquadronSelect( + guild_id, notif_type, option_rows, lang, + over_cap_keys=over_cap_keys, wildcard_blocked=wildcard_blocked, + )) + return view + + +class ToggleButton(discord.ui.Button): + """Button that enables or disables a notification for a specific squadron.""" + + def __init__(self, guild_id, notif_type, squadron, channel_value, lang: str = 'en'): + self.lang = lang + # If it already has "DISABLED-", we're currently disabled → label should read "Enable" + label = t(lang, "buttons.enable") if "DISABLED-" in channel_value else t(lang, "buttons.disable") + super().__init__(label=label, style=discord.ButtonStyle.primary) + self.guild_id = guild_id + self.notif_type = notif_type + self.squadron = squadron + + async def callback(self, interaction: discord.Interaction): + # 1) reload prefs + prefs = await load_guild_preferences(self.guild_id) + current = prefs.get(self.squadron, {}).get(self.notif_type, "") + + # 2) extract the raw channel ID (handles bare digits, <#ID>, or <#DISABLED-ID>) + m = re.search(r"(?:<#DISABLED-?)?(\d+)>?$", current) + if not m: + return await interaction.response.edit_message( + content="⚠️ Couldn't parse stored channel ID.", view=self.view + ) + raw_id = m.group(1) + + # 3) flip state + is_currently_disabled = "DISABLED-" in current + + # Tier enforcement — only runs when flipping DISABLED → ENABLED. + # Leaderboard/Weekly BR routes are uncapped; disabling is always allowed. + # Free/unentitled users skip this gate — the autologging premium gate blocks + # dispatch for them anyway, and letting them configure prefs up-front is better UX. + tier = await get_guild_tier(self.guild_id) + if is_currently_disabled and tier is not None and tier_enforcement_active() and self.notif_type not in ("Leaderboard", "WeeklyBR"): + if self.squadron.lower() in WILDCARD_KEYS and not tier_allows_wildcard(tier): + wb_embed = discord.Embed( + title=t(self.lang, "autolog.wildcard_blocked_title"), + description=t(self.lang, "autolog.wildcard_blocked_desc", + tier=(tier or "none").title(), notif=_notif_type_label(self.notif_type)), + color=discord.Color.orange(), + ) + wb_embed.set_footer(text=t(self.lang, "autolog.over_cap_footer")) + await interaction.response.send_message(embed=wb_embed, ephemeral=True) + return + + cap = tier_cap(tier, self.notif_type) + # Wildcards don't count toward the cap — skip the cap check when enabling one + if cap is not None and self.squadron.lower() not in WILDCARD_KEYS: + enabled_now = set(enabled_non_wildcard_keys_for(prefs, self.notif_type)) - {self.squadron} + if len(enabled_now) >= cap: + entry = prefs.get(self.squadron) or {} + display_squadron = (entry.get("Long") if isinstance(entry, dict) else None) or self.squadron + oc_embed = discord.Embed( + title=t(self.lang, "autolog.over_cap_title"), + description=t(self.lang, "autolog.over_cap_desc", + tier=(tier or "none").title(), notif=_notif_type_label(self.notif_type), + cap=cap, squadron=display_squadron), + color=discord.Color.orange(), + ) + oc_embed.set_footer(text=t(self.lang, "autolog.over_cap_footer")) + await interaction.response.send_message(embed=oc_embed, ephemeral=True) + return + + if is_currently_disabled: + new_value = f"<#{raw_id}>" + state_word = t(self.lang, "common.enabled") + self.label = t(self.lang, "buttons.disable") + else: + new_value = f"<#DISABLED-{raw_id}>" + state_word = t(self.lang, "common.disabled") + self.label = t(self.lang, "buttons.enable") + + # 4) save and update UI + prefs[self.squadron][self.notif_type] = new_value + await save_guild_preferences(self.guild_id, prefs) + + entry = prefs.get(self.squadron) or {} + display_squadron = (entry.get("Long") if isinstance(entry, dict) else None) or self.squadron + # Special messaging for Global (Leaderboard) + if self.squadron == "Global": + message = t(self.lang, "autolog.global_toggled", type=_notif_type_label(self.notif_type), state=state_word) + else: + message = t(self.lang, "autolog.squadron_toggled", type=_notif_type_label(self.notif_type), squadron=display_squadron, state=state_word) + + await interaction.response.edit_message( + content=message, + view=self.view + ) + + + +class ChangeChannelButton(discord.ui.Button): + """Button that opens a channel selector to reassign a notification's target channel.""" + + def __init__(self, guild_id, notif_type, squadron, lang: str = 'en'): + super().__init__(label=t(lang, "buttons.change_channel"), + style=discord.ButtonStyle.secondary) + self.guild_id = guild_id + self.notif_type = notif_type + self.squadron = squadron + self.lang = lang + + async def callback(self, interaction: discord.Interaction): + guild = interaction.guild + # If there are more than 25 channels, use the paginated view. + if len(guild.text_channels) > 25: # type: ignore + view = PaginatedChannelSelectView(guild, self.squadron, + self.notif_type, lang=self.lang) + else: + view = ChannelSelectView(guild, self.notif_type, self.squadron, lang=self.lang) + await interaction.response.send_message(t(self.lang, "autolog.select_channel"), + view=view, + ephemeral=True) + + +# The existing ChannelSelect view (for servers with 25 or fewer text channels) +class ChannelSelect(discord.ui.Select): + """Dropdown listing all text channels in a guild (<=25 channels).""" + + def __init__(self, guild, notif_type, squadron, lang: str = 'en'): + self.notif_type = notif_type + self.squadron = squadron + self.lang = lang + options = [] + for channel in guild.text_channels: + options.append( + discord.SelectOption(label=channel.name, + value=str(channel.id))) + super().__init__(placeholder=t(lang, "autolog.select_channel_placeholder"), + min_values=1, + max_values=1, + options=options) + + async def callback(self, interaction: discord.Interaction): + selected_channel_id = self.values[0] + new_value = f"<#{selected_channel_id}>" + preferences = await load_guild_preferences(interaction.guild.id) # type: ignore + if self.squadron in preferences and self.notif_type in preferences[ + self.squadron]: + preferences[self.squadron][self.notif_type] = new_value + await save_guild_preferences(interaction.guild.id, preferences) # type: ignore + entry = preferences.get(self.squadron) or {} + display_squadron = (entry.get("Long") if isinstance(entry, dict) else None) or self.squadron + # Special messaging for Global (Leaderboard) + if self.squadron == "Global": + message = t(self.lang, "autolog.channel_updated_global", type=_notif_type_label(self.notif_type), channel=new_value) + else: + message = t(self.lang, "autolog.channel_updated_squadron", type=_notif_type_label(self.notif_type), squadron=display_squadron, channel=new_value) + await interaction.response.send_message( + message, + ephemeral=True) + else: + await interaction.response.send_message(t(self.lang, "common.configuration_not_found"), + ephemeral=True) + + +class ChannelSelectView(discord.ui.View): + """Container view wrapping a single ChannelSelect dropdown.""" + + def __init__(self, guild, notif_type, squadron, lang: str = 'en'): + super().__init__(timeout=1200) + self.add_item(ChannelSelect(guild, notif_type, squadron, lang)) + + +class ToggleView(discord.ui.View): + """View combining a ToggleButton and ChangeChannelButton for a notification entry.""" + + def __init__(self, guild_id, notif_type, squadron, channel_value="Not configured", lang: str = 'en'): + super().__init__(timeout=1200) + self.add_item( + ToggleButton(guild_id, notif_type, squadron, channel_value, lang)) + self.add_item(ChangeChannelButton(guild_id, notif_type, squadron, lang)) + + +# Paginated select menu for channels. +class PaginatedChannelSelect(discord.ui.Select): + """Paginated channel dropdown for servers with more than 25 text channels.""" + + def __init__(self, guild, squadron, notif_type, page=0, lang: str = 'en'): + self.guild = guild + self.squadron = squadron + self.notif_type = notif_type + self.page = page + self.lang = lang + self.channels = list(guild.text_channels) + options = self.get_options(page) + super().__init__(placeholder=t(lang, "autolog.select_channel_page_placeholder", page=page+1), + min_values=1, + max_values=1, + options=options) + + def get_options(self, page): + start = page * 25 + end = start + 25 + options = [] + for channel in self.channels[start:end]: + options.append( + discord.SelectOption(label=channel.name, + value=str(channel.id))) + # If there are no channels for this page, provide a fallback option. + if not options: + options.append( + discord.SelectOption(label=t(self.lang, "common.none_option"), + description=t(self.lang, "autolog.no_channels_desc"), + value="none")) + return options + + async def callback(self, interaction: discord.Interaction): + selected_channel_id = self.values[0] + if selected_channel_id == "none": + await interaction.response.send_message(t(self.lang, "common.no_channel_selected"), + ephemeral=True) + return + + new_value = f"<#{selected_channel_id}>" + preferences = await load_guild_preferences(interaction.guild.id) # type: ignore + if self.squadron in preferences and self.notif_type in preferences[ + self.squadron]: + preferences[self.squadron][self.notif_type] = new_value + await save_guild_preferences(interaction.guild.id, preferences) # type: ignore + entry = preferences.get(self.squadron) or {} + display_squadron = (entry.get("Long") if isinstance(entry, dict) else None) or self.squadron + # Special messaging for Global (Leaderboard) + if self.squadron == "Global": + message = t(self.lang, "autolog.channel_updated_global", type=_notif_type_label(self.notif_type), channel=new_value) + else: + message = t(self.lang, "autolog.channel_updated_squadron", type=_notif_type_label(self.notif_type), squadron=display_squadron, channel=new_value) + await interaction.response.send_message( + message, + ephemeral=True) + else: + await interaction.response.send_message(t(self.lang, "common.configuration_not_found"), + ephemeral=True) + + +# Button to go to the previous page. +class PrevChannelPageButton(discord.ui.Button): + """Previous-page button for PaginatedChannelSelectView.""" + + def __init__(self, lang: str = 'en'): + super().__init__(label=t(lang, "buttons.previous"), style=discord.ButtonStyle.secondary) + self.lang = lang + + async def callback(self, interaction: discord.Interaction): + view: PaginatedChannelSelectView = self.view # type: ignore + if view.page > 0: + view.page -= 1 + view.select.page = view.page + view.select.options = view.select.get_options(view.page) + view.select.placeholder = t(self.lang, "autolog.select_channel_page_placeholder", page=view.page+1) + await interaction.response.edit_message(view=view) + + +# Button to go to the next page. +class NextChannelPageButton(discord.ui.Button): + """Next-page button for PaginatedChannelSelectView.""" + + def __init__(self, lang: str = 'en'): + super().__init__(label=t(lang, "buttons.next"), style=discord.ButtonStyle.secondary) + self.lang = lang + + async def callback(self, interaction: discord.Interaction): + view: PaginatedChannelSelectView = self.view # type: ignore + if view.page < view.total_pages - 1: + view.page += 1 + view.select.page = view.page + view.select.options = view.select.get_options(view.page) + view.select.placeholder = t(self.lang, "autolog.select_channel_page_placeholder", page=view.page+1) + await interaction.response.edit_message(view=view) + + +# View that contains the paginated channel select and navigation buttons. +class PaginatedChannelSelectView(discord.ui.View): + """View combining a paginated channel select with prev/next navigation buttons.""" + + def __init__(self, guild, squadron, notif_type, page=0, lang: str = 'en'): + super().__init__(timeout=1200) + self.guild = guild + self.squadron = squadron + self.notif_type = notif_type + self.channels = list(guild.text_channels) + self.page = page + self.lang = lang + self.total_pages = math.ceil(len(self.channels) / 25) + self.select = PaginatedChannelSelect(guild, squadron, notif_type, page, lang) + self.add_item(self.select) + self.add_item(PrevChannelPageButton(lang)) + self.add_item(NextChannelPageButton(lang)) + + + +class DiagnoseChannelSelect(discord.ui.ChannelSelect): + """Channel picker that triggers autolog permission diagnostics on the selected channel.""" + + def __init__(self, lang: str = 'en'): + super().__init__( + placeholder=t(lang, "autolog.diagnose_channel_placeholder"), + channel_types=[discord.ChannelType.text, discord.ChannelType.news], + min_values=1, + max_values=1, + ) + + async def callback(self, interaction: discord.Interaction): + channel = self.values[0] + resolved = interaction.guild.get_channel(channel.id) if interaction.guild else None + await _diagnose_perms_logic(interaction, target_channel=resolved or channel) + + +class DiagnoseChannelView(discord.ui.View): + """Container view wrapping a DiagnoseChannelSelect dropdown.""" + + def __init__(self, lang: str = 'en'): + super().__init__(timeout=1200) + self.add_item(DiagnoseChannelSelect(lang)) + + +class AutologManagementView(discord.ui.View): + """Main autolog view with buttons for managing notifications and diagnosing permissions.""" + + def __init__(self, lang: str = 'en'): + super().__init__(timeout=1200) + self.lang = lang + # Update button labels to translated values + self.manage_notifications.label = t(lang, "buttons.manage_notifications") + self.diagnose_permissions.label = t(lang, "buttons.diagnose_permissions") + + @discord.ui.button(label="Manage Notifications", style=discord.ButtonStyle.primary, emoji="🔔") + async def manage_notifications(self, interaction: discord.Interaction, button: discord.ui.Button): + view = NotificationManagementView(self.lang) + await interaction.response.send_message( + t(self.lang, "autolog.select_notif_type"), view=view, ephemeral=True) + view.message = await interaction.original_response() + + @discord.ui.button(label="Diagnose Permissions", style=discord.ButtonStyle.secondary, emoji="🔧") + async def diagnose_permissions(self, interaction: discord.Interaction, button: discord.ui.Button): + view = DiagnoseChannelView(self.lang) + await interaction.response.send_message( + t(self.lang, "autolog.select_channel_diagnose"), view=view, ephemeral=True) + + +@is_blacklisted() +@is_admin() +@bot.tree.command( + name="autolog-management", + description=command_locale("Manage autolog notifications and diagnose permissions", "commands.autolog_management.description") +) +async def autolog_management(interaction: discord.Interaction): + """Show premium status and present the autolog management panel.""" + await collect_command_stats(interaction) + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + if interaction.guild_id and await is_guild_entitled(interaction.guild_id): + premium_line = t(lang, "autolog.premium_active_line") + else: + premium_line = t(lang, "autolog.premium_not_subscribed_line") + + view = AutologManagementView(lang) + await interaction.response.send_message( + f"{premium_line}{t(lang, 'autolog.what_to_do')}", view=view, ephemeral=True) + +@autolog_management.error # type: ignore[attr-defined] +async def autolog_management_error(interaction, error): + await permission_fail(interaction, error) + + +@is_blacklisted() +@is_admin() +@bot.tree.command( + name="diagnose-perms", + description=command_locale("Diagnose autolog permissions for this channel", "commands.diagnose_perms.description") +) +async def diagnose_perms(interaction: discord.Interaction): + """Run autolog permission diagnostics on the current channel.""" + await collect_command_stats(interaction) + await _diagnose_perms_logic(interaction) + +@diagnose_perms.error +async def diagnose_perms_error(interaction, error): + await permission_fail(interaction, error) + + + +@is_blacklisted() +@is_admin() +@bot.tree.command(name="unlock", description=command_locale("Unlock premium features for this server", "commands.unlock.description")) +async def unlock_cmd(interaction: discord.Interaction): + """Show premium info and subscription buttons, or active subscription details if already subscribed.""" + await collect_command_stats(interaction) + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + + current_tier = await get_guild_tier(interaction.guild_id) if interaction.guild_id else None + + # Detect billing source so we surface the right upgrade/manage path. + is_discord_sub = False + is_website_sub = False + if current_tier is not None: + try: + async for ent in bot.entitlements(exclude_ended=True): + if ent.guild_id == interaction.guild_id: + is_discord_sub = True + break + except Exception: + pass + try: + async with aiosqlite.connect(ENTITLEMENTS_DB_PATH) as db: + cursor = await db.execute( + "SELECT 1 FROM guild_entitlements WHERE guild_id=? AND status='active'", + [str(interaction.guild_id)], + ) + if await cursor.fetchone(): + is_website_sub = True + except Exception: + pass + + # Build the view with appropriate buttons for each scenario. + view = discord.ui.View() + + # SKU → Discord premium buttons, only added when the env var is set. + def _maybe_add_sku(sku_env: Optional[str | int]) -> None: + try: + sku_int = int(sku_env) if sku_env else 0 + except (TypeError, ValueError): + return + if sku_int: + view.add_item(discord.ui.Button(style=discord.ButtonStyle.premium, sku_id=sku_int)) + + if current_tier is None: + # Not subscribed — offer all 3 Discord SKUs plus a website "See plans" link. + embed = discord.Embed( + title=t(lang, "unlock.title"), + description=t(lang, "unlock.desc"), + color=discord.Color.gold(), + ) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + + _maybe_add_sku(DISCORD_SKU_ID_STANDARD) + _maybe_add_sku(DISCORD_SKU_ID_PRO) + _maybe_add_sku(DISCORD_SKU_ID_MAX) + view.add_item(discord.ui.Button( + label=t(lang, "buttons.subscribe_website"), + url="https://srebot-meow.ing/premium", + style=discord.ButtonStyle.link, + )) + + if not view.children: + embed.add_field(name=t(lang, "unlock.coming_soon_field"), + value=t(lang, "unlock.coming_soon_value"), inline=False) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + await interaction.response.send_message(embed=embed, view=view, ephemeral=True) + return + + # Subscribed — show current tier, plus an upgrade path (SKU buttons for Discord + # subs, website link for Whop subs) and a manage link. + tier_label = current_tier.title() + sub_embed = discord.Embed( + title=t(lang, "unlock.already_subscribed_title"), + description=t(lang, "unlock.current_tier", tier=tier_label), + color=discord.Color.green(), + ) + + higher_tiers = [t_ for t_ in ("pro", "max") if TIER_ORDER.index(t_) > TIER_ORDER.index(current_tier)] + + if higher_tiers: + # Show upgrade buttons. Prefer Discord SKU button for Discord subs; fallback + # to website anchor for Whop subs (Discord subs can also deep-link to website). + sub_embed.add_field( + name=t(lang, "unlock.upgrade_to", tier="/".join(ht.title() for ht in higher_tiers)), + value=t(lang, "unlock.upgrade_to_value", tier="/".join(ht.title() for ht in higher_tiers)), + inline=False, + ) + if is_discord_sub: + # Add SKU buttons for each higher tier + for ht in higher_tiers: + sku = DISCORD_SKU_ID_PRO if ht == "pro" else DISCORD_SKU_ID_MAX + _maybe_add_sku(sku) + # Always add a website upgrade link too — Whop users need it, Discord users + # might prefer it, and it surfaces the full 4-card comparison. + for ht in higher_tiers: + view.add_item(discord.ui.Button( + label=t(lang, "unlock.upgrade_to", tier=ht.title()), + url=f"https://srebot-meow.ing/premium#{ht}", + style=discord.ButtonStyle.link, + )) + + if is_discord_sub: + sub_embed.add_field( + name=t(lang, "unlock.manage_discord_field"), + value=t(lang, "unlock.manage_discord_value"), + inline=False, + ) + elif is_website_sub: + sub_embed.add_field( + name=t(lang, "unlock.manage_website_field"), + value=t(lang, "unlock.manage_website_value"), + inline=False, + ) + + sub_embed.set_footer(text=DEFAULT_FOOTER_CAT) + if view.children: + await interaction.response.send_message(embed=sub_embed, view=view, ephemeral=True) + else: + await interaction.response.send_message(embed=sub_embed, ephemeral=True) + + +@unlock_cmd.error +async def unlock_error(interaction, error): + await permission_fail(interaction, error) + + +@is_blacklisted() +@bot.tree.command(name="credits", description=command_locale("View the team credited for building this", "commands.credits.description")) +async def credits(interaction: discord.Interaction): + """Display the SRE Bot development team credits.""" + await collect_command_stats(interaction) + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + + embed = discord.Embed( + title=t(lang, "misc.credits_title"), + description=t(lang, "misc.credits_desc"), + color=discord.Color.gold() + ) + + embed.set_footer(text=DEFAULT_FOOTER_CAT) + await interaction.response.send_message(embed=embed, ephemeral=False) + + +# ── /schedule config ── edit these when the season changes ───────────────── +SCHEDULE_TITLE = "SEASON SCHEDULE" +_SCHEDULE_JSON = Path(__file__).parent / "SCHEDULE.json" + +def _schedule_footer(lang: str) -> str: + """Build timeslot footer with Discord timestamps for today's posted SQB slots.""" + today = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) + lines = [] + for name, posted_start, posted_end in SQB_SLOTS_POSTED: + start_ts = int(today.replace(hour=posted_start.hour, minute=posted_start.minute).timestamp()) + end_ts = int(today.replace(hour=posted_end.hour, minute=posted_end.minute).timestamp()) + label = t(lang, "misc.schedule_timeslot_label", region=name) + lines.append(f"{label}: ** - **") + return "\n".join(lines) +# ──────────────────────────────────────────────────────────────────────────── + + +@is_blacklisted() +@bot.tree.command(name="schedule", description=command_locale("View the current season BR schedule", "commands.schedule.description")) +async def schedule_cmd(interaction: discord.Interaction): + """Load SCHEDULE.json and display BR tiers with dates, highlighting the current tier.""" + await collect_command_stats(interaction) + await interaction.response.defer() + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + + if not _SCHEDULE_JSON.exists(): + await interaction.followup.send( + embed=discord.Embed( + title=t(lang, "misc.schedule_not_found_title"), + description=t(lang, "misc.schedule_not_found_desc"), + color=discord.Color.red() + ), + ephemeral=True + ) + return + + entries = await load_json(_SCHEDULE_JSON, []) + + now = int(time_module.time()) + lines = [] + + for entry in entries: + br = entry["max_br"] + start_ts = entry["start"] + end_ts = entry["end"] + br_str = f"{br:.1f}" + date_str = f"" + + is_past = now > end_ts + 86399 + is_current = start_ts <= now <= end_ts + 86399 + + if is_past: + lines.append(f"> ~~**{br_str} ({date_str})**~~") + elif is_current: + lines.append("") + lines.append(f"> > **{br_str} ({date_str})** <") + lines.append("") + else: + lines.append(f"> **{br_str} ({date_str})**") + + description = "\n".join(lines) + f"\n\n{_schedule_footer(lang)}" + + embed = discord.Embed( + title=t(lang, "misc.schedule_title"), + description=description, + color=discord.Color.gold() + ) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + + await interaction.followup.send(embed=embed) + + +@schedule_cmd.error +async def schedule_error(interaction, error): + await permission_fail(interaction, error) + + +# ── /news config ───────────────────────────────────────────────────────────── +_NEWS_JSON = Path(__file__).parent / "NEWS.json" +# ───────────────────────────────────────────────────────────────────────────── + + +@is_blacklisted() +@bot.tree.command(name="news", description=command_locale("View the latest SRE Bot news and announcements", "commands.news.description")) +async def news_cmd(interaction: discord.Interaction): + """Load NEWS.json and display up to 10 news entries as embeds.""" + await collect_command_stats(interaction) + await interaction.response.defer() + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + + if not _NEWS_JSON.exists(): + await interaction.followup.send( + embed=discord.Embed( + title=t(lang, "misc.news_no_news_title"), + description=t(lang, "misc.news_no_news_desc"), + color=discord.Color.blurple() + ), + ephemeral=True + ) + return + + all_entries = await load_json(_NEWS_JSON, []) + + # Filter out expired entries + now_ts = int(time_module.time()) + entries = [e for e in all_entries if now_ts < e.get("expires", float("inf"))] + + if not entries: + await interaction.followup.send( + embed=discord.Embed( + title=t(lang, "misc.news_no_news_title"), + description=t(lang, "misc.news_no_news_desc"), + color=discord.Color.blurple() + ), + ephemeral=True + ) + return + + embeds = [] + for i, item in enumerate(entries[:10]): # Discord max 10 embeds per message + embed = discord.Embed( + title=item.get("title", "Announcement"), + description=item.get("body", ""), + color=discord.Color.blurple() + ) + if i == len(entries) - 1 or i == 9: + embed.set_footer(text=t(lang, "misc.news_footer")) + embeds.append(embed) + + await interaction.followup.send(embeds=embeds) + + +@news_cmd.error +async def news_error(interaction, error): + await permission_fail(interaction, error) + + + +@is_blacklisted() +@bot.tree.command(name="help", description=command_locale("View the guide, ToS, and support links", "commands.help.description")) +async def help(interaction: discord.Interaction): + """Display the full 29-command guide with documentation and support links.""" + await collect_command_stats(interaction) + support_server = "https://discord.gg/BCvkK8JhPe" + documentation_link = "https://srebot-meow.ing/docs" + tos_link = "https://srebot-meow.ing/terms" + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + + command_rows = [ + ("/setup", "commands.setup.description"), + ("/quick-log", "commands.quick_log.description"), + ("/comp", "commands.comp.description"), + ("/player-stats", "commands.player_stats.description"), + ("/view-player-games", "commands.view_player_games.description"), + ("/view-match", "commands.view_match.description"), + ("/compare", "commands.compare.description"), + ("/leaderboard", "commands.leaderboard.description"), + ("/top", "commands.top.description"), + ("/set-squadron", "commands.set_squadron.description"), + ("/sq-info", "commands.sq_info.description"), + ("/sq-info-graph", "commands.sq_info_graph.description"), + ("/sq-stats", "commands.sq_stats.description"), + ("/sq-track", "commands.sq_track.description"), + ("/loss-calculator", "commands.loss_calculator.description"), + ("/autolog-management", "commands.autolog_management.description"), + ("/diagnose-perms", "commands.diagnose_perms.description"), + ("/language", "commands.language.description"), + ("/credits", "commands.credits.description"), + ("/meta-management", "commands.meta_management.description"), + ("/meta", "commands.meta.description"), + ("/recent", "commands.recent.description"), + ("/vs", "commands.vs.description"), + ("/schedule", "commands.schedule.description"), + ("/website", "commands.website.description"), + ("/unlock", "commands.unlock.description"), + ("Translate Message", "commands.translate_message.name"), + ("/stack-create", "commands.stack_create.description"), + ("/stack-manage", "commands.stack_manage.description"), + ("/news", "commands.news.description"), + ("/donate", "commands.donate.description"), + ("/analytics", "commands.analytics.description"), + ("/sq-card", "commands.sq_card.description"), + ("/card", "commands.card.description"), + ("/bot-status", "commands.bot_status.description"), + ] + guide_lines = [t(lang, "misc.help_commands_header")] + guide_lines.extend( + f"{idx}. **{name}** - {t(lang, key)}" + for idx, (name, key) in enumerate(command_rows, start=1) + ) + guide_lines.append("") + guide_lines.append(t(lang, "misc.help_links", docs=documentation_link, support=support_server)) + guide_lines.append(t(lang, "misc.help_terms", terms=tos_link)) + guide_text = "\n".join(guide_lines) + + embed = discord.Embed(title=t(lang, "misc.help_title"), description=guide_text, color=discord.Color.blue()) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + + await interaction.response.send_message(embed=embed, ephemeral=False) + + + +@is_blacklisted() +@bot.tree.command(name="donate", description=command_locale("Support the development of SRE Bot", "commands.donate.description")) +async def donate_cmd(interaction: discord.Interaction): + """Show the Ko-fi donation link.""" + await collect_command_stats(interaction) + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + embed = discord.Embed( + title=t(lang, "misc.donate_title"), + description=t(lang, "misc.donate_desc"), + color=discord.Color.from_rgb(255, 94, 91), + ) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + await interaction.response.send_message(embed=embed, ephemeral=False) + + +@is_blacklisted() +@bot.tree.command( + name="bot-status", + description=command_locale( + "View bot status: last game received and average TTL", + "commands.bot_status.description", + ), +) +async def bot_status_public(interaction: discord.Interaction): + """Public-facing status: last received game timestamp + avg TTL across the last 30 games.""" + await collect_command_stats(interaction) + lang = await guild_lang(interaction.guild.id) if interaction.guild else 'en' + + await interaction.response.defer(ephemeral=False) + + last_received_ts: int | None = None + avg_delay: int | None = None + sample_size = 0 + try: + async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=10.0) as db: + rows = list(await db.execute_fetchall( + "SELECT endtime_unix, received_unix FROM match_summary " + "WHERE received_unix IS NOT NULL AND endtime_unix IS NOT NULL " + "ORDER BY endtime_unix DESC LIMIT 30" + )) + if rows: + delays = [max(int(r[1]) - int(r[0]), 0) for r in rows] + avg_delay = int(sum(delays) / len(delays)) + sample_size = len(delays) + last_received_ts = max(int(r[1]) for r in rows) + except Exception: + logging.exception("Failed to compute /bot-status TTL stats") + + embed = discord.Embed( + title=t(lang, "misc.status_title"), + color=discord.Color.green(), + ) + + if last_received_ts: + last_value = f" ()" + else: + last_value = t(lang, "misc.status_no_data") + embed.add_field(name=t(lang, "misc.status_last_received"), value=last_value, inline=False) + + if avg_delay is not None: + a_min, a_sec = divmod(avg_delay, 60) + ttl_value = f"{a_min}m {a_sec:02d}s" + embed.add_field(name=t(lang, "misc.status_avg_ttl"), value=ttl_value, inline=False) + if avg_delay > 20 * 60: + embed.add_field(name="​", value=t(lang, "misc.status_gaijin_slow"), inline=False) + embed.color = discord.Color.orange() + else: + embed.add_field(name=t(lang, "misc.status_avg_ttl"), value=t(lang, "misc.status_no_data"), inline=False) + + embed.set_footer(text=DEFAULT_FOOTER_CAT) + await interaction.followup.send(embed=embed, ephemeral=False) + + +class EntitlementsPaginator(discord.ui.View): + """Paginated view displaying active entitlements from Discord, Whop, and manual sources.""" + + ITEMS_PER_PAGE = 10 + + def __init__(self, discord_lines: list[str], whop_lines: list[str], owner_id: int, manual_lines: list[str] | None = None, lang: str = 'en'): + super().__init__(timeout=300) + self.discord_lines = discord_lines + self.whop_lines = whop_lines + self.manual_lines = manual_lines or [] + self.owner_id = owner_id + self.page = 0 + self.lang = lang + # Build flat list of (line, source) for unified pagination + self.all_entries: list[tuple[str, str]] = [] + for line in discord_lines: + self.all_entries.append((line, "discord")) + for line in whop_lines: + self.all_entries.append((line, "whop")) + for line in self.manual_lines: + self.all_entries.append((line, "manual")) + self.max_page = max(0, (len(self.all_entries) - 1) // self.ITEMS_PER_PAGE) + self._update_buttons() + self.prev_btn.label = t(lang, "buttons.previous") + self.next_btn.label = t(lang, "buttons.next") + + def _update_buttons(self): + self.prev_btn.disabled = self.page <= 0 + self.next_btn.disabled = self.page >= self.max_page + + def build_embed(self) -> discord.Embed: + """Build the embed for the current page of entitlement entries.""" + start = self.page * self.ITEMS_PER_PAGE + end = start + self.ITEMS_PER_PAGE + page_entries = self.all_entries[start:end] + + description_parts = [] + for line, source in page_entries: + tag = {"discord": t(self.lang, "dev.entitlements_tag_discord"), "whop": t(self.lang, "dev.entitlements_tag_whop"), "manual": t(self.lang, "dev.entitlements_tag_manual")}[source] + description_parts.append(f"[**{tag}**] {line}") + + embed = discord.Embed( + title=t(self.lang, "dev.entitlements_title", count=len(self.all_entries)), + description="\n\n".join(description_parts) if description_parts else t(self.lang, "dev.entitlements_no_entries"), + color=discord.Color.blurple(), + ) + embed.set_footer(text=f"Page {self.page + 1}/{self.max_page + 1} · {len(self.discord_lines)} {t(self.lang, 'dev.entitlements_tag_discord')} · {len(self.whop_lines)} {t(self.lang, 'dev.entitlements_tag_whop')} · {len(self.manual_lines)} {t(self.lang, 'dev.entitlements_tag_manual')}") + return embed + + @discord.ui.button(label="Previous", style=discord.ButtonStyle.secondary) + async def prev_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + if interaction.user.id != self.owner_id: + return await interaction.response.defer() + self.page = max(0, self.page - 1) + self._update_buttons() + await interaction.response.edit_message(embed=self.build_embed(), view=self) + + @discord.ui.button(label="Next", style=discord.ButtonStyle.secondary) + async def next_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + if interaction.user.id != self.owner_id: + return await interaction.response.defer() + self.page = min(self.max_page, self.page + 1) + self._update_buttons() + await interaction.response.edit_message(embed=self.build_embed(), view=self) + + +@is_blacklisted() +@bot.tree.command(name="bot-status-dev", description="[DEV] View bot health dashboard") +async def bot_status(interaction: discord.Interaction): + """Display the bot health dashboard: uptime, guilds, task statuses, WebSocket info, and avg TTL (last 30).""" + await collect_command_stats(interaction) + if not await is_dev_team(interaction): + await interaction.response.send_message(t("en", "dev.restricted_dev_team"), ephemeral=True) + return + + await interaction.response.defer(ephemeral=True) + + snapshot = await get_health_snapshot() + + # Uptime + started = snapshot.get("bot_started_at", 0) + uptime_s = int(time_module.time() - started) if started else 0 + hours, rem = divmod(uptime_s, 3600) + minutes, seconds = divmod(rem, 60) + uptime_str = f"{hours}h {minutes}m {seconds}s" + + embed = discord.Embed( + title=t("en", "dev.health_title"), + color=discord.Color.green(), + ) + embed.add_field(name=t("en", "dev.health_uptime"), value=uptime_str, inline=True) + embed.add_field(name=t("en", "dev.health_guilds"), value=str(snapshot.get("guild_count", 0)), inline=True) + embed.add_field( + name=t("en", "dev.health_games_processed"), + value=f"1h: {snapshot.get('games_processed_1h', 0)} | 24h: {snapshot.get('games_processed_24h', 0)}", + inline=False, + ) + + # Task statuses + tasks_info = snapshot.get("tasks", {}) + if tasks_info: + lines = [] + for name, info in sorted(tasks_info.items()): + status_icon = "\u2705" if info.get("status") == "ok" else "\u274c" + last_run = info.get("last_run", 0) + ago = f"" if last_run else t("en", "dev.health_never") + errs = info.get("error_count", 0) + line = f"{status_icon} **{name}** — {ago}" + if errs: + line += " " + t("en", "dev.health_errors", count=errs) + lines.append(line) + embed.add_field(name=t("en", "dev.health_tasks"), value="\n".join(lines), inline=False) + + # WebSocket statuses + ws_info = snapshot.get("websocket", {}) + if ws_info: + ws_lines = [] + for name, info in ws_info.items(): + connected = "\u2705" if info.get("connected") else "\u274c" + last_msg = info.get("last_message_at", 0) + ago = f"" if last_msg else t("en", "dev.health_never") + count = info.get("messages_processed", 0) + ws_lines.append(f"{connected} **{name}** — " + t("en", "dev.health_last_msg", ago=ago, count=count)) + embed.add_field(name=t("en", "dev.health_websocket"), value="\n".join(ws_lines), inline=False) + + # Avg TTL (Spectra receive delay) for the last 30 games + try: + async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=10.0) as db: + ttl_rows = list(await db.execute_fetchall( + "SELECT endtime_unix, received_unix FROM match_summary " + "WHERE received_unix IS NOT NULL AND endtime_unix IS NOT NULL " + "ORDER BY endtime_unix DESC LIMIT 30" + )) + if ttl_rows: + delays = [max(int(r[1]) - int(r[0]), 0) for r in ttl_rows] + avg = sum(delays) / len(delays) + a_min, a_sec = divmod(int(avg), 60) + mn_min, mn_sec = divmod(min(delays), 60) + mx_min, mx_sec = divmod(max(delays), 60) + ttl_value = ( + f"**Avg:** {a_min}m{a_sec:02d}s • " + f"**Min:** {mn_min}m{mn_sec:02d}s • " + f"**Max:** {mx_min}m{mx_sec:02d}s • " + f"**N:** {len(delays)}" + ) + else: + ttl_value = t("en", "dev.health_never") + except Exception as e: + ttl_value = f"⚠️ {e}" + embed.add_field(name=t("en", "dev.health_avg_ttl"), value=ttl_value, inline=False) + + embed.set_footer(text=DEFAULT_FOOTER_CAT) + await interaction.followup.send(embed=embed, ephemeral=True) + + +@is_blacklisted() +@bot.tree.command(name="view-entitlements", description="[DEV] View all active guild entitlements") +async def view_entitlements_cmd(interaction: discord.Interaction): + """Show a paginated list of all active entitlements (Discord, Whop, manual).""" + await collect_command_stats(interaction) + if not await is_dev_team(interaction): + await interaction.response.send_message(t("en", "dev.restricted_dev_team"), ephemeral=True) + return + + await interaction.response.defer(ephemeral=True) + + # --- Discord native entitlements --- + from .utils import sku_id_to_tier + discord_lines: list[str] = [] + async for ent in bot.entitlements(guild=None, exclude_ended=True): + guild_id = ent.guild_id or "—" + guild = bot.get_guild(ent.guild_id) if ent.guild_id else None + guild_name = guild.name if guild else "Unknown Server" + starts = f"" if ent.starts_at else "—" + ends = f"" if ent.ends_at else "—" + tier = (sku_id_to_tier(str(ent.sku_id)) or "standard").title() + discord_lines.append( + f"**{guild_name}** (`{guild_id}`)\n" + f"> Tier: `{tier}` · ID: `{ent.id}` · SKU: `{ent.sku_id}`\n" + f"> Started: {starts} · Expires: {ends}" + ) + + # --- Whop (website) entitlements from entitlements.db --- + whop_lines: list[str] = [] + manual_lines: list[str] = [] + try: + async with aiosqlite.connect(ENTITLEMENTS_DB_PATH) as db: + async for row in await db.execute( + "SELECT guild_id, whop_membership_id, status, renewed_at, tier FROM guild_entitlements WHERE status='active'" + ): + gid = int(row[0]) + membership_id = row[1] or "—" + renewed = f"" if row[3] else "—" + tier = (row[4] or "standard").title() + guild = bot.get_guild(gid) + guild_name = guild.name if guild else "Unknown Server" + whop_lines.append( + f"**{guild_name}** (`{gid}`)\n" + f"> Tier: `{tier}` · Membership: `{membership_id}`\n" + f"> Renewed: {renewed}" + ) + # --- Manual entitlements --- + now = int(time_module.time()) + async for row in await db.execute( + "SELECT guild_id, expires_at, created_at, tier FROM manual_entitlements WHERE expires_at > ?", (now,) + ): + gid = int(row[0]) + expires = f"" if row[1] else "—" + created = f"" if row[2] else "—" + tier = (row[3] or "standard").title() + guild = bot.get_guild(gid) + guild_name = guild.name if guild else "Unknown Server" + manual_lines.append( + f"**{guild_name}** (`{gid}`)\n" + f"> Tier: `{tier}`\n" + f"> Expires: {expires}\n" + f"> Created: {created}" + ) + except Exception as e: + whop_lines.append(f"*Failed to read entitlements.db: {e}*") + + if not discord_lines and not whop_lines and not manual_lines: + await interaction.followup.send( + embed=discord.Embed( + title=t("en", "dev.entitlements_empty_title"), + description=t("en", "dev.entitlements_empty_desc"), + color=discord.Color.blurple() + ), + ephemeral=True + ) + return + + paginator = EntitlementsPaginator(discord_lines, whop_lines, interaction.user.id, manual_lines=manual_lines) + await interaction.followup.send(embed=paginator.build_embed(), view=paginator, ephemeral=True) + + +@view_entitlements_cmd.error +async def view_entitlements_error(interaction, error): + await permission_fail(interaction, error) + + +@is_blacklisted() +@bot.tree.command(name="entitle", description="[BOT OWNER] Manually entitle a server until a given UNIX timestamp") +@app_commands.describe( + server_id="The Discord server (guild) ID to entitle", + unix_ts="UNIX timestamp for when the entitlement expires (must be >1 month from now)", + tier="Which tier to grant (standard / pro / max)", +) +@app_commands.choices(tier=[ + app_commands.Choice(name="Standard (10 squads)", value="standard"), + app_commands.Choice(name="Pro (25 squads, wildcards)", value="pro"), + app_commands.Choice(name="Max (unlimited)", value="max"), +]) +async def entitle_cmd( + interaction: discord.Interaction, + server_id: str, + unix_ts: int, + tier: str = "max", +): + """Manually create a premium entitlement for a guild until the given UNIX timestamp.""" + await collect_command_stats(interaction) + if not await bot.is_owner(interaction.user): + await interaction.response.send_message(t("en", "dev.restricted_bot_owner"), ephemeral=True) + return + + await interaction.response.defer(ephemeral=True) + + # Validate server_id format + if not server_id.isdigit() or not (17 <= len(server_id) <= 19): + await interaction.followup.send(t("en", "dev.invalid_server_id"), ephemeral=True) + return + + if tier not in ("standard", "pro", "max"): + await interaction.followup.send("Invalid tier. Must be standard/pro/max.", ephemeral=True) + return + + # Validate unix_ts is at least 1 month ahead + now = int(time_module.time()) + one_month = 30 * 24 * 60 * 60 # 30 days in seconds + if unix_ts <= now + one_month: + await interaction.followup.send( + t("en", "dev.expiry_too_soon", now=now, min=now + one_month, provided=unix_ts), + ephemeral=True + ) + return + + # Insert into manual_entitlements + try: + async with aiosqlite.connect(ENTITLEMENTS_DB_PATH) as db: + await db.execute( + "CREATE TABLE IF NOT EXISTS manual_entitlements (" + "guild_id TEXT PRIMARY KEY, expires_at INTEGER NOT NULL, " + "created_at INTEGER DEFAULT (strftime('%s','now')), tier TEXT)" + ) + # Ensure tier column exists on pre-existing tables. + try: + await db.execute("ALTER TABLE manual_entitlements ADD COLUMN tier TEXT") + except Exception: + pass # column already exists + await db.execute( + "INSERT INTO manual_entitlements (guild_id, expires_at, created_at, tier) " + "VALUES (?, ?, ?, ?) " + "ON CONFLICT(guild_id) DO UPDATE SET " + "expires_at=excluded.expires_at, created_at=excluded.created_at, tier=excluded.tier", + (server_id, unix_ts, now, tier) + ) + await db.commit() + except Exception as e: + await interaction.followup.send(t("en", "dev.entitlement_write_failed", error=e), ephemeral=True) + return + + # Invalidate entitlement cache so it takes effect immediately + invalidate_entitled_guilds_cache() + + guild = bot.get_guild(int(server_id)) + guild_name = guild.name if guild else "Unknown Server" + + embed = discord.Embed( + title=t("en", "dev.entitlement_created_title"), + description=t("en", "dev.entitlement_created_desc", guild_name=guild_name, server_id=server_id, unix_ts=unix_ts, now=now) + + f"\nTier: **{tier.title()}**", + color=discord.Color.green() + ) + await interaction.followup.send(embed=embed, ephemeral=True) + + +@entitle_cmd.error +async def entitle_error(interaction, error): + await permission_fail(interaction, error) + + +# ── /query — predefined admin queries ─────────────────────────────────── + +QUERY_CHOICES = [ + # Squadrons (squadrons.db) + app_commands.Choice(name="All squadrons with points", value="sq_with_points"), + app_commands.Choice(name="Top 20 squadrons by rating", value="sq_top20"), + app_commands.Choice(name="Squadron member count breakdown", value="sq_member_counts"), + app_commands.Choice(name="Total squadrons stored", value="sq_total"), + # SQB (sq_battles.db) + app_commands.Choice(name="Total stored games", value="sqb_total_games"), + app_commands.Choice(name="Games in last 24 hours", value="sqb_games_24h"), + app_commands.Choice(name="Games in last 7 days", value="sqb_games_7d"), + app_commands.Choice(name="Games in last 30 days", value="sqb_games_30d"), + app_commands.Choice(name="Most active players (top 20)", value="sqb_active_players"), + app_commands.Choice(name="Most played maps (top 15)", value="sqb_top_maps"), + app_commands.Choice(name="Spectra receive delay (last 20 games)", value="sqb_receive_delay"), + # Command usage (COMMAND_DATA.db) + app_commands.Choice(name="Command usage (top 20)", value="cmd_top20"), + app_commands.Choice(name="Command usage (last 24h)", value="cmd_last24h"), + app_commands.Choice(name="Command usage by guild (top 15)", value="cmd_by_guild"), +] + + +async def _run_query(query_key: str) -> tuple[str, str]: + """Run a predefined query and return (title, body) strings.""" + now = int(time_module.time()) + + # ── Squadrons DB ── + if query_key == "sq_with_points": + async with aiosqlite.connect(SQUADRONS_DB_PATH, timeout=10.0) as db: + rows = list(await db.execute_fetchall( + "SELECT short_name, long_name, clanrating FROM squadrons_data " + "WHERE clanrating IS NOT NULL AND clanrating > 0 " + "ORDER BY clanrating DESC" + )) + total = len(rows) + lines = [f"**{i}.** {esc(r[0])} ({esc(r[1])}) — {int(r[2]):,}" for i, r in enumerate(rows[:50], 1)] + if total > 50: + lines.append(f"\n*… and {total - 50} more*") + return f"Squadrons With Points ({total})", "\n".join(lines) or "None" + + if query_key == "sq_top20": + async with aiosqlite.connect(SQUADRONS_DB_PATH, timeout=10.0) as db: + rows = list(await db.execute_fetchall( + "SELECT short_name, long_name, clanrating, members FROM squadrons_data " + "WHERE clanrating IS NOT NULL AND clanrating > 0 " + "ORDER BY clanrating DESC LIMIT 20" + )) + lines = [f"**{i}.** {esc(r[0])} ({esc(r[1])}) — {int(r[2]):,} pts, {r[3] or '?'} members" + for i, r in enumerate(rows, 1)] + return "Top 20 Squadrons by Rating", "\n".join(lines) or "No data" + + if query_key == "sq_member_counts": + async with aiosqlite.connect(SQUADRONS_DB_PATH, timeout=10.0) as db: + rows = list(await db.execute_fetchall( + "SELECT sd.short_name, COUNT(sm.uid) as cnt " + "FROM squadrons_data sd " + "LEFT JOIN squadron_members sm ON sd.clan_id = sm.clan_id " + "GROUP BY sd.clan_id " + "ORDER BY cnt DESC LIMIT 25" + )) + lines = [f"**{i}.** {esc(r[0])} — {r[1]} members" for i, r in enumerate(rows, 1)] + return "Squadron Member Counts (Top 25)", "\n".join(lines) or "No data" + + if query_key == "sq_total": + async with aiosqlite.connect(SQUADRONS_DB_PATH, timeout=10.0) as db: + row_total = list(await db.execute_fetchall("SELECT COUNT(*) FROM squadrons_data")) + row_pts = list(await db.execute_fetchall( + "SELECT COUNT(*) FROM squadrons_data WHERE clanrating IS NOT NULL AND clanrating > 0" + )) + row_members = list(await db.execute_fetchall("SELECT COUNT(*) FROM squadron_members")) + body = ( + f"**Total squadrons stored:** {row_total[0][0]:,}\n" + f"**With points (rating > 0):** {row_pts[0][0]:,}\n" + f"**Total members tracked:** {row_members[0][0]:,}" + ) + return "Squadrons DB Summary", body + + # ── SQ Battles DB ── + if query_key == "sqb_total_games": + async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=10.0) as db: + r_matches = list(await db.execute_fetchall("SELECT COUNT(*) FROM match_summary")) + r_entries = list(await db.execute_fetchall("SELECT COUNT(*) FROM player_games_hist")) + r_players = list(await db.execute_fetchall("SELECT COUNT(DISTINCT UID) FROM player_games_hist")) + body = ( + f"**Total matches:** {r_matches[0][0]:,}\n" + f"**Total player-vehicle entries:** {r_entries[0][0]:,}\n" + f"**Unique players:** {r_players[0][0]:,}" + ) + return "SQ Battles DB Summary", body + + if query_key.startswith("sqb_games_"): + window_map = {"sqb_games_24h": (86400, "24 Hours"), "sqb_games_7d": (604800, "7 Days"), "sqb_games_30d": (2592000, "30 Days")} + seconds, label = window_map[query_key] + cutoff = now - seconds + async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=10.0) as db: + r_matches = list(await db.execute_fetchall( + "SELECT COUNT(*) FROM match_summary WHERE endtime_unix >= ?", (cutoff,) + )) + r_entries = list(await db.execute_fetchall( + "SELECT COUNT(*) FROM player_games_hist WHERE endtime_unix >= ?", (cutoff,) + )) + r_players = list(await db.execute_fetchall( + "SELECT COUNT(DISTINCT UID) FROM player_games_hist WHERE endtime_unix >= ?", (cutoff,) + )) + body = ( + f"**Matches:** {r_matches[0][0]:,}\n" + f"**Player-vehicle entries:** {r_entries[0][0]:,}\n" + f"**Unique players:** {r_players[0][0]:,}" + ) + return f"SQ Battles — Last {label}", body + + if query_key == "sqb_active_players": + async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=10.0) as db: + rows = list(await db.execute_fetchall( + "SELECT nick, COUNT(DISTINCT session_id) as games " + "FROM player_games_hist " + "GROUP BY UID " + "ORDER BY games DESC LIMIT 20" + )) + lines = [f"**{i}.** {esc(r[0])} — {r[1]:,} games" for i, r in enumerate(rows, 1)] + return "Most Active Players (All Time)", "\n".join(lines) or "No data" + + if query_key == "sqb_top_maps": + async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=10.0) as db: + rows = list(await db.execute_fetchall( + "SELECT map_name, COUNT(*) as cnt " + "FROM match_summary " + "WHERE map_name IS NOT NULL AND map_name != '' " + "GROUP BY map_name " + "ORDER BY cnt DESC LIMIT 15" + )) + lines = [f"**{i}.** {esc(r[0])} — {r[1]:,} games" for i, r in enumerate(rows, 1)] + return "Most Played Maps", "\n".join(lines) or "No data" + + if query_key == "sqb_receive_delay": + async with aiosqlite.connect(SQ_BATTLES_DB_PATH, timeout=10.0) as db: + rows = list(await db.execute_fetchall( + "SELECT session_id, endtime_unix, received_unix " + "FROM match_summary " + "WHERE received_unix IS NOT NULL AND endtime_unix IS NOT NULL " + "ORDER BY endtime_unix DESC LIMIT 20" + )) + if not rows: + return ( + "Spectra Receive Delay (Last 20 Games)", + "No data yet — `received_unix` is only populated for games processed after this build.", + ) + delays = [int(r[2]) - int(r[1]) for r in rows] + lines = [] + for r, d in zip(rows, delays): + sid = str(r[0]) + end_str = datetime.fromtimestamp(int(r[1]), tz=timezone.utc).strftime("%m-%d %H:%M:%S") + mins, secs = divmod(max(d, 0), 60) + lines.append(f"`{sid}` end {end_str}Z → +{mins}m{secs:02d}s") + avg = sum(delays) / len(delays) + a_min, a_sec = divmod(int(avg), 60) + m_min, m_sec = divmod(min(delays), 60) + x_min, x_sec = divmod(max(delays), 60) + header = ( + f"**Avg:** {a_min}m{a_sec:02d}s • " + f"**Min:** {m_min}m{m_sec:02d}s • " + f"**Max:** {x_min}m{x_sec:02d}s • " + f"**N:** {len(rows)}\n\n" + ) + return "Spectra Receive Delay (Last 20 Games)", header + "\n".join(lines) + + # ── Command Usage (COMMAND_DATA.db) ── + if query_key == "cmd_top20": + async with aiosqlite.connect(COMMAND_DATA_DB_PATH, timeout=10.0) as db: + rows = list(await db.execute_fetchall( + "SELECT command_name, COUNT(*) as cnt " + "FROM command_usage " + "GROUP BY command_name " + "ORDER BY cnt DESC LIMIT 20" + )) + lines = [f"**{i}.** /{r[0]} — {r[1]:,} uses" for i, r in enumerate(rows, 1)] + return "Command Usage (All Time, Top 20)", "\n".join(lines) or "No data yet" + + if query_key == "cmd_last24h": + cutoff = now - 86400 + async with aiosqlite.connect(COMMAND_DATA_DB_PATH, timeout=10.0) as db: + rows = list(await db.execute_fetchall( + "SELECT command_name, COUNT(*) as cnt " + "FROM command_usage " + "WHERE timestamp >= ? " + "GROUP BY command_name " + "ORDER BY cnt DESC LIMIT 20", + (cutoff,) + )) + r_total = list(await db.execute_fetchall( + "SELECT COUNT(*) FROM command_usage WHERE timestamp >= ?", (cutoff,) + )) + total = r_total[0][0] if r_total else 0 + lines = [f"**{i}.** /{r[0]} — {r[1]:,} uses" for i, r in enumerate(rows, 1)] + header = f"**Total invocations (24h):** {total:,}\n\n" + return "Command Usage (Last 24h)", header + ("\n".join(lines) or "No data yet") + + if query_key == "cmd_by_guild": + async with aiosqlite.connect(COMMAND_DATA_DB_PATH, timeout=10.0) as db: + rows = list(await db.execute_fetchall( + "SELECT guild_id, COUNT(*) as cnt " + "FROM command_usage " + "WHERE guild_id IS NOT NULL " + "GROUP BY guild_id " + "ORDER BY cnt DESC LIMIT 15" + )) + lines = [] + for i, r in enumerate(rows, 1): + gid = r[0] + guild = bot.get_guild(int(gid)) if gid else None + name = esc(guild.name) if guild else f"ID: {gid}" + lines.append(f"**{i}.** {name} — {r[1]:,} uses") + return "Command Usage by Guild (Top 15)", "\n".join(lines) or "No data yet" + + return "Unknown Query", "Query not recognised." + + +@is_blacklisted() +@bot.tree.command(name="query", description="[DEV] Run predefined database queries") +@app_commands.describe(query="Select a query to run") +@app_commands.choices(query=QUERY_CHOICES) +async def query_cmd(interaction: discord.Interaction, query: app_commands.Choice[str]): + """Run a predefined read-only database query. Restricted to dev team and bot owner.""" + await collect_command_stats(interaction) + if not await is_dev_team(interaction): + await interaction.response.send_message(t("en", "dev.restricted_dev_team"), ephemeral=True) + return + + await interaction.response.defer(ephemeral=True) + + try: + title, body = await _run_query(query.value) + except Exception as e: + logging.error(f"[/query] Error running query '{query.value}': {e}", exc_info=True) + await interaction.followup.send(t("en", "dev.query_failed", error=e), ephemeral=True) + return + + # Discord embed description limit is 4096 chars + if len(body) > 4000: + body = body[:3997] + "…" + + embed = discord.Embed(title=title, description=body, color=discord.Color.blurple()) + embed.set_footer(text=f"{t('en', 'dev.query_prefix', name=query.name)} • {DEFAULT_FOOTER_CAT}") + await interaction.followup.send(embed=embed, ephemeral=True) + + +@query_cmd.error +async def query_error(interaction, error): + await permission_fail(interaction, error) + + +if __name__ == "__main__": + # Make sure required secrets are available + for key in ['DISCORD_KEY', 'DEEPL_KEY']: + if key not in os.environ: + print(f"Warning: {key} environment variable is not set") + + if TOKEN != None: + bot.run(TOKEN) diff --git a/BOT/game_api.py b/BOT/game_api.py new file mode 100644 index 0000000..f4eb74e --- /dev/null +++ b/BOT/game_api.py @@ -0,0 +1,1101 @@ +""" +game_api.py + +War Thunder game API client. Handles JWT authentication, clan/squadron data retrieval, +leaderboard fetching, player points tracking, and squadron member points diff calculation +with SQLite caching for replay idempotency. +""" + +# Standard Library Imports +import asyncio +import json +import logging +import os +import sqlite3 +import sys +import time +from pathlib import Path +from typing import Any, Dict, Tuple + +# Third-Party Library Imports +import aiofiles +import aiohttp +import aiosqlite +from dotenv import load_dotenv + +# Ensure project root is in path for DAGOR_FILES imports +_project_root = Path(__file__).resolve().parent.parent +if str(_project_root) not in sys.path: + sys.path.insert(0, str(_project_root)) + +from DAGOR_FILES.WtFileUtils.blk.BlkParser import BlkDecoder + +# Local Module Imports +from .utils import CACHE_DIR, STORAGE_DIR, compress_json, decompress_json + +load_dotenv() + + +session = None + +CHAR_URL = os.getenv("WT_CHAR_URL", "") + +# Auth file - use the required shared storage root +AUTH_DIR = STORAGE_DIR / "AUTH" +_auth_new_location = AUTH_DIR / "auth_JWT.json" +_auth_old_location = Path(__file__).parent / "auth_JWT.json" +AUTH_FILE = _auth_new_location if _auth_new_location.exists() else _auth_old_location +SQ_DB_PATH = STORAGE_DIR / "squadrons.db" +_POINTS_DB = STORAGE_DIR / "points.db" +_POINTS_DB_READY = False + +# In-process cache of squadron-name → clan_id resolutions used by _apply_game_points. +# Cleared periodically — squadrons_data is the authority and renames are rare. +_CLAN_ID_CACHE: dict[str, int] = {} +_CLAN_ID_CACHE_AT: float = 0.0 +_CLAN_ID_CACHE_TTL = 300.0 + + +def _resolve_clan_id_for_points(squadron_name: str) -> int: + """Resolve a squadron's clan_id by name (long_name preferred, then short_name). + + Returns -1 if not found - the caller treats this as "orphan" but still + persists data under that sentinel so it isn't lost. + """ + global _CLAN_ID_CACHE, _CLAN_ID_CACHE_AT + now = time.time() + if now - _CLAN_ID_CACHE_AT > _CLAN_ID_CACHE_TTL: + _CLAN_ID_CACHE = {} + _CLAN_ID_CACHE_AT = now + + key = (squadron_name or "").lower() + if not key: + return -1 + cached = _CLAN_ID_CACHE.get(key) + if cached is not None: + return cached + + try: + with sqlite3.connect(SQ_DB_PATH) as con: + row = con.execute( + "SELECT clan_id FROM squadrons_data " + "WHERE LOWER(long_name) = ? OR LOWER(short_name) = ? LIMIT 1", + (key, key), + ).fetchone() + cid = int(row[0]) if row and row[0] is not None else -1 + except Exception: + cid = -1 + + _CLAN_ID_CACHE[key] = cid + return cid + +def _points_db_init(db_path: str | None = None) -> None: + """Initialize the points SQLite database schema. + + Creates the ``profile_member_points``, ``profile_totals``, and + ``game_cache`` tables if they do not already exist, and enables + WAL journal mode. Subsequent calls are no-ops unless ``db_path`` + changes the target file. + + Args: + db_path: Optional override for the database file path. + Defaults to ``_POINTS_DB`` under the required HC storage volume. + """ + global _POINTS_DB, _POINTS_DB_READY + if db_path: _POINTS_DB = db_path + if _POINTS_DB_READY: return + # Schema is keyed on clan_id post-clan_id migration. Fresh installs get the + # new shape directly. Existing installs were rebuilt by scripts/migrate_clan_id.py. + with sqlite3.connect(_POINTS_DB) as con: + con.executescript(""" + PRAGMA journal_mode=WAL; + PRAGMA synchronous=NORMAL; + PRAGMA foreign_keys=ON; + + CREATE TABLE IF NOT EXISTS profile_member_points( + clan_id INTEGER NOT NULL, + squadron TEXT NOT NULL, + uid TEXT NOT NULL, + points INTEGER NOT NULL, + PRIMARY KEY (clan_id, uid) + ); + CREATE INDEX IF NOT EXISTS idx_pmp_squadron ON profile_member_points(squadron); + + CREATE TABLE IF NOT EXISTS profile_totals( + clan_id INTEGER PRIMARY KEY, + squadron TEXT NOT NULL, + total INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS game_cache( + game_id TEXT NOT NULL, + clan_id INTEGER NOT NULL, + squadron TEXT NOT NULL, + diffs_json TEXT NOT NULL, + diff_total INTEGER NOT NULL, + updated_json TEXT NOT NULL, + created_at REAL NOT NULL DEFAULT (strftime('%s','now')), + PRIMARY KEY (game_id, clan_id) + ); + """) + _POINTS_DB_READY = True + +def _points_conn() -> sqlite3.Connection: + """Open a WAL-mode SQLite connection to the points database. + + Returns: + A ``sqlite3.Connection`` with ``isolation_level=None`` (autocommit), + WAL journal mode, and ``NORMAL`` synchronous pragma already set. + """ + con = sqlite3.connect(_POINTS_DB, timeout=10, isolation_level=None) + con.execute("PRAGMA journal_mode=WAL;") + con.execute("PRAGMA synchronous=NORMAL;") + return con + +def _apply_game_points( + game_id: str, + squadron: str, + team_uids: set[str], + curr_points_by_uid: dict[str, int], + curr_total: int, +) -> tuple[dict[str, int], int, dict[str, int]]: + """ + Returns (diffs, diff_total, updated_snapshot) + diffs: {uid: delta} with rule: if previous==0 → delta=0 + diff_total: curr_total - prev_total + updated: {uid: curr, ..., "total": curr_total} + Cached by (game_id, squadron) for replay idempotency. + """ + team_uids = {str(u) for u in team_uids} + clan_id = _resolve_clan_id_for_points(squadron) + + with _points_conn() as con: + # Cache hit? + row = con.execute( + "SELECT diffs_json, diff_total, updated_json FROM game_cache WHERE game_id=? AND clan_id=?", + (game_id, clan_id) + ).fetchone() + if row: + return decompress_json(row[0]), int(row[1]), decompress_json(row[2]) + + con.execute("BEGIN IMMEDIATE") + try: + prev_rows = [] + if team_uids: + placeholders = ",".join("?" * len(team_uids)) + prev_rows = con.execute( + f"SELECT uid, points FROM profile_member_points WHERE clan_id=? AND uid IN ({placeholders})", + (clan_id, *team_uids) + ).fetchall() + prev = {u: int(p) for u, p in prev_rows} + + prev_total_row = con.execute( + "SELECT total FROM profile_totals WHERE clan_id=?", + (clan_id,) + ).fetchone() + prev_total = int(prev_total_row[0]) if prev_total_row else 0 + + # Compute per-UID diffs (preserve rule) + diffs: dict[str, int] = {} + updated: dict[str, int] = {} + for uid in team_uids: + curr = int(curr_points_by_uid.get(uid, 0)) + was = int(prev.get(uid, 0)) + diffs[uid] = 0 if was == 0 else (curr - was) + updated[uid] = curr + + diff_total = int(curr_total) - prev_total + updated["total"] = int(curr_total) + + # Debug logging for zero squadron diff with non-zero player diffs + if diff_total == 0 and any(d != 0 for d in diffs.values()): + logging.warning( + f"[POINTS-DEBUG] Squadron diff=0 but players changed! " + f"game={game_id}, squadron={squadron}, " + f"curr_total={curr_total}, prev_total={prev_total}, " + f"player_diffs={diffs}" + ) + + # Upsert touched members + if team_uids: + con.executemany( + """INSERT INTO profile_member_points (clan_id, squadron, uid, points) + VALUES (?,?,?,?) + ON CONFLICT(clan_id, uid) DO UPDATE SET + points=excluded.points, + squadron=excluded.squadron""", + [(clan_id, squadron, uid, int(curr_points_by_uid.get(uid, 0))) for uid in team_uids] + ) + + # Upsert total + con.execute( + """INSERT INTO profile_totals (clan_id, squadron, total) + VALUES (?,?,?) + ON CONFLICT(clan_id) DO UPDATE SET + total=excluded.total, + squadron=excluded.squadron""", + (clan_id, squadron, int(curr_total)) + ) + + # Cache this game's result + con.execute( + "INSERT INTO game_cache (game_id, clan_id, squadron, diffs_json, diff_total, updated_json, created_at) VALUES (?,?,?,?,?,?,?)", + (game_id, clan_id, squadron, compress_json(diffs), int(diff_total), compress_json(updated), time.time()) + ) + + con.execute("COMMIT") + except Exception: + con.execute("ROLLBACK"); raise + + return diffs, diff_total, updated + +def join_duplicate_keys(ordered_pairs): + """JSON object-pairs hook that merges duplicate keys into lists. + + When the same key appears more than once in a JSON object, standard + ``json.loads`` keeps only the last value. This hook collects all + values for a repeated key into a list instead. + + Args: + ordered_pairs: Sequence of ``(key, value)`` pairs produced by + ``json.JSONDecoder``. + + Returns: + A dict where single-occurrence keys map to their value and + multi-occurrence keys map to a list of values. + """ + d = {} + for k, v in ordered_pairs: + if k in d: + if type(d[k]) == list: + d[k].append(v) + else: + newlist = [] + newlist.append(d[k]) + newlist.append(v) + d[k] = newlist + else: + d[k] = v + return d + +# --- BEGIN dump helper --- +def _dump_failed_payload(status: int, content_type: str, raw: bytes, clan_name: str) -> None: + """Save a failed API response payload to disk for debugging. + + If the server returned JSON, saves a pretty-printed JSON file. + Otherwise, saves a text file with headers and raw bytes appended. + Files are written to the CACHE_DIR directory. + + Args: + status: HTTP status code from the response. + content_type: Content-Type header value. + raw: Raw response bytes. + clan_name: Clan name used in the request (used for filename). + """ + out_dir = CACHE_DIR + try: + if raw[:1] in (b"{", b"[") or "json" in (content_type or "").lower(): + try: + body = json.loads(raw.decode("utf-8", "replace")) + except Exception: + body = {"_note": "Failed to decode JSON. UTF-8 text follows.", "_text": raw.decode("utf-8", "replace")} + payload = { + "note": "Unexpected JSON from cln_clan_get", + "status": status, + "content_type": content_type, + "clan": clan_name, + "body": body, + } + (out_dir / "failed_points_api.json").write_text( + json.dumps(payload, ensure_ascii=False, indent=2), + encoding="utf-8" + ) + else: + with open(out_dir / "failed_points_api.txt", "wb") as f: + f.write(f"status: {status}\n".encode("ascii", "ignore")) + f.write(f"content-type: {content_type}\n".encode("ascii", "ignore")) + f.write(f"clan: {clan_name}\n\n".encode("ascii", "ignore")) + f.write(b"raw-bytes:\n") + f.write(raw) + except Exception: + # Don’t let dumping mask the original error + logging.exception("Failed to dump unexpected payload") +# --- END dump helper --- + + +class ClanInfoError(Exception): + """Raised when we fail to fetch or parse clan info.""" + +async def bin_blk_to_json(data: bytes) -> Dict[str, Any]: + """ + Parse the War Thunder BLK blob into a Python dict. + Raises ClanInfoError on ANY parse failure. + """ + try: + return BlkDecoder(data).to_dict() + except Exception: + # don’t log the raw KeyError:123 here—just wrap it + raise ClanInfoError("BLK payload malformed or squadron doesn’t exist") + +def api_init_session(): + """Lazily initialize the module-level ``aiohttp.ClientSession``. + + Creates a new session only if one does not already exist. Must be + called inside a running asyncio event loop. + """ + global session + if not session: + session = aiohttp.ClientSession() + +async def obtain_clans_leaderboard(start=0, count=5): + """Fetch the squadron leaderboard sorted by era-5 historical rating. + + Args: + start: Zero-based offset into the leaderboard. + count: Number of squadrons to retrieve. + + Returns: + A list of dicts, each containing squadron metadata (``clan_id``, + ``tag``, ``long_name``, ``clanrating``, ``members``, etc.). + + Raises: + Exception: If the HTTP request returns a non-200 status. + ClanInfoError: If the BLK response cannot be parsed. + """ + api_init_session() + logging.info('Loading squadrons leaderboard %s:%s', start, count) + headers = { + 'action': 'cln_clan_get_leaderboard', + 'token': json.load(open(AUTH_FILE))['jwt'], + 'start': str(start), + 'count': str(count), + 'sortField': 'dr_era5_hist', + 'shortMode': 'off', + } + if session: + async with session.get(CHAR_URL, headers=headers) as res: + if res.status != 200: + raise Exception('cln_clan_get_leaderboard HTTP '+str(res.status)) + data = await bin_blk_to_json(await res.read()) + + # Unwrap root if present + if 'root' in data: + data = data['root'] + + result = [] + for clan in data['clan']: + astat = clan.get('astat', {}) + result.append({ + 'position': clan.get('pos', 0), + 'clan_id': clan['_id'], + 'tag': clan['tag'], + 'short_name': clan['tag'][1:-1], + 'long_name': clan['name'], + 'description': clan.get('desc', ''), + 'slogan': clan.get('slogan', ''), + 'region': clan.get('region', ''), + 'members': clan.get('members_cnt', 0), + 'clanrating': astat.get('dr_era5_hist', 0), + 'wins': astat.get('wins_hist', 0), + 'battles': astat.get('battles_hist', 0), + 'a_kills': astat.get('akills_hist', 0), + 'g_kills': astat.get('gkills_hist', 0), + 'deaths': astat.get('deaths_hist', 0), + 'playtime': astat.get('ftime_hist', 0), + }) + + return result + + + + +async def save_squadrons_to_db(count: int = 1000): + """Fetch top squadrons via Game API and upsert into squadrons.db. + + Performs schema migration for missing columns, inserts or updates + squadron records, and detects season resets by comparing old vs new + total ratings (>50% drop threshold). When a season reset is detected, + resets all member points in the squadron_members table. + + Args: + count: Number of top squadrons to fetch from the leaderboard. + + Returns: + Number of squadrons inserted or updated. + """ + logging.info(f"[SQ-DB] Fetching top {count} squadrons via Game API...") + + try: + clans = await obtain_clans_leaderboard(start=0, count=count) + except Exception as e: + logging.error(f"[SQ-DB] Failed to fetch leaderboard: {e}") + raise + + if not clans: + logging.warning("[SQ-DB] No clans returned from API") + return 0 + + # Initialize DB + ensure schema + async with aiosqlite.connect(SQ_DB_PATH, timeout=30.0) as db: + await db.execute("PRAGMA busy_timeout=30000;") + await db.execute(""" + CREATE TABLE IF NOT EXISTS squadrons_data ( + clan_id INTEGER PRIMARY KEY, + long_name TEXT UNIQUE, + short_name TEXT UNIQUE, + tag_name TEXT + ) + """) + await db.commit() + + expected_cols = { + "position": "INTEGER", + "description": "TEXT", + "slogan": "TEXT", + "region": "TEXT", + "members": "INTEGER", + "wins": "INTEGER", + "battles": "INTEGER", + "a_kills": "INTEGER", + "g_kills": "INTEGER", + "deaths": "INTEGER", + "playtime": "INTEGER", + "clanrating": "INTEGER", + } + + async with db.execute("PRAGMA table_info(squadrons_data);") as cursor: + existing_cols = {row[1] for row in await cursor.fetchall()} + + for col, typ in expected_cols.items(): + if col not in existing_cols: + try: + await db.execute(f"ALTER TABLE squadrons_data ADD COLUMN {col} {typ}") + logging.info(f"[SQ-DB] Added missing column: {col} ({typ})") + except Exception as e: + logging.warning(f"[SQ-DB] Could not add column {col}: {e}") + + await db.commit() + + # Snapshot old total before reset to detect season resets later. + async with db.execute("SELECT COALESCE(SUM(clanrating), 0) FROM squadrons_data") as cursor: + row = await cursor.fetchone() + old_total = row[0] if row is not None else 0 + + # Reset both rating and position before inserting fresh data so clans + # that drop out of the current top-N do not keep a stale rank. + await db.execute("UPDATE squadrons_data SET clanrating = 0, position = NULL") + await db.commit() + + # Insert/update clans + inserted = 0 + for clan in clans: + try: + await db.execute(""" + INSERT OR REPLACE INTO squadrons_data ( + clan_id, position, long_name, short_name, tag_name, + description, slogan, region, members, wins, + battles, a_kills, g_kills, deaths, playtime, clanrating + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + clan['clan_id'], + clan['position'], + clan['long_name'], + clan['short_name'], + clan['tag'], + clan['description'], + clan['slogan'], + clan['region'], + clan['members'], + clan['wins'], + clan['battles'], + clan['a_kills'], + clan['g_kills'], + clan['deaths'], + clan['playtime'], + clan['clanrating'], + )) + inserted += 1 + except Exception as e: + logging.error(f"[SQ-DB] Failed to insert {clan.get('long_name')}: {e}") + + await db.commit() + + # Season-reset detection: if total points dropped by more than 50% from a + # meaningful baseline, the season rolled over. Reset per-member points so + # the squadron profile pages don't show stale pre-season values while the + # slow points-loop catches up. + async with db.execute("SELECT COALESCE(SUM(clanrating), 0) FROM squadrons_data") as cursor: + row = await cursor.fetchone() + new_total = row[0] if row is not None else 0 + + if old_total > 10000 and new_total < old_total * 0.5: + logging.warning( + f"[SQ-DB] Season reset detected (old={old_total}, new={new_total}). " + "Resetting squadron_members.points to 0." + ) + await db.execute("UPDATE squadron_members SET points = 0") + await db.commit() + logging.info("[SQ-DB] Member points reset complete.") + + async with db.execute(""" + SELECT + COUNT(*) AS total_rows, + SUM(CASE WHEN clanrating > 0 THEN 1 ELSE 0 END) AS active_rows, + SUM(CASE WHEN clanrating = 0 AND position IS NOT NULL THEN 1 ELSE 0 END) AS stale_ranked_rows + FROM squadrons_data + """) as cursor: + stats_row = await cursor.fetchone() + total_rows = stats_row[0] if stats_row else 0 + active_rows = stats_row[1] if stats_row else 0 + stale_ranked_rows = stats_row[2] if stats_row else 0 + + logging.info( + "[SQ-DB] Post-refresh stats: total=%s active=%s stale_ranked=%s", + total_rows, + active_rows, + stale_ranked_rows, + ) + + logging.info(f"[SQ-DB] Done. Inserted/updated {inserted} squadrons.") + return inserted + + +async def obtain_clan_new_points( + clan_name: str, + max_retries: int = 5, + base_wait: float = 2.0, +) -> Tuple[Dict[str, Dict[str, Any]], int]: + """Fetch clan member points by long name from the War Thunder API. + + Parses the BLK payload, unwraps nested wrappers, and extracts + rounded ``dr_era5_hist`` for each member. + + Retries with exponential backoff on 403 or network errors. + + Args: + clan_name: The squadron's long (display) name. + max_retries: Maximum number of retry attempts. + base_wait: Base wait time in seconds for exponential backoff. + + Returns: + Tuple of ({uid: {"nick": str, "points": int}, ...}, total_points). + + Raises: + ClanInfoError: If all retries are exhausted or the response is + unparseable. + """ + if not clan_name or not clan_name.strip(): + raise ValueError("clan_name must be a non-empty string") + + # 1) Load JWT + try: + async with aiofiles.open(AUTH_FILE, "r", encoding="utf-8") as f: + jwt = json.loads(await f.read())["jwt"] + except Exception as e: + logging.error("Failed to load JWT from %s", AUTH_FILE, exc_info=e) + raise ClanInfoError("Authentication token load failed") from e + + headers = {"action": "cln_clan_get", "token": jwt, "clanName": clan_name} + + raw = b"" + status = 0 + content_type = "" + attempt = 0 + while attempt < max_retries: + + try: + async with aiohttp.ClientSession() as session: + async with session.get( + CHAR_URL, headers=headers + ) as res: + raw = await res.read() + status = res.status + content_type = res.headers.get("Content-Type", "") + + # Retry on 403 Forbidden + if status == 403: + raise aiohttp.ClientResponseError( + res.request_info, res.history, status=status, message="Forbidden" + ) + + res.raise_for_status() + + # Inspect JSON for token expiry + if raw[:1] in (b"{", b"[") or "json" in content_type.lower(): + try: + j = json.loads(raw.decode("utf-8", "replace")) + except Exception: + j = {} + + err = (j.get("result") or {}).get("error") + if err in ("TOKEN_EXPIRED", "INVALID_TOKEN"): + logging.warning( + f"JWT ERROR ({err}) for '%s'; refreshing token and exiting early.", + clan_name, + ) + try: + await get_JWT() + logging.info("JWT refreshed at %s", AUTH_FILE) + except Exception: + logging.exception("JWT refresh failed") + return {}, 0 + + try: + _dump_failed_payload(status, content_type, raw, clan_name) + except Exception: + pass + raise ClanInfoError(f"Server returned JSON (error={err!r})") + + except (aiohttp.ClientError, aiohttp.ClientResponseError) as e: + attempt += 1 + wait = base_wait * attempt + await asyncio.sleep(wait) + continue # try again with new proxy + + # 2) Parse BLK if we got here without exceptions + try: + data = await bin_blk_to_json(raw) + + # unwrap single-key envelopes + while isinstance(data, dict) and "member_ratings" not in data and len(data) == 1: + data = next(iter(data.values())) + + total_score = data["astat"]["dr_era5_hist"] + raw_ratings = data["member_ratings"] + + except ClanInfoError as e: + try: + _dump_failed_payload(status, content_type, raw, clan_name) + except Exception: + pass + raise ClanInfoError(f"Squadron '{clan_name}' unavailable or deleted") from e + except (TypeError, KeyError) as e: + try: + _dump_failed_payload(status, content_type, raw, clan_name) + except Exception: + pass + raise ClanInfoError(f"Squadron '{clan_name}' unavailable or deleted") from e + + # 3) Build uid→nick lookup + uid_to_nick = { + str(m.get("uid", "")): m.get("nick", "") + for m in data.get("members", []) + } + + # 4) Build full ratings including zero-point members + full_ratings: Dict[str, Dict[str, Any]] = {} + for uid, nick in uid_to_nick.items(): + pts = int(round(raw_ratings.get(uid, {}).get("dr_era5_hist", 0))) + full_ratings[uid] = {"nick": nick, "points": pts} + + # 5) Sort descending by points + sorted_items = sorted( + full_ratings.items(), + key=lambda kv: kv[1]["points"], + reverse=True, + ) + + return dict(sorted_items), total_score + + # Retries exhausted + raise ClanInfoError( + f"Failed to fetch points for '{clan_name}' after {max_retries} attempts" + ) + + + +async def get_point_diff( + game_id: str, + team: Dict[str, Any], +) -> Tuple[Dict[str, int], int, Dict[str, int]]: + """ + Fast per-game diffs using SQLite (no JSON files). + Returns (per-UID diffs, total diff, updated snapshot). + """ + _points_db_init(str(_POINTS_DB)) + + squadron = team["squadron_long"] + team_uids = { str(p["uid"]) for p in team.get("players", []) } + + # Pull current snapshot from your (already correct) parser + clan_ratings, total_score = await obtain_clan_new_points(squadron) # returns {uid:{"nick","points"}}, total. :contentReference[oaicite:2]{index=2} + if not clan_ratings and total_score == 0: + # same contract as before: caller can retry if token was refreshed + return {}, 0, {} + + # per-UID points for this team + curr_points_by_uid = { uid: int((clan_ratings.get(uid) or {}).get("points", 0)) for uid in team_uids } + + diffs, diff_total, updated = _apply_game_points( + game_id=game_id, + squadron=squadron, + team_uids=team_uids, + curr_points_by_uid=curr_points_by_uid, + curr_total=int(total_score), + ) + return diffs, diff_total, updated + + + +def _clean_player_data(data: dict) -> dict: + """ + Clean up raw player data to only include essential fields. + + Returns a dict with: + - nick: Player nickname + - userID: Player user ID + - clanTag: Clan tag (if in clan) + - clanName: Clan name (if in clan) + - clanID: Clan ID (if in clan) + - lastDay: Unix timestamp of last login + - vehicles: List of vehicles with stats from arcade/historical/simulation total sections + """ + root = data[0].get("root", {}) if isinstance(data, list) and len(data) > 0 else data.get("root", {}) + + cleaned = { + "nick": root.get("nick", ""), + "userID": root.get("userid", ""), + "clanTag": root.get("clanTag", ""), + "clanName": root.get("clanName", ""), + "clanID": root.get("clanId", ""), + "lastDay": root.get("lastDay", 0), + "vehicles": [] + } + + # Extract vehicles from userstat > arcade/historical/simulation > total + userstat = root.get("userstat", {}) + game_modes = ["arcade", "historical", "simulation"] + + for mode in game_modes: + mode_data = userstat.get(mode, {}) + total = mode_data.get("total", {}) + + for vehicle_name, vehicle_stats in total.items(): + # Create vehicle entry + vehicle_entry = { + "name": vehicle_name, + "mode": mode, + "flyouts": vehicle_stats.get("flyouts", 0), + "was_in_session": vehicle_stats.get("was_in_session", 0), + } + + # Include all other stats that exist + for key, value in vehicle_stats.items(): + if key not in ["flyouts", "was_in_session"]: + vehicle_entry[key] = value + + cleaned["vehicles"].append(vehicle_entry) + + return cleaned + + +async def obtain_player_data_api(player_uid: str, raw: bool = False): + """ + Fetch player info by uid, parse the BLK payload + + Args: + player_uid: The player's UID + raw: If True, return raw parsed data. If False, return cleaned data with only essential fields. + + If the server returns JSON with error TOKEN_EXPIRED, refresh the JWT + via get_JWT() and exit early by returning {} so the caller can retry later. + """ + if not player_uid or not player_uid.strip(): + raise ValueError("player_uid must be a non-empty string") + + # 1) Load JWT + try: + async with aiofiles.open(AUTH_FILE, "r", encoding="utf-8") as f: + jwt = json.loads(await f.read())["jwt"] + except Exception as e: + logging.error("Failed to load JWT from %s", AUTH_FILE, exc_info=e) + raise ClanInfoError("Authentication token load failed") from e + + # 2) Fetch raw payload + headers = {"action": "ano_get_public_userstat", "token": jwt, "userid": player_uid} + raw_payload = b"" + status = 0 + content_type = "" + try: + async with aiohttp.ClientSession() as session: + async with session.get(CHAR_URL, headers=headers) as res: + res.raise_for_status() + raw_payload = await res.read() + status = res.status + content_type = res.headers.get("Content-Type", "") #type: ignore + + # If server returned JSON, inspect for TOKEN_EXPIRED and bail after refreshing + if raw_payload[:1] in (b"{", b"[") or "json" in content_type.lower(): + try: + j = json.loads(raw_payload.decode("utf-8", "replace")) + except Exception: + j = {} + + err = (j.get("result") or {}).get("error") + if err in ("TOKEN_EXPIRED", "INVALID_TOKEN"): + logging.warning(f"JWT ERROR ({err}) for '%s'; refreshing token and exiting early.", player_uid) + try: + await get_JWT() + logging.info("JWT refreshed at %s", AUTH_FILE) + except Exception: + logging.exception("JWT refresh failed") + + return {} + + try: + _dump_failed_payload(status, content_type, raw_payload, player_uid) + except Exception: + pass + raise ClanInfoError(f"Server returned JSON (error={err!r})") + + except aiohttp.ClientError as e: + # Check if it's a rate limit (503) error - don't spam logs for these + error_msg = str(e) + if "503" in error_msg or "RETRY" in error_msg: + # Rate limit - log at debug level only + logging.info("HTTP request to %s rate limited (503)", CHAR_URL) + else: + # Other errors - log normally + logging.error("HTTP request to %s failed", CHAR_URL, exc_info=e) + raise ClanInfoError("Network request failed") from e + + # 3) Parse BLK, unwrap, and extract + try: + data = await bin_blk_to_json(raw_payload) + + # If raw=True, return the full data + if raw: + return data + + # Otherwise, clean up the data + cleaned_data = _clean_player_data(data) + return cleaned_data + + except ClanInfoError as e: + # BLK parsing failures + try: + _dump_failed_payload(status, content_type, raw_payload, player_uid) + except Exception: + pass + raise ClanInfoError(f"Squadron '{player_uid}' unavailable or deleted") from e + except (TypeError, KeyError) as e: + # missing fields + try: + _dump_failed_payload(status, content_type, raw_payload, player_uid) + except Exception: + pass + raise ClanInfoError(f"Squadron '{player_uid}' unavailable or deleted") from e + + +async def obtain_clan_info_api( + squadron_key: str, + key_type: str, # must be one of: "clanName", "clanTag", "clanID" + max_retries: int = 5, + base_wait: float = 2.0, +): + """Fetch full clan/squadron info from the War Thunder API. + + Looks up a clan by name, tag, or numeric ID depending on + ``key_type``, parses the BLK response, and returns the unwrapped + data dict. Retries with linear backoff on 403 or network errors. + + Args: + squadron_key: The clan identifier (name, tag, or ID string). + key_type: One of ``"clanName"``, ``"clanTag"``, or ``"clanID"``. + max_retries: Maximum number of retry attempts. + base_wait: Base wait time in seconds between retries. + + Returns: + A dict of parsed clan data with the ``"root"`` wrapper removed. + Returns an empty dict if the JWT is expired and was refreshed. + + Raises: + ValueError: If ``squadron_key`` is empty or ``key_type`` is invalid. + ClanInfoError: If all retries are exhausted or parsing fails. + """ + if not squadron_key or not squadron_key.strip(): + raise ValueError("squadron_key must be a non-empty string") + + if key_type not in ("clanName", "clanTag", "clanID"): + raise ValueError("key_type must be one of: 'clanName', 'clanTag', 'clanID'") + + # 1) Load JWT + try: + async with aiofiles.open(AUTH_FILE, "r", encoding="utf-8") as f: + jwt = json.loads(await f.read())["jwt"] + except Exception as e: + logging.error("Failed to load JWT from %s", AUTH_FILE, exc_info=e) + raise ClanInfoError("Authentication token load failed") from e + + headers = {"action": "cln_clan_get", "token": jwt, key_type: squadron_key} + + attempt = 0 + while attempt < max_retries: + + try: + async with aiohttp.ClientSession() as session: + async with session.get( + CHAR_URL, headers=headers + ) as res: + raw = await res.read() + status = res.status + content_type = res.headers.get("Content-Type", "") + + # Retry on 403 + if status == 403: + raise aiohttp.ClientResponseError( + res.request_info, res.history, status=status, message="Forbidden" + ) + + res.raise_for_status() + + # Handle JSON responses + if raw[:1] in (b"{", b"[") or "json" in content_type.lower(): + try: + j = json.loads(raw.decode("utf-8", "replace")) + except Exception: + j = {} + + err = (j.get("result") or {}).get("error") + if err in ("TOKEN_EXPIRED", "INVALID_TOKEN"): + logging.warning( + f"JWT ERROR ({err}) for '%s'; refreshing token and exiting early.", + squadron_key, + ) + try: + await get_JWT() + logging.info("JWT refreshed at %s", AUTH_FILE) + except Exception: + logging.exception("JWT refresh failed") + return {} + + try: + _dump_failed_payload(status, content_type, raw, squadron_key) + except Exception: + pass + raise ClanInfoError(f"Server returned JSON (error={err!r})") + + except (aiohttp.ClientError, aiohttp.ClientResponseError) as e: + attempt += 1 + wait = base_wait * attempt + + await asyncio.sleep(wait) + continue # try again with new proxy + + # 4) Parse BLK if we got here without exceptions + try: + data = await bin_blk_to_json(raw) + + # unwrap "root" if it's the only key + if isinstance(data, dict) and "root" in data and isinstance(data["root"], dict): + data = data["root"] + + return data + except (ClanInfoError, TypeError, KeyError) as e: + try: + _dump_failed_payload(status, content_type, raw, squadron_key) + except Exception: + pass + raise ClanInfoError(f"Clan '{squadron_key}' unavailable or deleted") from e + + # If retries exhausted + raise ClanInfoError( + f"Failed to fetch clan '{squadron_key}' after {max_retries} attempts" + ) + + +### JWT MANAGEMENT ### + +# Load credentials from environment +_wt_client_id = os.getenv("WT_CLIENT_ID", "") +_wt_auth_url = os.getenv("WT_AUTH_URL", "https://auth.gaijinent.com/login.php") +_wt_login_email = os.getenv("WT_LOGIN_EMAIL", "") +_wt_login_password = os.getenv("WT_LOGIN_PASSWORD", "") + +_jwt_headers = { + "Host": "auth.gaijinent.com", + "User-Agent": "yuplay2 lib / WarThunder", + "Accept": "*/*", + "Accept-Encoding": "deflate, gzip, br, zstd", + "Content-Type": "application/x-www-form-urlencoded", + "X-Client-Id": _wt_client_id +} + +_jwt_payload = { + "client": _wt_client_id, + "game": "wt", + "gapp_id": 50278, + "login": _wt_login_email, + "meta": 1, + "password": _wt_login_password, + "v": 2, +} + +async def get_JWT(): + """Authenticate with the War Thunder auth server and persist the JWT. + + Posts login credentials to the Gaijin auth endpoint, writes the + returned JSON (containing the ``jwt`` field) to ``AUTH_FILE``. + + Returns: + The parsed JSON response dict from the auth server. + + Raises: + aiohttp.ClientError: If the HTTP request fails. + """ + async with aiohttp.ClientSession() as session: + async with session.post(_wt_auth_url, headers=_jwt_headers, data=_jwt_payload) as resp: + logging.info("JWT Status: %s", resp.status) + data = await resp.json() + # Write where obtain_clan_new_points() reads from + async with aiofiles.open(AUTH_FILE, "w", encoding="utf-8") as f: + await f.write(json.dumps(data, ensure_ascii=False, indent=4)) + return data # optional: return for logging/tests + +### JWT MANAGEMENT ### + + + + +pts__api_test = True +if __name__ == "__main__" and pts__api_test: + pts = asyncio.run(obtain_clan_new_points("Death Sentence")) + out_path = Path("clan_pts.json") + with out_path.open("w", encoding="utf-8") as f: + json.dump(pts, f, indent=2, ensure_ascii=False) + + print(f"✅ Clan data dumped to {out_path.resolve()}") + + +clan_info_api_test = False +if __name__ == "__main__" and clan_info_api_test: + clan_data = asyncio.run(obtain_clan_info_api("Death Sentence", "clanName")) + out_path = Path("clan_data.json") + with out_path.open("w", encoding="utf-8") as f: + json.dump(clan_data, f, indent=2, ensure_ascii=False) + + print(f"✅ Clan data dumped to {out_path.resolve()}") + + +player_test = False +if __name__ == "__main__" and player_test: + player_data = asyncio.run(obtain_player_data_api("199874256", raw=True)) + out_path = Path("player_data.json") + with out_path.open("w", encoding="utf-8") as f: + json.dump(player_data, f, indent=2, ensure_ascii=False) + + print(f"✅ Player data dumped to {out_path.resolve()}") + +jwt_test = False +if __name__ == "__main__" and jwt_test: + pts = asyncio.run(get_JWT()) + logging.info("Saved JSON response to JWT.json") + +leaderboard_test = False +if __name__ == "__main__" and leaderboard_test: + lb_data = asyncio.run(obtain_clans_leaderboard(start=0, count=1000)) + out_path = Path("leaderboard_data.json") + with out_path.open("w", encoding="utf-8") as f: + json.dump(lb_data, f, indent=2, ensure_ascii=False) + + print(f"✅ Leaderboard data dumped to {out_path.resolve()}") diff --git a/BOT/gob.py b/BOT/gob.py new file mode 100644 index 0000000..08d79c5 --- /dev/null +++ b/BOT/gob.py @@ -0,0 +1,2486 @@ +""" +gob.py + +Handles GOB replay files: renders MP4 videos and exports slim JSON for the +web canvas replay viewer. Output mode is picked from the output file extension. + +Usage: + python -m BOT.gob # render video + python -m BOT.gob # export json + +Public API: + render_gob(d, out_path, fps, speed, n_workers, progress_cb) + load_gob_file(gob_path) + export_replay_json(gob_path) +""" + +# Standard Library Imports +import json +import math +import os +import subprocess +import sys +import threading +import urllib.request +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field +from functools import lru_cache +from pathlib import Path +from typing import Any, Callable, Optional + +# Third-Party Library Imports +import numpy as np +import pygob +import zstandard as zstd + +from .utils import REPLAYS_DIR +from PIL import Image, ImageDraw, ImageFilter, ImageFont + +# Make SHARED (sibling of SREBOT under BOTS/) importable +_SHARED_DIR = Path(__file__).resolve().parents[2] / "SHARED" +if str(_SHARED_DIR) not in sys.path: + sys.path.insert(0, str(_SHARED_DIR)) + +try: + from data_parser import ( + LangTableReader as _LangTableReader, + WeaponTableReader as _WeaponTableReader, + apply_vehicle_name_filters as _apply_filters, + ) + _lang = _LangTableReader("English") + _weapons = _WeaponTableReader("English") + def _translate_vehicle(internal: str) -> str: + name = _lang.get_translate(internal) + return _apply_filters(name) if name else internal + def _translate_weapon(internal: str) -> str: + return _weapons.get_translate(internal) or internal +except Exception: + def _translate_vehicle(internal: str) -> str: # type: ignore[misc] + return internal + def _translate_weapon(internal: str) -> str: # type: ignore[misc] + return internal + +# Prune verbose suffixes from translated weapon names +_WEAPON_PRUNE = [ + " air-to-ground missiles", + " air-to-ground missile", + " air-to-air missiles", + " air-to-air missile", + " anti-radiation missile", + " anti-tank guided missile", + " anti-tank guided missiles", + " guided bomb", + " guided bombs", +] +_orig_translate_weapon = _translate_weapon +def _translate_weapon(internal: str) -> str: # type: ignore[misc] # noqa: F811 + name = _orig_translate_weapon(internal) + for suffix in _WEAPON_PRUNE: + if name.endswith(suffix): + name = name[:-len(suffix)] + break + return name + +# Load .env from repo root +_env_path = Path(__file__).parent.parent / ".env" +if _env_path.exists(): + for _line in _env_path.read_text().splitlines(): + if "=" in _line and not _line.startswith("#"): + _k, _, _v = _line.partition("=") + os.environ.setdefault(_k.strip(), _v.strip()) + +# ── Config ───────────────────────────────────────────────────────────────────── + +CANVAS_MIN = 1024 # Minimum canvas resolution (px) +CANVAS_MAX = 4096 # Maximum canvas resolution (px) +MIN_OUTPUT = 1024 # Target minimum crop output size (px) +FPS = 24 # Video frames per second +SPEED = 4 # Playback speed multiplier (4× = 1min sim → 15s video) +TRAIL_MS = 18_000 # Ground trail length (ms, video time) +AIR_TRAIL_MS = 4_000 # Aircraft trail length (ms, video time) +DRONE_TRAIL_MS = 2_000 # Drone trail length (ms, video time) +DOT_R = 5 # Player dot radius (px) +DRONE_R = 3 # Drone dot radius (px) +AIR_R = 4 # Aircraft triangle half-size (px) +TANK_ICON_SIZE = 15 # Ground vehicle icon size (px, at 1024px reference) +AIR_ICON_SIZE = 20 # Aircraft icon size (px, at 1024px reference) +ICON_HIGHLIGHT_PAD = 3 # Highlight border padding around icon (px, at 1024px reference) +ICON_HIGHLIGHT_COLOR = (200, 200, 200, 120) # RGBA highlight backdrop color +KILL_TTL = 4_000 # Kill marker display duration (ms, video time) +GHOST_TTL = 3_000 # Death fade-to-black duration (ms, video time) +WIPE_GRACE = 90 # Extra frames rendered after a team wipe +N_WORKERS = min(8, os.cpu_count() or 4) # Thread pool size for parallel frame rendering + +WIN_COLOR = (0, 200, 0) # green +LOSE_COLOR = (220, 30, 30) # red + +MINIMAPS_DIR = _SHARED_DIR / "MAPS" / "MINIMAPS" +LEVELS_DIR = _SHARED_DIR / "MAPS" / "LEVELS" +ICONS_DIR = _SHARED_DIR / "ICONS" + +_tl = threading.local() + +# ── Vehicle-type icon system ────────────────────────────────────────────────── + +# Map raw unittags tags → icon key (checked in order, first match wins) +_TAG_TO_ICON = { + "type_spaa": "spaa", + "type_light_tank": "light_tank", + "type_tank_destroyer": "tank_destroyer", + "type_heavy_tank": "heavy_tank", + "type_medium_tank": "medium_tank", + "type_missile_tank": "tank_destroyer", + "type_jet_bomber": "jet_bomber", + "type_bomber": "bomber", + "type_strike_aircraft": "fighter", + "type_jet_fighter": "jet", + "type_fighter": "fighter", + "type_strike_ucav": "drone", + "type_helicopter": "helicopter", +} + +# Icon key → PNG filename (relative to ICONS_DIR) +_ICON_FILES = { + "light_tank": "light.png", + "medium_tank": "medium.png", + "heavy_tank": "heavy.png", + "tank_destroyer": "tank_destroyer.png", + "spaa": "spaa.png", + "fighter": "FALLBACKS/fighter_icon.png", + "jet": "FALLBACKS/jet_icon.png", + "jet_bomber": "FALLBACKS/jet_bomber_icon.png", + "bomber": "FALLBACKS/bomber_icon.png", + "drone": "drone.png", + "helicopter": "FALLBACKS/helicopter_icon.png", + "unknown": "tank_icon.png", +} + + +# ── Premultiplied sprite ─────────────────────────────────────────────────────── + +@dataclass +class Sprite: + """Premultiplied RGBA sprite for fast alpha compositing onto RGB buffers. + + Attributes: + pm: Premultiplied RGB array, shape (h, w, 3), uint8. + ia: Inverse alpha array, shape (h, w, 1), uint16. + h: Sprite height in pixels. + w: Sprite width in pixels. + """ + pm: np.ndarray # (h, w, 3) uint8 premultiplied RGB + ia: np.ndarray # (h, w, 1) uint16 inverse alpha, pre-cast + h: int + w: int + + +def make_sprite(rgba: np.ndarray) -> Sprite: + a = rgba[..., 3:4].astype(np.uint16) + pm = ((rgba[..., :3].astype(np.uint16) * a) >> 8).astype(np.uint8) + ia = (255 - rgba[..., 3:4]).astype(np.uint16) + return Sprite(pm=pm, ia=ia, h=rgba.shape[0], w=rgba.shape[1]) + + +def make_black_sprite(spr: Sprite) -> Sprite: + """Create a black version of a sprite (RGB=0, same alpha).""" + pm = np.zeros_like(spr.pm) + ia = np.full_like(spr.ia, 255, dtype=np.uint16) + # Reconstruct alpha from inverse alpha, then rebuild + alpha = 255 - spr.ia.astype(np.int16) + ia = (255 - alpha).astype(np.uint16) + return Sprite(pm=pm, ia=ia, h=spr.h, w=spr.w) + + +def make_bare_black_sprite(icon_key: str, size: int, highlight_pad: int) -> Sprite: + """Create a black icon sprite with no highlight glow — just the bare silhouette. + + The sprite is sized to match the highlighted version (with padding) so it can be + used as a drop-in replacement when the highlight fades away. + """ + rgba = _load_icon_rgba(icon_key) + src_h, src_w = rgba.shape[:2] + scale_f = size / max(src_h, src_w) + new_w = max(1, int(src_w * scale_f + 0.5)) + new_h = max(1, int(src_h * scale_f + 0.5)) + img = Image.fromarray(rgba).resize((new_w, new_h), Image.Resampling.LANCZOS) + arr = np.asarray(img).copy() + # Zero out RGB, keep alpha — black silhouette + arr[..., :3] = 0 + if highlight_pad > 0: + # Embed in padded canvas to match highlighted sprite dimensions + out_h, out_w = new_h + highlight_pad * 2, new_w + highlight_pad * 2 + canvas = np.zeros((out_h, out_w, 4), dtype=np.uint8) + canvas[highlight_pad:highlight_pad + new_h, highlight_pad:highlight_pad + new_w] = arr + arr = canvas + return make_sprite(arr) + + +def _get_unit_tags(internal_name: str) -> list[str] | None: + """Get raw tags for a vehicle from UnitTags, returns None if not found.""" + from data_parser import UnitTags + return UnitTags.get()._get_tags(internal_name) + + +MINIS_DIR = _SHARED_DIR / "ICONS" / "MINIS" + + +_AIRCRAFT_TAGS = {"air", "aircraft", "helicopter"} + + +def get_icon_key(model_name: str) -> str: + """Map a ModelName (e.g. 'tankModels/ussr_t_34') to an icon key. + + For aircraft/helicopters, tries a per-vehicle mini icon first + ('{internal}_ico.png' in MINIS/), then falls back to tag-based. + Ground vehicles always use tag-based icons. + """ + internal = model_name.split("/")[-1] + tags = _get_unit_tags(internal) + if tags: + tag_set = set(tags) + # Only aircraft/helis get per-vehicle mini icons + if tag_set & _AIRCRAFT_TAGS: + mini_path = MINIS_DIR / f"{internal}_ico.png" + if mini_path.exists(): + return f"mini:{internal}" + # Tag-based fallback (used for all ground vehicles, and aircraft without a mini) + for tag, icon in _TAG_TO_ICON.items(): + if tag in tag_set: + return icon + return "unknown" + + +@lru_cache(maxsize=512) +def _load_icon_rgba(icon_key: str) -> np.ndarray: + """Load an icon PNG as RGBA numpy array, cropped to content bounds. Cached.""" + if icon_key.startswith("mini:"): + internal = icon_key[5:] + path = MINIS_DIR / f"{internal}_ico.png" + else: + filename = _ICON_FILES.get(icon_key, _ICON_FILES["unknown"]) + path = ICONS_DIR / filename + arr = np.asarray(Image.open(path).convert("RGBA")).copy() + # Crop to bounding box of non-transparent pixels + alpha = arr[..., 3] + rows = np.any(alpha > 0, axis=1) + cols = np.any(alpha > 0, axis=0) + if rows.any() and cols.any(): + y0, y1 = np.where(rows)[0][[0, -1]] + x0, x1 = np.where(cols)[0][[0, -1]] + arr = arr[y0:y1 + 1, x0:x1 + 1].copy() + return arr + + +def make_tinted_icon_sprite(icon_key: str, color: tuple[int, int, int], + size: int, highlight_pad: int = 0, + highlight_color: tuple[int, int, int, int] = (0, 0, 0, 0), + ) -> Sprite: + """Load an icon, resize preserving aspect ratio, tint, add outline highlight.""" + rgba = _load_icon_rgba(icon_key) + src_h, src_w = rgba.shape[:2] + # Resize to fit within `size` height, preserving aspect ratio + scale_f = size / max(src_h, src_w) + new_w = max(1, int(src_w * scale_f + 0.5)) + new_h = max(1, int(src_h * scale_f + 0.5)) + img = Image.fromarray(rgba).resize((new_w, new_h), Image.Resampling.LANCZOS) + arr = np.asarray(img).copy().astype(np.float32) + # Tint: multiply RGB by color/255, preserving alpha + arr[..., 0] *= color[0] / 255.0 + arr[..., 1] *= color[1] / 255.0 + arr[..., 2] *= color[2] / 255.0 + tinted = np.clip(arr, 0, 255).astype(np.uint8) + if highlight_pad <= 0 or highlight_color[3] == 0: + return make_sprite(tinted) + # Build outline highlight by painting the highlight color at offsets around the icon + pad = highlight_pad + out_h, out_w = new_h + pad * 2, new_w + pad * 2 + canvas = np.zeros((out_h, out_w, 4), dtype=np.uint8) + tinted_img = Image.fromarray(tinted) + hl_layer = Image.new("RGBA", (out_w, out_h), (0, 0, 0, 0)) + # Stamp the icon alpha at each offset around the center to create an outline + for dy in range(-pad, pad + 1): + for dx in range(-pad, pad + 1): + if dx * dx + dy * dy > pad * pad: + continue + hl_layer.paste(tinted_img, (pad + dx, pad + dy), tinted_img) + # Replace RGB with highlight color, keep the merged alpha capped + hl_arr = np.asarray(hl_layer).copy() + mask = hl_arr[..., 3] > 0 + hl_arr[mask, 0] = highlight_color[0] + hl_arr[mask, 1] = highlight_color[1] + hl_arr[mask, 2] = highlight_color[2] + hl_arr[..., 3] = np.minimum(hl_arr[..., 3], highlight_color[3]) + # Composite tinted icon on top of highlight + bg = Image.fromarray(hl_arr) + bg.paste(tinted_img, (pad, pad), tinted_img) + return make_sprite(np.asarray(bg).copy()) + + +def load_target_sprite(filename: str, size: int) -> Sprite: + """Load a target icon PNG, resize, return as Sprite.""" + path = ICONS_DIR / filename + rgba = np.asarray(Image.open(path).convert("RGBA").resize( + (size, size), Image.Resampling.LANCZOS + )).copy() + return make_sprite(rgba) + + +# ── Rotated sprite cache for aircraft ───────────────────────────────────────── + +ROTATION_STEPS = 72 # one sprite every 5° + +def _sprite_to_rgba(spr: Sprite) -> np.ndarray: + """Reconstruct RGBA array from a premultiplied Sprite.""" + alpha = (255 - spr.ia[..., 0]).astype(np.uint8) + rgba = np.zeros((spr.h, spr.w, 4), dtype=np.uint8) + safe_a = np.maximum(alpha, 1).astype(np.float32) + rgba[..., 0] = np.clip(spr.pm[..., 0].astype(np.float32) * 255.0 / safe_a, 0, 255).astype(np.uint8) + rgba[..., 1] = np.clip(spr.pm[..., 1].astype(np.float32) * 255.0 / safe_a, 0, 255).astype(np.uint8) + rgba[..., 2] = np.clip(spr.pm[..., 2].astype(np.float32) * 255.0 / safe_a, 0, 255).astype(np.uint8) + rgba[..., 3] = alpha + return rgba + + +def make_rotation_cache(spr: Sprite) -> list[Sprite]: + """Pre-compute rotated sprites at ROTATION_STEPS even angles. Index 0 = 0°, etc.""" + rgba = _sprite_to_rgba(spr) + img = Image.fromarray(rgba) + cache: list[Sprite] = [] + for i in range(ROTATION_STEPS): + deg = i * (360.0 / ROTATION_STEPS) + # PIL rotates CCW; we want CW rotation for heading, so negate + rot = img.rotate(-deg, resample=Image.Resampling.BILINEAR, expand=True) + cache.append(make_sprite(np.asarray(rot).copy())) + return cache + + +def precompute_headings(px: np.ndarray, py: np.ndarray) -> np.ndarray: + """Compute heading angle (degrees, 0=up/north, CW) per entity per frame. + + Returns (n_entities, n_frames) float32 array. -1 where invalid. + """ + n_ents, n_frames = px.shape + headings = np.full((n_ents, n_frames), -1.0, dtype=np.float32) + for i in range(n_ents): + last_heading = 0.0 + for f in range(1, n_frames): + if px[i, f] < 0 or px[i, f - 1] < 0: + headings[i, f] = last_heading + continue + dx = float(px[i, f] - px[i, f - 1]) + dy = float(py[i, f] - py[i, f - 1]) + if abs(dx) < 0.5 and abs(dy) < 0.5: + headings[i, f] = last_heading + continue + # atan2(dx, -dy): 0=up, 90=right, 180=down, 270=left + deg = math.degrees(math.atan2(dx, -dy)) % 360 + last_heading = deg + headings[i, f] = deg + # Fill frame 0 with frame 1's heading + if n_frames > 1: + headings[i, 0] = headings[i, 1] + return headings + + +def heading_to_rot_index(deg: float) -> int: + """Convert a heading in degrees to a rotation cache index.""" + return int(round(deg / (360.0 / ROTATION_STEPS))) % ROTATION_STEPS + + +def blit(buf: np.ndarray, spr: Sprite, x: int, y: int) -> None: + x1, y1 = max(x, 0), max(y, 0) + H, W = buf.shape[0], buf.shape[1] + x2, y2 = min(x + spr.w, W), min(y + spr.h, H) + if x1 >= x2 or y1 >= y2: + return + sy1, sx1 = y1 - y, x1 - x + sy2, sx2 = sy1 + (y2 - y1), sx1 + (x2 - x1) + pm = spr.pm[sy1:sy2, sx1:sx2].astype(np.uint16) + buf[y1:y2, x1:x2] = (pm + + ((buf[y1:y2, x1:x2] * spr.ia[sy1:sy2, sx1:sx2]) >> 8) + ).astype(np.uint8) + + +def blit_alpha(buf: np.ndarray, spr: Sprite, x: int, y: int, alpha: float) -> None: + """Like blit() but with an additional [0,1] alpha multiplier.""" + if alpha <= 0: + return + x1, y1 = max(x, 0), max(y, 0) + H, W = buf.shape[0], buf.shape[1] + x2, y2 = min(x + spr.w, W), min(y + spr.h, H) + if x1 >= x2 or y1 >= y2: + return + sy1, sx1 = y1 - y, x1 - x + sy2, sx2 = sy1 + (y2 - y1), sx1 + (x2 - x1) + a16 = int(alpha * 256) + pm = ((spr.pm[sy1:sy2, sx1:sx2].astype(np.uint16) * a16) >> 8).astype(np.uint16) + ia = 255 - (((255 - spr.ia[sy1:sy2, sx1:sx2].astype(np.uint16)) * a16) >> 8) + buf[y1:y2, x1:x2] = (pm + ((buf[y1:y2, x1:x2] * ia) >> 8)).astype(np.uint8) + + +def blit_batch(buf: np.ndarray, items: list[tuple[Sprite, int, int]]) -> None: + """Blit multiple sprites in one call — avoids per-call Python/numpy overhead.""" + H = buf.shape[0] + W = buf.shape[1] + for spr, x, y in items: + x1 = max(x, 0); y1 = max(y, 0) + x2 = min(x + spr.w, W); y2 = min(y + spr.h, H) + if x1 >= x2 or y1 >= y2: + continue + sy1 = y1 - y; sx1 = x1 - x + sy2 = sy1 + (y2 - y1); sx2 = sx1 + (x2 - x1) + pm = spr.pm[sy1:sy2, sx1:sx2].astype(np.uint16) + buf[y1:y2, x1:x2] = (pm + + ((buf[y1:y2, x1:x2] * spr.ia[sy1:sy2, sx1:sx2]) >> 8) + ).astype(np.uint8) + + +# ── VideoCtx — all pre-computed data for one output video ───────────────────── + +@dataclass +class VideoCtx: + """Pre-computed rendering context for one GOB replay video. + + Holds all interpolated positions, colors, death states, kill/damage events, + label sprites, and the baked background for every frame so that + render_one_ctx can draw each frame without re-computing anything. + """ + n_players: int + px_all: np.ndarray # (n_players, n_frames) int16, -1=absent + py_all: np.ndarray + colors_arr: np.ndarray # (n_players, 3) uint8 + trail_colors_arr: np.ndarray # (n_players, 3) uint8 + is_dead: np.ndarray # (n_players, n_frames) bool + death_frame: np.ndarray # (n_players,) int32 — frame of death, or n_frames + death_fade: np.ndarray # (n_players, n_frames) float32 — 1→0 over ghost ttl + last_dead_px: np.ndarray # (n_players,) int16 — last known x at death + last_dead_py: np.ndarray # (n_players,) int16 — last known y at death + kills_by_frame: list # per-frame kill events + damages_by_frame: list # per-frame damage events + label_sprites: list # list[Sprite] combined name+vehicle per player + bg_arr: np.ndarray # pre-baked background RGB + end_frame: int + n_drones: int + px_drone: np.ndarray | None + py_drone: np.ndarray | None + drone_colors_arr: np.ndarray | None + drone_trail_colors: np.ndarray | None + drone_sprites: list # list[Sprite] + drone_is_hit: np.ndarray | None = None # True from kill onward (for fade) + drone_hit_fade: np.ndarray | None = None # 1→0 from kill to crash + drone_is_crashed: np.ndarray | None = None # True after path ends + drone_crash_frame: np.ndarray | None = None + drone_last_dead_px: np.ndarray | None = None + drone_last_dead_py: np.ndarray | None = None + drone_alt: np.ndarray | None = None + drone_alt_sprites: dict = field(default_factory=dict) + n_aircraft: int = 0 + px_air: np.ndarray | None = None + py_air: np.ndarray | None = None + air_colors_arr: np.ndarray | None = None + air_trail_colors: np.ndarray | None = None + air_sprites: list = field(default_factory=list) + air_is_hit: np.ndarray | None = None # True from kill onward + air_hit_fade: np.ndarray | None = None # 1→0 from kill to crash + air_is_crashed: np.ndarray | None = None # True after path ends + air_crash_frame: np.ndarray | None = None + air_last_dead_px: np.ndarray | None = None + air_last_dead_py: np.ndarray | None = None + air_alt: np.ndarray | None = None + air_alt_sprites: dict = field(default_factory=dict) + air_trail_f: int = 1 + drone_trail_f: int = 1 + # Scaled circle masks + dot_dy: np.ndarray = field(default_factory=lambda: DOT_DY) + dot_dx: np.ndarray = field(default_factory=lambda: DOT_DX) + shadow_dy: np.ndarray = field(default_factory=lambda: SHADOW_DY) + shadow_dx: np.ndarray = field(default_factory=lambda: SHADOW_DX) + drone_dy: np.ndarray = field(default_factory=lambda: DRONE_DY) + drone_dx: np.ndarray = field(default_factory=lambda: DRONE_DX) + air_dy: np.ndarray = field(default_factory=lambda: AIR_DY) + air_dx: np.ndarray = field(default_factory=lambda: AIR_DX) + dot_r: int = DOT_R + drone_r: int = DRONE_R + air_r: int = AIR_R + canvas: int = CANVAS_MIN + # Per-player vehicle icon sprites (tinted to player color) + player_icon_sprites: list = field(default_factory=list) # list[Sprite], len=n_players + player_dead_sprites: list = field(default_factory=list) # list[Sprite], black versions + air_icon_sprites: list = field(default_factory=list) # list[Sprite], len=n_aircraft + air_dead_sprites: list = field(default_factory=list) # list[Sprite], black versions + # Per-aircraft rotation caches and heading angles + air_rot_caches: list = field(default_factory=list) # list[list[Sprite]], per-aircraft + air_dead_rot_caches: list = field(default_factory=list) # list[list[Sprite]], black versions + air_headings: np.ndarray | None = None # (n_aircraft, n_frames) float32 + # Per-drone icon sprites + rotation + drone_icon_sprites: list = field(default_factory=list) + drone_dead_sprites: list = field(default_factory=list) + drone_rot_caches: list = field(default_factory=list) + drone_dead_rot_caches: list = field(default_factory=list) + drone_headings: np.ndarray | None = None + # Kill/damage target icons + kill_target_spr: Sprite | None = None + dmg_target_spr: Sprite | None = None + + +# ── Map load ─────────────────────────────────────────────────────────────────── + +NANACHI_ASSETS = "https://thunder.nanachi.party/assets/maps" +NANACHI_API_URL = "https://thunder.nanachi.party" +NANACHI_VISUAL_TOKEN = os.getenv("NANACHI_VISUAL_TOKEN", "") + + +def load_map_image(level_path: str, canvas: int = CANVAS_MIN) -> Image.Image | None: + """Load tankmap PNG from local MINIMAPS, falling back to Nanachi CDN.""" + stem = Path(level_path).stem # e.g. "avg_ardennes_snow" + path = MINIMAPS_DIR / (stem + "_tankmap.png") + if not path.exists(): + url = f"{NANACHI_ASSETS}/tankmap/full/{level_path}" + print(f" Not local, downloading from Nanachi …", end=" ", flush=True) + try: + urllib.request.urlretrieve(url, path) + print("ok") + except Exception as e: + print(f"failed ({e})") + return None + return Image.open(path).convert("RGB").resize( + (canvas, canvas), Image.Resampling.LANCZOS + ) + + +# ── Coordinate transform ─────────────────────────────────────────────────────── + +def _fetch_nanachi_level_def(session_id: int) -> dict | None: + """Fallback: fetch LevelDef from Nanachi API.""" + auth = f"Bearer {NANACHI_VISUAL_TOKEN}" + url = f"{NANACHI_API_URL}/api/0/visuals/session/{session_id}/interactive" + req = urllib.request.Request(url, headers={"Authorization": auth}) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + data = json.loads(resp.read()) + return data.get("LevelDef") + except Exception as e: + print(f"failed ({e})") + return None + + +def load_level_coords(level_path: str, session_id: int = 0) -> dict | None: + """Load tankMapCoord0/1 from local .blkx, falling back to Nanachi API.""" + stem = Path(level_path).stem + blkx = LEVELS_DIR / (stem + ".blkx") + if blkx.exists(): + try: + data = json.loads(blkx.read_text()) + if "tankMapCoord0" in data and "tankMapCoord1" in data: + return data + except Exception as e: + print(f" Failed to parse level def: {e}") + # Fallback to Nanachi API + if session_id: + print("not local, trying Nanachi …", end=" ", flush=True) + ld = _fetch_nanachi_level_def(session_id) + if ld and "tankMapCoord0" in ld and "tankMapCoord1" in ld: + return ld + return None + + +class CoordTransform: + """Transforms world coordinates (X, Z) to pixel coordinates on the canvas. + + Args: + x0: World X origin (left edge of the map). + z0: World Z origin (bottom edge of the map). + x1: World X extent (right edge of the map). + z1: World Z extent (top edge of the map). + canvas: Canvas size in pixels (square). + """ + def __init__(self, x0: float = 0, z0: float = 0, x1: float = 4096, z1: float = 4096, + canvas: int = CANVAS_MIN): + self.x0 = x0 + self.z0 = z0 + self.x_range = x1 - x0 + self.z_range = z1 - z0 + self.canvas = canvas + + def world_to_px(self, x: np.ndarray, z: np.ndarray): + """Convert world X/Z arrays to pixel coordinates on the canvas. + + Args: + x: World X positions as a numpy array. + z: World Z positions as a numpy array. + + Returns: + Tuple of (px, py) int16 numpy arrays in pixel space. + """ + px = ((x - self.x0) / self.x_range * self.canvas).astype(np.int16) + py = ((self.z0 + self.z_range - z) / self.z_range * self.canvas).astype(np.int16) + return px, py + + def point(self, x: float, z: float) -> tuple[int, int]: + xi, zi = self.world_to_px(np.array([x]), np.array([z])) + return int(np.clip(xi[0], 0, self.canvas - 1)), int(np.clip(zi[0], 0, self.canvas - 1)) + + +# ── Pre-computation ──────────────────────────────────────────────────────────── + +def precompute_positions(players: list[dict], xfm: CoordTransform, + frame_times: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Interpolate player world positions onto the frame time grid and convert to pixels. + + Args: + players: List of player dicts, each containing a '_samples' key with + time-stamped X/Z path data. + xfm: Coordinate transform for world-to-pixel conversion. + frame_times: 1-D array of frame timestamps in milliseconds. + + Returns: + Tuple of (px_all, py_all) int16 arrays, shape (n_players, n_frames). + -1 indicates the player is absent at that frame. + """ + n_p, n_f = len(players), len(frame_times) + px_all = np.full((n_p, n_f), -1, dtype=np.int16) + py_all = np.full((n_p, n_f), -1, dtype=np.int16) + for i, p in enumerate(players): + s = p["_samples"] + t = np.array([x["Time"] for x in s], dtype=np.float64) + xa = np.array([x["X"] for x in s], dtype=np.float64) + za = np.array([x["Z"] for x in s], dtype=np.float64) + mask = (frame_times >= t[0]) & (frame_times <= t[-1]) + if not mask.any(): + continue + xi, zi = np.interp(frame_times[mask], t, xa), np.interp(frame_times[mask], t, za) + pxi, pyi = xfm.world_to_px(xi, zi) + in_bounds = (pxi >= 0) & (pxi < xfm.canvas) & (pyi >= 0) & (pyi < xfm.canvas) + full_mask = np.where(mask)[0][in_bounds] + px_all[i, full_mask] = pxi[in_bounds] + py_all[i, full_mask] = pyi[in_bounds] + return px_all, py_all + + +def precompute_altitudes(players: list[dict], frame_times: np.ndarray) -> np.ndarray: + """Return (n_players, n_frames) int16 array of altitude in metres. -1 = absent.""" + n_p, n_f = len(players), len(frame_times) + alt_all = np.full((n_p, n_f), -1, dtype=np.int16) + for i, p in enumerate(players): + s = p["_samples"] + t = np.array([x["Time"] for x in s], dtype=np.float64) + ya = np.array([x["Y"] for x in s], dtype=np.float64) + mask = (frame_times >= t[0]) & (frame_times <= t[-1]) + if not mask.any(): + continue + yi = np.interp(frame_times[mask], t, ya) + alt_all[i, mask] = np.clip(yi, 0, 32767).astype(np.int16) + return alt_all + + +def precompute_kills(kills: list[dict], xfm: CoordTransform, + t_start: float, ms_per_frame: float, + n_frames: int, fps: int, + font: ImageFont.FreeTypeFont | ImageFont.ImageFont, + offset_x: int = 0, offset_y: int = 0, + pid_pos: dict[int, tuple[np.ndarray, np.ndarray]] | None = None, + ) -> list[list[tuple]]: + """ + Returns per-frame list of tuples: + (vx, vy, kx, ky, age_frac, label_sprite | None) + label_sprite is shown for the first half of the kill TTL then fades out. + offset_x/y: crop origin to shift coordinates into crop-space. + pid_pos: optional {PlayerID: (px_arr, py_arr)} for tracking moving entities. + When provided, kill lines follow the entity's interpolated position + each frame instead of using the static GOB snapshot position. + """ + out: list[list[tuple]] = [[] for _ in range(n_frames)] + kill_f = int(math.ceil(KILL_TTL * fps / 1000.0)) + for k in kills: + vp = k.get("VictimPosition") + if not vp: + continue + kf = int((k["Time"] - t_start) / ms_per_frame) + if not (0 <= kf < n_frames): + continue + # Static fallback positions from the kill event + svx, svy = xfm.point(vp["X"], vp["Z"]) + svx -= offset_x; svy -= offset_y + kp = k.get("KillerPosition") + if kp: + skx, sky = xfm.point(kp["X"], kp["Z"]) + skx -= offset_x; sky -= offset_y + else: + skx, sky = -1, -1 + # Look up tracked position arrays for killer/victim + kid = k.get("KillerID", 0) + vid = k.get("VictimID", 0) + k_tracked = pid_pos.get(kid) if pid_pos and kid else None + v_tracked = pid_pos.get(vid) if pid_pos and vid else None + killer_model = k.get("KillerModel", "") + weapon = k.get("Weapon", "") + label = make_kill_label(killer_model, weapon, font) if killer_model else None + for df in range(kill_f): + f = kf + df + if f >= n_frames: + break + # Use tracked position if available and valid at this frame + if v_tracked is not None and int(v_tracked[0][f]) >= 0: + vx, vy = int(v_tracked[0][f]), int(v_tracked[1][f]) + else: + vx, vy = svx, svy + if k_tracked is not None and int(k_tracked[0][f]) >= 0: + kx, ky = int(k_tracked[0][f]), int(k_tracked[1][f]) + else: + kx, ky = skx, sky + out[f].append((vx, vy, kx, ky, df / kill_f, label)) + return out + + +DMG_TTL = 2_000 # Damage line display duration (ms, video time) + + +def precompute_damages(damages: list[dict], active: list[dict], + px_all: np.ndarray, py_all: np.ndarray, + t_start: float, ms_per_frame: float, + n_frames: int, fps: int, + ) -> list[list[tuple]]: + """Build per-frame damage line events from raw damage reports. + + Each tuple is (ox, oy, vx, vy, age_frac) where positions are looked + up from px_all/py_all at the damage time. + + Args: + damages: Raw damage report dicts from the GOB replay. + active: Active player dicts (used for PlayerID-to-index mapping). + px_all: Precomputed pixel X positions, shape (n_players, n_frames). + py_all: Precomputed pixel Y positions, shape (n_players, n_frames). + t_start: Replay start time in milliseconds. + ms_per_frame: Milliseconds per video frame. + n_frames: Total number of video frames. + fps: Frames per second. + + Returns: + List of lists, one per frame, containing damage line tuples. + """ + pid_to_idx = {p["PlayerID"]: i for i, p in enumerate(active)} + out: list[list[tuple]] = [[] for _ in range(n_frames)] + dmg_f = int(math.ceil(DMG_TTL * fps / 1000.0)) + for dr in damages: + off_id = dr.get("OffenderID", 0) + vic_id = dr.get("OffendedID", 0) + if off_id not in pid_to_idx or vic_id not in pid_to_idx: + continue + oi = pid_to_idx[off_id] + vi = pid_to_idx[vic_id] + kf = int((dr["Time"] - t_start) / ms_per_frame) + if not (0 <= kf < n_frames): + continue + ox, oy = int(px_all[oi, kf]), int(py_all[oi, kf]) + vx, vy = int(px_all[vi, kf]), int(py_all[vi, kf]) + if ox < 0 or oy < 0 or vx < 0 or vy < 0: + continue + for df in range(dmg_f): + f = kf + df + if f >= n_frames: + break + out[f].append((ox, oy, vx, vy, df / dmg_f)) + return out + + +def precompute_deaths(active: list[dict], kills: list[dict], + t_start: float, ms_per_frame: float, + n_frames: int, fps: int, + px_all: np.ndarray, py_all: np.ndarray, + ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Compute per-player death state, fade-to-black curve, and last known position. + + Args: + active: Active ground player dicts. + kills: Kill event dicts from the replay. + t_start: Replay start time in ms. + ms_per_frame: Milliseconds per video frame. + n_frames: Total video frames. + fps: Frames per second. + px_all: Precomputed pixel X positions. + py_all: Precomputed pixel Y positions. + + Returns: + Tuple of (is_dead, death_frame, death_fade, last_dead_px, last_dead_py). + """ + pid_to_idx = {p["PlayerID"]: i for i, p in enumerate(active)} + n_p = len(active) + ghost_f = max(1, int(GHOST_TTL * fps / 1000.0)) + is_dead = np.zeros((n_p, n_frames), dtype=bool) + death_frame = np.full(n_p, n_frames, dtype=np.int32) + death_fade = np.zeros((n_p, n_frames), dtype=np.float32) + last_dead_px = np.full(n_p, -1, dtype=np.int16) + last_dead_py = np.full(n_p, -1, dtype=np.int16) + for k in kills: + vid = k.get("VictimID", 0) + if vid not in pid_to_idx: + continue + idx = pid_to_idx[vid] + kf = int((k["Time"] - t_start) / ms_per_frame) + if not (0 <= kf < n_frames): + continue + is_dead[idx, kf + 1:] = True + death_frame[idx] = kf + # Find last valid position at or before death + for f in range(kf, -1, -1): + if px_all[idx, f] >= 0: + last_dead_px[idx] = px_all[idx, f] + last_dead_py[idx] = py_all[idx, f] + break + # Fade from 1.0 (full color) to 0.0 (black) over ghost_f video frames + fade_len = min(ghost_f, n_frames - kf - 1) + death_fade[idx, kf + 1:kf + 1 + fade_len] = np.maximum( + 0.0, 1.0 - np.arange(1, fade_len + 1) / ghost_f + ) + # After fade completes, stays 0.0 (black) + return is_dead, death_frame, death_fade, last_dead_px, last_dead_py + + +def precompute_air_deaths(active: list[dict], kills: list[dict], + t_start: float, ms_per_frame: float, + n_frames: int, fps: int, + px_all: np.ndarray, py_all: np.ndarray, + ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Death state for aircraft/drones: they keep moving after kill until path ends (crash). + + Returns: + is_hit: (n, n_frames) bool — True from kill onward (for color fade) + hit_fade: (n, n_frames) float32 — 1→0 from kill to crash + is_crashed: (n, n_frames) bool — True only after path data ends + crash_frame: (n,) int32 — frame when path ends + last_px/py: (n,) int16 — position at crash (last valid path point) + """ + pid_to_idx: dict[int, int] = {} + eidx_to_idx: dict[int, int] = {} + for i, p in enumerate(active): + pid = p.get("PlayerID", 0) + if pid: + pid_to_idx[pid] = i + eidx = p.get("EntityIndex", 0) + if eidx: + eidx_to_idx[eidx] = i + n_p = len(active) + is_hit = np.zeros((n_p, n_frames), dtype=bool) + hit_fade = np.ones((n_p, n_frames), dtype=np.float32) + is_crashed = np.zeros((n_p, n_frames), dtype=bool) + crash_frame = np.full(n_p, n_frames, dtype=np.int32) + last_px = np.full(n_p, -1, dtype=np.int16) + last_py = np.full(n_p, -1, dtype=np.int16) + + # Find last valid position frame for each entity + for i in range(n_p): + for f in range(n_frames - 1, -1, -1): + if px_all[i, f] >= 0: + crash_frame[i] = f + last_px[i] = px_all[i, f] + last_py[i] = py_all[i, f] + break + # Mark crashed after last valid frame + cf = int(crash_frame[i]) + if cf < n_frames - 1: + is_crashed[i, cf + 1:] = True + + ghost_f = max(1, int(GHOST_TTL * fps / 1000.0)) + hit_set: set[int] = set() + + for k in kills: + # Match by PlayerID first, then by EntityIndex (for drones with PlayerID=0) + vid = k.get("VictimID", 0) + idx = pid_to_idx.get(vid) + if idx is None: + veidx = k.get("VictimEntityIndex", 0) + idx = eidx_to_idx.get(veidx) + if idx is None: + continue + kf = int((k["Time"] - t_start) / ms_per_frame) + if not (0 <= kf < n_frames): + continue + hit_set.add(idx) + is_hit[idx, kf:] = True + # Fade from 1.0 → 0.0 between kill frame and crash frame + cf = int(crash_frame[idx]) + fade_len = cf - kf + if fade_len > 0: + n_slots = min(fade_len + 1, n_frames - kf) + hit_fade[idx, kf:kf + n_slots] = np.maximum( + 0.0, 1.0 - np.arange(n_slots, dtype=np.float32) / fade_len + ) + hit_fade[idx, cf + 1:] = 0.0 + + # For entities not matched by any kill (e.g. drones with PlayerID=0), + # fade to black over GHOST_TTL before their path ends (crash) + for i in range(n_p): + if i in hit_set: + continue + cf = int(crash_frame[i]) + if cf >= n_frames: + continue # path never ends in this clip + fade_start = max(0, cf - ghost_f) + fade_len = cf - fade_start + if fade_len > 0: + is_hit[i, fade_start:] = True + n_slots = min(fade_len + 1, n_frames - fade_start) + hit_fade[i, fade_start:fade_start + n_slots] = np.maximum( + 0.0, 1.0 - np.arange(n_slots, dtype=np.float32) / fade_len + ) + hit_fade[i, cf + 1:] = 0.0 + + return is_hit, hit_fade, is_crashed, crash_frame, last_px, last_py + + +def find_team_wipe_frame(active: list[dict], is_dead: np.ndarray, n_frames: int) -> int: + """Find the first frame where all players on any one team are dead. + + Args: + active: Active player dicts (must contain a 'Team' key). + is_dead: Boolean array, shape (n_players, n_frames). + n_frames: Total video frames. + + Returns: + Frame index of the first team wipe, or n_frames if none occurs. + """ + team_groups: dict[int, list[int]] = {} + for i, p in enumerate(active): + team_groups.setdefault(p["Team"], []).append(i) + for f in range(n_frames): + for idxs in team_groups.values(): + if idxs and all(is_dead[idx, f] for idx in idxs): + return f + return n_frames + + +def build_drone_list(d: dict, ground_active: list[dict], xfm: CoordTransform, + px_ground: np.ndarray, py_ground: np.ndarray, + frame_times: np.ndarray, t_start: float, + ms_per_frame: float, colors_arr: np.ndarray) -> list[dict]: + n_frames = len(frame_times) + drones = [] + for e in d["Entities"]: + if "ucav" not in e["ModelName"].lower() or not e["Path"]: + continue + spawn_t = e["Path"][0]["Time"] + spawn_f = max(0, min(int((spawn_t - t_start) / ms_per_frame), n_frames - 1)) + spx, spy = xfm.point(e["Path"][0]["X"], e["Path"][0]["Z"]) + best_idx, best_dist = 0, float("inf") + for i in range(len(ground_active)): + pxi, pyi = int(px_ground[i, spawn_f]), int(py_ground[i, spawn_f]) + if pxi < 0: + continue + dist = (pxi - spx) ** 2 + (pyi - spy) ** 2 + if dist < best_dist: + best_dist, best_idx = dist, i + color = (colors_arr[best_idx].astype(np.float32) * 0.75).astype(np.uint8) + drones.append({"entity": e, "color": color, + "_samples": e["Path"], "EntityIndex": e["EntityIndex"]}) + return drones + + +# ── Circle masks ─────────────────────────────────────────────────────────────── + +def make_circle_masks(r: int) -> tuple[np.ndarray, np.ndarray]: + y, x = np.ogrid[-r:r+1, -r:r+1] + mask = x*x + y*y <= r*r + ys, xs = np.where(mask) + return (ys - r).astype(np.int32), (xs - r).astype(np.int32) + + +SHADOW_DY, SHADOW_DX = make_circle_masks(DOT_R + 1) +DOT_DY, DOT_DX = make_circle_masks(DOT_R) +DRONE_DY, DRONE_DX = make_circle_masks(DRONE_R) + + +def make_triangle_masks(r: int) -> tuple[np.ndarray, np.ndarray]: + """Downward-pointing triangle of half-size r. Returns (dy, dx) offsets.""" + pts = [] + for y in range(-r, r + 1): + # width narrows linearly from full at top (-r) to point at bottom (+r) + half_w = int(r * (r - y) / (2 * r)) if r else 0 + for x in range(-half_w, half_w + 1): + pts.append((y, x)) + if not pts: + pts = [(0, 0)] + arr = np.array(pts, dtype=np.int32) + return arr[:, 0], arr[:, 1] + + +AIR_DY, AIR_DX = make_triangle_masks(AIR_R) + +_TRAIL_DY = np.array([0, 0, 1, 1], dtype=np.int32) +_TRAIL_DX = np.array([0, 1, 0, 1], dtype=np.int32) + + +# ── Sprite factories ─────────────────────────────────────────────────────────── + +def _render_rgba(w: float, h: float, draw_fn) -> np.ndarray: + img = Image.new("RGBA", (int(w), int(h)), (0, 0, 0, 0)) + draw_fn(ImageDraw.Draw(img)) + return np.asarray(img).copy() + + +def make_name_sprites(names: list[str], + font: ImageFont.FreeTypeFont | ImageFont.ImageFont) -> list[Sprite]: + """Render a list of text strings into premultiplied Sprite objects. + + Args: + names: Text labels to render. + font: PIL font used for rendering. + + Returns: + List of Sprite objects, one per name. + """ + dummy = ImageDraw.Draw(Image.new("RGBA", (1, 1))) + PAD = 3 # padding each side (covers stroke_width=1 + margin) + out = [] + for name in names: + bb = dummy.textbbox((0, 0), name, font=font) + w, h = bb[2] - bb[0] + PAD * 2, bb[3] - bb[1] + PAD * 2 + def _draw(d, _n=name, _x=bb[0], _y=bb[1]): + d.text((PAD - _x, PAD - _y), _n, font=font, + fill=(255, 255, 255, 220), + stroke_width=1, stroke_fill=(0, 0, 0, 200)) + out.append(make_sprite(_render_rgba(w, h, _draw))) + return out + + +def merge_sprites(top: Sprite, bottom: Sprite, gap: int = -2) -> Sprite: + """Stack two sprites vertically with a gap, return a single combined sprite.""" + w = max(top.w, bottom.w) + h = top.h + gap + bottom.h + pm = np.zeros((h, w, 3), dtype=np.uint8) + ia = np.full((h, w, 1), 255, dtype=np.uint16) + # top sprite + pm[:top.h, :top.w] = top.pm + ia[:top.h, :top.w] = top.ia + # bottom sprite + y_off = top.h + gap + pm[y_off:y_off + bottom.h, :bottom.w] = bottom.pm + ia[y_off:y_off + bottom.h, :bottom.w] = bottom.ia + return Sprite(pm=pm, ia=ia, h=h, w=w) + + +def _short_model(model_name: str) -> str: + """Return human-readable vehicle name, falling back to internal ID.""" + internal = model_name.split("/")[-1] + return _translate_vehicle(internal) + + +def make_kill_label(killer_model: str, weapon: str, + font: ImageFont.FreeTypeFont | ImageFont.ImageFont) -> Sprite: + """Render a kill event label showing the killer's vehicle and weapon. + + Args: + killer_model: Internal model path of the killer vehicle. + weapon: Internal weapon identifier. + font: PIL font used for rendering. + + Returns: + A Sprite containing the rendered kill label text. + """ + text = f"{_short_model(killer_model)} [{_translate_weapon(weapon)}]" + dummy = ImageDraw.Draw(Image.new("RGBA", (1, 1))) + bb = dummy.textbbox((0, 0), text, font=font) + PAD = 3 + w, h = bb[2] - bb[0] + PAD * 2, bb[3] - bb[1] + PAD * 2 + def _draw(d, _x=bb[0], _y=bb[1]): + d.text((PAD - _x, PAD - _y), text, font=font, + fill=(255, 230, 100, 230), + stroke_width=1, stroke_fill=(0, 0, 0, 200)) + return make_sprite(_render_rgba(w, h, _draw)) + + +def make_hud_sprite(team_won: int, + font: ImageFont.FreeTypeFont | ImageFont.ImageFont) -> Sprite: + """Render a HUD overlay sprite displaying which team won. + + Args: + team_won: Winning team index. + font: PIL font used for rendering. + + Returns: + A Sprite containing the "Team N wins" text. + """ + text = f"Team {team_won} wins" + dummy = ImageDraw.Draw(Image.new("RGBA", (1, 1))) + bb = dummy.textbbox((0, 0), text, font=font) + PAD = 3 + w, h = bb[2] - bb[0] + PAD * 2, bb[3] - bb[1] + PAD * 2 + def _draw(d, _x=bb[0], _y=bb[1]): + d.text((PAD - _x, PAD - _y), text, font=font, + fill=(160, 255, 120, 230), + stroke_width=1, stroke_fill=(0, 0, 0, 180)) + return make_sprite(_render_rgba(w, h, _draw)) + + +def make_time_sprites(max_secs: int, speed: float, + font: ImageFont.FreeTypeFont | ImageFont.ImageFont, + ) -> dict[int, Sprite]: + dummy = ImageDraw.Draw(Image.new("RGBA", (1, 1))) + out: dict[int, Sprite] = {} + for sec in range(max_secs + 1): + m, s = divmod(sec, 60) + text = f"{m:02d}:{s:02d} ×{speed:.0f}" + bb = dummy.textbbox((0, 0), text, font=font) + w, h = bb[2] - bb[0] + 4, bb[3] - bb[1] + 4 + def _draw(d, _t=text): + d.text((2, 2), _t, font=font, fill=(210, 210, 210, 230)) + out[sec] = make_sprite(_render_rgba(w, h, _draw)) + return out + + +# ── Drawing helpers ──────────────────────────────────────────────────────────── + +def draw_all_trails_np(buf: np.ndarray, fi: int, + px_all: np.ndarray, py_all: np.ndarray, + trail_f: int, trail_colors_arr: np.ndarray, + is_dead: np.ndarray, death_frame: np.ndarray, + canvas: int = CANVAS_MIN) -> None: + n_players = px_all.shape[0] + all_vx, all_vy, all_cols = [], [], [] + for pi in range(n_players): + # Dead players: anchor trail at death frame; alive: current frame + if is_dead[pi, fi]: + ef = int(death_frame[pi]) + else: + ef = fi + start = max(0, ef - trail_f) + pxs = px_all[pi, start:ef + 1] + pys = py_all[pi, start:ef + 1] + valid = (pxs >= 0) & (pys >= 0) + if not valid.any(): + continue + vx = pxs[valid].astype(np.int32) + vy = pys[valid].astype(np.int32) + trail_len = ef + 1 - start + ti = np.where(valid)[0] + bright = ((ti + 1).astype(np.float32) / trail_len) ** 0.3 + faded = (trail_colors_arr[pi] * bright[:, None]).astype(np.uint8) + all_vx.append(vx) + all_vy.append(vy) + all_cols.append(faded) + if not all_vx: + return + vx = np.concatenate(all_vx) + vy = np.concatenate(all_vy) + cols = np.concatenate(all_cols) + all_ys = np.clip(vy[:, None] + _TRAIL_DY, 0, canvas - 1).ravel() + all_xs = np.clip(vx[:, None] + _TRAIL_DX, 0, canvas - 1).ravel() + buf[all_ys, all_xs] = np.repeat(cols, 4, axis=0) + + +def draw_all_dots_np(buf: np.ndarray, fi: int, + px_all: np.ndarray, py_all: np.ndarray, + colors_arr: np.ndarray, shadow_color: np.ndarray, + is_dead: np.ndarray | None = None, + shadow_dy: np.ndarray = SHADOW_DY, shadow_dx: np.ndarray = SHADOW_DX, + dot_dy: np.ndarray = DOT_DY, dot_dx: np.ndarray = DOT_DX, + canvas: int = CANVAS_MIN) -> None: + cxs = px_all[:, fi].astype(np.int32) + cys = py_all[:, fi].astype(np.int32) + valid = (cxs >= 0) & (cys >= 0) + if is_dead is not None: + valid &= ~is_dead[:, fi] + if not valid.any(): + return + cx = cxs[valid]; cy = cys[valid] + sy = np.clip(cy[:, None] + shadow_dy, 0, canvas - 1) + sx = np.clip(cx[:, None] + shadow_dx, 0, canvas - 1) + buf[sy.ravel(), sx.ravel()] = shadow_color + nd = len(dot_dy) + dy = np.clip(cy[:, None] + dot_dy, 0, canvas - 1) + dx = np.clip(cx[:, None] + dot_dx, 0, canvas - 1) + buf[dy.ravel(), dx.ravel()] = np.repeat(colors_arr[valid], nd, axis=0) + + +def draw_dead_dots_np(buf: np.ndarray, fi: int, + colors_arr: np.ndarray, + is_dead: np.ndarray, death_fade: np.ndarray, + last_dead_px: np.ndarray, last_dead_py: np.ndarray, + dot_dy: np.ndarray = DOT_DY, dot_dx: np.ndarray = DOT_DX, + canvas: int = CANVAS_MIN, + ) -> None: + """Draw dead player dots: fade from color to black, then stay black.""" + dead = is_dead[:, fi] + if not dead.any(): + return + cxs = last_dead_px[dead].astype(np.int32) + cys = last_dead_py[dead].astype(np.int32) + valid = (cxs >= 0) & (cys >= 0) + if not valid.any(): + return + cx, cy = cxs[valid], cys[valid] + fade = death_fade[dead, fi][valid] + cols = (colors_arr[dead][valid].astype(np.float32) * fade[:, None]).astype(np.uint8) + nd = len(dot_dy) + dy = np.clip(cy[:, None] + dot_dy, 0, canvas - 1) + dx = np.clip(cx[:, None] + dot_dx, 0, canvas - 1) + buf[dy.ravel(), dx.ravel()] = np.repeat(cols, nd, axis=0) + + +def draw_drone_dots_np(buf: np.ndarray, fi: int, + px_drone: np.ndarray, py_drone: np.ndarray, + drone_colors: np.ndarray, + is_crashed: np.ndarray | None = None, + hit_fade: np.ndarray | None = None, + drone_dy: np.ndarray = DRONE_DY, drone_dx: np.ndarray = DRONE_DX, + canvas: int = CANVAS_MIN) -> None: + cxs = px_drone[:, fi].astype(np.int32) + cys = py_drone[:, fi].astype(np.int32) + valid = (cxs >= 0) & (cys >= 0) + if is_crashed is not None: + valid &= ~is_crashed[:, fi] + if not valid.any(): + return + cx = cxs[valid]; cy = cys[valid] + cols = drone_colors[valid] + if hit_fade is not None: + fade = hit_fade[valid, fi] + cols = (cols.astype(np.float32) * fade[:, None]).astype(np.uint8) + nd = len(drone_dy) + dy = np.clip(cy[:, None] + drone_dy, 0, canvas - 1) + dx = np.clip(cx[:, None] + drone_dx, 0, canvas - 1) + buf[dy.ravel(), dx.ravel()] = np.repeat(cols, nd, axis=0) + + +def draw_aircraft_np(buf: np.ndarray, fi: int, + px_air: np.ndarray, py_air: np.ndarray, + air_colors: np.ndarray, + is_crashed: np.ndarray | None = None, + hit_fade: np.ndarray | None = None, + air_dy: np.ndarray = AIR_DY, air_dx: np.ndarray = AIR_DX, + canvas: int = CANVAS_MIN) -> None: + """Draw aircraft as small triangles, fading to black after hit.""" + cxs = px_air[:, fi].astype(np.int32) + cys = py_air[:, fi].astype(np.int32) + valid = (cxs >= 0) & (cys >= 0) + if is_crashed is not None: + valid &= ~is_crashed[:, fi] + if not valid.any(): + return + cx = cxs[valid]; cy = cys[valid] + cols = air_colors[valid] + if hit_fade is not None: + fade = hit_fade[valid, fi] + cols = (cols.astype(np.float32) * fade[:, None]).astype(np.uint8) + nd = len(air_dy) + dy = np.clip(cy[:, None] + air_dy, 0, canvas - 1) + dx = np.clip(cx[:, None] + air_dx, 0, canvas - 1) + buf[dy.ravel(), dx.ravel()] = np.repeat(cols, nd, axis=0) + + +def draw_dead_entities_np(buf: np.ndarray, fi: int, + colors_arr: np.ndarray, + is_dead: np.ndarray, death_fade: np.ndarray, + last_dead_px: np.ndarray, last_dead_py: np.ndarray, + mask_dy: np.ndarray, mask_dx: np.ndarray, + canvas: int = CANVAS_MIN) -> None: + """Draw dead entity markers (circle or triangle) fading from color to black.""" + dead = is_dead[:, fi] + if not dead.any(): + return + cxs = last_dead_px[dead].astype(np.int32) + cys = last_dead_py[dead].astype(np.int32) + valid = (cxs >= 0) & (cys >= 0) + if not valid.any(): + return + cx, cy = cxs[valid], cys[valid] + fade = death_fade[dead, fi][valid] + cols = (colors_arr[dead][valid].astype(np.float32) * fade[:, None]).astype(np.uint8) + nd = len(mask_dy) + dy = np.clip(cy[:, None] + mask_dy, 0, canvas - 1) + dx = np.clip(cx[:, None] + mask_dx, 0, canvas - 1) + buf[dy.ravel(), dx.ravel()] = np.repeat(cols, nd, axis=0) + + +def draw_icon_sprites(buf: np.ndarray, fi: int, + px_all: np.ndarray, py_all: np.ndarray, + icon_sprites: list, + is_dead: np.ndarray | None = None, + is_crashed: np.ndarray | None = None, + hit_fade: np.ndarray | None = None, + rot_caches: list | None = None, + headings: np.ndarray | None = None) -> None: + """Draw per-entity icon sprites instead of circles/triangles.""" + n = px_all.shape[0] + for i in range(n): + px_i, py_i = int(px_all[i, fi]), int(py_all[i, fi]) + if px_i < 0 or py_i < 0: + continue + if is_dead is not None and is_dead[i, fi]: + continue + if is_crashed is not None and is_crashed[i, fi]: + continue + if rot_caches and headings is not None and headings[i, fi] >= 0: + spr = rot_caches[i][heading_to_rot_index(headings[i, fi])] + else: + spr = icon_sprites[i] + alpha = 1.0 + if hit_fade is not None: + alpha = float(hit_fade[i, fi]) + if alpha <= 0: + continue + x, y = px_i - spr.w // 2, py_i - spr.h // 2 + if alpha < 1.0: + blit_alpha(buf, spr, x, y, alpha) + else: + blit(buf, spr, x, y) + + +def draw_dead_icon_sprites(buf: np.ndarray, fi: int, + icon_sprites: list, + dead_sprites: list, + is_dead: np.ndarray, death_fade: np.ndarray, + last_dead_px: np.ndarray, + last_dead_py: np.ndarray, + rot_caches: list | None = None, + dead_rot_caches: list | None = None, + headings: np.ndarray | None = None) -> None: + """Draw dead entity icons fading from color to black at their last known position.""" + n = len(icon_sprites) + for i in range(n): + if not is_dead[i, fi]: + continue + px_i, py_i = int(last_dead_px[i]), int(last_dead_py[i]) + if px_i < 0 or py_i < 0: + continue + # Pick rotated sprite if available (use last valid heading) + if rot_caches and dead_rot_caches and headings is not None and headings[i, fi] >= 0: + ri = heading_to_rot_index(headings[i, fi]) + spr = rot_caches[i][ri] + dspr = dead_rot_caches[i][ri] + else: + spr = icon_sprites[i] + dspr = dead_sprites[i] + x, y = px_i - spr.w // 2, py_i - spr.h // 2 + fade = float(death_fade[i, fi]) + blit(buf, dspr, x, y) + if fade > 0: + blit_alpha(buf, spr, x, y, fade) + + +def draw_air_trails_np(buf: np.ndarray, fi: int, + px_all: np.ndarray, py_all: np.ndarray, + trail_f: int, trail_colors: np.ndarray, + is_crashed: np.ndarray | None, crash_frame: np.ndarray | None, + canvas: int = CANVAS_MIN) -> None: + """Short trails for aircraft/drones with line interpolation between frames. + Anchors at crash_frame once crashed (path ended).""" + n_ents = px_all.shape[0] + all_vx, all_vy, all_cols = [], [], [] + CM = canvas - 1 + for ei in range(n_ents): + if is_crashed is not None and crash_frame is not None and is_crashed[ei, fi]: + ef = int(crash_frame[ei]) + else: + ef = fi + start = max(0, ef - trail_f) + pxs = px_all[ei, start:ef + 1] + pys = py_all[ei, start:ef + 1] + valid = (pxs >= 0) & (pys >= 0) + if not valid.any(): + continue + vx = pxs[valid].astype(np.int32) + vy = pys[valid].astype(np.int32) + trail_len = ef + 1 - start + ti = np.where(valid)[0] + # Interpolate lines between consecutive valid points to fill gaps + if len(vx) >= 2: + seg_vx, seg_vy, seg_bright = [], [], [] + for si in range(len(vx) - 1): + x0, y0, x1, y1 = vx[si], vy[si], vx[si + 1], vy[si + 1] + dist = max(abs(x1 - x0), abs(y1 - y0)) + if dist <= 1: + seg_vx.append(x0) + seg_vy.append(y0) + seg_bright.append((ti[si] + 1) / trail_len) + else: + n_pts = min(dist, 64) # cap to avoid huge arrays + t = np.arange(n_pts, dtype=np.float32) / n_pts + seg_vx.append(np.clip((x0 + (x1 - x0) * t).astype(np.int32), 0, CM)) + seg_vy.append(np.clip((y0 + (y1 - y0) * t).astype(np.int32), 0, CM)) + b0 = (ti[si] + 1) / trail_len + b1 = (ti[si + 1] + 1) / trail_len + seg_bright.append(b0 + (b1 - b0) * t) + # Last point + seg_vx.append(vx[-1]) + seg_vy.append(vy[-1]) + seg_bright.append((ti[-1] + 1) / trail_len) + vx_interp = np.concatenate([np.atleast_1d(s) for s in seg_vx]) + vy_interp = np.concatenate([np.atleast_1d(s) for s in seg_vy]) + bright = np.concatenate([np.atleast_1d(s) for s in seg_bright]) + else: + vx_interp, vy_interp = vx, vy + bright = np.array([(ti[0] + 1) / trail_len], dtype=np.float32) + bright = bright ** 0.3 + faded = (trail_colors[ei] * bright[:, None]).astype(np.uint8) + all_vx.append(vx_interp) + all_vy.append(vy_interp) + all_cols.append(faded) + if not all_vx: + return + vx = np.concatenate(all_vx) + vy = np.concatenate(all_vy) + cols = np.concatenate(all_cols) + all_ys = np.clip(vy[:, None] + _TRAIL_DY, 0, canvas - 1).ravel() + all_xs = np.clip(vx[:, None] + _TRAIL_DX, 0, canvas - 1).ravel() + buf[all_ys, all_xs] = np.repeat(cols, 4, axis=0) + + +def draw_kill_events(buf: np.ndarray, events: list[tuple], + kill_target_spr: Sprite | None = None, + canvas: int = CANVAS_MIN) -> None: + CM = canvas - 1 + full_line = np.array([255, 30, 30], dtype=np.float32) + _LINE_OFFSETS = [(0, 0), (1, 0), (-1, 0), (0, 1), (0, -1)] + for (vx, vy, kx, ky, age_frac, label) in events: + alpha = 1.0 - age_frac + # Kill line from killer to victim + if kx >= 0 and ky >= 0: + pts = max(abs(vx - kx), abs(vy - ky)) + 1 + if pts >= 2: + t = np.arange(pts, dtype=np.float32) / (pts - 1) + cx = (kx + (vx - kx) * t).astype(np.int32) + cy = (ky + (vy - ky) * t).astype(np.int32) + for odx, ody in _LINE_OFFSETS: + xs = np.clip(cx + odx, 0, CM) + ys = np.clip(cy + ody, 0, CM) + bg = buf[ys, xs].astype(np.float32) + buf[ys, xs] = (bg + (full_line - bg) * alpha).astype(np.uint8) + # Target icon at victim position (replaces starburst) + if kill_target_spr is not None: + blit_alpha(buf, kill_target_spr, + vx - kill_target_spr.w // 2, vy - kill_target_spr.h // 2, + alpha) + # Label + if label is not None: + label_alpha = 1.0 if age_frac < 0.8 else max(0.0, 1.0 - (age_frac - 0.8) / 0.2) + blit_alpha(buf, label, vx - label.w // 2, vy - label.h - 14, label_alpha) + + +def draw_damage_events(buf: np.ndarray, events: list[tuple], + dmg_target_spr: Sprite | None = None, + canvas: int = CANVAS_MIN) -> None: + CM = canvas - 1 + full_line = np.array([255, 255, 80], dtype=np.float32) + for (ox, oy, vx, vy, age_frac) in events: + alpha = 1.0 - age_frac + pts = max(abs(vx - ox), abs(vy - oy)) + 1 + if pts >= 2: + t = np.arange(pts, dtype=np.float32) / (pts - 1) + xs = np.clip((ox + (vx - ox) * t).astype(np.int32), 0, CM) + ys = np.clip((oy + (vy - oy) * t).astype(np.int32), 0, CM) + bg = buf[ys, xs].astype(np.float32) + buf[ys, xs] = (bg + (full_line - bg) * alpha).astype(np.uint8) + # Target icon at victim position + if dmg_target_spr is not None: + blit_alpha(buf, dmg_target_spr, + vx - dmg_target_spr.w // 2, vy - dmg_target_spr.h // 2, + alpha) + + +# ── Per-frame render for one VideoCtx ───────────────────────────────────────── + +def render_one_ctx(fi: int, buf: np.ndarray, ctx: VideoCtx, + shadow_color: np.ndarray, trail_f: int) -> None: + """Render a single video frame into the provided buffer. + + Draws background, trails, damage/kill events, dots (alive + dead), + drones, aircraft, and player name labels in compositing order. + + Args: + fi: Frame index to render. + buf: Mutable RGB numpy array, shape (canvas, canvas, 3). + ctx: Pre-computed VideoCtx with all per-frame data. + shadow_color: RGB color for dot shadows, shape (3,). + trail_f: Number of trailing frames to draw behind each player. + """ + np.copyto(buf, ctx.bg_arr) + + cv = ctx.canvas + # Ground trails + draw_all_trails_np(buf, fi, ctx.px_all, ctx.py_all, trail_f, ctx.trail_colors_arr, + ctx.is_dead, ctx.death_frame, cv) + # Drone trails (very short, line-interpolated) + if ctx.n_drones and ctx.drone_trail_colors is not None: + assert ctx.px_drone is not None and ctx.py_drone is not None + draw_air_trails_np(buf, fi, ctx.px_drone, ctx.py_drone, ctx.drone_trail_f, + ctx.drone_trail_colors, ctx.drone_is_crashed, ctx.drone_crash_frame, cv) + # Aircraft trails (short, line-interpolated) + if ctx.n_aircraft and ctx.air_trail_colors is not None: + assert ctx.px_air is not None and ctx.py_air is not None + draw_air_trails_np(buf, fi, ctx.px_air, ctx.py_air, ctx.air_trail_f, + ctx.air_trail_colors, ctx.air_is_crashed, ctx.air_crash_frame, cv) + + if ctx.damages_by_frame[fi]: + draw_damage_events(buf, ctx.damages_by_frame[fi], ctx.dmg_target_spr, cv) + + if ctx.kills_by_frame[fi]: + draw_kill_events(buf, ctx.kills_by_frame[fi], ctx.kill_target_spr, cv) + + # Ground: dead icons + alive icons + if ctx.player_icon_sprites: + draw_dead_icon_sprites(buf, fi, ctx.player_icon_sprites, + ctx.player_dead_sprites, + ctx.is_dead, ctx.death_fade, + ctx.last_dead_px, ctx.last_dead_py) + draw_icon_sprites(buf, fi, ctx.px_all, ctx.py_all, + ctx.player_icon_sprites, is_dead=ctx.is_dead) + else: + draw_dead_dots_np(buf, fi, ctx.colors_arr, ctx.is_dead, ctx.death_fade, + ctx.last_dead_px, ctx.last_dead_py, + ctx.dot_dy, ctx.dot_dx, cv) + draw_all_dots_np(buf, fi, ctx.px_all, ctx.py_all, + ctx.colors_arr, shadow_color, ctx.is_dead, + ctx.shadow_dy, ctx.shadow_dx, ctx.dot_dy, ctx.dot_dx, cv) + + # Drones: freeze at hit position, fade to transparent over ~2-3s + if ctx.n_drones: + assert ctx.px_drone is not None and ctx.py_drone is not None + assert ctx.drone_colors_arr is not None + if ctx.drone_icon_sprites: + n_dr = ctx.px_drone.shape[0] + for di in range(n_dr): + is_hit = ctx.drone_is_hit is not None and ctx.drone_is_hit[di, fi] + if is_hit: + # Frozen at hit position, fading out + if ctx.drone_last_dead_px is None or ctx.drone_last_dead_py is None or ctx.drone_hit_fade is None: + continue + dpx, dpy = int(ctx.drone_last_dead_px[di]), int(ctx.drone_last_dead_py[di]) + fade = float(ctx.drone_hit_fade[di, fi]) + if fade <= 0 or dpx < 0 or dpy < 0: + continue + else: + # Alive — draw at current position + dpx, dpy = int(ctx.px_drone[di, fi]), int(ctx.py_drone[di, fi]) + fade = 1.0 + if dpx < 0 or dpy < 0: + continue + if ctx.drone_rot_caches and ctx.drone_headings is not None and ctx.drone_headings[di, fi] >= 0: + spr = ctx.drone_rot_caches[di][heading_to_rot_index(ctx.drone_headings[di, fi])] + else: + spr = ctx.drone_icon_sprites[di] + x, y = dpx - spr.w // 2, dpy - spr.h // 2 + if fade < 1.0: + blit_alpha(buf, spr, x, y, fade) + else: + blit(buf, spr, x, y) + else: + # Fallback to dots if no icon sprites + if ctx.drone_is_crashed is not None: + draw_dead_entities_np(buf, fi, ctx.drone_colors_arr, + ctx.drone_is_crashed, ctx.drone_hit_fade, # type: ignore[arg-type] + ctx.drone_last_dead_px, ctx.drone_last_dead_py, # type: ignore[arg-type] + ctx.drone_dy, ctx.drone_dx, cv) + draw_drone_dots_np(buf, fi, ctx.px_drone, ctx.py_drone, ctx.drone_colors_arr, + ctx.drone_is_crashed, ctx.drone_hit_fade, + ctx.drone_dy, ctx.drone_dx, cv) + + # Aircraft: dead icons + alive icons + if ctx.n_aircraft: + assert ctx.px_air is not None and ctx.py_air is not None + assert ctx.air_colors_arr is not None + if ctx.air_icon_sprites: + if ctx.air_is_crashed is not None: + draw_dead_icon_sprites(buf, fi, ctx.air_icon_sprites, + ctx.air_dead_sprites, + ctx.air_is_crashed, ctx.air_hit_fade, # type: ignore[arg-type] + ctx.air_last_dead_px, ctx.air_last_dead_py, # type: ignore[arg-type] + rot_caches=ctx.air_rot_caches, + dead_rot_caches=ctx.air_dead_rot_caches, + headings=ctx.air_headings) + draw_icon_sprites(buf, fi, ctx.px_air, ctx.py_air, + ctx.air_icon_sprites, + is_crashed=ctx.air_is_crashed, + hit_fade=ctx.air_hit_fade, + rot_caches=ctx.air_rot_caches, + headings=ctx.air_headings) + else: + if ctx.air_is_crashed is not None: + draw_dead_entities_np(buf, fi, ctx.air_colors_arr, + ctx.air_is_crashed, ctx.air_hit_fade, # type: ignore[arg-type] + ctx.air_last_dead_px, ctx.air_last_dead_py, # type: ignore[arg-type] + ctx.air_dy, ctx.air_dx, cv) + draw_aircraft_np(buf, fi, ctx.px_air, ctx.py_air, ctx.air_colors_arr, + ctx.air_is_crashed, ctx.air_hit_fade, + ctx.air_dy, ctx.air_dx, cv) + + items: list[tuple[Sprite, int, int]] = [] + for i in range(ctx.n_players): + px_i, py_i = int(ctx.px_all[i, fi]), int(ctx.py_all[i, fi]) + if px_i >= 0 and py_i >= 0: + ls = ctx.label_sprites[i] + icon_w = ctx.player_icon_sprites[i].w if ctx.player_icon_sprites else ctx.dot_r * 2 + items.append((ls, px_i + icon_w // 2 + 3, py_i - ls.h // 2)) + if ctx.n_drones: + assert ctx.px_drone is not None and ctx.py_drone is not None + for i in range(ctx.n_drones): + px_i, py_i = int(ctx.px_drone[i, fi]), int(ctx.py_drone[i, fi]) + if px_i >= 0 and py_i >= 0: + if ctx.drone_is_crashed is not None and ctx.drone_is_crashed[i, fi]: + continue + ds = ctx.drone_sprites[i] + items.append((ds, px_i + ctx.drone_r + 2, py_i - ds.h // 2)) + if ctx.n_aircraft: + assert ctx.px_air is not None and ctx.py_air is not None + assert ctx.air_alt is not None + for i in range(ctx.n_aircraft): + px_i, py_i = int(ctx.px_air[i, fi]), int(ctx.py_air[i, fi]) + if px_i >= 0 and py_i >= 0: + if ctx.air_is_crashed is not None and ctx.air_is_crashed[i, fi]: + continue + # Name+model label to the right + ls = ctx.air_sprites[i] + air_icon_w = ctx.air_icon_sprites[i].w if ctx.air_icon_sprites else ctx.air_r * 2 + items.append((ls, px_i + air_icon_w // 2 + 3, py_i - ls.h // 2)) + # Altitude label to the left + alt_m = int(ctx.air_alt[i, fi]) + if alt_m >= 0: + alt_key = alt_m // 10 * 10 + alt_spr = ctx.air_alt_sprites.get(alt_key) + if alt_spr is not None: + items.append((alt_spr, px_i - air_icon_w // 2 - 3 - alt_spr.w, py_i - alt_spr.h // 2)) + if items: + blit_batch(buf, items) + + +def _get_thread_buf(tmpl: np.ndarray) -> np.ndarray: + """Thread-local render buffer.""" + if not hasattr(_tl, "buf"): + _tl.buf = np.empty_like(tmpl) + return _tl.buf + + +# ── FFmpeg / font ────────────────────────────────────────────────────────────── + +def open_ffmpeg(output_path: Path, fps: int, w: int = CANVAS_MIN, h: int = CANVAS_MIN) -> subprocess.Popen: + """Open an FFmpeg subprocess that reads raw RGB24 frames from stdin and encodes to H.264 MP4. + + Args: + output_path: Destination MP4 file path. + fps: Frames per second for the output video. + w: Frame width in pixels. + h: Frame height in pixels. + + Returns: + A Popen handle whose stdin accepts raw RGB24 frame bytes. + """ + return subprocess.Popen([ + "ffmpeg", "-y", + "-f", "rawvideo", "-vcodec", "rawvideo", + "-s", f"{w}x{h}", "-pix_fmt", "rgb24", + "-r", str(fps), "-i", "pipe:0", + "-vcodec", "libx264", "-preset", "ultrafast", + "-crf", "34", "-pix_fmt", "yuv420p", "-threads", "4", + "-movflags", "+faststart", + str(output_path), + ], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + for p in ["/usr/share/fonts/TTF/DejaVuSans.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/dejavu/DejaVuSans.ttf"]: + if Path(p).exists(): + return ImageFont.truetype(p, size) + return ImageFont.load_default() + + +# ── Helpers ──────────────────────────────────────────────────────────────────── + +def assign_colors(active: list[dict], team_won: int + ) -> tuple[np.ndarray, np.ndarray]: + """Assign per-player RGB colors based on team membership (winner vs loser palette). + + Args: + active: Active player dicts, each with a 'Team' key. + team_won: The winning team index. + + Returns: + Tuple of (colors_arr, trail_colors_arr), both uint8 arrays of shape + (n_players, 3). Trail colors are dimmed to 60% brightness. + """ + colors_list = [] + for p in active: + if p["Team"] == team_won: + colors_list.append(WIN_COLOR) + else: + colors_list.append(LOSE_COLOR) + colors_arr = np.array(colors_list, dtype=np.uint8) + return colors_arr, (colors_arr * 0.6).astype(np.uint8) + + +def make_bg(map_pil: Image.Image) -> np.ndarray: + return np.asarray(map_pil).copy() + + +# ── Public render function ───────────────────────────────────────────────────── + +def render_gob( + d: dict, + out_path: Path, + fps: int = FPS, + speed: float = SPEED, + n_workers: int = N_WORKERS, + progress_cb: Optional[Callable[[int], None]] = None, +) -> None: + """ + Render a GOB replay dict to an MP4 file. + + Args: + d: Parsed GOB replay dict (from _gob_to_dict or json.load) + out_path: Output MP4 path + fps: Frames per second + speed: Playback speed multiplier + n_workers: Thread pool size for parallel frame render + progress_cb: Optional callback called with int 0-100 during render + """ + players_by_id = {p["PlayerID"]: p for p in d["Players"]} + team_won = d["TeamWon"] + + def build_active(entities: list[dict]) -> list[dict]: + out = [] + for e in entities: + if e.get("PlayerID", 0) == 0: + continue + p = dict(players_by_id[e["PlayerID"]]) + p["_samples"] = e["Path"] + p["_model"] = e["ModelName"] + out.append(p) + return out + + ground_ents = [e for e in d["Entities"] if e.get("PlayerID", 0) != 0 + and e["ModelName"].startswith("tankModels/")] + active = build_active(ground_ents) + + if not active: + raise ValueError("No ground unit entities found in replay.") + + print(f"Ground : {len(active)}") + + # Time grid + all_t = [s["Time"] for e in ground_ents for s in e["Path"]] + t_start = float(min(all_t)) + t_end = float(max(all_t)) + ms_per_frame = (1000.0 / fps) * speed + n_frames = int(math.ceil((t_end - t_start) / ms_per_frame)) + frame_times = t_start + np.arange(n_frames, dtype=np.float64) * ms_per_frame + trail_f = max(1, int(TRAIL_MS * fps / 1000.0)) + duration_s = (t_end - t_start) / 1000.0 + + print(f"Duration : {duration_s:.1f}s → ~{n_frames/fps:.0f}s video ({n_frames} frames)") + + # Load map coordinate bounds (local datamine, fallback to Nanachi API) + level = d["Mission"]["Level"] + session_id = d.get("SessionID", 0) + print("Loading LevelDef …", end=" ", flush=True) + level_def = load_level_coords(level, session_id) + if level_def: + tc0 = level_def["tankMapCoord0"] + tc1 = level_def["tankMapCoord1"] + print(f"ok X=[{tc0[0]}, {tc1[0]}] Z=[{tc0[1]}, {tc1[1]}]") + else: + tc0, tc1 = [0.0, 0.0], [4096.0, 4096.0] + print("not found — falling back to [0, 4096]") + + # Pick canvas size dynamically: estimate player extent in world coords + all_x = [s["X"] for p in active for s in p["_samples"]] + all_z = [s["Z"] for p in active for s in p["_samples"]] + world_span_x = max(all_x) - min(all_x) + world_span_z = max(all_z) - min(all_z) + map_range_x = tc1[0] - tc0[0] + map_range_z = tc1[1] - tc0[1] + frac = max(world_span_x / map_range_x, world_span_z / map_range_z) if map_range_x and map_range_z else 1.0 + # Scale canvas so player activity ≈ MIN_OUTPUT pixels + ideal_canvas = int(MIN_OUTPUT / max(frac, 0.1)) + canvas = max(CANVAS_MIN, min(CANVAS_MAX, ideal_canvas & ~1)) + + xfm = CoordTransform(tc0[0], tc0[1], tc1[0], tc1[1], canvas=canvas) + print(f"Canvas : {canvas}px (players use ~{frac*100:.0f}% of map)") + + print("Pre-computing positions …", end=" ", flush=True) + px, py = precompute_positions(active, xfm, frame_times) + print("done") + + kills = [k for k in d.get("Kills", []) if t_start <= k["Time"] <= t_end] + damages = [dr for dr in d.get("DamageReports", []) if t_start <= dr["Time"] <= t_end] + print(f"Events : {len(kills)} kills, {len(damages)} damage reports") + + is_dead, death_frame, death_fade, last_dead_px, last_dead_py = precompute_deaths( + active, kills, t_start, ms_per_frame, n_frames, fps, px, py) + + wipe_f = find_team_wipe_frame(active, is_dead, n_frames) + end_frame = min(wipe_f + WIPE_GRACE, n_frames) + if wipe_f < n_frames: + print(f"Team wipe : frame {wipe_f} (~{wipe_f/fps:.1f}s video)") + + colors, trail_colors = assign_colors(active, team_won) + shadow_color = np.array([0, 0, 0], dtype=np.uint8) + + drones = build_drone_list(d, active, xfm, px, py, + frame_times, t_start, ms_per_frame, colors) + n_drones = len(drones) + if n_drones: + px_dr, py_dr = precompute_positions(drones, xfm, frame_times) + drone_alt = precompute_altitudes(drones, frame_times) + drone_cols = np.array([dr["color"] for dr in drones], dtype=np.uint8) + drone_trail_cols = (drone_cols * 0.6).astype(np.uint8) + # Match drone kills by EntityIndex (since PlayerID=0) + drone_is_hit, drone_hit_fade, drone_is_crashed, drone_crash_frame, \ + drone_last_dead_px, drone_last_dead_py = \ + precompute_air_deaths(drones, kills, t_start, ms_per_frame, n_frames, fps, px_dr, py_dr) + # Override: drones freeze at kill/path-end and fade to transparent over ~2.5s + drone_fade_frames = max(1, int(2.5 * fps)) + for i in range(len(drones)): + hit_frames = np.where(drone_is_hit[i])[0] + if len(hit_frames): + # Kill-matched: freeze at kill frame + hf = hit_frames[0] + else: + # Unmatched: freeze at path end + cf = int(drone_crash_frame[i]) + if cf >= n_frames: + continue # path extends beyond video, drone stays alive + hf = cf + # Apply 2.5s fade from freeze point + drone_is_hit[i, :] = False + drone_is_hit[i, hf:] = True + n_slots = min(drone_fade_frames + 1, n_frames - hf) + drone_hit_fade[i, :] = 1.0 + drone_hit_fade[i, hf:hf + n_slots] = np.maximum( + 0.0, 1.0 - np.arange(n_slots, dtype=np.float32) / drone_fade_frames) + drone_hit_fade[i, hf + n_slots:] = 0.0 + # Freeze position at hit/end frame + drone_last_dead_px[i] = px_dr[i, min(hf, px_dr.shape[1] - 1)] + drone_last_dead_py[i] = py_dr[i, min(hf, py_dr.shape[1] - 1)] + # Also stop the trail at this frame + drone_crash_frame[i] = hf + drone_is_crashed[i, :] = False + if hf < n_frames - 1: + drone_is_crashed[i, hf + 1:] = True + print(f"Drones : {n_drones}") + else: + px_dr = py_dr = drone_cols = drone_trail_cols = drone_alt = None + drone_is_hit = drone_hit_fade = drone_is_crashed = drone_crash_frame = None + drone_last_dead_px = drone_last_dead_py = None + + # Aircraft — non-tank, non-drone entities with path data + air_ents = [e for e in d["Entities"] + if e.get("PlayerID", 0) != 0 + and not e["ModelName"].startswith("tankModels/") + and "ucav" not in e["ModelName"].lower() + and e.get("Path")] + air_active = build_active(air_ents) + n_aircraft = len(air_active) + if n_aircraft: + px_air, py_air = precompute_positions(air_active, xfm, frame_times) + air_alt = precompute_altitudes(air_active, frame_times) + # Air death: keep flying after hit, fade to black, crash at end of path + air_is_hit, air_hit_fade, air_is_crashed, air_crash_frame, \ + air_last_dead_px, air_last_dead_py = \ + precompute_air_deaths(air_active, kills, t_start, ms_per_frame, n_frames, fps, px_air, py_air) + # Assign colors: match team using same win/lose scheme + air_cols_list = [] + for p in air_active: + if p["Team"] == team_won: + air_cols_list.append(WIN_COLOR) + else: + air_cols_list.append(LOSE_COLOR) + air_cols = np.array(air_cols_list, dtype=np.uint8) + air_trail_cols = (air_cols * 0.6).astype(np.uint8) + print(f"Air : {n_aircraft}") + else: + px_air = py_air = air_cols = air_trail_cols = air_alt = None + air_is_hit = air_hit_fade = air_is_crashed = air_crash_frame = None + air_last_dead_px = air_last_dead_py = None + + dark = Image.new("RGB", (canvas, canvas), (18, 22, 28)) + + print("Loading tank map …", end=" ", flush=True) + map_img = load_map_image(level, canvas) + print("ok" if map_img else "not found — dark background") + + bg = make_bg(map_img or dark) + + # ── Auto-crop to player activity ── + PAD = 80 + all_px_valid = px[px >= 0] + all_py_valid = py[py >= 0] + if all_px_valid.size > 0: + cx0 = max(0, int(all_px_valid.min()) - PAD) + cy0 = max(0, int(all_py_valid.min()) - PAD) + cx1 = min(canvas, int(all_px_valid.max()) + PAD) + cy1 = min(canvas, int(all_py_valid.max()) + PAD) + else: + cx0, cy0, cx1, cy1 = 0, 0, canvas, canvas + raw_w = cx1 - cx0 + raw_h = cy1 - cy0 + side = max(raw_w, raw_h, MIN_OUTPUT) + side = (side + 1) & ~1 # round up to even (libx264 requirement) + # Centre the square on the activity region, clamped to canvas + mid_x = (cx0 + cx1) // 2 + mid_y = (cy0 + cy1) // 2 + cx0 = max(0, mid_x - side // 2) + cy0 = max(0, mid_y - side // 2) + if cx0 + side > canvas: + cx0 = max(0, canvas - side) + if cy0 + side > canvas: + cy0 = max(0, canvas - side) + cx1, cy1 = cx0 + side, cy0 + side + crop_w = crop_h = side + print(f"Crop : ({cx0},{cy0}) → ({cx1},{cy1}) {crop_w}×{crop_h}px") + + # Shift all coordinates into crop-space so we draw on a smaller buffer + px -= cx0 + py -= cy0 + last_dead_px = (last_dead_px - cx0).astype(np.int16) + last_dead_py = (last_dead_py - cy0).astype(np.int16) + if px_dr is not None and py_dr is not None: + px_dr -= cx0 + py_dr -= cy0 + if drone_last_dead_px is not None and drone_last_dead_py is not None: + drone_last_dead_px = (drone_last_dead_px - cx0).astype(np.int16) + drone_last_dead_py = (drone_last_dead_py - cy0).astype(np.int16) + if px_air is not None and py_air is not None: + px_air -= cx0 + py_air -= cy0 + if air_last_dead_px is not None and air_last_dead_py is not None: + air_last_dead_px = (air_last_dead_px - cx0).astype(np.int16) + air_last_dead_py = (air_last_dead_py - cy0).astype(np.int16) + # Invalidate out-of-bounds positions + oob = (px < 0) | (px >= side) | (py < 0) | (py >= side) + px[oob] = -1; py[oob] = -1 + if px_dr is not None and py_dr is not None: + oob_dr = (px_dr < 0) | (px_dr >= side) | (py_dr < 0) | (py_dr >= side) + px_dr[oob_dr] = -1; py_dr[oob_dr] = -1 + if px_air is not None and py_air is not None: + oob_air = (px_air < 0) | (px_air >= side) | (py_air < 0) | (py_air >= side) + px_air[oob_air] = -1; py_air[oob_air] = -1 + # Crop the background + bg = bg[cy0:cy1, cx0:cx1].copy() + + # Scale icons and text — reference size is 1024px (original design target) + scale = side / 1024.0 + s_dot_r = max(2, int(DOT_R * scale + 0.5)) + s_drone_r = max(1, int(DRONE_R * scale + 0.5)) + s_font_sz = max(8, int(11 * scale + 0.5)) + s_air_r = max(2, int(AIR_R * scale + 0.5)) + s_shadow_dy, s_shadow_dx = make_circle_masks(s_dot_r + 1) + s_dot_dy, s_dot_dx = make_circle_masks(s_dot_r) + s_drone_dy, s_drone_dx = make_circle_masks(s_drone_r) + s_air_dy, s_air_dx = make_triangle_masks(s_air_r) + + font = load_font(s_font_sz) + kbf = precompute_kills(kills, xfm, t_start, ms_per_frame, n_frames, fps, font, + offset_x=cx0, offset_y=cy0) + dbf = precompute_damages(damages, active, px, py, t_start, ms_per_frame, n_frames, fps) + name_sprs = make_name_sprites([p["Name"] for p in active], font) + model_sprs = make_name_sprites([_short_model(p["_model"]) for p in active], font) + label_sprs = [merge_sprites(ns, ms) for ns, ms in zip(name_sprs, model_sprs)] + drone_spr = make_name_sprites(["(Drone)"] * n_drones, font) + # Build altitude sprite cache — shared between drones and aircraft + all_alt_vals: set[int] = set() + if drone_alt is not None: + all_alt_vals.update(int(v) // 10 * 10 for v in drone_alt[drone_alt >= 0]) + if air_alt is not None: + all_alt_vals.update(int(v) // 10 * 10 for v in air_alt[air_alt >= 0]) + alt_sprites: dict[int, Sprite] = {} + for a in all_alt_vals: + sprs = make_name_sprites([f"(Alt: {a}m)"], font) + alt_sprites[a] = sprs[0] + + # Build per-player vehicle icon sprites (tinted to player color) + icon_size = max(12, int(TANK_ICON_SIZE * scale + 0.5)) + hl_pad = max(1, int(ICON_HIGHLIGHT_PAD * scale + 0.5)) + player_icon_sprs: list[Sprite] = [] + player_icon_keys: list[str] = [] + for i, p in enumerate(active): + ik = get_icon_key(p["_model"]) + player_icon_keys.append(ik) + c = (int(colors[i][0]), int(colors[i][1]), int(colors[i][2])) + player_icon_sprs.append(make_tinted_icon_sprite( + ik, c, icon_size, highlight_pad=hl_pad, highlight_color=ICON_HIGHLIGHT_COLOR)) + # Dead sprites: bare black silhouette (no highlight glow) + player_dead_sprs = [make_bare_black_sprite(ik, icon_size, hl_pad) + for ik in player_icon_keys] + print(f"Icons : {len(player_icon_sprs)} ground @ {icon_size}px (+{hl_pad}px highlight)") + + # Build per-aircraft icon sprites + air_icon_sprs: list[Sprite] = [] + if n_aircraft: + assert air_cols is not None + air_name_sprs = make_name_sprites([p["Name"] for p in air_active], font) + air_model_sprs = make_name_sprites([_short_model(p["_model"]) for p in air_active], font) + air_label_sprs = [merge_sprites(ns, ms) for ns, ms in zip(air_name_sprs, air_model_sprs)] + air_icon_size = max(10, int(AIR_ICON_SIZE * scale + 0.5)) + for i, p in enumerate(air_active): + ik = get_icon_key(p["_model"]) + c = (int(air_cols[i][0]), int(air_cols[i][1]), int(air_cols[i][2])) + air_icon_sprs.append(make_tinted_icon_sprite( + ik, c, air_icon_size, highlight_pad=hl_pad, highlight_color=ICON_HIGHLIGHT_COLOR)) + air_dead_sprs = [make_black_sprite(s) for s in air_icon_sprs] + air_rot_caches = [make_rotation_cache(s) for s in air_icon_sprs] + air_dead_rot_caches = [make_rotation_cache(s) for s in air_dead_sprs] + assert px_air is not None and py_air is not None + air_headings = precompute_headings(px_air, py_air) + print(f"Icons : {n_aircraft} aircraft @ {air_icon_size}px (+{hl_pad}px highlight, {ROTATION_STEPS} rotations)") + else: + air_label_sprs = [] + air_dead_sprs = [] + air_rot_caches = [] + air_dead_rot_caches = [] + air_headings = None + + # Build per-drone icon sprites with rotation (like aircraft) + drone_icon_sprs: list[Sprite] = [] + drone_dead_sprs_list: list[Sprite] = [] + drone_rot_caches: list = [] + drone_dead_rot_caches: list = [] + drone_headings_arr: np.ndarray | None = None + drone_icon_keys: list[str] = [] + if n_drones: + drone_icon_size = max(10, int(AIR_ICON_SIZE * scale + 0.5)) + for dr in drones: + ik = get_icon_key(dr["entity"]["ModelName"]) + drone_icon_keys.append(ik) + c = (int(dr["color"][0]), int(dr["color"][1]), int(dr["color"][2])) + drone_icon_sprs.append(make_tinted_icon_sprite( + ik, c, drone_icon_size, highlight_pad=hl_pad, highlight_color=ICON_HIGHLIGHT_COLOR)) + # Drones: bare black silhouette (no glow) when dead + drone_dead_sprs_list = [make_bare_black_sprite(ik, drone_icon_size, hl_pad) + for ik in drone_icon_keys] + drone_rot_caches = [make_rotation_cache(s) for s in drone_icon_sprs] + drone_dead_rot_caches = [make_rotation_cache(s) for s in drone_dead_sprs_list] + if px_dr is not None and py_dr is not None: + drone_headings_arr = precompute_headings(px_dr, py_dr) + print(f"Icons : {n_drones} drones @ {drone_icon_size}px (+{hl_pad}px highlight, {ROTATION_STEPS} rotations)") + + # Load target icons for kill/damage events + target_size = max(12, int(20 * scale + 0.5)) + kill_target = load_target_sprite("target_red.png", target_size) + dmg_target = load_target_sprite("target_yellow.png", target_size) + + air_trail_f = max(1, int(AIR_TRAIL_MS * fps / 1000.0)) + drone_trail_f = max(1, int(DRONE_TRAIL_MS * fps / 1000.0)) + + ctx = VideoCtx( + n_players=len(active), px_all=px, py_all=py, + colors_arr=colors, trail_colors_arr=trail_colors, + is_dead=is_dead, death_frame=death_frame, death_fade=death_fade, + last_dead_px=last_dead_px, last_dead_py=last_dead_py, + kills_by_frame=kbf, damages_by_frame=dbf, label_sprites=label_sprs, + bg_arr=bg, end_frame=end_frame, + n_drones=n_drones, px_drone=px_dr, py_drone=py_dr, + drone_colors_arr=drone_cols, drone_trail_colors=drone_trail_cols, + drone_sprites=drone_spr, + drone_icon_sprites=drone_icon_sprs, drone_dead_sprites=drone_dead_sprs_list, + drone_rot_caches=drone_rot_caches, drone_dead_rot_caches=drone_dead_rot_caches, + drone_headings=drone_headings_arr, + drone_is_hit=drone_is_hit, drone_hit_fade=drone_hit_fade, + drone_is_crashed=drone_is_crashed, drone_crash_frame=drone_crash_frame, + drone_last_dead_px=drone_last_dead_px, drone_last_dead_py=drone_last_dead_py, + drone_alt=drone_alt, drone_alt_sprites=alt_sprites, + n_aircraft=n_aircraft, px_air=px_air, py_air=py_air, + air_colors_arr=air_cols, air_trail_colors=air_trail_cols, + air_sprites=air_label_sprs, + air_is_hit=air_is_hit, air_hit_fade=air_hit_fade, + air_is_crashed=air_is_crashed, air_crash_frame=air_crash_frame, + air_last_dead_px=air_last_dead_px, air_last_dead_py=air_last_dead_py, + air_alt=air_alt, air_alt_sprites=alt_sprites, + air_trail_f=air_trail_f, drone_trail_f=drone_trail_f, + dot_dy=s_dot_dy, dot_dx=s_dot_dx, + shadow_dy=s_shadow_dy, shadow_dx=s_shadow_dx, + drone_dy=s_drone_dy, drone_dx=s_drone_dx, + air_dy=s_air_dy, air_dx=s_air_dx, + dot_r=s_dot_r, drone_r=s_drone_r, air_r=s_air_r, + canvas=side, + player_icon_sprites=player_icon_sprs, + player_dead_sprites=player_dead_sprs, + air_icon_sprites=air_icon_sprs, + air_dead_sprites=air_dead_sprs, + air_rot_caches=air_rot_caches, + air_dead_rot_caches=air_dead_rot_caches, + air_headings=air_headings, + kill_target_spr=kill_target, + dmg_target_spr=dmg_target, + ) + + shared = (shadow_color, trail_f) + + def render_frame(fi: int) -> bytes: + buf = _get_thread_buf(ctx.bg_arr) + render_one_ctx(fi, buf, ctx, *shared) + return buf.tobytes() + + ff = open_ffmpeg(out_path, fps, crop_w, crop_h) + assert ff.stdin is not None + fd = ff.stdin.fileno() + + BATCH = n_workers * 8 + + with ThreadPoolExecutor(max_workers=n_workers) as pool: + for chunk_start in range(0, end_frame, BATCH): + chunk_end = min(chunk_start + BATCH, end_frame) + futs = [pool.submit(render_frame, fi) + for fi in range(chunk_start, chunk_end)] + for fut in futs: + os.write(fd, fut.result()) + if progress_cb is not None: + pct = int(chunk_end / end_frame * 100) + progress_cb(pct) + + ff.stdin.close() + ff.wait() + + sz = out_path.stat().st_size / 1_048_576 + print(f"\nDone → {out_path} ({sz:.1f} MB)") + + +# ── GOB loading helpers ─────────────────────────────────────────────────────── + +def _gob_to_dict(obj: object) -> Any: + """Recursively convert pygob namedtuples to plain dicts.""" + if isinstance(obj, tuple) and hasattr(obj, '_fields'): # type: ignore[union-attr] + return {f: _gob_to_dict(getattr(obj, f)) for f in obj._fields} # type: ignore[union-attr] + elif isinstance(obj, list): + return [_gob_to_dict(i) for i in obj] + elif isinstance(obj, dict): + return { + (k.decode('utf-8', errors='replace') if isinstance(k, bytes) else k): _gob_to_dict(v) + for k, v in obj.items() + } + elif isinstance(obj, bytes): + return obj.decode('utf-8', errors='replace') + return obj + + +def load_gob_file(gob_path: Path) -> dict[str, Any]: + """Load a .gob (zstd-compressed) or .json replay file and return the dict.""" + raw = gob_path.read_bytes() + if gob_path.suffix == ".json": + return json.loads(raw) + # zstd-compressed gob binary + decompressor = zstd.ZstdDecompressor() + data = decompressor.decompress(raw, max_output_size=200 * 1024 * 1024) + replay = pygob.load(data) + return _gob_to_dict(replay) # type: ignore[return-value] + + +# ── JSON export (slim dict for the web canvas replay viewer) ────────────────── + +def _entity_type(model_name: str) -> str: + if model_name.startswith("tankModels/"): + return "ground" + if "ucav" in model_name.lower(): + return "drone" + return "aircraft" + + +_TAG_TO_ICON_KEY = [ + ("type_spaa", "spaa"), + ("type_light_tank", "light"), + ("type_tank_destroyer", "tank_destroyer"), + ("type_heavy_tank", "heavy"), + ("type_medium_tank", "medium"), + ("type_missile_tank", "tank_destroyer"), + ("type_bomber", "bomber_icon"), + ("type_strike_aircraft", "fighter_icon"), + ("type_jet_fighter", "jet_icon"), + ("type_fighter", "fighter_icon"), + ("type_strike_ucav", "drone"), + ("type_helicopter", "helicopter_icon"), +] + + +def _vehicle_icon_key(model_name: str) -> str: + """Return an icon key for ground vehicles: light, medium, heavy, spaa, tank_destroyer, drone. + Aircraft return tag-based fallback keys (fighter_icon, bomber_icon, etc.).""" + internal = model_name.split("/")[-1] + if "ucav" in model_name.lower(): + return "drone" + tags = _get_unit_tags(internal) + if tags: + tag_set = set(tags) + for tag, icon in _TAG_TO_ICON_KEY: + if tag in tag_set: + return icon + return "medium" + + +def _vehicle_mini_icon(model_name: str) -> str | None: + """Return the mini icon filename for aircraft (e.g. 'spitfire_ix_ico'), or None.""" + internal = model_name.split("/")[-1] + mini_path = Path(__file__).resolve().parent / "ICONS" / "MINIS" / f"{internal}_ico.png" + if mini_path.exists(): + return f"mini:{internal}_ico" + return None + + +def _subsample_path(path: list[dict], threshold: float = 1.0) -> list[dict]: + """Keep first, last, and points that moved >= threshold world units.""" + if len(path) <= 2: + return path + out = [path[0]] + lx, lz = path[0]["X"], path[0]["Z"] + for pt in path[1:-1]: + dx = pt["X"] - lx + dz = pt["Z"] - lz + if dx * dx + dz * dz >= threshold * threshold: + out.append(pt) + lx, lz = pt["X"], pt["Z"] + out.append(path[-1]) + return out + + +def _resolve_drone_team(drone_entity: dict, ground_entities: list[dict], + players_by_id: dict) -> int: + """Return team index for a drone by finding nearest ground player at spawn.""" + if not drone_entity.get("Path") or not ground_entities: + return 0 + spawn_t = drone_entity["Path"][0]["Time"] + spawn_x = drone_entity["Path"][0]["X"] + spawn_z = drone_entity["Path"][0]["Z"] + + best_team = 0 + best_dist = float("inf") + for ge in ground_entities: + pid = ge.get("PlayerID", 0) + if pid == 0 or pid not in players_by_id: + continue + closest_pt = None + closest_dt = float("inf") + for pt in ge.get("Path", []): + dt = abs(pt["Time"] - spawn_t) + if dt < closest_dt: + closest_dt = dt + closest_pt = pt + if closest_pt is None: + continue + dx = closest_pt["X"] - spawn_x + dz = closest_pt["Z"] - spawn_z + dist = dx * dx + dz * dz + if dist < best_dist: + best_dist = dist + best_team = players_by_id[pid].get("Team", 0) + return best_team + + +def export_replay_json(gob_path: Path) -> dict: + """Load a GOB file and produce a slim dict for the web viewer.""" + d = load_gob_file(gob_path) + + players_by_id = {p["PlayerID"]: p for p in d.get("Players", [])} + team_won = d.get("TeamWon", 0) + + level_path = d.get("Mission", {}).get("Level", "") + session_id = d.get("SessionID", 0) + level_data = load_level_coords(level_path, session_id=session_id) + if level_data: + c0 = level_data["tankMapCoord0"] + c1 = level_data["tankMapCoord1"] + level_coords = {"x0": c0[0], "z0": c0[1], "x1": c1[0], "z1": c1[1]} + else: + level_coords = {"x0": 0, "z0": 0, "x1": 4096, "z1": 4096} + + map_coords = None + full_map_level = None + if level_data and "mapCoord0" in level_data and "mapCoord1" in level_data: + mc0 = level_data["mapCoord0"] + mc1 = level_data["mapCoord1"] + if isinstance(mc0[0], list): + mc0 = mc0[0] + if isinstance(mc1[0], list): + mc1 = mc1[0] + map_coords = {"x0": mc0[0], "z0": mc0[1], "x1": mc1[0], "z1": mc1[1]} + stem = Path(level_path).stem if level_path else "" + custom = level_data.get("customLevelMap", "") + if custom: + full_map_level = custom.rstrip("*") + else: + full_map_level = stem + "_map" if stem else None + + players_out = [] + for p in d.get("Players", []): + player = { + "id": p["PlayerID"], + "name": p.get("Name", ""), + "team": p.get("Team", 0), + } + clan = p.get("ClanTag", "") + if clan: + player["clan"] = clan + players_out.append(player) + + ground_entities = [e for e in d.get("Entities", []) + if e.get("PlayerID", 0) != 0 + and e["ModelName"].startswith("tankModels/") + and e.get("Path")] + + entities_out = [] + for e in d.get("Entities", []): + path = e.get("Path", []) + if not path: + continue + pid = e.get("PlayerID", 0) + etype = _entity_type(e["ModelName"]) + + if pid == 0 and etype != "drone": + continue + + internal = e["ModelName"].split("/")[-1] + vehicle_name = _translate_vehicle(internal) + + if etype == "drone": + drone_team = _resolve_drone_team(e, ground_entities, players_by_id) + else: + drone_team = None + + sampled = _subsample_path(path) + path_out = [{"t": round(pt["Time"]), "x": round(pt["X"], 1), + "z": round(pt["Z"], 1), "y": round(pt.get("Y", 0), 1)} + for pt in sampled] + + icon_key = _vehicle_icon_key(e["ModelName"]) + ent = { + "playerId": pid, + "entityIndex": e.get("EntityIndex", 0), + "type": etype, + "iconKey": icon_key, + "vehicleName": vehicle_name, + "path": path_out, + } + if etype == "aircraft": + mini = _vehicle_mini_icon(e["ModelName"]) + if mini: + ent["miniIcon"] = mini + if drone_team is not None: + ent["droneTeam"] = drone_team + entities_out.append(ent) + + kills_out = [] + for k in d.get("Kills", []): + kill = { + "time": round(k.get("Time", 0)), + "victimId": k.get("VictimID", 0), + "killerId": k.get("KillerID", 0), + "victimEntityIndex": k.get("VictimEntityIndex", 0), + "weapon": _translate_weapon(k.get("Weapon", "")), + } + vp = k.get("VictimPosition") + if vp: + kill["victimPos"] = {"x": round(vp.get("X", 0), 1), "z": round(vp.get("Z", 0), 1)} + kp = k.get("KillerPosition") + if kp: + kill["killerPos"] = {"x": round(kp.get("X", 0), 1), "z": round(kp.get("Z", 0), 1)} + km = k.get("KillerModel", "") + if km: + kill["killerVehicle"] = _translate_vehicle(km.split("/")[-1]) + vm = k.get("VictimModel", "") + if vm: + kill["victimVehicle"] = _translate_vehicle(vm.split("/")[-1]) + kills_out.append(kill) + + damages_out = [] + for dm in d.get("DamageReports", []): + damages_out.append({ + "time": round(dm.get("Time", 0)), + "offenderId": dm.get("OffenderID", 0), + "offendedId": dm.get("OffendedID", 0), + }) + + out = { + "teamWon": team_won, + "mission": {"level": Path(level_path).stem if level_path else ""}, + "levelCoords": level_coords, + "players": players_out, + "entities": entities_out, + "kills": kills_out, + "damages": damages_out, + } + if map_coords and full_map_level: + out["mapCoords"] = map_coords + out["fullMapLevel"] = full_map_level + return out + + +# ── Main (CLI wrapper) ───────────────────────────────────────────────────────── + +def main(): + """CLI entry point: render a GOB replay to MP4, or export a slim JSON for the web viewer. + + Output mode is selected by the output file extension: `.json` → json export, + anything else → mp4 render. Supports --profile for cProfile hotspot analysis. + """ + import argparse + parser = argparse.ArgumentParser(description="Render GOB replay to MP4") + parser.add_argument("gob", nargs="?", help="Path to .gob or .json replay") + parser.add_argument("out", nargs="?", help="Output .mp4 path") + parser.add_argument("--fps", type=int, default=FPS) + parser.add_argument("--speed", type=float, default=SPEED) + parser.add_argument("--workers", type=int, default=N_WORKERS) + parser.add_argument("--profile", action="store_true", + help="Run with cProfile and print top 40 hotspots") + args = parser.parse_args() + + if args.gob: + gob_path = Path(args.gob) + else: + candidates = sorted(REPLAYS_DIR.glob("*/replay.gob")) + if not candidates: + candidates = sorted(REPLAYS_DIR.glob("*.json")) + if not candidates: + sys.exit(f"No .gob or .json files in {REPLAYS_DIR}") + gob_path = candidates[0] + + out_path = Path(args.out) if args.out else gob_path.parent / "replay_video.mp4" + + if out_path.suffix.lower() == ".json": + data = export_replay_json(gob_path) + raw = json.dumps(data, separators=(",", ":")) + out_path.write_text(raw, encoding="utf-8") + print(f"Exported {len(raw):,} bytes to {out_path}") + return + + print(f"Input : {gob_path}") + print(f"Output : {out_path}") + print(f"Settings : {args.fps}fps {args.speed:.0f}× {args.workers} threads") + + d = load_gob_file(gob_path) + + if args.profile: + import cProfile + import pstats + import io + import time + + # Phase 1: profile just the prep (everything before frame loop) + # We do this by profiling render_gob with 0 workers trick — not feasible, + # so profile the whole thing and the stats will show us. + print("\n=== PROFILING ===\n") + t0 = time.perf_counter() + pr = cProfile.Profile() + pr.enable() + render_gob(d, out_path, fps=args.fps, speed=args.speed, n_workers=args.workers) + pr.disable() + wall = time.perf_counter() - t0 + + print(f"\n{'='*70}") + print(f"Total wall time: {wall:.2f}s") + print(f"{'='*70}\n") + + s = io.StringIO() + ps = pstats.Stats(pr, stream=s) + ps.strip_dirs().sort_stats("cumulative") + ps.print_stats(40) + print(s.getvalue()) + + s2 = io.StringIO() + ps2 = pstats.Stats(pr, stream=s2) + ps2.strip_dirs().sort_stats("tottime") + ps2.print_stats(40) + print("\n--- Sorted by total time ---\n") + print(s2.getvalue()) + else: + render_gob(d, out_path, fps=args.fps, speed=args.speed, n_workers=args.workers) + + +if __name__ == "__main__": + main() diff --git a/BOT/health.py b/BOT/health.py new file mode 100644 index 0000000..523d804 --- /dev/null +++ b/BOT/health.py @@ -0,0 +1,132 @@ +""" +health.py + +Bot health monitoring. Tracks task execution status, WebSocket connectivity, +and game processing metrics. Writes periodic heartbeat to STORAGE/bot_health.json. +""" + +# Standard Library Imports +import json +import logging +import time +from collections import deque +from pathlib import Path + +# Third-Party Library Imports +import aiofiles + +# Local Module Imports +from .utils import STORAGE_DIR, get_bot + +HEALTH_PATH = STORAGE_DIR / "bot_health.json" + +# Rolling window for games-processed counters +_games_timestamps: deque[float] = deque() + +_health_state: dict = { + "bot_started_at": None, + "guild_count": 0, + "last_heartbeat": 0, + "tasks": {}, + "websocket": {}, + "games_processed_1h": 0, + "games_processed_24h": 0, +} + + +def init_health(started_at: float, guild_count: int) -> None: + """Initialize health state on bot startup.""" + _health_state["bot_started_at"] = started_at + _health_state["guild_count"] = guild_count + + +async def record_task_run(task_name: str, success: bool, error: str = "") -> None: + """Record a task execution result.""" + entry = _health_state["tasks"].setdefault(task_name, { + "status": "unknown", + "last_run": 0, + "run_count": 0, + "error_count": 0, + "last_error": "", + }) + entry["last_run"] = time.time() + entry["run_count"] += 1 + if success: + entry["status"] = "ok" + else: + entry["status"] = "error" + entry["error_count"] += 1 + entry["last_error"] = str(error)[:200] + + +async def record_ws_message(ws_name: str) -> None: + """Record a WebSocket message receipt.""" + entry = _health_state["websocket"].setdefault(ws_name, { + "connected": True, + "last_message_at": 0, + "messages_processed": 0, + }) + entry["connected"] = True + entry["last_message_at"] = time.time() + entry["messages_processed"] += 1 + + +def record_ws_disconnect(ws_name: str) -> None: + """Mark a WebSocket as disconnected.""" + entry = _health_state["websocket"].get(ws_name) + if entry: + entry["connected"] = False + + +def record_game_processed() -> None: + """Record that a game was processed (for hourly/daily counters).""" + _games_timestamps.append(time.time()) + + +def _prune_games_window() -> tuple[int, int]: + """Count games in the last 1h and 24h, pruning old entries.""" + now = time.time() + cutoff_24h = now - 86400 + while _games_timestamps and _games_timestamps[0] < cutoff_24h: + _games_timestamps.popleft() + + cutoff_1h = now - 3600 + count_1h = sum(1 for ts in _games_timestamps if ts >= cutoff_1h) + return count_1h, len(_games_timestamps) + + +async def write_heartbeat() -> None: + """Dump current health state to HEALTH_PATH as JSON.""" + try: + bot = get_bot() + _health_state["guild_count"] = len(bot.guilds) + except Exception: + pass + + _health_state["last_heartbeat"] = time.time() + games_1h, games_24h = _prune_games_window() + _health_state["games_processed_1h"] = games_1h + _health_state["games_processed_24h"] = games_24h + + try: + HEALTH_PATH.parent.mkdir(parents=True, exist_ok=True) + async with aiofiles.open(HEALTH_PATH, "w", encoding="utf-8") as f: + await f.write(json.dumps(_health_state, indent=2, default=str)) + except Exception as e: + logging.error(f"[HEALTH] Failed to write heartbeat: {e}") + + +async def get_health_snapshot() -> dict: + """Return current health state dict (live, not from file).""" + try: + bot = get_bot() + _health_state["guild_count"] = len(bot.guilds) + except Exception: + pass + + _health_state["last_heartbeat"] = time.time() + games_1h, games_24h = _prune_games_window() + _health_state["games_processed_1h"] = games_1h + _health_state["games_processed_24h"] = games_24h + + return dict(_health_state) diff --git a/BOT/locales/cs.json b/BOT/locales/cs.json new file mode 100644 index 0000000..3599f62 --- /dev/null +++ b/BOT/locales/cs.json @@ -0,0 +1,856 @@ +{ + "common": { + "error_title": "Chyba", + "no_data_title": "Žádná data", + "access_denied_title": "Přístup zamítnut", + "access_denied_desc": "Tento server byl zablokován.", + "no_players_selected": "Žádní hráči nevybráni. Vyberte prosím alespoň jednoho hráče.", + "must_use_in_server": "Tento příkaz lze použít pouze na serveru.", + "could_not_resolve_channel": "Nelze najít vybraný kanál.", + "failed_update_setting": "❌ Nastavení se nepodařilo aktualizovat.", + "configuration_not_found": "Konfigurace nenalezena.", + "no_channel_selected": "Žádný kanál nevybrán.", + "no_selection_received": "Nebyla přijata žádná volba.", + "database_error": "❌ Chyba databáze: {error}", + "enabled": "Zapnuto", + "disabled": "Vypnuto", + "not_configured": "Nenastaveno", + "unknown": "Neznámý", + "rating_field": "Hodnocení", + "battles_field": "Bitvy", + "wins_field": "Výhry", + "losses_field": "Prohry", + "win_rate_field": "Úspěšnost", + "kills_field": "Zabití", + "deaths_field": "Úmrtí", + "kd_field": "K/D", + "members_field": "Členové", + "placement_field": "Umístění", + "points_field": "Body", + "ground_kills_field": "Pozemní zabití", + "air_kills_field": "Vzdušná zabití", + "total_kills_field": "Celková zabití", + "assists_field": "Asistence", + "captures_field": "Obsazení", + "none_option": "Žádný" + }, + "buttons": { + "skip": "Přeskočit", + "previous": "Předchozí", + "next": "Další", + "prev": "Zpět", + "prev_arrow": "◀ Předchozí", + "next_arrow": "Další ▶", + "prev_arrow_only": "◀", + "next_arrow_only": "▶", + "generate_chart": "📊 Generovat graf", + "show_graph": "Zobrazit graf", + "view_player_stats": "📊 Zobrazit statistiky hráčů", + "compare_nearby": "📈 Porovnat okolní svazy", + "confirm_swap": "Ano, přepnout", + "cancel_swap": "Ne, ponechat stávající", + "set_squadron": "Nastavit svaz", + "same_as_logs": "Stejný jako logy", + "require_password": "🔒 Vyžadovat heslo", + "password_required": "🔒 Heslo vyžadováno", + "lock_data": "🔐 Připojit data svazu", + "data_locked": "🔐 Data připojena k serveru", + "allow_public": "👥 Povolit veřejná meta data", + "public_enabled": "👥 Veřejná meta data povolena", + "update_accounts": "📋 Aktualizovat meta účty", + "change_password": "🔑 Změnit heslo", + "help": "❓ Nápověda", + "add_player": "➕ Přidat hráče", + "update_all": "🔄 Aktualizovat všechny členy", + "back_to_settings": "⬅ Zpět na nastavení", + "manage_notifications": "Správa oznámení", + "diagnose_permissions": "Diagnostika oprávnění", + "enable": "Zapnout", + "disable": "Vypnout", + "change_channel": "Změnit kanál", + "view_replay": "Zobrazit záznam", + "view_website": "Zobrazit na webu", + "view_video": "Zobrazit video", + "view_log": "Zobrazit log", + "view_chat": "Zobrazit chat", + "subscribe_website": "Přihlásit přes web", + "yes_disband": "Ano, rozpustit", + "cancel": "Zrušit", + "transfer_leave": "Předat a odejít", + "accept_selected": "Přijmout vybrané", + "accept_all": "Přijmout všechny", + "decline_selected": "Odmítnout vybrané", + "back": "Zpět", + "remove_all": "Odebrat všechny", + "remove_active": "Odebrat aktivní", + "remove_queued": "Odebrat ve frontě", + "remove_selected": "Odebrat vybrané", + "ping_all": "Pingnout všechny", + "ping_active": "Pingnout aktivní", + "ping_queued": "Pingnout ve frontě", + "ping_selected": "Pingnout vybrané", + "accept_members": "Přijmout členy", + "remove_members": "Odebrat členy", + "ping_members": "Pingnout členy", + "rename_stack": "Přejmenovat stack", + "request_to_join": "Požádat o vstup", + "leave_withdraw": "Odejít / Stáhnout", + "manage_stack": "Spravovat stack ⚙️", + "disband_stack": "Rozpustit stack", + "force_disband_create": "Vynutit rozpuštění a vytvořit nový" + }, + "events": { + "guild_join_title": "Díky za přidání!", + "guild_join_desc": "Spusťte `/setup` pro nastavení bota na tomto serveru." + }, + "comp": { + "not_found_title": "Sestavy nenalezeny", + "not_found_desc": "Žádná data pro **{squadron}**, zkuste to znovu později.", + "error_loading_title": "Chyba při načítání sestav", + "error_loading_desc": "Nepodařilo se načíst data sestav: {error}", + "title": "Sestavy pro {squadron}", + "desc": "Sestavy viděné za posledních {minutes} minut", + "no_recent_title": "Žádné nedávné sestavy", + "no_recent_desc": "Žádné sestavy za posledních {minutes} minut.", + "comp_title": "SESTAVA {index}", + "last_seen_label": "**Naposledy viděna** : {timestamp}{warning}", + "comp_label": "**Sestava**: {notation}", + "no_players_recorded": "Žádní hráči nezaznamenáni.", + "limit_reached_title": "Limit sestav dosažen", + "limit_reached_desc": "Tento server vyčerpal všech {limit} vyhledávání sestav pro tento časový slot. Předplaťte si (pomocí /unlock) neomezený přístup nebo počkejte na další časový slot.", + "remaining_footer": "{remaining}/{limit} vyhledávání sestav zbývá v tomto časovém slotu" + }, + "quick_log": { + "invalid_type": "Typ lze nastavit pouze na Logy, Body, Žebříček, Týdenní BR nebo Oba.", + "squadron_required": "Pro alarmy Logy, Body nebo Obojí musíte zadat název svazu.", + "wildcard_logs_only": "Pouze Logy lze nastavit na zástupný svaz.", + "squadron_not_resolved": "Svaz `{squadron}` se nepodařilo najít.", + "save_failed": "Nepodařilo se uložit předvolby. Zkuste to prosím znovu.", + "premium_warning": "\n\n> ⚠️ **Herní logy vyžadují Premium.** Spusťte `/unlock` pro přihlášení k odběru (2,99 $/měs.) — logy se nebudou odesílat, dokud tak neučiníte.", + "leaderboard_set": "Globální alarm žebříčku nastaven na tento kanál.", + "both_set": "Alarmy logů a bodů pro {squadron} nastaveny na tento kanál.{premium_note}", + "alarm_set": "Alarm {alarm_type} pro {squadron} nastaven na tento kanál.{premium_note}", + "weekly_br_wildcard_set": "Týdenní zpráva BR (top 20 letek) nastavena pro tento kanál. Odesílá se na konci každé rotace BR.", + "weekly_br_squadron_set": "Týdenní zpráva BR pro {squadron} (top 15 hráčů) nastavena pro tento kanál. Odesílá se na konci každé rotace BR." + }, + "diagnostics": { + "title": "Diagnostika autologu", + "channel_permissions_header": "**Oprávnění kanálu** (<#{channel_id}>)", + "perms_needed": " ^ Autologování potřebuje vše výše uvedené pro odesílání výsledkových tabulek.", + "server_squadron_header": "**Svaz serveru** (`/set-squadron`)", + "server_squadron_short": " Zkratka: `{short}`", + "server_squadron_long": " Celý název: `{long}`", + "server_squadron_not_set": " Nenastaveno (barva pruhu výsledkové tabulky bude zobrazena jako 'not_set')", + "autolog_prefs_header": "**Předvolby autologu** (`/quick-log`)", + "autolog_none_configured": " ❌ ŽÁDNÉ nenastaveno — autologování NEBUDE odesílat nic na tento server.", + "autolog_setup_hint": " Použijte `/quick-log Logs` v cílovém kanálu pro nastavení.", + "autolog_no_logs_channels": " ❌ Žádné kanály pro Logy nenakonfigurovány. Nalezeny pouze Body/Žebříček.", + "autolog_enable_hint": " Použijte `/quick-log Logs` pro zapnutí autologování.", + "selected_channel_tag": " **(vybraný kanál)**", + "missing_send_attach": " (chybí oprávnění k odesílání/přílohám)", + "channel_not_found": " (kanál nenalezen)", + "invalid_channel_id": " (neplatné ID kanálu)", + "premium_status_header": "**Stav Premium** (`/unlock`)", + "premium_active": " ✅ Tento server má aktivní Premium předplatné.", + "premium_not_subscribed": " ❌ Tento server **nemá** Premium předplatné.", + "premium_autolog_required": " Autologování vyžaduje Premium. Použijte `/unlock` pro přihlášení k odběru.", + "premium_not_subscribed_free": " ⚪ Nepřihlášeno k odběru — použijte `/unlock` pro přihlášení (2,99 $/měs.).", + "premium_free_note": " *(Autology jsou nyní zdarma pro všechny servery.)*" + }, + "sq_info": { + "title": "Informace o svazu: {squadron}", + "placement_field": "Umístění", + "total_points_field": "Celkové body", + "total_members_field": "Celkem členů", + "members_field": "Členové", + "fetch_failed": "Nepodařilo se načíst informace o svazu." + }, + "sq_info_graph": { + "title": "{squadron} — SQ-INFO (Sezóna {season})", + "embed_title": "{squadron} — Složení svazu", + "embed_desc": "Sezóna **{season}** · Medián zápasů: **{median}** · Jádro: **{core}** · Aktivní: **{active}** · Slabí: **{weak}**\nSloupce řazeny podle zápasů sestupně; výška = procento výher. Jádro = horních 30 % WR a zápasů ≥ medián. Aktivní = horních 30–45 % WR a zápasů ≈ medián. Slabí = všichni ostatní.", + "core_threshold_line": "JÁDRO ≥ {wr} %", + "weak_threshold_line": "SLABÍ < {wr} %", + "y_label": "Procento výher", + "core_header": "JÁDRO — {count} · WR {avg}%", + "active_header": "AKTIVNÍ — {count} · WR {avg}%", + "weak_header": "SLABÍ — {count} · WR {avg}%", + "no_active_season": "Nebyla nalezena žádná aktivní sezóna. Zkuste to znovu, až začne další.", + "no_members": "Nebyli nalezeni žádní aktuální členové pro {squadron}." + }, + "recap_card": { + "unknown_season": "Neznámá sezóna: `{season}`.", + "no_clan_id": "Nepodařilo se určit ID svazu `{squadron}`.", + "render_failed": "Nepodařilo se vygenerovat kartu sezónního přehledu. Zkuste to znovu později." + }, + "sq_stats": { + "no_data_title": "Žádná data", + "no_data_desc": "Pro svaz nenalezena žádná historická data: {squadron}", + "title": "{squadron} // SVAZ", + "desc": "Trend celkového skóre (posledních {count} datových bodů)", + "previous_score_field": "Předchozí skóre", + "current_score_field": "Aktuální skóre", + "change_field": "Změna", + "player_title": "{squadron} // HRÁČI", + "player_desc": "Trendy bodů jednotlivých hráčů", + "comparison_title": "{squadron} // POROVNÁNÍ ŽEBŘÍČKU", + "comparison_desc": "Porovnání se svazy na pozicích {range}", + "current_position_field": "Aktuální pozice", + "squadrons_shown_field": "Zobrazené svazy", + "squadron_not_found_error": "Svaz nenalezen v žebříčku", + "no_nearby_error": "Žádné okolní svazy nenalezeny", + "no_historical_error": "Nenalezena žádná historická data pro okolní svazy", + "comparison_chart_failed": "Nepodařilo se vygenerovat srovnávací graf", + "select_players_placeholder": "Vyberte hráče (strana {page})" + }, + "loss_calc": { + "title": "Ztráta bodů — {squadron}", + "players_leaving_field": "Odcházející hráči", + "share_of_total_field": "% podíl z celku", + "points_lost_real_field": "Ztracené body (skutečné)", + "points_lost_raw_field": "Ztracené body (hrubé)", + "squadron_rating_field": "Hodnocení svazu", + "squadron_position_field": "Pozice svazu", + "positions_lost_field": "Ztracené pozice", + "not_found_footer": "Nenalezeno ve svazu: {players}", + "fetch_failed": "Nepodařilo se načíst data svazu: {error}", + "no_point_data": "Pro tento svaz nejsou dostupná žádná bodová data.", + "no_matching_players": "Ve **{squadron}** nebyli nalezeni žádní shodující se hráči." + }, + "player": { + "select_player_placeholder": "Vyberte hráče", + "no_stats_found": "❌ Nenalezeny žádné statistiky pro UID: {uid}", + "no_vehicle_stats": "❌ Pro tohoto hráče nebyla nalezena žádná statistika vozidel.", + "vehicles_found": "Nalezeno **{count}** vozidel pro **{nick}**\nVyberte vozidlo pro zobrazení podrobných statistik:", + "vehicle_select_placeholder": "Vyberte vozidlo (strana {page}/{total})", + "combat_stats_header": "**__BOJOVÉ STATISTIKY__**", + "ground_kills_label": "**Pozemní zabití:** {value}", + "air_kills_label": "**Vzdušná zabití:** {value}", + "total_kills_label": "**Celková zabití:** {value}", + "assists_label": "**Asistence:** {value}", + "deaths_label": "**Úmrtí:** {value}", + "kd_label": "**K/D:** {value}", + "captures_label": "**Obsazení:** {value}", + "battle_record_header": "**__HERNÍ ZÁZNAM__**", + "total_battles_label": "**Celkové bitvy:** {value}", + "wins_label": "**Výhry:** {value}", + "losses_label": "**Prohry:** {value}", + "win_rate_label": "**Úspěšnost:** {value}%", + "stats_desc": "Statistiky pro **{nick}** (**{squadron}**)\nUID: `{uid}`", + "not_found_title": "Hráč nenalezen", + "not_found_desc": "Pro `{player}` nebyla nalezena žádná herní historie.", + "no_players_found": "Nebyli nalezeni žádní hráči odpovídající **{username}**\nZkuste použít `/website` pro vyhledávání na webu.", + "multiple_matches": "Nalezeno více shod, vyberte správnou níže:", + "must_provide_input": "Musíte zadat alespoň UID nebo uživatelské jméno." + }, + "player_games": { + "no_recent_title": "Žádné nedávné hry", + "no_recent_desc": "Pro hráče **{player}** nebyly nalezeny žádné hry za posledních 8 hodin.", + "squadron_label": "**Svaz:** {squadron}", + "record_label": "**V:** {wins} **P:** {losses} **Ú:** {wr}%", + "comps_played_header": "\n\n**Odehrané sestavy**" + }, + "match": { + "missing_input_title": "Chybějící vstup", + "missing_input_desc": "Zadejte `match_id` nebo `player_name`.", + "not_found_title": "Zápas nenalezen", + "not_found_desc": "Nebyl nalezen zápas s ID `{match_id}`.", + "invalid_data_title": "Neplatná data zápasu", + "invalid_data_desc": "Data záznamu nebylo možné zpracovat.", + "scoreboard_error_title": "Chyba výsledkové tabulky", + "scoreboard_error_desc": "Nepodařilo se vygenerovat obrázek výsledkové tabulky.", + "no_games_title": "Žádné hry nenalezeny", + "no_games_desc": "Pro hráče **{player}** nebyla nalezena žádná herní historie.", + "recent_matches_title": "Nedávné zápasy pro {player}", + "recent_matches_desc": "Zobrazeno až {count} nedávných her. Vyberte jednu pro zobrazení celé výsledkové tabulky.", + "select_match_placeholder": "Vyberte zápas k zobrazení..." + }, + "compare": { + "no_players_found": "Nebyli nalezeni žádní hráči odpovídající **{name}**.", + "multiple_matches": "Více shod pro **{name}**: {matches}\nPoužijte prosím přesnější jméno (návrhy automatického doplňování jsou přesné).", + "could_not_resolve": "Hráče nebylo možné najít.", + "could_not_fetch": "❌ Nelze načíst statistiky pro **{name}**.", + "no_graph_data": "Žádná data dostupná za posledních 90 dní.", + "no_squadron_points_data": "Žádná data bodů svazu pro {names} (hráč nenalezen v historii sledovaného svazu).", + "graph_title": "Body hráče — posledních 90 dní", + "battles_label": "Bitvy", + "wins_label": "Výhry", + "losses_label": "Prohry", + "win_rate_label": "Úspěšnost", + "ground_kills_label": "Pozemní zabití", + "air_kills_label": "Vzdušná zabití", + "total_kills_label": "Celková zabití", + "assists_label": "Asistence", + "deaths_label": "Úmrtí", + "kd_label": "K/D", + "captures_label": "Obsazení" + }, + "squadron": { + "not_found_desc": "Svaz `{squadron}` nenalezen.", + "set_title": "✅ Svaz nastaven", + "set_desc": "Svaz **{squadron}** byl nastaven pro tento server.", + "short_name_field": "Zkratka", + "long_name_field": "Celý název", + "swap_title": "✅ Svaz přepnut", + "swap_desc": "Svaz **{old}** nahrazen svazem **{new}** na tomto serveru.", + "already_set_title": "⚠️ Svaz již nastaven", + "already_set_desc": "Tento server je momentálně nastaven na **{old}**.\nPřepnout na **{new}**?", + "swap_cancelled": "❌ Změna svazu zrušena." + }, + "setup": { + "step1_title": "Nastavení serveru — krok 1 ze 3", + "step1_desc": "Tento průvodce vás provede nastavením bota pro váš server.\n\n**Krok 1** — Nastavte svaz\n**Krok 2** — Zvolte kanál pro logy\n**Krok 3** — Zvolte kanál pro body\n", + "step1_current_sq": "\nAktuálně nastavený svaz: **[{short}] {long}**", + "step2_title": "Nastavení serveru — krok 2 ze 3", + "step2_desc": "Svaz nastaven na **[{short}] {long}**.\n\nKam mají být odesílány **bojové logy**?\nVyberte textový kanál níže nebo tento krok přeskočte.", + "step3_title": "Nastavení serveru — krok 3 ze 3", + "step3_desc": "Kam mají být odesílána **bodová oznámení**?\nVyberte textový kanál níže nebo tento krok přeskočte.", + "step3_same_as_logs": "\n\nMůžete také kliknout na „Stejna jako Logy“ a znovu použít kanál logů.", + "summary_title": "Nastavení dokončeno", + "summary_desc": "Tato nastavení lze později změnit pomocí `/autolog-management`.", + "squadron_field": "Svaz", + "logs_channel_field": "Kanál logů", + "points_channel_field": "Kanál bodů", + "premium_required_field": "⚠️ Herní logy vyžadují Premium", + "premium_required_value": "Automatické herní výsledkové tabulky se nebudou odesílat, dokud tento server nemá aktivní předplatné. Spusťte `/unlock` pro přihlášení k odběru (2,99 $/měs.).", + "modal_title": "Nastavit svaz", + "modal_label": "Zkratka svazu", + "modal_placeholder": "např. AXYS", + "squadron_not_found": "Svaz `{squadron}` nenalezen. Zkuste to prosím znovu.", + "logs_channel_placeholder": "Vyberte kanál logů...", + "points_channel_placeholder": "Vyberte kanál bodů..." + }, + "meta_management": { + "squadron_not_found_title": "❌ Svaz nenalezen", + "squadron_not_found_desc": "Nelze najít ID klanu pro svaz: **{squadron}**", + "access_denied_title": "❌ Přístup zamítnut", + "access_denied_desc": "Nesprávné heslo. Meta data tohoto svazu jsou chráněna.", + "data_locked_title": "🔐 Data svazu připojena", + "data_locked_desc": "**{squadron}** má zapnuté připojení dat a nemůže být přenesen na jiný server.\n\nVlastník svazu musí vypnout **Připojit data svazu** před přenosem.", + "error_retrieving_settings": "❌ Chyba při získávání nastavení serveru po přenosu. Zkuste to prosím znovu.", + "error_retrieving_settings_retry": "❌ Chyba při získávání nastavení serveru. Zkuste prosím příkaz spustit znovu.", + "authenticated_title": "✅ Ověřeno", + "authenticated_desc": "Heslo ověřeno. Spravuji nastavení pro **{squadron}**.", + "claimed_title": "✅ Svaz přijat", + "claimed_desc": "**{squadron}** byl úspěšně přijat pro tento server!", + "password_requirement_field": "🔒 Požadavek na heslo", + "data_lock_field": "🔐 Připojení dat svazu", + "public_meta_field": "👥 Veřejný přístup k meta datům", + "access_password_field": "🔑 Přístupové heslo", + "enabled_value": "✅ Zapnuto", + "disabled_value": "❌ Vypnuto", + "settings_title": "🔐 Nastavení správy meta dat", + "settings_desc": "**Svaz:** {squadron}\n**ID klanu:** {clan_id}", + "first_time_title": "🔐 Správa meta dat — první nastavení", + "first_time_owner_desc": "**Svaz:** {squadron}\n**ID klanu:** {clan_id}\n\n🔑 Vaše přístupové heslo bylo vygenerováno. **Uložte si toto heslo** — budete ho potřebovat pro ověření přístupu k meta datům v budoucnu.\n\n**Heslo:** `{password}`", + "first_time_non_owner_desc": "**Svaz:** {squadron}\n**ID klanu:** {clan_id}\n\nSvaz byl nastaven. Požádejte vlastníka serveru o přístupové heslo.", + "settings_field": "Nastavení", + "settings_hint": "Pro konfiguraci nastavení přístupu použijte tlačítka níže.", + "password_toggled": "✅ Požadavek na heslo: **{state}**", + "lock_toggled": "✅ Připojení dat svazu: **{state}**", + "public_meta_toggled": "✅ Veřejný přístup k meta datům: **{state}**\n{detail}", + "public_meta_enabled_detail": "Nesprávcové nyní mohou používat příkaz `/meta`.", + "public_meta_disabled_detail": "Pouze správci mohou používat příkaz `/meta`.", + "owner_only_password": "❌ Heslo svazu může změnit pouze vlastník serveru.", + "help_title": "📖 Nápověda ke správě meta dat", + "help_desc": "Vysvětlení každého nastavení a funkce:", + "help_password_field": "🔑 Přístupové heslo", + "help_password_value": "Přístupové heslo vašeho svazu. Heslo v panelu nastavení vidí pouze **vlastník serveru**. Kdokoli s heslem může přijmout meta data vašeho svazu na svůj server, proto ho chraňte.", + "help_require_field": "🔒 Vyžadovat heslo", + "help_require_value": "Pokud je zapnuto, i správci na tomto serveru musí zadat heslo svazu pro přístup k `/meta-management`. Přidává další vrstvu zabezpečení proti nechtěným změnám.", + "help_lock_field": "🔐 Připojit data svazu", + "help_lock_value": "Pokud je zapnuto, připojí data svazu k tomuto serveru a zabrání přenosu i se správným heslem. Musí být vypnuto před přenosem svazu.", + "help_public_field": "👥 Povolit veřejná meta data", + "help_public_value": "Pokud je zapnuto, umožňuje nesprávcům používat příkaz `/meta` pro vyhledávání vozidel svazu. Pokud je vypnuto, příkaz `/meta` mohou používat pouze správci serveru.", + "help_accounts_field": "📋 Aktualizovat meta účty", + "help_accounts_value": "Otevře správce seznamu hráčů, kde můžete přidávat nebo odebírat hráče z meta seznamu svazu. Použijte **Aktualizovat všechny členy** pro synchronizaci celého svazu najednou.", + "help_change_pw_field": "🔑 Změnit heslo", + "help_change_pw_value": "**Pouze vlastník serveru.** Změňte přístupové heslo svazu a nastavte volitelnou nápovědu. Nápověda se zobrazí ve výzvě k zadání hesla, aby si ho bylo snazší zapamatovat.", + "password_modal_title": "Přístupové heslo svazu", + "password_modal_label": "Zadejte heslo svazu", + "password_modal_placeholder": "XXXX-XXXX-XXXX", + "change_pw_modal_title": "Změnit heslo svazu", + "current_password_label": "Aktuální heslo", + "current_password_placeholder": "Zadejte aktuální heslo", + "new_password_label": "Nové heslo", + "new_password_placeholder": "Zadejte nové heslo", + "confirm_password_label": "Potvrďte nové heslo", + "confirm_password_placeholder": "Zadejte nové heslo znovu", + "hint_label": "Nápověda k heslu (volitelné)", + "hint_placeholder": "Nápověda pro zapamatování hesla", + "pw_incorrect": "❌ Aktuální heslo je nesprávné.", + "pw_mismatch": "❌ Nová hesla se neshodují. Zkuste to prosím znovu.", + "pw_empty": "❌ Nové heslo nemůže být prázdné.", + "pw_changed": "✅ Heslo pro **{squadron}** bylo úspěšně aktualizováno.\n**Nové heslo:** `{password}`", + "pw_changed_hint": "\n**Nápověda:** {hint}", + "player_add_modal_title": "Přidat hráče do meta seznamu", + "player_add_label": "UID nebo přezdívka hráče", + "player_add_placeholder": "Zadejte UID hráče (např. 12345678) nebo přezdívku", + "player_not_found": "❌ Hráč `{player}` nebyl nalezen v databázi Players_Global.\n", + "roster_title": "📋 Správa meta seznamu — {squadron}", + "roster_desc": "**ID klanu svazu:** {clan_id}\n**Celkem hráčů:** {count}", + "roster_page_field": "Hráči (strana {page}/{total})", + "no_players_field": "Žádní hráči", + "no_players_hint": "Do meta seznamu zatím nebyli přidáni žádní hráči. Klikněte na **Přidat hráče** pro začátek.", + "remove_player_placeholder": "Vyberte hráče k odebrání...", + "fetch_members_failed": "❌ Nepodařilo se načíst členy svazu: {error}", + "no_members_found": "❌ Ve svazu nebyli nalezeni žádní členové nebo volání API selhalo.", + "roster_synced": "✅ Seznam byl synchronizován se svazem.", + "roster_added": "**+{count}** přidáno", + "roster_removed": "**-{count}** odebráno (opustilo svaz)", + "roster_up_to_date": "**{count}** již aktuální", + "refreshing_vehicles": "Aktualizuji data vozidel na pozadí..." + }, + "meta": { + "not_configured": "❌ Meta data nejsou nastavena pro tento server. Nejprve spusťte `/meta-management`.", + "no_permission": "❌ Pro použití tohoto příkazu potřebujete oprávnění správce.\nSprávcové mohou veřejný přístup povolit přes `/meta-management`.", + "no_results": "❌ Žádný hráč ve vašem meta seznamu svazu nemá **{vehicle}**.", + "no_results_admin_hint": "\n*Čekáte, že to někdo má? Klikněte na tlačítko aktualizace členů v `/meta-management` a zkontrolujte to.*", + "search_title": "🔍 Výsledky hledání — {vehicle}", + "matches_found": "**Nalezené shody:** {count} hráč(ů)", + "spawns_label": "Nasazení", + "deaths_label": "Úmrtí", + "gk_label": "PZ", + "ak_label": "VZ", + "points_label": "Body", + "kdr_label": "KDR", + "games_label": "Hry", + "no_points": "—" + }, + "top": { + "title": "**Top 20 svazů**", + "rating_label": "**Hodnocení:** {value}", + "air_kills_label": "**Vzdušná zabití:** {value}", + "ground_kills_label": "**Pozemní zabití:** {value}", + "deaths_label": "**Úmrtí:** {value}", + "kd_label": "**K/D:** {value}", + "win_rate_label": "**Úspěšnost:** {value}", + "playtime_label": "**Herní čas:** {value}", + "fetch_failed": "Nepodařilo se načíst data svazu." + }, + "analytics": { + "no_data_title": "Žádná data", + "no_matches_desc": "Žádné zápasy nenalezeny.", + "no_comp_desc": "Nenalezena žádná data o sestavách.", + "no_consistency_desc": "Nedostatek dat o hráčích (minimum 50 zápasů).", + "no_time_desc": "Nenalezena žádná časová data.", + "unknown_view": "Neznámý pohled.", + "map_title": "Úspěšnost na mapách: {squadron}", + "comp_title": "Týmové sestavy: {squadron}", + "consistency_title": "Konzistentnost hráčů: {squadron}", + "consistency_desc": "Seřazeno podle K/D", + "time_title": "Výkonnost podle denní doby: {squadron}", + "eu_timeslot": "\n**EU časový úsek**", + "na_timeslot": "\n**NA časový úsek**", + "off_peak": "\n**Mimo špičku**", + "matchups_title": "📜 {squadron} — Historie Soubojů", + "matchups_won_field": "🏆 Nejvíce Vítězství Proti", + "matchups_lost_field": "💀 Nejvíce Proher Od", + "no_matchups_desc": "Žádné zaznamenané zápasy proti jiným klanům." + }, + "recent": { + "title": "Nedávné zápasy: {squadron}", + "no_matches_desc": "Pro tento svaz nebyly nalezeny žádné zápasy." + }, + "h2h": { + "two_required_title": "Vyžadovány dva svazy", + "two_required_desc": "Zadejte alespoň jeden svaz nebo použijte `/set-squadron` a zadejte soupeře.", + "provide_a_desc": "Zadejte `squadron_a` nebo nejprve použijte `/set-squadron`.", + "provide_b_desc": "Zadejte `squadron_b` nebo nejprve použijte `/set-squadron`.", + "squadron_not_found_title": "Svaz nenalezen", + "same_squadron_title": "Stejný svaz", + "same_squadron_desc": "Nemůžete kontrolovat head-to-head sami proti sobě.", + "record_desc": "**Záznam:** {a_wins}V - {b_wins}P ({total} her)", + "no_matches_desc": "Žádné zaznamenané zápasy mezi **{a}** a **{b}**." + }, + "autolog": { + "premium_active_line": "✅ **Premium:** Aktivní — autologování je na tomto serveru povoleno.", + "premium_not_subscribed_line": "❌ **Premium:** Nepřihlášeno k odběru — použijte `/unlock` pro zapnutí autologování.", + "premium_free_line": "⚪ **Premium:** Nepřihlášeno k odběru — použijte `/unlock` pro přihlášení (2,99 $/měs.). *(Autology jsou nyní zdarma pro všechny servery.)*", + "what_to_do": "\n\nCo byste chtěli udělat?", + "select_notif_type": "Vyberte typ oznámení ke správě:", + "select_notif_placeholder": "Vyberte typ oznámení", + "logs_option": "Logy", + "logs_option_desc": "Správa oznámení logů", + "points_option": "Body", + "points_option_desc": "Správa oznámení bodů", + "leaderboard_option": "Žebříček", + "leaderboard_option_desc": "Správa oznámení žebříčku", + "selected_type": "Vybráno **{type}**. Nyní zvolte svaz ke správě:", + "select_squadron_placeholder": "Vyberte svaz", + "select_squadron_page_placeholder": "Vyberte svaz (strana {page})", + "no_squadrons_available": "Pro tento typ oznámení není dostupná žádný svaz.", + "managing_global": "Spravuji **{type}** (globální) v kanálu **{channel}**.", + "managing_squadron": "Spravuji **{type}** pro svaz **{squadron}** v kanálu **{channel}**.", + "select_channel": "Vyberte nový kanál:", + "select_channel_placeholder": "Vyberte kanál", + "select_channel_page_placeholder": "Vyberte kanál (strana {page})", + "global_toggled": "{type} (globální) je nyní {state}.", + "squadron_toggled": "{type} pro **{squadron}** je nyní {state}.", + "channel_updated_global": "Aktualizováno {type} (globální) na {channel}", + "channel_updated_squadron": "Aktualizováno {type} pro **{squadron}** na {channel}", + "diagnose_channel_placeholder": "Vyberte kanál k diagnostice...", + "select_channel_diagnose": "Vyberte kanál k diagnostice:", + "game_not_logged_title": "Hra nezaznamenána", + "game_not_logged_desc": "Použijte `/unlock` pro předplatné tarifu **Standard** (nebo vyšší) a získejte automatické výsledkové tabulky.", + "server_not_upgraded_title": "⚠️ Server není upgradován", + "server_not_upgraded_autolog_desc": "Tento server nemá aktivní Premium předplatné.\n\n**Automatické herní výsledkové tabulky přestanou být odesílány na neupgradované servery po .**\n\nPoužijte `/unlock` pro přihlášení k odběru a zachování automatických herních logů.", + "replay_not_available": "Data záznamu ještě nejsou k dispozici — chvíli počkejte a zkuste to znovu!", + "too_many_videos": "Právě se renderuje příliš mnoho videí — zkuste to prosím za chvíli.", + "video_gen_failed": "Chyba při generování videa: `{error}`", + "video_missing": "Nepodařilo se vygenerovat video záznamu — výstupní soubor chybí nebo je prázdný.", + "video_too_large": "Video záznamu je příliš velké pro nahrání ({file_mb:.1f} MB). Limit serveru je {limit_mb:.0f} MB.", + "video_web_fallback": "Tento zápas si také můžete prohlédnout na {url}", + "video_upload_failed": "Video je příliš velké pro nahrání — prohlédněte si ho na webu:\n{url}", + "video_unexpected_error": "Neočekávaná chyba při generování videa záznamu: `{error}`", + "replay_not_found": "Data záznamu nebyla nalezena pro sezení `{session_id}` na disku.", + "chat_log_title": "**Chat log hry [{session_id}]({url})**", + "chat_log_part_title": "**Chat log hry [{session_id}]({url}) (část {part}/{total})**", + "chat_log_part_only": "**Chat log (část {part}/{total})**", + "no_chat_log": "Pro sezení `{session_id}` nebyl nalezen žádný chat log.", + "chat_log_error": "Neočekávaná chyba při načítání chat logu: `{error}`", + "battle_log_title": "**Bojový log hry [{session_id}]({url})**", + "battle_log_part_title": "**Bojový log hry [{session_id}]({url}) (část {part}/{total})**", + "battle_log_part_only": "**Bojový log (část {part}/{total})**", + "no_battle_log": "Pro sezení `{session_id}` nebyly nalezeny žádné bojové události.", + "battle_log_error": "Neočekávaná chyba při načítání bojového logu: `{error}`", + "points_update_title": "**{squadron} {region} aktualizace bodů**", + "points_update_desc": "# {old_total} -> {new_total} {chart}{wl_line}{placement_line}\n\n**Změny hráčů:**", + "points_table_header": "Jméno Změna Nyní\n", + "wl_line": "\n**{squadron}** šla **{wins}V-{losses}P** v tomto sezení", + "placement_rose": "\n**{squadron}** stoupla na **{new_place}** z **{old_place}**", + "placement_fell": "\n**{squadron}** klesla na **{new_place}** z **{old_place}**", + "points_not_logged_title": "Body nezaznamenány", + "points_not_logged_desc": "Použijte `/unlock` pro předplatné tarifu **Standard** (nebo vyšší) a získejte automatické aktualizace bodů.", + "server_not_upgraded_points_desc": "Tento server nemá aktivní Premium předplatné.\n\n**Automatické aktualizace přestanou být odesílány na neupgradované servery po .**\n\nPoužijte `/unlock` pro přihlášení k odběru a zachování automatických aktualizací.", + "leave_title": "⚠️ Hráč opustil {squadron}", + "leave_desc": "**{nick}** ({uid}) opustil svaz.\n\nPosledních zaznamenaných bodů: **{points}**", + "no_squadrons_desc": "No squadrons configured", + "no_channels_desc": "No channels available", + "over_cap_title": "Squadrona nad limitem vašeho tarifu", + "over_cap_desc": "Váš server má tarif **{tier}**, který umožňuje **{cap} {notif}** squadron. Squadrona **{squadron}** je nad limitem a nezaznamenává se. Upgradujte pro obnovení.", + "over_cap_footer": "Upgrade na srebot-meow.ing/premium nebo /unlock", + "wildcard_blocked_title": "Wildcard vyžaduje vyšší tarif", + "wildcard_blocked_desc": "Wildcard záznamy (*, all, everything) jsou dostupné pouze v Pro nebo Max. Váš server je na **{tier}** pro {notif}. Upgradujte pro povolení.", + "cap_header": "{used}/{cap} {notif} aktivních — tarif {tier}" + }, + "track": { + "squadron_not_found": "Svaz nenalezen.", + "fetch_failed": "Nepodařilo se načíst informace o svazu." + }, + "unlock": { + "title": "SRE Bot Premium", + "desc": "**Odemkněte prémiové funkce pro tento server.**\n\nPremium zahrnuje:\n> • Automatické odesílání výsledkových tabulek\n> • Chat a bojové logy\n> • Vyhledávání záznamů\n> • Neomezené vyhledávání /comp\n> • Prioritní podpora\n\n**2,99 $ / měsíc · za server · zrušení kdykoli**\n\n⚠️ Discord platby jsou dostupné pouze ve vybraných zemích. Pokud tlačítko níže zobrazuje **„Produkt není dostupný“**, může to být způsobeno nepodporovanou zemí nebo použitím **mobilního zařízení**. Použijte místo toho tlačítko **Přihlásit přes web**.", + "already_subscribed_title": "SRE Bot Premium", + "already_subscribed_desc": "✅ **Tento server je již přihlášen k odběru!**", + "manage_discord_field": "Spravovat předplatné", + "manage_discord_value": "Vaše předplatné je přes **Discord**.\nPro zrušení přejděte do **Nastavení uživatele → Předplatná** v Discord.", + "manage_website_field": "Spravovat předplatné", + "manage_website_value": "Vaše předplatné je přes **web**.\nSpravujte ho na [whop.com/billing](https://whop.com/billing).", + "coming_soon_field": "Brzy k dispozici", + "coming_soon_value": "Premium předplatná zatím nejsou dostupná. Sledujte nás!", + "current_tier": "Máte tarif **{tier}**.", + "upgrade_to": "Upgrade na {tier}", + "upgrade_to_value": "Více squadron a funkcí s tarifem **{tier}**." + }, + "language": { + "prompt": "Vyberte prosím jazyk serveru:", + "select_placeholder": "Zvolte jazyk serveru", + "language_set": "Jazyk nastaven na {language}.", + "translate_prompt": "Níže vyber cílový jazyk 👇", + "translate_placeholder": "Zvolte cílový jazyk…", + "translate_result": "**{author} → {language}:**\n{text}", + "translation_unavailable": "Překlad není k dispozici (DeepL není nakonfigurován)", + "translation_failed": "Překlad selhal" + }, + "misc": { + "credits_title": "Poděkování", + "credits_desc": "**Meowww**\n\n> **NotSoToothless** - Vedoucí vývojář, správce bota, správce komunity\n> **Z3R0** - Vývojář, optimalizační vývojář, databázový inženýr\n> **Clippii (Heidi)** - Vývojář, webový vývojář, správce komunity\n> **LivingTheDagor** - Vývojář, vývojář parseru, konzultant\n> **Lux_** - API inženýr, vývojář Spectry\n> **Konigallerwaffen** - Konzultant pro zpětnou vazbu a funkce\n> **Žralok Tonda** - Český překladatel\n> **Styevy**, **Lopais** - Němečtí překladatelé\n> **Susogus**, **playforfun698** - Polští překladatelé\n> **Bobr** - Ruský překladatel\n\n\n[Chcete se přidat?](https://discord.gg/BCvkK8JhPe)", + "schedule_title": "ROZVRH SEZÓNY", + "schedule_not_found_title": "Rozvrh nenalezen", + "schedule_not_found_desc": "Žádná data rozvrhu zatím nejsou k dispozici.", + "news_no_news_title": "Žádné novinky", + "news_no_news_desc": "Momentálně nejsou žádná oznámení. Sledujte nás!", + "news_footer": "Díky za vaši podporu! ᖙᘘᗢ", + "help_title": "Průvodce botem", + "donate_title": "Podpořte SRE Bot", + "donate_desc": "Pokud vám SRE Bot přináší radost a chcete podpořit jeho vývoj, zvažte koupi kávy!\n\n**[Přispějte na Ko-fi](https://ko-fi.com/notsotoothless)**\n\nKaždý příspěvek pomáhá udržovat bota v provozu a podporuje nové funkce. Díky!", + "status_title": "Stav bota", + "status_last_received": "Poslední přijatá hra", + "status_avg_ttl": "Průměrné TTL (posledních 30)", + "status_no_data": "Zatím žádná data", + "status_gaijin_slow": "⚠️ Gaijin servery jsou pomalé", + "help_commands_header": "**Přehled příkazů**", + "help_links": "Podrobnosti najdeš v dokumentaci [zde]({docs}) nebo na podpoře [zde]({support}).", + "help_terms": "[Podmínky služby]({terms}) • [Zásady ochrany soukromí]({terms})" + }, + "dev": { + "restricted_dev_team": "This command is restricted to the dev team.", + "restricted_bot_owner": "❌ This command is restricted to the bot owner.", + "invalid_server_id": "❌ Invalid server ID. Must be a 17-19 digit Discord server ID.", + "expiry_too_soon": "❌ Expiry timestamp must be at least 1 month from now.\n> Now: \n> Minimum: \n> You provided: ", + "entitlement_write_failed": "❌ Failed to write entitlement: {error}", + "entitlement_created_title": "✅ Manual Entitlement Created", + "entitlement_created_desc": "**Server:** {guild_name} (`{server_id}`)\n**Expires:** ()\n**Created:** ", + "query_failed": "Query failed: {error}", + "health_title": "Bot Health Dashboard", + "health_uptime": "Uptime", + "health_guilds": "Guilds", + "health_games_processed": "Games Processed", + "health_tasks": "Tasks", + "health_websocket": "WebSocket", + "health_never": "never", + "health_errors": "({count} errors)", + "health_last_msg": "last msg {ago} ({count} total)", + "health_avg_ttl": "Avg TTL (Last 30)", + "entitlements_title": "Active Entitlements ({count} total)", + "entitlements_no_entries": "No entitlements.", + "entitlements_empty_title": "Active Entitlements", + "entitlements_empty_desc": "No active entitlements found.", + "entitlements_tag_discord": "Discord", + "entitlements_tag_whop": "Whop", + "entitlements_tag_manual": "Manual", + "query_prefix": "Query: {name}" + }, + "leaderboard_alarm": { + "title": "🏆 Žebříček svazu", + "top15_desc": "Top 15 svazů se statistikami, odesíláno 35 minut po zavření časového úseku.\nToto odesláno .", + "top30_desc": "Svazy 16–30 se statistikami.", + "not_logged_title": "Žebříček nezaznamenán", + "not_logged_desc": "Použijte `/unlock` pro předplatné tarifu **Standard** (nebo vyšší) a získejte automatické aktualizace žebříčku.", + "server_not_upgraded_title": "⚠️ Server není upgradován", + "server_not_upgraded_desc": "Tento server nemá aktivní Premium předplatné.\n\n**Automatické aktualizace přestanou být odesílány na neupgradované servery po .**\n\nPoužijte `/unlock` pro přihlášení k odběru a zachování automatických aktualizací." + }, + "stacks": { + "stack_title": "Stack hráče {leader}", + "stack_named_title": "{name}", + "no_members": "Zatím žádní členové.", + "members_field": "Členové ({count}/{max})", + "queue_field": "Fronta ({count}/{max})", + "manage_title": "Správa stacku", + "no_pending_requests": "Žádné nevyřízené žádosti.", + "disbanded_title": "Stack [Rozpuštěn]", + "disbanded_desc": "Tento stack byl rozpuštěn vedoucím.", + "expired_title": "Stack [Vypršel]", + "expired_desc": "Tento stack vypršel.", + "join_modal_title": "Žádost o vstup do stacku", + "join_vehicle_label": "S čím budeš hrát?", + "join_vehicle_placeholder": "např. F-16C, WZ305...", + "ping_modal_title": "Zpráva pingu", + "ping_message_label": "Vlastní zpráva (nepovinné)", + "ping_message_placeholder": "např. Pojďte! Stack začíná!", + "rename_modal_title": "Přejmenovat stack", + "rename_label": "Název stacku", + "rename_placeholder": "např. Noční sovy, Alfa tým...", + "select_new_leader": "Vyberte nového vedoucího…", + "select_applicants": "Vyberte žadatele…", + "no_pending_applications": "Žádné nevyřízené žádosti.", + "select_to_remove": "Vyberte osoby k odebrání…", + "no_members_or_applicants": "Žádní členové ani žadatelé.", + "select_to_ping": "Vyberte osoby k individuálnímu pingu…", + "stack_not_found": "❌ Stack nenalezen.", + "no_longer_exists": "❌ Tento stack již neexistuje.", + "member_not_exists": "❌ Tento člen již neexistuje.", + "already_has_stack": "❌ Tento hráč již má aktivní stack.", + "already_member": "❌ Již jste členem tohoto stacku.", + "already_applied": "❌ Již máte nevyřízenou žádost o tento stack.", + "queue_full": "❌ Fronta je plná ({max}/{max}). Zkuste to později.", + "application_sent": "✅ Žádost odeslána! Vedoucí stacku ji posoudí.", + "stack_disbanded": "✅ Stack rozpuštěn.", + "cancelled": "Zrušeno.", + "select_member_transfer": "❌ Vyberte člena, na kterého chcete převést vedení.", + "ownership_transferred": "✅ Vedení převedeno na {nick}. Opustili jste stack.", + "select_applicant_first": "❌ Nejprve vyberte alespoň jednoho žadatele.", + "stack_full": "❌ Stack je již plný ({max}/{max} členů).", + "select_person_first": "❌ Nejprve vyberte alespoň jednu osobu.", + "no_one_to_ping": "❌ Není koho pingnout.", + "ping_footer": "Pingnul {leader} pro {stack}.", + "pinged": "✅ Pingnuto!", + "select_from_dropdown": "❌ Nejprve vyberte alespoň jednu osobu z rozbalovací nabídky.", + "stack_renamed": "✅ Stack přejmenován na **{name}**.", + "only_member_use_disband": "❌ Jste jediný člen. Použijte **Rozpustit stack** k ukončení.", + "select_transfer_prompt": "Vyberte člena, na kterého chcete převést vedení před odchodem:", + "left_stack": "✅ Opustili jste stack.", + "application_withdrawn": "✅ Vaše žádost byla stažena.", + "not_member_or_applicant": "❌ Nejste členem ani žadatelem tohoto stacku.", + "leader_only_manage": "❌ Pouze vedoucí stacku může spravovat tento stack.", + "leader_only_disband": "❌ Pouze vedoucí stacku může rozpustit tento stack.", + "confirm_disband": "Opravdu chcete rozpustit tento stack? Tuto akci nelze vrátit zpět.", + "already_active_stack": "⚠️ Již máte aktivní stack. Pokud původní zpráva zmizela (např. po restartu bota), můžete vynutit rozpuštění a začít znovu.", + "force_created": "✅ Předchozí stack rozpuštěn. Nový stack vytvořen.", + "no_active_stack": "❌ Nemáte aktivní stack. Použijte `/stack-create` k vytvoření.", + "could_not_parse_channel": "⚠️ Nelze zpracovat uložené ID kanálu." + }, + "commands": { + "common": { + "season": "Sezóna pro vygenerování karty", + "theme": "Barevné téma karty", + "squadron_short": "Krátký název svazu", + "player_username": "Jméno hráče", + "choice_dark": "Tmavé", + "choice_light": "Světlé" + }, + "comp": { + "description": "Najít poslední známé sestavy týmu", + "squadron_short": "Krátký název nepřátelského týmu" + }, + "quick_log": { + "description": "Nastavit upozornění pro tento svaz v tomto kanálu", + "squadron_name": "KRÁTKÝ název svazu ke sledování", + "type": "Vyberte Logy, Body, Žebříček, Týdenní BR nebo Oba", + "choice_logs": "Logy", + "choice_points": "Body", + "choice_leaderboard": "Žebříček", + "choice_both": "Obojí (Logy + Body)", + "choice_weekly_br": "Týdenní BR" + }, + "sq_info": { + "description": "Získat informace o svazu" + }, + "sq_info_graph": { + "description": "Zobrazit graf složení svazu podle aktivity a procenta výher (aktuální sezóna)" + }, + "sq_card": { + "description": "Vygenerovat sezónní kartu pro svaz", + "squadron": "Krátký název svazu" + }, + "sq_stats": { + "description": "Zobrazit body svazu v čase" + }, + "loss_calculator": { + "description": "Spočítat ztrátu bodů při odchodu hráčů ze svazu", + "player1": "Odcházející hráč", + "player_optional": "Odcházející hráč (volitelné)" + }, + "website": { + "description": "Získat odkaz na web SRE Bot" + }, + "card": { + "description": "Vygenerovat sezónní kartu pro hráče" + }, + "player_stats": { + "description": "Zobrazit podrobné statistiky vozidel hráče", + "username": "WT jméno pro statistiky", + "uid": "WT UID pro statistiky" + }, + "view_player_games": { + "description": "Zobrazit posledních 20 her hráče" + }, + "view_match": { + "description": "Zobrazit skóre zápasu podle ID nebo hráče", + "match_id": "Hex ID session zápasu", + "player_name": "Hráč pro procházení posledních zápasů" + }, + "compare": { + "description": "Porovnat souhrnné SQB statistiky hráčů", + "player1": "První hráč", + "player2": "Druhý hráč", + "player_optional": "Další hráč (volitelné)" + }, + "leaderboard": { + "description": "Získat globální žebříček SRE Bot" + }, + "set_squadron": { + "description": "Nastavit tag svazu pro tento server", + "abbreviated_name": "Krátký název svazu k nastavení" + }, + "setup": { + "description": "Nastavit bota pro tento server" + }, + "meta_management": { + "description": "Spravovat přístup k meta datům tohoto serveru" + }, + "meta": { + "description": "Hledat v meta soupisce podle názvu vozidla", + "vehicle": "Název vozidla k hledání" + }, + "top": { + "description": "Zobrazit top 20 svazů s detailními statistikami" + }, + "language": { + "description": "Změnit jazyk bota." + }, + "translate_message": { + "name": "Přeložit zprávu" + }, + "sq_track": { + "description": "Sledovat svaz a porovnat s poslední kontrolou", + "squadron_short_name": "Krátký název sledovaného svazu" + }, + "analytics": { + "description": "Zobrazit pokročilé SQB analýzy svazu", + "view": "Který analytický pohled zobrazit", + "choice_maps": "Win rate map", + "choice_comps": "Týmové sestavy", + "choice_consistency": "Stálost hráčů", + "choice_time": "Denní doba", + "choice_matchups": "Historie soubojů" + }, + "recent": { + "description": "Zobrazit nedávné svazové bitvy", + "length": "Počet zápasů k zobrazení" + }, + "vs": { + "description": "Vzájemná bilance dvou svazů", + "squadron_a": "První svaz", + "squadron_b": "Druhý svaz" + }, + "autolog_management": { + "description": "Spravovat autolog upozornění a diagnostiku oprávnění" + }, + "diagnose_perms": { + "description": "Diagnostikovat autolog oprávnění v tomto kanálu" + }, + "unlock": { + "description": "Odemknout Premium funkce pro tento server" + }, + "credits": { + "description": "Zobrazit tým stojící za tímto projektem" + }, + "schedule": { + "description": "Zobrazit aktuální sezónní BR plán" + }, + "news": { + "description": "Zobrazit nejnovější zprávy a oznámení SRE Bot" + }, + "help": { + "description": "Zobrazit průvodce, ToS a odkazy podpory" + }, + "donate": { + "description": "Podpořit vývoj SRE Bot" + }, + "stack_create": { + "description": "Vytvořit hráčský stack", + "vehicle": "S jakým vozidlem začneš?" + }, + "stack_manage": { + "description": "Znovu poslat aktivní stack do tohoto kanálu" + }, + "bot_status": { + "description": "Zobrazit stav bota: poslední přijatá hra a průměrné TTL" + } + }, + "permission": { + "blacklisted_title": "❌ Blokováno", + "blacklisted_desc": "Tento příkaz nemůžeš použít, protože jsi na blacklistu.", + "reason_line": "**Důvod:** {reason}", + "access_denied_title": "⛔ Přístup odepřen", + "no_permission_desc": "Nemáš oprávnění použít tento příkaz.", + "unexpected_error_title": "❗ Chyba, nahlas ji...." + }, + "weekly_br": { + "title_wildcard": "Týdenní zpráva BR — {br} BR", + "title_squadron": "Týdenní zpráva BR — [{tag}] {long} • {br} BR", + "window_label": "Období: {start} → {end}", + "wildcard_desc_first": "Top {count} letek podle ELO • Pozice {low}–{high}", + "wildcard_desc_second": "Top {count} letek podle ELO • Pozice {low}–{high}", + "squadron_stats_line": "- {games} bitev • K/D {kdr} • Vítězství {wr}%", + "top_players_inline_header": "🥇 Nejlepší hráči:", + "player_line_short": " {rank}. {nick} ⭐ {score} ({games}b)", + "top_players_header": "**Top {count} hráčů podle ELO:**", + "player_line_full": "{rank}. **{nick}** ⭐ {score} • {games} bitev • K/D {kdr}", + "squadron_header_line": "ELO letky: {score} • {games} bitev • Vítězství {wr}% • K/D {kdr}", + "squadron_header_no_aggregate": "ELO letky: tento týden nedostatek týmové aktivity.", + "no_data": "Žádné zápasy pro [{tag}] v této rotaci BR." + } +} diff --git a/BOT/locales/de.json b/BOT/locales/de.json new file mode 100644 index 0000000..2223d70 --- /dev/null +++ b/BOT/locales/de.json @@ -0,0 +1,856 @@ +{ + "common": { + "error_title": "Fehler", + "no_data_title": "Keine Daten", + "access_denied_title": "Zugriff verweigert", + "access_denied_desc": "Dieser Server wurde gesperrt.", + "no_players_selected": "Keine Spieler ausgewählt. Bitte mindestens einen Spieler auswählen.", + "must_use_in_server": "Dieser Befehl muss auf einem Server verwendet werden.", + "could_not_resolve_channel": "Der ausgewählte Kanal konnte nicht gefunden werden.", + "failed_update_setting": "❌ Einstellung konnte nicht aktualisiert werden.", + "configuration_not_found": "Konfiguration nicht gefunden.", + "no_channel_selected": "Kein Kanal ausgewählt.", + "no_selection_received": "Keine Auswahl erhalten.", + "database_error": "❌ Datenbankfehler: {error}", + "enabled": "Aktiviert", + "disabled": "Deaktiviert", + "not_configured": "Nicht konfiguriert", + "unknown": "Unbekannt", + "rating_field": "Wertung", + "battles_field": "Gefechte", + "wins_field": "Siege", + "losses_field": "Niederlagen", + "win_rate_field": "Siegrate", + "kills_field": "Abschüsse", + "deaths_field": "Tode", + "kd_field": "K/D", + "members_field": "Mitglieder", + "placement_field": "Platzierung", + "points_field": "Punkte", + "ground_kills_field": "Bodenabschüsse", + "air_kills_field": "Luftabschüsse", + "total_kills_field": "Abschüsse gesamt", + "assists_field": "Unterstützungen", + "captures_field": "Eroberungen", + "none_option": "Keine" + }, + "buttons": { + "skip": "Überspringen", + "previous": "Zurück", + "next": "Weiter", + "prev": "Zurück", + "prev_arrow": "◀ Zurück", + "next_arrow": "Weiter ▶", + "prev_arrow_only": "◀", + "next_arrow_only": "▶", + "generate_chart": "📊 Diagramm erstellen", + "show_graph": "Grafik anzeigen", + "view_player_stats": "📊 Spielerstatistiken anzeigen", + "compare_nearby": "📈 mit umliegenden Kampfgruppen vergleichen", + "confirm_swap": "Ja, wechseln", + "cancel_swap": "Nein, behalten", + "set_squadron": "Kampfgruppe festlegen", + "same_as_logs": "Selber Channel wie die Logs", + "require_password": "🔒 Passwort erforderlich", + "password_required": "🔒 Passwort erforderlich", + "lock_data": "🔐 Kampfgruppendaten an diesen Server binden", + "data_locked": "🔐 Daten an diesen Server binden", + "allow_public": "👥 Öffentliche Metalisten-Daten erlauben", + "public_enabled": "👥 Öffentliche Metalisten-Daten aktiv", + "update_accounts": "📋 Konten für Meta-Liste aktualisieren", + "change_password": "🔑 Passwort ändern", + "help": "❓ Hilfe", + "add_player": "➕ Spieler hinzufügen", + "update_all": "🔄 Alle Mitglieder aktualisieren", + "back_to_settings": "⬅ Zurück zu den Einstellungen", + "manage_notifications": "Benachrichtigungen verwalten", + "diagnose_permissions": "Berechtigungen ansehen", + "enable": "Aktivieren", + "disable": "Deaktivieren", + "change_channel": "Kanal ändern", + "view_replay": "Replay anzeigen", + "view_website": "Auf Website anzeigen", + "view_video": "Video anzeigen", + "view_log": "Protokoll anzeigen", + "view_chat": "Chat anzeigen", + "subscribe_website": "Über Website abonnieren", + "yes_disband": "Ja, bitte auflösen", + "cancel": "Abbrechen", + "transfer_leave": "Übertragen & Verlassen", + "accept_selected": "Ausgewählte annehmen", + "accept_all": "Alle annehmen", + "decline_selected": "Ausgewählte ablehnen", + "back": "Zurück", + "remove_all": "Alle entfernen", + "remove_active": "Aktive entfernen", + "remove_queued": "Wartende entfernen", + "remove_selected": "Ausgewählte entfernen", + "ping_all": "Alle anpingen", + "ping_active": "Aktive anpingen", + "ping_queued": "Wartende anpingen", + "ping_selected": "Ausgewählte anpingen", + "accept_members": "Mitglieder annehmen", + "remove_members": "Mitglieder entfernen", + "ping_members": "Mitglieder anpingen", + "rename_stack": "Staffel umbenennen", + "request_to_join": "Beitritt anfragen", + "leave_withdraw": "Verlassen / Zurückziehen", + "manage_stack": "Staffel verwalten ⚙️", + "disband_stack": "Staffel auflösen", + "force_disband_create": "Auflösung erzwingen & Neu erstellen" + }, + "events": { + "guild_join_title": "Danke, dass du mich hinzugefügt hast!", + "guild_join_desc": "Führe `/setup` aus, um den Bot für diesen Server zu konfigurieren." + }, + "comp": { + "not_found_title": "Aufstellung nicht gefunden", + "not_found_desc": "Keine Daten für **{squadron}**, versuche es später erneut.", + "error_loading_title": "Fehler beim Laden der Aufstellungen", + "error_loading_desc": "Aufstellungs-Daten konnten nicht geladen werden: {error}", + "title": "Aufstellungen für {squadron}", + "desc": "Aufstellungen der letzten {minutes} Minuten", + "no_recent_title": "Keine aktuellen Aufstellungen", + "no_recent_desc": "Keine Aufstellungen in den letzten {minutes} Minuten.", + "comp_title": "Aufstellung {index}", + "last_seen_label": "**Zuletzt gesehen** : {timestamp}{warning}", + "comp_label": "**Aufstellung**: {notation}", + "no_players_recorded": "Keine Spieler erfasst.", + "limit_reached_title": "Aufstellungslimit erreicht", + "limit_reached_desc": "Dieser Server hat alle {limit} Aufstellungsabfragen für diesen Zeitslot verbraucht. Abonniere (mit /unlock) für unbegrenzten Zugang oder warte auf den nächsten Zeitslot.", + "remaining_footer": "{remaining}/{limit} Aufstellungsabfragen übrig in diesem Zeitslot" + }, + "quick_log": { + "invalid_type": "Typ kann nur auf Logs, Punkte, Leaderboard, Wöchentlicher BR oder Beide gesetzt werden.", + "squadron_required": "Du musst einen Kampfgruppennamen für Logs-, Punkte- oder Beide-Alarme angeben.", + "wildcard_logs_only": "Nur Logs können auf Platzhalter-Kampfgruppe gesetzt werden.", + "squadron_not_resolved": "Kampfgruppe `{squadron}` konnte nicht gefunden werden.", + "save_failed": "Einstellungen konnten nicht gespeichert werden. Bitte versuche es später erneut.", + "premium_warning": "\n\n> ⚠️ **Spielprotokolle erfordern Premium.** Führe `/unlock` aus, um zu abonnieren ($2.99/Monat) — Protokolle werden erst dann gepostet.", + "leaderboard_set": "Globaler Ranglisten-Alarm wurde auf diesen Kanal gesetzt.", + "both_set": "Logs- und Punkte-Alarme für {squadron} wurden auf diesen Kanal gesetzt.{premium_note}", + "alarm_set": "{alarm_type}-Alarm für {squadron} wurde auf diesen Kanal gesetzt.{premium_note}", + "weekly_br_wildcard_set": "Wöchentlicher BR-Bericht (Top 20 Geschwader) für diesen Kanal aktiviert. Wird am Ende jeder BR-Rotation gesendet.", + "weekly_br_squadron_set": "Wöchentlicher BR-Bericht für {squadron} (Top 15 Spieler) für diesen Kanal aktiviert. Wird am Ende jeder BR-Rotation gesendet." + }, + "diagnostics": { + "title": "Autolog-Diagnose", + "channel_permissions_header": "**Kanalberechtigungen** (<#{channel_id}>)", + "perms_needed": " ^ Autologging benötigt alle oben genannten Berechtigungen zum Senden von Ergebnistabellen.", + "server_squadron_header": "**Server-Kampfgruppe** (`/set-squadron`)", + "server_squadron_short": " Kurz: `{short}`", + "server_squadron_long": " Lang: `{long}`", + "server_squadron_not_set": " Nicht gesetzt (Ergebnistabellenfarbe wird als 'nicht gesetzt' angezeigt)", + "autolog_prefs_header": "**Autolog-Einstellungen** (`/quick-log`)", + "autolog_none_configured": " ❌ NICHTS konfiguriert - Autologging sendet NICHTS an diesen Server.", + "autolog_setup_hint": " Verwende `/quick-log Logs` im Zielkanal zum Einrichten.", + "autolog_no_logs_channels": " ❌ Keine Logs-Kanäle konfiguriert. Nur Punkte/Rangliste gefunden.", + "autolog_enable_hint": " Verwende `/quick-log Logs` um Autologging zu aktivieren.", + "selected_channel_tag": " **(ausgewählter Kanal)**", + "missing_send_attach": " (Senden/Anhängen fehlt)", + "channel_not_found": " (Kanal nicht gefunden)", + "invalid_channel_id": " (ungültige Kanal-ID)", + "premium_status_header": "**Premium-Status** (`/unlock`)", + "premium_active": " ✅ Dieser Server hat ein aktives Premium-Abonnement.", + "premium_not_subscribed": " ❌ Dieser Server hat **kein** Premium-Abonnement.", + "premium_autolog_required": " Autologging erfordert Premium. Verwende `/unlock` zum Abonnieren ($2.99/Monat).", + "premium_not_subscribed_free": " ⚪ Nicht abonniert — verwende `/unlock` zum Abonnieren ($2.99/Monat).", + "premium_free_note": " *(Autologs sind derzeit für alle Server kostenlos.)*" + }, + "sq_info": { + "title": "Kampfgruppe-Info: {squadron}", + "placement_field": "Platzierung", + "total_points_field": "Gesamtpunkte", + "total_members_field": "Mitglieder gesamt", + "members_field": "Mitglieder", + "fetch_failed": "Kampfgruppeninformationen konnten nicht abgerufen werden." + }, + "sq_info_graph": { + "title": "{squadron} — SQ-INFO (Saison {season})", + "embed_title": "{squadron} — Aufstellung", + "embed_desc": "Saison **{season}** · Median Spiele: **{median}** · Kern: **{core}** · Aktiv: **{active}** · Schwach: **{weak}**\nSäulen absteigend nach Spielen sortiert; Höhe = Siegrate. Kern = obere 30 % SR und Spiele ≥ Median. Aktiv = obere 30–45 % SR und Spiele ≈ Median. Schwach = alle anderen.", + "core_threshold_line": "KERN ≥ {wr} %", + "weak_threshold_line": "SCHWACH < {wr} %", + "y_label": "Siegrate", + "core_header": "KERN — {count} · SR {avg}%", + "active_header": "AKTIV — {count} · SR {avg}%", + "weak_header": "SCHWACH — {count} · SR {avg}%", + "no_active_season": "Keine aktive Saison gefunden. Bitte erneut versuchen, sobald die nächste beginnt.", + "no_members": "Keine aktuellen Mitglieder für {squadron} gefunden." + }, + "recap_card": { + "unknown_season": "Unbekannte Saison: `{season}`.", + "no_clan_id": "Kampfgruppen-ID für `{squadron}` konnte nicht ermittelt werden.", + "render_failed": "Die Saison-Rückblick-Karte konnte nicht erstellt werden. Bitte später erneut versuchen." + }, + "sq_stats": { + "no_data_title": "Keine Daten", + "no_data_desc": "Keine historischen Daten für Kampfgruppe: {squadron} gefunden", + "title": "{squadron} // Kampfgruppe", + "desc": "Gesamtpunktverlauf (Letzte {count} Datenpunkte)", + "previous_score_field": "Vorheriger Punktestand", + "current_score_field": "Aktueller Punktestand", + "change_field": "Änderung", + "player_title": "{squadron} // SPIELER", + "player_desc": "Individuelle Spielerpunktetrends", + "comparison_title": "{squadron} // RANGLISTEN-VERGLEICH", + "comparison_desc": "Vergleich mit Kampfgruppen auf den Plätzen {range}", + "current_position_field": "Aktuelle Position", + "squadrons_shown_field": "Angezeigte Kampfgruppe", + "squadron_not_found_error": "Kampfgruppe nicht in der Rangliste gefunden", + "no_nearby_error": "Keine umliegenden Kampfgruppen gefunden", + "no_historical_error": "Keine historischen Daten für nahe Kampfgruppen gefunden", + "comparison_chart_failed": "Vergleichsdiagramm konnte nicht erstellt werden", + "select_players_placeholder": "Spieler auswählen (Seite {page})" + }, + "loss_calc": { + "title": "Punktverlust — {squadron}", + "players_leaving_field": "Austretende Spieler", + "share_of_total_field": "% Anteil am Gesamt", + "points_lost_real_field": "Verlorene Punkte (Real)", + "points_lost_raw_field": "Verlorene Punkte (Roh)", + "squadron_rating_field": "Kampfgruppenwertung", + "squadron_position_field": "Kampfgruppenposition", + "positions_lost_field": "Verlorene Plätze", + "not_found_footer": "Nicht in Kampfgruppe gefunden: {players}", + "fetch_failed": "Kampfgruppendaten konnten nicht abgerufen werden: {error}", + "no_point_data": "Keine Punktedaten für diese Kampfgruppe verfügbar.", + "no_matching_players": "Keine passenden Spieler in **{squadron}** gefunden." + }, + "player": { + "select_player_placeholder": "Spieler auswählen", + "no_stats_found": "❌ Keine Statistiken für UID: {uid} gefunden", + "no_vehicle_stats": "❌ Keine Fahrzeugstatistiken für diesen Spieler gefunden.", + "vehicles_found": "**{count}** Fahrzeuge für **{nick}** gefunden\nFahrzeug auswählen um detaillierte Statistiken anzuzeigen:", + "vehicle_select_placeholder": "Fahrzeug auswählen (Seite {page}/{total})", + "combat_stats_header": "**__KAMPFSTATISTIKEN__**", + "ground_kills_label": "**Bodenabschüsse:** {value}", + "air_kills_label": "**Luftabschüsse:** {value}", + "total_kills_label": "**Abschüsse gesamt:** {value}", + "assists_label": "**Unterstützungen:** {value}", + "deaths_label": "**Tode:** {value}", + "kd_label": "**K/D:** {value}", + "captures_label": "**Eroberungen:** {value}", + "battle_record_header": "**__GEFECHTSREKORD__**", + "total_battles_label": "**Gefechte gesamt:** {value}", + "wins_label": "**Siege:** {value}", + "losses_label": "**Niederlagen:** {value}", + "win_rate_label": "**Siegrate:** {value}%", + "stats_desc": "Statistiken für **{nick}** (**{squadron}**)\nUID: `{uid}`", + "not_found_title": "Spieler nicht gefunden", + "not_found_desc": "Kein Spielverlauf für `{player}` gefunden.", + "no_players_found": "Keine Spieler gefunden, die **{username}** entsprechen\nVersuche `/website` zur Suche auf der Website.", + "multiple_matches": "Mehrere Treffer gefunden, wähle den richtigen unten aus:", + "must_provide_input": "Du musst mindestens eine UID oder einen Benutzernamen angeben." + }, + "player_games": { + "no_recent_title": "Keine aktuellen Spiele", + "no_recent_desc": "Keine Spiele für **{player}** in den letzten 8 Stunden gefunden.", + "squadron_label": "**Kampfgruppe:** {squadron}", + "record_label": "**S:** {wins} **N:** {losses} **SR:** {wr}%", + "comps_played_header": "\n\n**Gespielte Comps**" + }, + "match": { + "missing_input_title": "Fehlende Eingabe", + "missing_input_desc": "Gib entweder eine `match_id` oder einen `player_name` an.", + "not_found_title": "Gefecht nicht gefunden", + "not_found_desc": "Kein Gefecht mit ID `{match_id}` gefunden.", + "invalid_data_title": "Ungültige Gefechts-Daten", + "invalid_data_desc": "Die Replay-Daten konnten nicht verarbeitet werden.", + "scoreboard_error_title": "Ergebnistabellen-Fehler", + "scoreboard_error_desc": "Die Ergebnistabelle konnte nicht erstellt werden.", + "no_games_title": "Keine Spiele gefunden", + "no_games_desc": "Kein Spielverlauf für **{player}** gefunden.", + "recent_matches_title": "Aktuelle Gefechte für {player}", + "recent_matches_desc": "Zeige bis zu {count} aktuelle Spiele. Eines auswählen, um die vollständige Ergebnistabelle anzuzeigen.", + "select_match_placeholder": "Gefecht zum Anzeigen auswählen..." + }, + "compare": { + "no_players_found": "Keine Spieler gefunden, die **{name}** entsprechen.", + "multiple_matches": "Mehrere Treffer für **{name}**: {matches}\nBitte einen spezifischeren Namen verwenden (die Autovervollständigungs-Vorschläge sind exakt).", + "could_not_resolve": "Spieler konnten nicht aufgelöst werden.", + "could_not_fetch": "❌ Statistiken für **{name}** konnten nicht abgerufen werden.", + "no_graph_data": "Keine Daten für die letzten 90 Tage verfügbar.", + "no_squadron_points_data": "Keine Kampfgruppenpunkte-Daten für {names} (Spieler nicht im verfolgten Kampfgruppenverlauf gefunden).", + "graph_title": "Spielerpunkte — Letzte 90 Tage", + "battles_label": "Gefechte", + "wins_label": "Siege", + "losses_label": "Niederlagen", + "win_rate_label": "Siegrate", + "ground_kills_label": "Bodenabschüsse", + "air_kills_label": "Luftabschüsse", + "total_kills_label": "Abschüsse gesamt", + "assists_label": "Unterstützungen", + "deaths_label": "Tode", + "kd_label": "K/D", + "captures_label": "Eroberungen" + }, + "squadron": { + "not_found_desc": "Kampfgruppe `{squadron}` nicht gefunden.", + "set_title": "✅ Kampfgruppe gesetzt", + "set_desc": "Kampfgruppe **{squadron}** wurde für diesen Server gesetzt.", + "short_name_field": "Kurzname", + "long_name_field": "Langname", + "swap_title": "✅ Kampfgruppe gewechselt", + "swap_desc": "**{old}** wurde durch **{new}** für diesen Server ersetzt.", + "already_set_title": "⚠️ Kampfgruppe bereits gesetzt", + "already_set_desc": "Dieser Server ist derzeit auf **{old}** eingestellt.\nAuf **{new}** wechseln?", + "swap_cancelled": "❌ Kampfgruppenwechsel abgebrochen." + }, + "setup": { + "step1_title": "Server-Einrichtung — Schritt 1 von 3", + "step1_desc": "Dieser Assistent führt dich durch die Konfiguration des Bots für deinen Server.\n\n**Schritt 1** — Kampfgruppe festlegen\n**Schritt 2** — Logs-Kanal auswählen\n**Schritt 3** — Punkte-Kanal auswählen\n", + "step1_current_sq": "\nAktuell konfigurierte Kampfgruppe: **[{short}] {long}**", + "step2_title": "Server-Einrichtung — Schritt 2 von 3", + "step2_desc": "Kampfgruppe auf **[{short}] {long}** gesetzt.\n\nWo sollen **Gefechtsprotokolle** gepostet werden?\nUnten einen Textkanal auswählen oder diesen Schritt überspringen.", + "step3_title": "Server-Einrichtung — Schritt 3 von 3", + "step3_desc": "Wo sollen **Punkte-Benachrichtigungen** gepostet werden?\nUnten einen Textkanal auswählen oder diesen Schritt überspringen.", + "step3_same_as_logs": "\n\nDu kannst auch auf \"Wie Logs\" klicken, um den Logs-Kanal wiederzuverwenden.", + "summary_title": "Einrichtung abgeschlossen", + "summary_desc": "Du kannst `/autolog-management` verwenden, um diese Einstellungen später zu ändern.", + "squadron_field": "Kampfgruppe", + "logs_channel_field": "Logs-Kanal", + "points_channel_field": "Punkte-Kanal", + "premium_required_field": "⚠️ Spielprotokolle erfordern Premium", + "premium_required_value": "Automatische Spielergebnistabellen werden erst gepostet, wenn dieser Server ein aktives Abonnement hat. Führe `/unlock` aus, um zu abonnieren ($2.99/Monat).", + "modal_title": "Kampfgruppe festlegen", + "modal_label": "Kampfgruppe-Kurzname", + "modal_placeholder": "z.B. AXYS", + "squadron_not_found": "Kampfgruppe `{squadron}` nicht gefunden. Bitte erneut versuchen.", + "logs_channel_placeholder": "Logs-Kanal auswählen...", + "points_channel_placeholder": "Punkte-Kanal auswählen..." + }, + "meta_management": { + "squadron_not_found_title": "❌ Kampfgruppe nicht gefunden", + "squadron_not_found_desc": "Clan-ID für Kampfgruppe **{squadron}** konnte nicht gefunden werden", + "access_denied_title": "❌ Zugriff verweigert", + "access_denied_desc": "Falsches Passwort. Die Meta-Daten dieser Kampfgruppe sind geschützt.", + "data_locked_title": "🔐 Kampfgruppendaten gebunden", + "data_locked_desc": "**{squadron}** hat die Datenbindung aktiviert und kann nicht auf einen anderen Server übertragen werden.\n\nDer Kampfgruppeninhaber muss **Kampfgruppendaten binden** deaktivieren, bevor es übertragen werden kann.", + "error_retrieving_settings": "❌ Fehler beim Abrufen der Server-Einstellungen nach der Übertragung. Bitte erneut versuchen.", + "error_retrieving_settings_retry": "❌ Fehler beim Abrufen der Server-Einstellungen. Bitte den Befehl erneut ausführen.", + "authenticated_title": "✅ Authentifiziert", + "authenticated_desc": "Passwort bestätigt. Einstellungen für **{squadron}** werden verwaltet.", + "claimed_title": "✅ Kampfgruppe beansprucht", + "claimed_desc": "**{squadron}** wurde erfolgreich für diesen Server beansprucht!", + "password_requirement_field": "🔒 Passwortpflicht", + "data_lock_field": "🔐 Kampfgruppendatenbindung", + "public_meta_field": "👥 Öffentlicher Meta-Zugriff", + "access_password_field": "🔑 Zugriffspasswort", + "enabled_value": "✅ Aktiviert", + "disabled_value": "❌ Deaktiviert", + "settings_title": "🔐 Meta-Verwaltungs-Einstellungen", + "settings_desc": "**Kampfgruppe:** {squadron}\n**Clan-ID:** {clan_id}", + "first_time_title": "🔐 Meta-Verwaltung - Ersteinrichtung", + "first_time_owner_desc": "**Kampfgruppe:** {squadron}\n**Clan-ID:** {clan_id}\n\n🔑 Dein Zugriffspasswort wurde generiert. **Speichere dieses Passwort** — du wirst es benötigen, um in Zukunft auf die Meta-Daten zuzugreifen.\n\n**Passwort:** `{password}`", + "first_time_non_owner_desc": "**Kampfgruppe:** {squadron}\n**Clan-ID:** {clan_id}\n\nKampfgruppe wurde eingerichtet. Frage den Server-Inhaber nach dem Zugriffspasswort.", + "settings_field": "Einstellungen", + "settings_hint": "Verwende die Schaltflächen unten, um Zugriffseinstellungen zu konfigurieren.", + "password_toggled": "✅ Passwortpflicht: **{state}**", + "lock_toggled": "✅ Kampfgruppendatenbindung: **{state}**", + "public_meta_toggled": "✅ Öffentlicher Meta-Zugriff: **{state}**\n{detail}", + "public_meta_enabled_detail": "Nicht-Admins können jetzt den `/meta`-Befehl verwenden.", + "public_meta_disabled_detail": "Nur Admins können den `/meta`-Befehl verwenden.", + "owner_only_password": "❌ Nur der Server-Inhaber kann das Kampfgruppenpasswort ändern.", + "help_title": "📖 Meta-Verwaltungs-Hilfe", + "help_desc": "Erklärung jeder Einstellung und Funktion:", + "help_password_field": "🔑 Zugriffspasswort", + "help_password_value": "Das Zugriffspasswort deiner Kampfgruppe. Nur der **Server-Inhaber** kann das Passwort im Einstellungsbereich sehen. Jeder mit dem Passwort kann die Meta-Daten deiner Kampfgruppe auf seinem Server beanspruchen, also halte es sicher.", + "help_require_field": "🔒 Passwort erforderlich", + "help_require_value": "Wenn aktiviert, müssen auch Admins auf diesem Server das Kampfgruppenpasswort eingeben, um auf `/meta-management` zuzugreifen. Bietet eine zusätzliche Sicherheitsschicht gegen versehentliche Änderungen.", + "help_lock_field": "🔐 Kampfgruppendaten binden", + "help_lock_value": "Wenn aktiviert, verhindert die Übertragung der Kampfgruppe auf andere Server, auch mit dem richtigen Passwort. Muss deaktiviert werden, bevor die Kampfgruppe übertragen werden kann.", + "help_public_field": "👥 Öffentliches Meta erlauben", + "help_public_value": "Wenn aktiviert, können Nicht-Admin-Mitglieder den `/meta`-Befehl zur Suche nach Kampfgruppenfahrzeugen verwenden. Wenn deaktiviert, können nur Server-Administratoren `/meta` verwenden.", + "help_accounts_field": "📋 Meta-Konten aktualisieren", + "help_accounts_value": "Öffnet den Spieler-Roster-Manager, mit dem du Spieler aus dem Meta-Roster deiner Kampfgruppe hinzufügen oder entfernen kannst. Verwende **Alle Mitglieder aktualisieren**, um dein gesamtes Kampfgruppe auf einmal zu synchronisieren.", + "help_change_pw_field": "🔑 Passwort ändern", + "help_change_pw_value": "**Nur Server-Inhaber.** Ändere das Zugriffspasswort der Kampfgruppe und setze optional einen Hinweis. Der Hinweis wird in der Passwortabfrage angezeigt, um daran zu erinnern.", + "password_modal_title": "Kampfgruppe-Zugriffspasswort", + "password_modal_label": "Kampfgruppenpasswort eingeben", + "password_modal_placeholder": "XXXX-XXXX-XXXX", + "change_pw_modal_title": "Kampfgruppenpasswort ändern", + "current_password_label": "Aktuelles Passwort", + "current_password_placeholder": "Aktuelles Passwort eingeben", + "new_password_label": "Neues Passwort", + "new_password_placeholder": "Neues Passwort eingeben", + "confirm_password_label": "Neues Passwort bestätigen", + "confirm_password_placeholder": "Neues Passwort erneut eingeben", + "hint_label": "Passwort-Hinweis (Optional)", + "hint_placeholder": "Ein Hinweis zum Merken des Passworts", + "pw_incorrect": "❌ Aktuelles Passwort ist falsch.", + "pw_mismatch": "❌ Neue Passwörter stimmen nicht überein. Bitte erneut versuchen.", + "pw_empty": "❌ Neues Passwort darf nicht leer sein.", + "pw_changed": "✅ Passwort für **{squadron}** erfolgreich aktualisiert.\n**Neues Passwort:** `{password}`", + "pw_changed_hint": "\n**Hinweis:** {hint}", + "player_add_modal_title": "Spieler zum Meta-Roster hinzufügen", + "player_add_label": "Spieler-UID oder Spitzname", + "player_add_placeholder": "Spieler-UID eingeben (z.B. 12345678) oder Spitzname", + "player_not_found": "❌ Spieler `{player}` nicht in der Players_Global-Datenbank gefunden.\n", + "roster_title": "📋 Meta-Roster-Verwaltung - {squadron}", + "roster_desc": "**Kampfgruppe-Clan-ID:** {clan_id}\n**Spieler gesamt:** {count}", + "roster_page_field": "Spieler (Seite {page}/{total})", + "no_players_field": "Keine Spieler", + "no_players_hint": "Noch keine Spieler zum Meta-Roster hinzugefügt. Klicke auf **Spieler hinzufügen**, um zu beginnen.", + "remove_player_placeholder": "Spieler zum Entfernen auswählen...", + "fetch_members_failed": "❌ Kampfgruppenmitglieder konnten nicht abgerufen werden: {error}", + "no_members_found": "❌ Keine Mitglieder in Kampfgruppe gefunden oder API-Aufruf fehlgeschlagen.", + "roster_synced": "✅ Roster mit Kampfgruppe synchronisiert.", + "roster_added": "**+{count}** hinzugefügt", + "roster_removed": "**-{count}** entfernt (Kampfgruppe verlassen)", + "roster_up_to_date": "**{count}** bereits aktuell", + "refreshing_vehicles": "Fahrzeugdaten werden im Hintergrund aktualisiert..." + }, + "meta": { + "not_configured": "❌ Meta-Daten für diesen Server nicht konfiguriert. Führe zuerst `/meta-management` aus.", + "no_permission": "❌ Du benötigst Administratorrechte, um diesen Befehl zu verwenden.\nAdmins können den öffentlichen Zugriff über `/meta-management` aktivieren.", + "no_results": "❌ Kein Spieler in deiner Kampfgruppe-Roster hat **{vehicle}**.", + "no_results_admin_hint": "\n*Erwartest du, dass jemand dieses Fahrzeug hat? Klicke auf den Mitglieder-aktualisieren-Button in `/meta-management` und überprüfe es.*", + "search_title": "🔍 Suchergebnisse - {vehicle}", + "matches_found": "**Treffer gefunden:** {count} Spieler", + "spawns_label": "Spawns", + "deaths_label": "Tode", + "gk_label": "GK", + "ak_label": "AK", + "points_label": "Punkte", + "kdr_label": "KDR", + "games_label": "Spiele", + "no_points": "—" + }, + "top": { + "title": "**Top 20 Kampfgruppen**", + "rating_label": "**Wertung:** {value}", + "air_kills_label": "**Luftabschüsse:** {value}", + "ground_kills_label": "**Bodenabschüsse:** {value}", + "deaths_label": "**Tode:** {value}", + "kd_label": "**K/D:** {value}", + "win_rate_label": "**Siegrate:** {value}", + "playtime_label": "**Spielzeit:** {value}", + "fetch_failed": "Kampfgruppendaten konnten nicht abgerufen werden." + }, + "analytics": { + "no_data_title": "Keine Daten", + "no_matches_desc": "Keine Gefechte gefunden.", + "no_comp_desc": "Keine Kompositionsdaten gefunden.", + "no_consistency_desc": "Nicht genug Spielerdaten (mindestens 50 Gefechte).", + "no_time_desc": "Keine Zeitdaten gefunden.", + "unknown_view": "Unbekannte Ansicht.", + "map_title": "Karten-Siegraten: {squadron}", + "comp_title": "Teamzusammensetzungen: {squadron}", + "consistency_title": "Spielerkonstanz: {squadron}", + "consistency_desc": "Sortiert nach K/D-Verhältnis", + "time_title": "Tageszeit-Performance: {squadron}", + "eu_timeslot": "\n**EU-Zeitfenster**", + "na_timeslot": "\n**NA-Zeitfenster**", + "off_peak": "\n**Nebenstoßzeit**", + "matchups_title": "📜 {squadron} — Begegnungsverlauf", + "matchups_won_field": "🏆 Meiste Siege gegen", + "matchups_lost_field": "💀 Meiste Niederlagen gegen", + "no_matchups_desc": "Keine aufgezeichneten Spiele gegen andere Squadrons." + }, + "recent": { + "title": "Aktuelle Gefechte: {squadron}", + "no_matches_desc": "Keine Gefechte für diese Kampfgruppe gefunden." + }, + "h2h": { + "two_required_title": "Zwei Kampfgruppen erforderlich", + "two_required_desc": "Mindestens eine Kampfgruppe angeben oder `/set-squadron` verwenden und den Gegner angeben.", + "provide_a_desc": "`squadron_a` angeben oder zuerst `/set-squadron` verwenden.", + "provide_b_desc": "`squadron_b` angeben oder zuerst `/set-squadron` verwenden.", + "squadron_not_found_title": "Kampfgruppe nicht gefunden", + "same_squadron_title": "Gleiche Kampfgruppe", + "same_squadron_desc": "Du kannst kein direktes Duell gegen dich selbst prüfen.", + "record_desc": "**Bilanz:** {a_wins}S - {b_wins}N ({total} Spiele)", + "no_matches_desc": "Keine aufgezeichneten Gefechte zwischen **{a}** und **{b}**." + }, + "autolog": { + "premium_active_line": "✅ **Premium:** Aktiv — Autologging ist für diesen Server aktiviert.", + "premium_not_subscribed_line": "❌ **Premium:** Nicht abonniert — verwende `/unlock` um Autologging zu aktivieren.", + "premium_free_line": "⚪ **Premium:** Nicht abonniert — verwende `/unlock` zum Abonnieren ($2.99/Monat). *(Autologs sind derzeit für alle Server kostenlos.)*", + "what_to_do": "\n\nWas möchtest du tun?", + "select_notif_type": "Benachrichtigungstyp auswählen:", + "select_notif_placeholder": "Benachrichtigungstyp auswählen", + "logs_option": "Logs", + "logs_option_desc": "Logs-Benachrichtigungen verwalten", + "points_option": "Punkte", + "points_option_desc": "Punkte-Benachrichtigungen verwalten", + "leaderboard_option": "Rangliste", + "leaderboard_option_desc": "Ranglisten-Benachrichtigungen verwalten", + "selected_type": "**{type}** ausgewählt. Jetzt die zu verwaltende Kampfgruppe auswählen:", + "select_squadron_placeholder": "Kampfgruppe auswählen", + "select_squadron_page_placeholder": "Kampfgruppe auswählen (Seite {page})", + "no_squadrons_available": "Keine Kampfgruppe für diesen Benachrichtigungstyp verfügbar.", + "managing_global": "**{type}** (global) in Kanal **{channel}** wird verwaltet.", + "managing_squadron": "**{type}** für Kampfgruppe **{squadron}** in Kanal **{channel}** wird verwaltet.", + "select_channel": "Neuen Kanal auswählen:", + "select_channel_placeholder": "Kanal auswählen", + "select_channel_page_placeholder": "Kanal auswählen (Seite {page})", + "global_toggled": "{type} (global) ist jetzt {state}.", + "squadron_toggled": "{type} für **{squadron}** ist jetzt {state}.", + "channel_updated_global": "{type} (global) auf {channel} aktualisiert", + "channel_updated_squadron": "{type} für **{squadron}** auf {channel} aktualisiert", + "diagnose_channel_placeholder": "Zu diagnostizierenden Kanal auswählen...", + "select_channel_diagnose": "Zu diagnostizierenden Kanal auswählen:", + "game_not_logged_title": "Spiel nicht protokolliert", + "game_not_logged_desc": "Nutze `/unlock`, um den **Standard**-Tarif (oder höher) zu abonnieren und automatische Spielergebnistabellen zu erhalten.", + "server_not_upgraded_title": "⚠️ Server nicht geupgradet", + "server_not_upgraded_autolog_desc": "Dieser Server hat kein aktives Premium-Abonnement.\n\n**Automatische Spielergebnistabellen werden nach nicht mehr an nicht-upgegradete Server gesendet.**\n\nVerwende `/unlock` zum Abonnieren und weiterhin automatische Spielprotokolle zu erhalten.", + "replay_not_available": "Replay-Daten sind noch nicht verfügbar — kurz warten und erneut versuchen!", + "too_many_videos": "Zu viele Videos werden gerade gerendert — bitte in einem Moment erneut versuchen.", + "video_gen_failed": "Fehler beim Erstellen des Videos: `{error}`", + "video_missing": "Replay-Video konnte nicht erstellt werden - Ausgabedatei fehlt oder ist leer.", + "video_too_large": "Replay-Video zu groß zum Hochladen ({file_mb:.1f} MB). Serverlimit ist {limit_mb:.0f} MB.", + "video_web_fallback": "Du kannst dieses Gefecht auch unter {url} ansehen", + "video_upload_failed": "Video zu groß zum Hochladen — auf der Website ansehen:\n{url}", + "video_unexpected_error": "Unerwarteter Fehler beim Erstellen des Replay-Videos: `{error}`", + "replay_not_found": "Replay-Daten für Session `{session_id}` nicht auf Disk gefunden.", + "chat_log_title": "**Chat-Protokoll für Spiel [{session_id}]({url})**", + "chat_log_part_title": "**Chat-Protokoll für Spiel [{session_id}]({url}) (Teil {part}/{total})**", + "chat_log_part_only": "**Chat-Protokoll (Teil {part}/{total})**", + "no_chat_log": "Kein Chat-Protokoll für Session `{session_id}` gefunden.", + "chat_log_error": "Unerwarteter Fehler beim Laden des Chat-Protokolls: `{error}`", + "battle_log_title": "**Gefechtsbericht für Spiel [{session_id}]({url})**", + "battle_log_part_title": "**Gefechtsbericht für Spiel [{session_id}]({url}) (Teil {part}/{total})**", + "battle_log_part_only": "**Gefechtsbericht (Teil {part}/{total})**", + "no_battle_log": "Keine Kampfereignisse für Session `{session_id}` gefunden.", + "battle_log_error": "Unerwarteter Fehler beim Laden des Gefechtsberichts: `{error}`", + "points_update_title": "**{squadron} {region} Punkte-Update**", + "points_update_desc": "# {old_total} -> {new_total} {chart}{wl_line}{placement_line}\n\n**Spieleränderungen:**", + "points_table_header": "Name Änderung Jetzt\n", + "wl_line": "\n**{squadron}** hat in dieser Session **{wins}S-{losses}N** gespielt", + "placement_rose": "\n**{squadron}** stieg auf **{new_place}** von **{old_place}**", + "placement_fell": "\n**{squadron}** fiel auf **{new_place}** von **{old_place}**", + "points_not_logged_title": "Punkte nicht protokolliert", + "points_not_logged_desc": "Nutze `/unlock`, um den **Standard**-Tarif (oder höher) zu abonnieren und automatische Punkte-Updates zu erhalten.", + "server_not_upgraded_points_desc": "Dieser Server hat kein aktives Premium-Abonnement.\n\n**Automatische Updates werden nach nicht mehr an nicht-upgegradete Server gesendet.**\n\nVerwende `/unlock` zum Abonnieren und weiterhin automatische Updates zu erhalten.", + "leave_title": "⚠️ Spieler hat {squadron} verlassen", + "leave_desc": "**{nick}** ({uid}) hat die Kampfgruppe verlassen.\n\nZuletzt erfasste Punkte: **{points}**", + "no_squadrons_desc": "No squadrons configured", + "no_channels_desc": "No channels available", + "over_cap_title": "Geschwader über dem Tarif-Limit", + "over_cap_desc": "Dein Server nutzt den **{tier}**-Tarif, der **{cap}** Geschwader für **{notif}** erlaubt. Das Geschwader **{squadron}** liegt aktuell über dem Limit und wird nicht geloggt. Upgrade für mehr Kapazität.", + "over_cap_footer": "Upgrade unter srebot-meow.ing/premium oder /unlock", + "wildcard_blocked_title": "Wildcard-Logging erfordert einen höheren Tarif", + "wildcard_blocked_desc": "Wildcard-Einträge (*, all, everything) sind nur in Pro/Max verfügbar. Dein Server ist auf **{tier}** für {notif}. Upgrade zum Aktivieren.", + "cap_header": "{used}/{cap} {notif} aktiviert — {tier}-Tarif" + }, + "track": { + "squadron_not_found": "Kampfgruppe nicht gefunden.", + "fetch_failed": "Kampfgruppen-Informationen konnten nicht abgerufen werden." + }, + "unlock": { + "title": "SRE Bot Premium", + "desc": "**Premium-Funktionen für diesen Server freischalten.**\n\nPremium beinhaltet:\n> • Automatische Ergebnistabellenpostings\n> • Chat- & Gefechtsberichte\n> • Replay-Suche\n> • Unbegrenzte /comp-Abfragen\n> • Prioritätssupport\n\n**$2.99 / Monat · pro Server · jederzeit kündbar**\n\n⚠️ Discord-Abrechnung ist nur in ausgewählten Ländern verfügbar. Wenn die Schaltfläche unten **\"Produkt nicht verfügbar\"** anzeigt, kann dies an einem nicht unterstützten Land oder einem **Mobilgerät** liegen. Verwende stattdessen die Schaltfläche **Über Website abonnieren**.", + "already_subscribed_title": "SRE Bot Premium", + "already_subscribed_desc": "✅ **Dieser Server ist bereits abonniert!**", + "manage_discord_field": "Abonnement verwalten", + "manage_discord_value": "Dein Abonnement läuft über **Discord**.\nZum Kündigen gehe in Discord zu **Benutzereinstellungen → Abonnements**.", + "manage_website_field": "Abonnement verwalten", + "manage_website_value": "Dein Abonnement läuft über die **Website**.\nVerwalte es unter [whop.com/billing](https://whop.com/billing).", + "coming_soon_field": "Demnächst verfügbar", + "coming_soon_value": "Premium-Abonnements sind noch nicht verfügbar. Schau bald wieder vorbei!", + "current_tier": "Du nutzt den **{tier}**-Tarif.", + "upgrade_to": "Upgrade auf {tier}", + "upgrade_to_value": "Mehr Geschwader und Features durch Upgrade auf **{tier}**." + }, + "language": { + "prompt": "Bitte wähle deine Server-Sprache:", + "select_placeholder": "Server-Sprache auswählen", + "language_set": "Sprache auf {language} gesetzt.", + "translate_prompt": "Wähle unten eine Zielsprache aus 👇", + "translate_placeholder": "Zielsprache auswählen…", + "translate_result": "**{author} → {language}:**\n{text}", + "translation_unavailable": "Übersetzung nicht verfügbar (DeepL nicht konfiguriert)", + "translation_failed": "Übersetzung fehlgeschlagen" + }, + "misc": { + "credits_title": "Credits", + "credits_desc": "**Meowww**\n\n> **NotSoToothless** - Leitentwickler, Bot-Manager, Community-Manager\n> **Z3R0** - Entwickler, Optimierungsentwickler, Datenbankingenieur\n> **Clippii (Heidi)** - Entwickler, Website-Entwickler, Community-Manager\n> **LivingTheDagor** - Entwickler, Parser-Entwickler, Berater\n> **Lux_** - API-Ingenieur, Spectra-Entwickler\n> **Konigallerwaffen** - Berater für Feedback und Funktionen\n> **Žralok Tonda** - Tschechischer Übersetzer\n> **Styevy**, **Lopais** - Deutsche Übersetzer\n> **Susogus**, **playforfun698** - Polnische Übersetzer\n> **Bobr** - Russischer Übersetzer\n\n\n[Lust auf Mitmachen?](https://discord.gg/BCvkK8JhPe)", + "schedule_title": "SAISONKALENDER", + "schedule_not_found_title": "Kalender nicht gefunden", + "schedule_not_found_desc": "Es sind noch keine Kalenderdaten verfügbar.", + "news_no_news_title": "Keine Neuigkeiten", + "news_no_news_desc": "Es gibt derzeit keine Ankündigungen. Schau später wieder vorbei!", + "news_footer": "Danke für deine Unterstützung! ᕙᘘᗢ", + "help_title": "Bot-Anleitung", + "donate_title": "SRE Bot unterstützen", + "donate_desc": "Wenn du SRE Bot gerne nutzt und seine Entwicklung unterstützen möchtest, erwäge mir einen Kaffee zu spendieren!\n\n**[Auf Ko-fi spenden](https://ko-fi.com/notsotoothless)**\n\nJeder Beitrag hilft, den Bot am Laufen zu halten und neue Funktionen zu unterstützen. Danke!", + "status_title": "Bot-Status", + "status_last_received": "Letztes empfangenes Spiel", + "status_avg_ttl": "Durchschn. TTL (letzte 30)", + "status_no_data": "Noch keine Daten", + "status_gaijin_slow": "⚠️ Gaijin-Server sind langsam", + "help_commands_header": "**Command-Übersicht**", + "help_links": "Details findest du in der Dokumentation [hier]({docs}) oder beim Support [hier]({support}).", + "help_terms": "[Nutzungsbedingungen]({terms}) • [Datenschutzrichtlinie]({terms})" + }, + "dev": { + "restricted_dev_team": "This command is restricted to the dev team.", + "restricted_bot_owner": "❌ This command is restricted to the bot owner.", + "invalid_server_id": "❌ Invalid server ID. Must be a 17-19 digit Discord server ID.", + "expiry_too_soon": "❌ Expiry timestamp must be at least 1 month from now.\n> Now: \n> Minimum: \n> You provided: ", + "entitlement_write_failed": "❌ Failed to write entitlement: {error}", + "entitlement_created_title": "✅ Manual Entitlement Created", + "entitlement_created_desc": "**Server:** {guild_name} (`{server_id}`)\n**Expires:** ()\n**Created:** ", + "query_failed": "Query failed: {error}", + "health_title": "Bot Health Dashboard", + "health_uptime": "Uptime", + "health_guilds": "Guilds", + "health_games_processed": "Games Processed", + "health_tasks": "Tasks", + "health_websocket": "WebSocket", + "health_never": "never", + "health_errors": "({count} errors)", + "health_last_msg": "last msg {ago} ({count} total)", + "health_avg_ttl": "Avg TTL (Last 30)", + "entitlements_title": "Active Entitlements ({count} total)", + "entitlements_no_entries": "No entitlements.", + "entitlements_empty_title": "Active Entitlements", + "entitlements_empty_desc": "No active entitlements found.", + "entitlements_tag_discord": "Discord", + "entitlements_tag_whop": "Whop", + "entitlements_tag_manual": "Manual", + "query_prefix": "Query: {name}" + }, + "leaderboard_alarm": { + "title": "🏆 Kampfgruppen-Rangliste", + "top15_desc": "Top-15-Kampfgruppen mit Statistiken, gesendet 35 Minuten nach Zeitfenster-Schluss.\nDieses wurde gesendet .", + "top30_desc": "Kampfgruppen 16-30 mit Statistiken.", + "not_logged_title": "Rangliste nicht protokolliert", + "not_logged_desc": "Nutze `/unlock`, um den **Standard**-Tarif (oder höher) zu abonnieren und automatische Ranglisten-Updates zu erhalten.", + "server_not_upgraded_title": "⚠️ Server nicht geupgradet", + "server_not_upgraded_desc": "Dieser Server hat kein aktives Premium-Abonnement.\n\n**Automatische Updates werden nach nicht mehr an nicht-upgegradete Server gesendet.**\n\nVerwende `/unlock` zum Abonnieren und weiterhin automatische Updates zu erhalten." + }, + "stacks": { + "stack_title": "{leader}s Staffel", + "stack_named_title": "{name}", + "no_members": "Noch keine Mitglieder.", + "members_field": "Mitglieder ({count}/{max})", + "queue_field": "Warteschlange ({count}/{max})", + "manage_title": "Staffel verwalten", + "no_pending_requests": "Keine ausstehenden Anfragen.", + "disbanded_title": "Staffel [Aufgelöst]", + "disbanded_desc": "Diese Staffel wurde vom Anführer aufgelöst.", + "expired_title": "Staffel [Abgelaufen]", + "expired_desc": "Diese Staffel ist abgelaufen.", + "join_modal_title": "Staffel beitreten", + "join_vehicle_label": "Was wirst du spielen?", + "join_vehicle_placeholder": "z.B. F-16C, WZ305...", + "ping_modal_title": "Ping-Nachricht", + "ping_message_label": "Eigene Nachricht (optional)", + "ping_message_placeholder": "z.B. Kommt jetzt! Staffel startet!", + "rename_modal_title": "Staffel umbenennen", + "rename_label": "Staffel-Name", + "rename_placeholder": "z.B. Nachtschwärmer, Alpha Team...", + "select_new_leader": "Neuen Anführer auswählen…", + "select_applicants": "Bewerber auswählen…", + "no_pending_applications": "Keine ausstehenden Bewerbungen.", + "select_to_remove": "Personen zum Entfernen auswählen…", + "no_members_or_applicants": "Keine Mitglieder oder Bewerber.", + "select_to_ping": "Personen einzeln anpingen…", + "stack_not_found": "❌ Staffel nicht gefunden.", + "no_longer_exists": "❌ Diese Staffel existiert nicht mehr.", + "member_not_exists": "❌ Dieses Mitglied existiert nicht mehr.", + "already_has_stack": "❌ Dieser Spieler hat bereits eine aktive Staffel.", + "already_member": "❌ Du bist bereits Mitglied dieser Staffel.", + "already_applied": "❌ Du hast bereits eine ausstehende Bewerbung für diese Staffel.", + "queue_full": "❌ Die Warteschlange ist voll ({max}/{max}). Versuche es später erneut.", + "application_sent": "✅ Bewerbung gesendet! Der Staffel-Anführer wird sie prüfen.", + "stack_disbanded": "✅ Staffel aufgelöst.", + "cancelled": "Abgebrochen.", + "select_member_transfer": "❌ Bitte wähle ein Mitglied für die Übertragung aus.", + "ownership_transferred": "✅ Führung an {nick} übertragen. Du hast die Staffel verlassen.", + "select_applicant_first": "❌ Bitte wähle zuerst mindestens einen Bewerber aus.", + "stack_full": "❌ Staffel ist bereits voll ({max}/{max} Mitglieder).", + "select_person_first": "❌ Bitte wähle zuerst mindestens eine Person aus.", + "no_one_to_ping": "❌ Niemand zum Anpingen.", + "ping_footer": "Angepingt von {leader} für {stack}.", + "pinged": "✅ Angepingt!", + "select_from_dropdown": "❌ Bitte wähle zuerst mindestens eine Person aus dem Dropdown.", + "stack_renamed": "✅ Staffel umbenannt zu **{name}**.", + "only_member_use_disband": "❌ Du bist das einzige Mitglied. Verwende **Staffel auflösen** zum Beenden.", + "select_transfer_prompt": "Wähle ein Mitglied, an das du die Führung übertragen möchtest:", + "left_stack": "✅ Du hast die Staffel verlassen.", + "application_withdrawn": "✅ Deine Bewerbung wurde zurückgezogen.", + "not_member_or_applicant": "❌ Du bist weder Mitglied noch Bewerber dieser Staffel.", + "leader_only_manage": "❌ Nur der Staffel-Anführer kann diese Staffel verwalten.", + "leader_only_disband": "❌ Nur der Staffel-Anführer kann diese Staffel auflösen.", + "confirm_disband": "Bist du sicher, dass du diese Staffel auflösen möchtest? Dies kann nicht rückgängig gemacht werden.", + "already_active_stack": "⚠️ Du hast bereits eine aktive Staffel. Falls die ursprüngliche Nachricht nicht mehr existiert (z.B. nach einem Bot-Neustart), kannst du die Auflösung erzwingen und neu starten.", + "force_created": "✅ Vorherige Staffel aufgelöst. Neue Staffel erstellt.", + "no_active_stack": "❌ Du hast keine aktive Staffel. Verwende `/stack-create` um einen zu erstellen.", + "could_not_parse_channel": "⚠️ Gespeicherte Kanal-ID konnte nicht verarbeitet werden." + }, + "commands": { + "common": { + "season": "Saison für die Karte", + "theme": "Farbschema der Karte", + "squadron_short": "Kurzname der Staffel", + "player_username": "Spielername", + "choice_dark": "Dunkel", + "choice_light": "Hell" + }, + "comp": { + "description": "Letzte bekannte Aufstellungen eines Teams finden", + "squadron_short": "Kurzname des gegnerischen Teams" + }, + "quick_log": { + "description": "Alarm für diese Staffel in diesem Kanal setzen", + "squadron_name": "KURZNAME der zu überwachenden Staffel", + "type": "Wähle Logs, Punkte, Leaderboard, Wöchentlicher BR oder Beide", + "choice_logs": "Logs", + "choice_points": "Punkte", + "choice_leaderboard": "Rangliste", + "choice_both": "Beides (Logs + Punkte)", + "choice_weekly_br": "Wöchentlicher BR" + }, + "sq_info": { + "description": "Informationen zu einer Staffel abrufen" + }, + "sq_info_graph": { + "description": "Aufstellungsgrafik nach Aktivität und Siegrate anzeigen (aktuelle Saison)" + }, + "sq_card": { + "description": "Saisonkarte für eine Staffel erstellen", + "squadron": "Kurzname der Staffel" + }, + "sq_stats": { + "description": "Staffelpunkte im Zeitverlauf anzeigen" + }, + "loss_calculator": { + "description": "Punkteverlust berechnen, wenn Spieler eine Staffel verlassen", + "player1": "Spieler verlässt", + "player_optional": "Spieler verlässt (optional)" + }, + "website": { + "description": "Link zur SRE Bot-Webseite erhalten" + }, + "card": { + "description": "Saisonkarte für einen Spieler erstellen" + }, + "player_stats": { + "description": "Detaillierte Fahrzeugstatistiken eines Spielers anzeigen", + "username": "WT-Benutzername für Stats", + "uid": "WT-UID für Stats" + }, + "view_player_games": { + "description": "Die letzten 20 Spiele eines Spielers anzeigen" + }, + "view_match": { + "description": "Match-Scoreboard per ID oder Spieler anzeigen", + "match_id": "Hex-Session-ID des Matches", + "player_name": "Spielername zum Durchsuchen neuer Matches" + }, + "compare": { + "description": "Gesamte SQB-Stats von Spielern vergleichen", + "player1": "Erster Spielername", + "player2": "Zweiter Spielername", + "player_optional": "Weiterer Spielername (optional)" + }, + "leaderboard": { + "description": "Globale SRE Bot-Rangliste öffnen" + }, + "set_squadron": { + "description": "Staffel-Tag für diesen Server setzen", + "abbreviated_name": "Kurzname der zu setzenden Staffel" + }, + "setup": { + "description": "Bot für diesen Server einrichten" + }, + "meta_management": { + "description": "Zugriff auf Meta-Daten für diesen Server verwalten" + }, + "meta": { + "description": "Meta-Roster nach Fahrzeugname durchsuchen", + "vehicle": "Zu suchender Fahrzeugname" + }, + "top": { + "description": "Top 20 Staffeln mit Detailstats anzeigen" + }, + "language": { + "description": "Sprache des Bots ändern." + }, + "translate_message": { + "name": "Nachricht übersetzen" + }, + "sq_track": { + "description": "Staffel verfolgen und mit der letzten Prüfung vergleichen", + "squadron_short_name": "Kurzname der zu verfolgenden Staffel" + }, + "analytics": { + "description": "Erweiterte SQB-Analysen für eine Staffel anzeigen", + "view": "Welche Analyseansicht angezeigt wird", + "choice_maps": "Kartensiegquoten", + "choice_comps": "Teamaufstellungen", + "choice_consistency": "Spielerkonstanz", + "choice_time": "Tageszeit", + "choice_matchups": "Duellverlauf" + }, + "recent": { + "description": "Neue Staffelkämpfe einer Staffel anzeigen", + "length": "Anzahl der anzuzeigenden Matches" + }, + "vs": { + "description": "Direktvergleich zwischen zwei Staffeln", + "squadron_a": "Erste Staffel", + "squadron_b": "Zweite Staffel" + }, + "autolog_management": { + "description": "Autolog-Benachrichtigungen verwalten und Rechte prüfen" + }, + "diagnose_perms": { + "description": "Autolog-Rechte für diesen Kanal prüfen" + }, + "unlock": { + "description": "Premium-Funktionen für diesen Server freischalten" + }, + "credits": { + "description": "Das Team hinter diesem Projekt anzeigen" + }, + "schedule": { + "description": "Aktuellen Saison-BR-Plan anzeigen" + }, + "news": { + "description": "Neueste SRE Bot-News und Ankündigungen anzeigen" + }, + "help": { + "description": "Guide, Nutzungsbedingungen und Supportlinks anzeigen" + }, + "donate": { + "description": "Entwicklung von SRE Bot unterstützen" + }, + "stack_create": { + "description": "Neuen Spieler-Stack erstellen", + "vehicle": "Mit welchem Fahrzeug startest du?" + }, + "stack_manage": { + "description": "Deinen aktiven Stack in diesem Kanal neu posten" + }, + "bot_status": { + "description": "Bot-Status anzeigen: letztes empfangenes Spiel und durchschn. TTL" + } + }, + "permission": { + "blacklisted_title": "❌ Gesperrt", + "blacklisted_desc": "Du bist für diese Command-Nutzung gesperrt.", + "reason_line": "**Grund:** {reason}", + "access_denied_title": "⛔ Zugriff verweigert", + "no_permission_desc": "Du hast keine Berechtigung für diesen Command.", + "unexpected_error_title": "❗ Fehler, bitte melden...." + }, + "weekly_br": { + "title_wildcard": "Wöchentlicher BR-Bericht — {br} BR", + "title_squadron": "Wöchentlicher BR-Bericht — [{tag}] {long} • {br} BR", + "window_label": "Zeitraum: {start} → {end}", + "wildcard_desc_first": "Top {count} Geschwader nach ELO • Plätze {low}–{high}", + "wildcard_desc_second": "Top {count} Geschwader nach ELO • Plätze {low}–{high}", + "squadron_stats_line": "- {games} Spiele • K/D {kdr} • Siegrate {wr}%", + "top_players_inline_header": "🥇 Top-Spieler:", + "player_line_short": " {rank}. {nick} ⭐ {score} ({games}S)", + "top_players_header": "**Top {count} Spieler nach ELO:**", + "player_line_full": "{rank}. **{nick}** ⭐ {score} • {games} Spiele • K/D {kdr}", + "squadron_header_line": "Geschwader-ELO: {score} • {games} Spiele • Siegrate {wr}% • K/D {kdr}", + "squadron_header_no_aggregate": "Geschwader-ELO: nicht genügend Teamspiele in dieser Woche.", + "no_data": "Keine Spiele für [{tag}] in dieser BR-Rotation aufgezeichnet." + } +} diff --git a/BOT/locales/en.json b/BOT/locales/en.json new file mode 100644 index 0000000..312cc82 --- /dev/null +++ b/BOT/locales/en.json @@ -0,0 +1,857 @@ +{ + "common": { + "error_title": "Error", + "no_data_title": "No Data", + "access_denied_title": "Access Denied", + "access_denied_desc": "This server has been blacklisted.", + "no_players_selected": "No players selected. Please select at least one player.", + "must_use_in_server": "This command must be used in a server.", + "could_not_resolve_channel": "Could not resolve the selected channel.", + "failed_update_setting": "❌ Failed to update setting.", + "configuration_not_found": "Configuration not found.", + "no_channel_selected": "No channel selected.", + "no_selection_received": "No selection received.", + "database_error": "❌ Database error: {error}", + "enabled": "Enabled", + "disabled": "Disabled", + "not_configured": "Not configured", + "unknown": "Unknown", + "rating_field": "Rating", + "battles_field": "Battles", + "wins_field": "Wins", + "losses_field": "Losses", + "win_rate_field": "Win Rate", + "kills_field": "Kills", + "deaths_field": "Deaths", + "kd_field": "K/D", + "members_field": "Members", + "placement_field": "Placement", + "points_field": "Points", + "ground_kills_field": "Ground Kills", + "air_kills_field": "Air Kills", + "total_kills_field": "Total Kills", + "assists_field": "Assists", + "captures_field": "Captures", + "none_option": "None" + }, + "buttons": { + "skip": "Skip", + "previous": "Previous", + "next": "Next", + "prev": "Prev", + "prev_arrow": "◀ Previous", + "next_arrow": "Next ▶", + "prev_arrow_only": "◀", + "next_arrow_only": "▶", + "generate_chart": "📊 Generate Chart", + "show_graph": "Show Graph", + "view_player_stats": "📊 View Player Stats", + "compare_nearby": "📈 Compare Nearby Squadrons", + "confirm_swap": "Yes, swap it", + "cancel_swap": "No, keep the old one", + "set_squadron": "Set Squadron", + "same_as_logs": "Same as Logs", + "require_password": "🔒 Require Password", + "password_required": "🔒 Password Required", + "lock_data": "🔐 Bind Squadron Data", + "data_locked": "🔐 Data Bound to Server", + "allow_public": "👥 Allow Public Meta", + "public_enabled": "👥 Public Meta Enabled", + "update_accounts": "📋 Update Meta Accounts", + "change_password": "🔑 Change Password", + "help": "❓ Help", + "add_player": "➕ Add Player", + "update_all": "🔄 Update All Members", + "back_to_settings": "⬅ Back to Settings", + "manage_notifications": "Manage Notifications", + "diagnose_permissions": "Diagnose Permissions", + "enable": "Enable", + "disable": "Disable", + "change_channel": "Change Channel", + "view_replay": "View Replay", + "view_website": "View on Website", + "view_video": "View Video", + "view_log": "View Log", + "view_chat": "View Chat", + "subscribe_website": "Subscribe via Website", + "yes_disband": "Yes, Disband", + "cancel": "Cancel", + "transfer_leave": "Transfer & Leave", + "accept_selected": "Accept Selected", + "accept_all": "Accept All", + "decline_selected": "Decline Selected", + "back": "Back", + "remove_all": "Remove All", + "remove_active": "Remove Active", + "remove_queued": "Remove Queued", + "remove_selected": "Remove Selected", + "ping_all": "Ping All", + "ping_active": "Ping Active", + "ping_queued": "Ping Queued", + "ping_selected": "Ping Selected", + "accept_members": "Accept Members", + "remove_members": "Remove Members", + "ping_members": "Ping Members", + "rename_stack": "Rename Stack", + "request_to_join": "Request to Join", + "leave_withdraw": "Leave / Withdraw", + "manage_stack": "Manage Stack ⚙️", + "disband_stack": "Disband Stack", + "force_disband_create": "Force Disband & Create New" + }, + "events": { + "guild_join_title": "Thanks for adding me!", + "guild_join_desc": "Run `/setup` to configure the bot for this server." + }, + "comp": { + "not_found_title": "Comps Not Found", + "not_found_desc": "No data for **{squadron}**, try again later.", + "error_loading_title": "Error Loading Comps", + "error_loading_desc": "Failed to load comp data: {error}", + "title": "Comps for {squadron}", + "desc": "Comps seen in the last {minutes} minutes", + "no_recent_title": "No Recent Comps", + "no_recent_desc": "No comps in the last {minutes} minutes.", + "comp_title": "COMP {index}", + "last_seen_label": "**Last seen** : {timestamp}{warning}", + "comp_label": "**Comp**: {notation}", + "no_players_recorded": "No players recorded.", + "limit_reached_title": "Comp Limit Reached", + "limit_reached_desc": "This server has used all {limit} comp lookups for this timeslot. Subscribe (with /unlock) for unlimited access or wait for the next timeslot.", + "remaining_footer": "{remaining}/{limit} comp lookups remaining this timeslot" + }, + "quick_log": { + "invalid_type": "Type can only be set to Logs, Points, Leaderboard, Weekly BR, or Both.", + "squadron_required": "You must provide a squadron name for Logs, Points, or Both alarms.", + "wildcard_logs_only": "Only Logs can be set to wildcard squadron.", + "squadron_not_resolved": "Squadron `{squadron}` could not be resolved.", + "save_failed": "Failed to save preferences. Please try again later.", + "premium_warning": "\n\n> ⚠️ **Game logs require Premium.** Run `/unlock` to subscribe ($2.99/mo) — logs won't post until then.", + "leaderboard_set": "Global Leaderboard alarm set to this channel.", + "both_set": "Logs and Points alarms for {squadron} set to this channel.{premium_note}", + "alarm_set": "{alarm_type} alarm for {squadron} set to this channel.{premium_note}", + "weekly_br_wildcard_set": "Weekly BR Report (top-20 squadrons) set to this channel. Fires at the end of every BR rotation.", + "weekly_br_squadron_set": "Weekly BR Report for {squadron} (top-15 players) set to this channel. Fires at the end of every BR rotation." + }, + "diagnostics": { + "title": "Autolog Diagnostics", + "channel_permissions_header": "**Channel Permissions** (<#{channel_id}>)", + "perms_needed": " ^ Autologging needs all of the above to send scoreboards.", + "server_squadron_header": "**Server Squadron** (`/set-squadron`)", + "server_squadron_short": " Short: `{short}`", + "server_squadron_long": " Long: `{long}`", + "server_squadron_not_set": " Not set (scoreboard bar color will show as 'not_set')", + "autolog_prefs_header": "**Autolog Preferences** (`/quick-log`)", + "autolog_none_configured": " ❌ NONE configured - autologging will NOT send anything to this server.", + "autolog_setup_hint": " Use `/quick-log Logs` in the target channel to set up.", + "autolog_no_logs_channels": " ❌ No Logs channels configured. Only Points/Leaderboard found.", + "autolog_enable_hint": " Use `/quick-log Logs` to enable autologging.", + "selected_channel_tag": " **(selected channel)**", + "missing_send_attach": " (missing send/attach)", + "channel_not_found": " (channel not found)", + "invalid_channel_id": " (invalid channel ID)", + "premium_status_header": "**Premium Status** (`/unlock`)", + "premium_active": " ✅ This server has an active Premium subscription.", + "premium_not_subscribed": " ❌ This server does **not** have a Premium subscription.", + "premium_autolog_required": " Autologging requires Premium. Use `/unlock` to subscribe.", + "premium_not_subscribed_free": " ⚪ Not subscribed — use `/unlock` to subscribe ($2.99/mo).", + "premium_free_note": " *(Autologs are free for all servers right now.)*" + }, + "sq_info": { + "title": "Squadron Info: {squadron}", + "placement_field": "Placement", + "total_points_field": "Total Points", + "total_members_field": "Total Members", + "members_field": "Members", + "fetch_failed": "Failed to fetch squadron info." + }, + "sq_info_graph": { + "title": "{squadron} — SQ-INFO (Season {season})", + "embed_title": "{squadron} — Roster Composition", + "embed_desc": "Season **{season}** · Median games: **{median}** · Core: **{core}** · Active: **{active}** · Weak: **{weak}**\nBars sorted by games desc; height = WR%. Core = top 30% WR rank & games ≥ median. Active = top 30–45% WR rank & games ≈ median. Weak = everyone else.", + "core_threshold_line": "CORE ≥ {wr}%", + "weak_threshold_line": "WEAK < {wr}%", + "y_label": "Win Rate", + "core_header": "CORE — {count} · WR {avg}%", + "active_header": "ACTIVE — {count} · WR {avg}%", + "weak_header": "WEAK — {count} · WR {avg}%", + "no_active_season": "No active season was found. Try again once the next season starts.", + "no_members": "No current members were found for {squadron}." + }, + "recap_card": { + "unknown_season": "Unknown season: `{season}`.", + "no_clan_id": "Could not resolve a squadron ID for `{squadron}`.", + "render_failed": "Failed to generate the season recap card. Please try again later." + }, + "sq_stats": { + "no_data_title": "No Data", + "no_data_desc": "No historical data found for squadron: {squadron}", + "title": "{squadron} // SQUADRON", + "desc": "Total Score Trend (Last {count} data points)", + "previous_score_field": "Previous Score", + "current_score_field": "Current Score", + "change_field": "Change", + "player_title": "{squadron} // PLAYERS", + "player_desc": "Individual player point trends", + "comparison_title": "{squadron} // LEADERBOARD COMPARISON", + "comparison_desc": "Comparing with squadrons ranked {range}", + "current_position_field": "Current Position", + "squadrons_shown_field": "Squadrons Shown", + "squadron_not_found_error": "Squadron not found in leaderboard", + "no_nearby_error": "No nearby squadrons found", + "no_historical_error": "No historical data found for nearby squadrons", + "comparison_chart_failed": "Failed to generate comparison chart", + "select_players_placeholder": "Select players (Page {page})" + }, + "loss_calc": { + "title": "Point Loss — {squadron}", + "players_leaving_field": "Players Leaving", + "share_of_total_field": "% Share of Total", + "points_lost_real_field": "Points Lost (Real)", + "points_lost_raw_field": "Points Lost (Raw)", + "squadron_rating_field": "Squadron Rating", + "squadron_position_field": "Squadron Position", + "positions_lost_field": "Positions Lost", + "not_found_footer": "Not found in squadron: {players}", + "fetch_failed": "Failed to fetch squadron data: {error}", + "no_point_data": "No point data available for this squadron.", + "no_matching_players": "No matching players found in **{squadron}**." + }, + "player": { + "select_player_placeholder": "Select a player", + "no_stats_found": "❌ No stats found for UID: {uid}", + "no_vehicle_stats": "❌ No vehicle stats found for this player.", + "vehicles_found": "Found **{count}** vehicles for **{nick}**\nSelect a vehicle to view detailed stats:", + "vehicle_select_placeholder": "Select a vehicle (Page {page}/{total})", + "combat_stats_header": "**__COMBAT STATS__**", + "ground_kills_label": "**Ground Kills:** {value}", + "air_kills_label": "**Air Kills:** {value}", + "total_kills_label": "**Total Kills:** {value}", + "assists_label": "**Assists:** {value}", + "deaths_label": "**Deaths:** {value}", + "kd_label": "**K/D:** {value}", + "captures_label": "**Captures:** {value}", + "battle_record_header": "**__BATTLE RECORD__**", + "total_battles_label": "**Total Battles:** {value}", + "wins_label": "**Wins:** {value}", + "losses_label": "**Losses:** {value}", + "win_rate_label": "**Win Rate:** {value}%", + "stats_desc": "Stats for **{nick}** (**{squadron}**)\nUID: `{uid}`", + "not_found_title": "Player Not Found", + "not_found_desc": "No game history found for `{player}`.", + "no_players_found": "No players found matching **{username}**\nTry using `/website` to search on the website.", + "multiple_matches": "Multiple matches found, choose the correct one below:", + "must_provide_input": "You must provide at least a UID or username." + }, + "player_games": { + "no_recent_title": "No Recent Games", + "no_recent_desc": "No games found for **{player}** in the last 8 hours.", + "squadron_label": "**Squadron:** {squadron}", + "record_label": "**W:** {wins} **L:** {losses} **WR:** {wr}%", + "comps_played_header": "\n\n**Comps Played**" + }, + "match": { + "missing_input_title": "Missing Input", + "missing_input_desc": "Provide either a `match_id` or a `player_name`.", + "not_found_title": "Match Not Found", + "not_found_desc": "Could not find a match with ID `{match_id}`.", + "invalid_data_title": "Invalid Match Data", + "invalid_data_desc": "The replay data could not be parsed.", + "scoreboard_error_title": "Scoreboard Error", + "scoreboard_error_desc": "Failed to generate the scoreboard image.", + "no_games_title": "No Games Found", + "no_games_desc": "No game history found for **{player}**.", + "recent_matches_title": "Recent matches for {player}", + "recent_matches_desc": "Showing up to {count} recent games. Select one to view the full scoreboard.", + "select_match_placeholder": "Select a match to view..." + }, + "compare": { + "no_players_found": "No players found matching **{name}**.", + "multiple_matches": "Multiple matches for **{name}**: {matches}\nPlease use a more specific name (the autocomplete suggestions are exact).", + "could_not_resolve": "Could not resolve players.", + "could_not_fetch": "❌ Could not fetch stats for **{name}**.", + "no_graph_data": "No data available for the last 90 days.", + "no_squadron_points_data": "No squadron points data for {names} (player not found in tracked squadron history).", + "graph_title": "Player Points — Last 90 Days", + "battles_label": "Battles", + "wins_label": "Wins", + "losses_label": "Losses", + "win_rate_label": "Win Rate", + "ground_kills_label": "Ground Kills", + "air_kills_label": "Air Kills", + "total_kills_label": "Total Kills", + "assists_label": "Assists", + "deaths_label": "Deaths", + "kd_label": "K/D", + "captures_label": "Captures" + }, + "squadron": { + "not_found_desc": "Squadron `{squadron}` not found.", + "set_title": "✅ Squadron Set", + "set_desc": "Squadron **{squadron}** has been set for this server.", + "short_name_field": "Short Name", + "long_name_field": "Long Name", + "swap_title": "✅ Squadron Swapped", + "swap_desc": "Replaced **{old}** with **{new}** for this server.", + "already_set_title": "⚠️ Squadron Already Set", + "already_set_desc": "This server is currently set to **{old}**.\nSwap it to **{new}**?", + "swap_cancelled": "❌ Squadron change cancelled." + }, + "setup": { + "step1_title": "Server Setup — Step 1 of 3", + "step1_desc": "This wizard will walk you through configuring the bot for your server.\n\n**Step 1** — Set your squadron\n**Step 2** — Choose a logs channel\n**Step 3** — Choose a points channel\n", + "step1_current_sq": "\nCurrently configured squadron: **[{short}] {long}**", + "step2_title": "Server Setup — Step 2 of 3", + "step2_desc": "Squadron set to **[{short}] {long}**.\n\nWhere should **battle logs** be posted?\nSelect a text channel below, or skip this step.", + "step3_title": "Server Setup — Step 3 of 3", + "step3_desc": "Where should **points notifications** be posted?\nSelect a text channel below, or skip this step.", + "step3_same_as_logs": "\n\nYou can also click \"Same as Logs\" to reuse the logs channel.", + "summary_title": "Setup Complete", + "summary_desc": "You can use `/autolog-management` to change these settings later.", + "squadron_field": "Squadron", + "logs_channel_field": "Logs Channel", + "points_channel_field": "Points Channel", + "premium_required_field": "⚠️ Game Logs Require Premium", + "premium_required_value": "Automatic game scoreboards won't post until this server has an active subscription. Run `/unlock` to subscribe ($2.99/mo).", + "modal_title": "Set Squadron", + "modal_label": "Squadron Short Name", + "modal_placeholder": "e.g. AXYS", + "squadron_not_found": "Squadron `{squadron}` not found. Please try again.", + "logs_channel_placeholder": "Select a logs channel...", + "points_channel_placeholder": "Select a points channel..." + }, + "meta_management": { + "squadron_not_found_title": "❌ Squadron Not Found", + "squadron_not_found_desc": "Could not find clan ID for squadron: **{squadron}**", + "access_denied_title": "❌ Access Denied", + "access_denied_desc": "Incorrect password. This squadron's meta data is protected.", + "data_locked_title": "🔐 Squadron Data Bound", + "data_locked_desc": "**{squadron}** has data binding enabled and cannot be transferred to another server.\n\nThe squadron owner must disable **Bind Squadron Data** before it can be moved.", + "error_retrieving_settings": "❌ Error retrieving guild settings after transfer. Please try again.", + "error_retrieving_settings_retry": "❌ Error retrieving guild settings. Please try running the command again.", + "authenticated_title": "✅ Authenticated", + "authenticated_desc": "Password verified. Managing settings for **{squadron}**.", + "claimed_title": "✅ Squadron Claimed", + "claimed_desc": "**{squadron}** has been successfully claimed for this server!", + "password_requirement_field": "🔒 Password Requirement", + "data_lock_field": "🔐 Squadron Data Binding", + "public_meta_field": "👥 Public Meta Access", + "access_password_field": "🔑 Access Password", + "enabled_value": "✅ Enabled", + "disabled_value": "❌ Disabled", + "settings_title": "🔐 Meta Management Settings", + "settings_desc": "**Squadron:** {squadron}\n**Clan ID:** {clan_id}", + "first_time_title": "🔐 Meta Management - First Time Setup", + "first_time_owner_desc": "**Squadron:** {squadron}\n**Clan ID:** {clan_id}\n\n🔑 Your access password has been generated. **Save this password** — you'll need it to authenticate meta data access in the future.\n\n**Password:** `{password}`", + "first_time_non_owner_desc": "**Squadron:** {squadron}\n**Clan ID:** {clan_id}\n\nSquadron has been set up. Ask the server owner for the access password.", + "settings_field": "Settings", + "settings_hint": "Use the buttons below to configure access settings.", + "password_toggled": "✅ Password requirement: **{state}**", + "lock_toggled": "✅ Squadron data binding: **{state}**", + "public_meta_toggled": "✅ Public meta access: **{state}**\n{detail}", + "public_meta_enabled_detail": "Non-admins can now use `/meta` command.", + "public_meta_disabled_detail": "Only admins can use `/meta` command.", + "owner_only_password": "❌ Only the server owner can change the squadron password.", + "help_title": "📖 Meta Management Help", + "help_desc": "Explanation of each setting and feature:", + "help_password_field": "🔑 Access Password", + "help_password_value": "Your squadron's access password. Only the **server owner** can see the password in the settings panel. Anyone with the password can claim your squadron's meta data on their server, so keep it secure.", + "help_require_field": "🔒 Require Password", + "help_require_value": "When enabled, even admins on this server must enter the squadron password to access `/meta-management`. Adds an extra layer of security to prevent accidental changes.", + "help_lock_field": "🔐 Bind Squadron Data", + "help_lock_value": "When enabled, prevents the squadron from being transferred to other servers, even with the correct password. Must be disabled before the squadron can be transferred.", + "help_public_field": "👥 Allow Public Meta", + "help_public_value": "When enabled, allows non-admin members to use the `/meta` command to search squadron vehicles. When disabled, only server administrators can use `/meta`.", + "help_accounts_field": "📋 Update Meta Accounts", + "help_accounts_value": "Opens the player roster manager where you can add or remove players from your squadron's meta roster. Use **Update All Members** to sync your entire squadron at once.", + "help_change_pw_field": "🔑 Change Password", + "help_change_pw_value": "**Server owner only.** Change the squadron's access password and set an optional hint. The hint is shown in the password prompt to help remember it.", + "password_modal_title": "Squadron Access Password", + "password_modal_label": "Enter Squadron Password", + "password_modal_placeholder": "XXXX-XXXX-XXXX", + "change_pw_modal_title": "Change Squadron Password", + "current_password_label": "Current Password", + "current_password_placeholder": "Enter your current password", + "new_password_label": "New Password", + "new_password_placeholder": "Enter your new password", + "confirm_password_label": "Confirm New Password", + "confirm_password_placeholder": "Re-enter your new password", + "hint_label": "Password Hint (Optional)", + "hint_placeholder": "A hint to help remember the password", + "pw_incorrect": "❌ Current password is incorrect.", + "pw_mismatch": "❌ New passwords do not match. Please try again.", + "pw_empty": "❌ New password cannot be empty.", + "pw_changed": "✅ Password updated successfully for **{squadron}**.\n**New Password:** `{password}`", + "pw_changed_hint": "\n**Hint:** {hint}", + "player_add_modal_title": "Add Player to Meta Roster", + "player_add_label": "Player UID or Nickname", + "player_add_placeholder": "Enter player's UID (e.g., 12345678) or nickname", + "player_not_found": "❌ Player `{player}` not found in Players_Global database.\n", + "roster_title": "📋 Meta Roster Management - {squadron}", + "roster_desc": "**Squadron Clan ID:** {clan_id}\n**Total Players:** {count}", + "roster_page_field": "Players (Page {page}/{total})", + "no_players_field": "No Players", + "no_players_hint": "No players added to meta roster yet. Click **Add Player** to get started.", + "remove_player_placeholder": "Select player to remove...", + "fetch_members_failed": "❌ Failed to fetch squadron members: {error}", + "no_members_found": "❌ No members found in squadron or API call failed.", + "roster_synced": "✅ Synced roster with squadron.", + "roster_added": "**+{count}** added", + "roster_removed": "**-{count}** removed (left squadron)", + "roster_up_to_date": "**{count}** already up to date", + "refreshing_vehicles": "Refreshing vehicle data in background..." + }, + "meta": { + "not_configured": "❌ Meta data not configured for this server. Run `/meta-management` first.", + "no_permission": "❌ You need administrator permissions to use this command.\nAdmins can enable public access via `/meta-management`.", + "no_results": "❌ No players in your squadron roster have **{vehicle}**.", + "no_results_admin_hint": "\n*Expecting someone to have this? Click the update members button in `/meta-management` and double check.*", + "search_title": "🔍 Search Results - {vehicle}", + "matches_found": "**Matches Found:** {count} player(s)", + "spawns_label": "Spawns", + "deaths_label": "Deaths", + "gk_label": "GK", + "ak_label": "AK", + "points_label": "Points", + "kdr_label": "KDR", + "games_label": "Games", + "no_points": "—" + }, + "top": { + "title": "**Top 20 Squadrons**", + "rating_label": "**Rating:** {value}", + "air_kills_label": "**Air Kills:** {value}", + "ground_kills_label": "**Ground Kills:** {value}", + "deaths_label": "**Deaths:** {value}", + "kd_label": "**K/D:** {value}", + "win_rate_label": "**Win Rate:** {value}", + "playtime_label": "**Playtime:** {value}", + "fetch_failed": "Failed to retrieve squadron data." + }, + "analytics": { + "no_data_title": "No Data", + "no_matches_desc": "No matches found.", + "no_comp_desc": "No composition data found.", + "no_consistency_desc": "Not enough player data (minimum 50 matches).", + "no_time_desc": "No time data found.", + "unknown_view": "Unknown view.", + "map_title": "Map Win Rates: {squadron}", + "comp_title": "Team Compositions: {squadron}", + "consistency_title": "Player Consistency: {squadron}", + "consistency_desc": "Sorted by K/D ratio", + "time_title": "Time of Day Performance: {squadron}", + "eu_timeslot": "\n**EU Timeslot**", + "na_timeslot": "\n**NA Timeslot**", + "off_peak": "\n**Off-Peak**", + "matchups_title": "📜 {squadron} — Matchup History", + "matchups_won_field": "🏆 Most Won Against", + "matchups_lost_field": "💀 Most Lost To", + "no_matchups_desc": "No recorded matches against other squadrons yet." + }, + "recent": { + "title": "Recent Matches: {squadron}", + "no_matches_desc": "No matches found for this squadron." + }, + "h2h": { + "two_required_title": "Two Squadrons Required", + "two_required_desc": "Provide at least one squadron, or use `/set-squadron` and provide the opponent.", + "provide_a_desc": "Provide `squadron_a` or use `/set-squadron` first.", + "provide_b_desc": "Provide `squadron_b` or use `/set-squadron` first.", + "squadron_not_found_title": "Squadron Not Found", + "same_squadron_title": "Same Squadron", + "same_squadron_desc": "You can't check head-to-head against yourself.", + "record_desc": "**Record:** {a_wins}W - {b_wins}L ({total} games)", + "no_matches_desc": "No recorded matches between **{a}** and **{b}**." + }, + "autolog": { + "premium_active_line": "✅ **Premium:** Active — autologging is enabled for this server.", + "premium_not_subscribed_line": "❌ **Premium:** Not subscribed — use `/unlock` to enable autologging.", + "premium_free_line": "⚪ **Premium:** Not subscribed — use `/unlock` to subscribe ($2.99/mo). *(Autologs are free for all servers right now.)*", + "what_to_do": "\n\nWhat would you like to do?", + "select_notif_type": "Select the notification type to manage:", + "select_notif_placeholder": "Select notification type", + "logs_option": "Logs", + "logs_option_desc": "Manage Logs notifications", + "points_option": "Points", + "points_option_desc": "Manage Points notifications", + "leaderboard_option": "Leaderboard", + "leaderboard_option_desc": "Manage Leaderboard notifications", + "selected_type": "Selected **{type}**. Now choose the squadron to manage:", + "select_squadron_placeholder": "Select a squadron", + "select_squadron_page_placeholder": "Select a squadron (Page {page})", + "no_squadrons_available": "No squadron available for this notification type.", + "managing_global": "Managing **{type}** (global) in channel **{channel}**.", + "managing_squadron": "Managing **{type}** for squadron **{squadron}** in channel **{channel}**.", + "select_channel": "Select a new channel:", + "select_channel_placeholder": "Select a channel", + "select_channel_page_placeholder": "Select a channel (Page {page})", + "global_toggled": "{type} (global) is now {state}.", + "squadron_toggled": "{type} for **{squadron}** is now {state}.", + "channel_updated_global": "Updated {type} (global) to {channel}", + "channel_updated_squadron": "Updated {type} for **{squadron}** to {channel}", + "diagnose_channel_placeholder": "Select a channel to diagnose...", + "select_channel_diagnose": "Select the channel to diagnose:", + "game_not_logged_title": "Game Not Logged", + "game_not_logged_desc": "Use `/unlock` to subscribe to the **Standard** tier (or higher) to receive automatic game scoreboards.", + "server_not_upgraded_title": "⚠️ Server Not Upgraded", + "server_not_upgraded_autolog_desc": "This server does not have an active Premium subscription.\n\n**Automatic game scoreboards will stop being sent to non-upgraded servers after .**\n\nUse `/unlock` to subscribe and keep receiving automatic game logs.", + "replay_not_available": "Replay data isn't available yet — wait a bit then try again!", + "too_many_videos": "Too many videos rendering right now — please try again in a moment.", + "video_gen_failed": "Error generating video: `{error}`", + "video_missing": "Failed to generate replay video - output file missing or empty.", + "video_too_large": "Replay video too large to upload ({file_mb:.1f} MB). Server limit is {limit_mb:.0f} MB.", + "video_web_fallback": "You can also view this match at {url}", + "video_upload_failed": "Video too large to upload — view it on the website:\n{url}", + "video_unexpected_error": "Unexpected error generating replay video: `{error}`", + "replay_not_found": "Replay data not found for session `{session_id}` on disk.", + "chat_log_title": "**Chat Log for Game [{session_id}]({url})**", + "chat_log_part_title": "**Chat Log for Game [{session_id}]({url}) (Part {part}/{total})**", + "chat_log_part_only": "**Chat Log (Part {part}/{total})**", + "no_chat_log": "No chat log found for session `{session_id}`.", + "chat_log_error": "Unexpected error loading chat log: `{error}`", + "battle_log_title": "**Battle Log for Game [{session_id}]({url})**", + "battle_log_part_title": "**Battle Log for Game [{session_id}]({url}) (Part {part}/{total})**", + "battle_log_part_only": "**Battle Log (Part {part}/{total})**", + "no_battle_log": "No combat events found for session `{session_id}`.", + "battle_log_error": "Unexpected error loading battle log: `{error}`", + "points_update_title": "**{squadron} {region} Points Update**", + "points_update_desc": "# {old_total} -> {new_total} {chart}{wl_line}{placement_line}\n\n**Player Changes:**", + "points_table_header": "Name Change Now\n", + "wl_line": "\n**{squadron}** went **{wins}W-{losses}L** this session", + "placement_rose": "\n**{squadron}** rose to **{new_place}** from **{old_place}**", + "placement_fell": "\n**{squadron}** fell to **{new_place}** from **{old_place}**", + "points_not_logged_title": "Points Not Logged", + "points_not_logged_desc": "Use `/unlock` to subscribe to the **Standard** tier (or higher) to receive automatic points updates.", + "server_not_upgraded_points_desc": "This server does not have an active Premium subscription.\n\n**Automatic updates will stop being sent to non-upgraded servers after .**\n\nUse `/unlock` to subscribe and keep receiving automatic updates.", + "leave_title": "⚠️ Player Left {squadron}", + "leave_desc": "**{nick}** ({uid}) has left the squadron.\n\nLast recorded points: **{points}**", + "no_squadrons_desc": "No squadrons configured", + "no_channels_desc": "No channels available", + "over_cap_title": "Squadron over your tier cap", + "over_cap_desc": "Your server is on the **{tier}** tier, which allows **{cap} {notif}** squadrons. The squadron **{squadron}** is currently over that limit and not being logged. Upgrade to a higher tier to restore it.", + "over_cap_footer": "Upgrade at srebot-meow.ing/premium or via /unlock", + "wildcard_blocked_title": "Wildcard logging requires a higher tier", + "wildcard_blocked_desc": "Wildcard squadron entries (*, all, everything) are only available on Pro or Max tiers. Your server is on **{tier}** for {notif}. Upgrade to re-enable wildcard logging.", + "cap_header": "{used}/{cap} {notif} enabled — {tier} tier" + }, + "track": { + "squadron_not_found": "Squadron not found.", + "fetch_failed": "Failed to fetch squadron info." + }, + "unlock": { + "title": "SRE Bot Premium", + "desc": "**Unlock premium features for this server.**\n\nPremium includes:\n> • Auto scoreboard posts\n> • Chat & battle logs\n> • Replay lookups\n> • Unlimited /comp lookups\n> • Priority support\n\n**$2.99 / month · per server · cancel anytime**\n\n⚠️ Discord billing is only available in select countries. If the button below shows **\"Product Unavailable\"**, this may be due to an unsupported country or using a **mobile device**. Use the **Subscribe via Website** button instead.", + "already_subscribed_title": "SRE Bot Premium", + "already_subscribed_desc": "✅ **This server is already subscribed!**", + "manage_discord_field": "Manage Subscription", + "manage_discord_value": "Your subscription is through **Discord**.\nTo cancel, go to **User Settings → Subscriptions** in Discord.", + "manage_website_field": "Manage Subscription", + "manage_website_value": "Your subscription is through the **website**.\nManage it at [whop.com/billing](https://whop.com/billing).", + "coming_soon_field": "Coming Soon", + "coming_soon_value": "Premium subscriptions are not yet available. Check back soon!", + "current_tier": "You're on the **{tier}** plan.", + "upgrade_to": "Upgrade to {tier}", + "upgrade_to_value": "Get a higher squadron cap and more features by upgrading to **{tier}**." + }, + "language": { + "prompt": "Please select your server language:", + "select_placeholder": "Choose your server language", + "language_set": "Language set to {language}.", + "translate_prompt": "Select a target language below 👇", + "translate_placeholder": "Choose a target language…", + "translate_result": "**{author} → {language}:**\n{text}", + "translation_unavailable": "Translation unavailable (DeepL not configured)", + "translation_failed": "Translation failed" + }, + "misc": { + "credits_title": "Credits", + "credits_desc": "**Meowww**\n\n> **NotSoToothless** - Lead Developer, Bot Manager, Community Manager\n> **Z3R0** - Developer, Optimization Developer, Database Engineer\n> **Clippii (Heidi) ** - Developer, Website Developer, Community Manager\n> **LivingTheDagor** - Developer, Parser Developer, Consultant\n> **Lux_** - API Engineer, Spectra Developer\n> **Konigallerwaffen** - Feedback and Feature Consultant\n> **Žralok Tonda** - Czech Translator\n> **Styevy**, **Lopais** - German Translators\n> **Susogus**, **playforfun698** - Polish Translators\n> **Bobr** - Russian Translator\n\n\n[Feel like joining us?](https://discord.gg/BCvkK8JhPe)", + "schedule_title": "SEASON SCHEDULE", + "schedule_timeslot_label": "{region} TIMESLOT", + "schedule_not_found_title": "Schedule Not Found", + "schedule_not_found_desc": "No schedule data is available yet.", + "news_no_news_title": "No News", + "news_no_news_desc": "There are no announcements right now. Check back later!", + "news_footer": "Thank you for your support! ᕙᘘᗢ", + "help_title": "Bot Guide", + "donate_title": "Support SRE Bot", + "donate_desc": "If you enjoy using SRE Bot and want to support its development, consider buying me a coffee!\n\n**[Donate on Ko-fi](https://ko-fi.com/notsotoothless)**\n\nEvery contribution helps keep the bot running and supports new features. Thank you!", + "status_title": "Bot Status", + "status_last_received": "Last Game Received", + "status_avg_ttl": "Avg TTL (Last 30)", + "status_no_data": "No data yet", + "status_gaijin_slow": "⚠️ Gaijin servers slow", + "help_commands_header": "**Commands Overview**", + "help_links": "For detailed information, read the documentation [here]({docs}) or get support [here]({support}).", + "help_terms": "[Terms of Service]({terms}) • [Privacy Policy]({terms})" + }, + "dev": { + "restricted_dev_team": "This command is restricted to the dev team.", + "restricted_bot_owner": "❌ This command is restricted to the bot owner.", + "invalid_server_id": "❌ Invalid server ID. Must be a 17-19 digit Discord server ID.", + "expiry_too_soon": "❌ Expiry timestamp must be at least 1 month from now.\n> Now: \n> Minimum: \n> You provided: ", + "entitlement_write_failed": "❌ Failed to write entitlement: {error}", + "entitlement_created_title": "✅ Manual Entitlement Created", + "entitlement_created_desc": "**Server:** {guild_name} (`{server_id}`)\n**Expires:** ()\n**Created:** ", + "query_failed": "Query failed: {error}", + "health_title": "Bot Health Dashboard", + "health_uptime": "Uptime", + "health_guilds": "Guilds", + "health_games_processed": "Games Processed", + "health_tasks": "Tasks", + "health_websocket": "WebSocket", + "health_never": "never", + "health_errors": "({count} errors)", + "health_last_msg": "last msg {ago} ({count} total)", + "health_avg_ttl": "Avg TTL (Last 30)", + "entitlements_title": "Active Entitlements ({count} total)", + "entitlements_no_entries": "No entitlements.", + "entitlements_empty_title": "Active Entitlements", + "entitlements_empty_desc": "No active entitlements found.", + "entitlements_tag_discord": "Discord", + "entitlements_tag_whop": "Whop", + "entitlements_tag_manual": "Manual", + "query_prefix": "Query: {name}" + }, + "leaderboard_alarm": { + "title": "🏆 Squadron Leaderboard", + "top15_desc": "Top 15 squadrons with statistics, sent 35 minutes after timeslot close.\nThis one sent .", + "top30_desc": "Squadrons 16-30 with statistics.", + "not_logged_title": "Leaderboard Not Logged", + "not_logged_desc": "Use `/unlock` to subscribe to the **Standard** tier (or higher) to receive automatic leaderboard updates.", + "server_not_upgraded_title": "⚠️ Server Not Upgraded", + "server_not_upgraded_desc": "This server does not have an active Premium subscription.\n\n**Automatic updates will stop being sent to non-upgraded servers after .**\n\nUse `/unlock` to subscribe and keep receiving automatic updates." + }, + "stacks": { + "stack_title": "{leader}'s Stack", + "stack_named_title": "{name}", + "no_members": "No members yet.", + "members_field": "Members ({count}/{max})", + "queue_field": "Queue ({count}/{max})", + "manage_title": "Manage Stack", + "no_pending_requests": "No pending requests.", + "disbanded_title": "Stack [Disbanded]", + "disbanded_desc": "This stack was disbanded by the leader.", + "expired_title": "Stack [Expired]", + "expired_desc": "This stack has expired.", + "join_modal_title": "Apply to Join Stack", + "join_vehicle_label": "What will you play?", + "join_vehicle_placeholder": "e.g. F-16C, WZ305...", + "ping_modal_title": "Ping Message", + "ping_message_label": "Custom message (optional)", + "ping_message_placeholder": "e.g. Come now! Stack starting!", + "rename_modal_title": "Rename Stack", + "rename_label": "Stack name", + "rename_placeholder": "e.g. Night Owls, Alpha Squad...", + "select_new_leader": "Select new leader…", + "select_applicants": "Select applicants…", + "no_pending_applications": "No pending applications.", + "select_to_remove": "Select people to remove…", + "no_members_or_applicants": "No members or applicants.", + "select_to_ping": "Select people to ping individually…", + "stack_not_found": "❌ Stack not found.", + "no_longer_exists": "❌ This stack no longer exists.", + "member_not_exists": "❌ That member no longer exists.", + "already_has_stack": "❌ That player already has an active stack.", + "already_member": "❌ You are already a member of this stack.", + "already_applied": "❌ You already have a pending application for this stack.", + "queue_full": "❌ The queue is full ({max}/{max}). Try again later.", + "application_sent": "✅ Application sent! The stack leader will review it.", + "stack_disbanded": "✅ Stack disbanded.", + "cancelled": "Cancelled.", + "select_member_transfer": "❌ Please select a member to transfer ownership to.", + "ownership_transferred": "✅ Ownership transferred to {nick}. You have left the stack.", + "select_applicant_first": "❌ Please select at least one applicant first.", + "stack_full": "❌ Stack is already full ({max}/{max} members).", + "select_person_first": "❌ Please select at least one person first.", + "no_one_to_ping": "❌ No one to ping.", + "ping_footer": "Pinged by {leader} for {stack}.", + "pinged": "✅ Pinged!", + "select_from_dropdown": "❌ Please select at least one person from the dropdown first.", + "stack_renamed": "✅ Stack renamed to **{name}**.", + "only_member_use_disband": "❌ You are the only member. Use **Disband Stack** to end it.", + "select_transfer_prompt": "Select a member to transfer ownership to before leaving:", + "left_stack": "✅ You have left the stack.", + "application_withdrawn": "✅ Your application has been withdrawn.", + "not_member_or_applicant": "❌ You are not a member of or applicant to this stack.", + "leader_only_manage": "❌ Only the stack leader can manage this stack.", + "leader_only_disband": "❌ Only the stack leader can disband this stack.", + "confirm_disband": "Are you sure you want to disband this stack? This cannot be undone.", + "already_active_stack": "⚠️ You already have an active stack. If the original embed is gone (e.g. after a bot restart), you can force disband it and start fresh.", + "force_created": "✅ Previous stack disbanded. New stack created.", + "no_active_stack": "❌ You don't have an active stack. Use `/stack-create` to start one.", + "could_not_parse_channel": "⚠️ Couldn't parse stored channel ID." + }, + "commands": { + "common": { + "season": "The season to generate the card for", + "theme": "Card color theme", + "squadron_short": "The short name of the squadron", + "player_username": "The player's username", + "choice_dark": "Dark", + "choice_light": "Light" + }, + "comp": { + "description": "Find the last known comps for a given team", + "squadron_short": "The shortname of the enemy team" + }, + "quick_log": { + "description": "Quickly set an alarm for this squadron in this channel", + "squadron_name": "The SHORT name of the squadron to monitor", + "type": "Choose Logs, Points, Leaderboard, Weekly BR, or Both", + "choice_logs": "Logs", + "choice_points": "Points", + "choice_leaderboard": "Leaderboard", + "choice_both": "Both (Logs + Points)", + "choice_weekly_br": "Weekly BR" + }, + "sq_info": { + "description": "Fetch information about a squadron" + }, + "sq_info_graph": { + "description": "Show a roster composition graph by activity and WR (current season)" + }, + "sq_card": { + "description": "Generate a season recap card for a squadron", + "squadron": "The short name of the squadron" + }, + "sq_stats": { + "description": "Display a squadron's points over time" + }, + "loss_calculator": { + "description": "Calculate the point loss if players leave a squadron", + "player1": "Player leaving", + "player_optional": "Player leaving (optional)" + }, + "website": { + "description": "Get a link to the SRE Bot website" + }, + "card": { + "description": "Generate a season recap card for a player" + }, + "player_stats": { + "description": "View detailed vehicle statistics for a player", + "username": "The WT username for stats request", + "uid": "The WT UID for stats request" + }, + "view_player_games": { + "description": "View the last 20 games for a player" + }, + "view_match": { + "description": "View a match scoreboard by ID or player", + "match_id": "The session hex ID of the match to view", + "player_name": "A player's username to browse recent matches" + }, + "compare": { + "description": "Compare aggregate SQB stats between players", + "player1": "First player username", + "player2": "Second player username", + "player_optional": "Additional player username (optional)" + }, + "leaderboard": { + "description": "Get the SRE Bot global leaderboard" + }, + "set_squadron": { + "description": "Set the squadron tag for this server", + "abbreviated_name": "The short name of the squadron to set" + }, + "setup": { + "description": "Set up the bot for this server" + }, + "meta_management": { + "description": "Manage meta data access settings for this server" + }, + "meta": { + "description": "Search squadron meta roster by vehicle name", + "vehicle": "Vehicle name to search for" + }, + "top": { + "description": "Get the top 20 squadrons with detailed stats" + }, + "language": { + "description": "Change the bot's language." + }, + "translate_message": { + "name": "Translate Message" + }, + "sq_track": { + "description": "Track a squadron and compare stats against the last check", + "squadron_short_name": "Short name of the squadron to track" + }, + "analytics": { + "description": "View advanced SQB analytics for a squadron", + "view": "Which analytics view to show", + "choice_maps": "Map Win Rates", + "choice_comps": "Team Compositions", + "choice_consistency": "Player Consistency", + "choice_time": "Time of Day", + "choice_matchups": "Matchup History" + }, + "recent": { + "description": "Show recent squadron battles for a squadron", + "length": "Number of matches to show" + }, + "vs": { + "description": "Head-to-head record between two squadrons", + "squadron_a": "First squadron", + "squadron_b": "Second squadron" + }, + "autolog_management": { + "description": "Manage autolog notifications and diagnose permissions" + }, + "diagnose_perms": { + "description": "Diagnose autolog permissions for this channel" + }, + "unlock": { + "description": "Unlock premium features for this server" + }, + "credits": { + "description": "View the team credited for building this" + }, + "schedule": { + "description": "View the current season BR schedule" + }, + "news": { + "description": "View the latest SRE Bot news and announcements" + }, + "help": { + "description": "View the guide, ToS, and support links" + }, + "donate": { + "description": "Support the development of SRE Bot" + }, + "stack_create": { + "description": "Create a new player stack", + "vehicle": "What vehicle will you start with?" + }, + "stack_manage": { + "description": "Re-post your active stack embed to this channel" + }, + "bot_status": { + "description": "View bot status: last game received and average TTL" + } + }, + "permission": { + "blacklisted_title": "❌ Blacklisted", + "blacklisted_desc": "You are blacklisted from using this command.", + "reason_line": "**Reason:** {reason}", + "access_denied_title": "⛔ Access Denied", + "no_permission_desc": "You do not have permission to use this command.", + "unexpected_error_title": "❗ Error, report this...." + }, + "weekly_br": { + "title_wildcard": "Weekly BR Report — {br} BR", + "title_squadron": "Weekly BR Report — [{tag}] {long} • {br} BR", + "window_label": "Window: {start} → {end}", + "wildcard_desc_first": "Top {count} squadrons by ELO • Ranks {low}–{high}", + "wildcard_desc_second": "Top {count} squadrons by ELO • Ranks {low}–{high}", + "squadron_stats_line": "- {games} games • K/D {kdr} • WR {wr}%", + "top_players_inline_header": "🥇 Top players:", + "player_line_short": " {rank}. {nick} ⭐ {score} ({games}g)", + "top_players_header": "**Top {count} players by ELO:**", + "player_line_full": "{rank}. **{nick}** ⭐ {score} • {games} games • K/D {kdr}", + "squadron_header_line": "Squadron ELO: {score} • {games} games • WR {wr}% • K/D {kdr}", + "squadron_header_no_aggregate": "Squadron ELO: not enough team activity to score this week.", + "no_data": "No matches recorded for [{tag}] this BR rotation." + } +} diff --git a/BOT/locales/es.json b/BOT/locales/es.json new file mode 100644 index 0000000..b8ffe18 --- /dev/null +++ b/BOT/locales/es.json @@ -0,0 +1,856 @@ +{ + "common": { + "error_title": "Error", + "no_data_title": "Sin datos", + "access_denied_title": "Acceso denegado", + "access_denied_desc": "Este servidor ha sido bloqueado.", + "no_players_selected": "No hay jugadores seleccionados. Selecciona al menos uno.", + "must_use_in_server": "Este comando debe usarse en un servidor.", + "could_not_resolve_channel": "No se pudo resolver el canal seleccionado.", + "failed_update_setting": "❌ Error al actualizar la configuración.", + "configuration_not_found": "Configuración no encontrada.", + "no_channel_selected": "Ningún canal seleccionado.", + "no_selection_received": "No se recibió ninguna selección.", + "database_error": "❌ Error de base de datos: {error}", + "enabled": "Habilitado", + "disabled": "Deshabilitado", + "not_configured": "No configurado", + "unknown": "Desconocido", + "rating_field": "Clasificación", + "battles_field": "Batallas", + "wins_field": "Victorias", + "losses_field": "Derrotas", + "win_rate_field": "% Victorias", + "kills_field": "Eliminaciones", + "deaths_field": "Muertes", + "kd_field": "K/D", + "members_field": "Miembros", + "placement_field": "Posición", + "points_field": "Puntos", + "ground_kills_field": "Elim. terrestres", + "air_kills_field": "Elim. aéreas", + "total_kills_field": "Eliminaciones totales", + "assists_field": "Asistencias", + "captures_field": "Capturas", + "none_option": "Ninguno" + }, + "buttons": { + "skip": "Omitir", + "previous": "Anterior", + "next": "Siguiente", + "prev": "Ant.", + "prev_arrow": "◀ Anterior", + "next_arrow": "Siguiente ▶", + "prev_arrow_only": "◀", + "next_arrow_only": "▶", + "generate_chart": "📊 Generar gráfico", + "show_graph": "Mostrar gráfico", + "view_player_stats": "📊 Ver estadísticas de jugadores", + "compare_nearby": "📈 Comparar escuadrones cercanos", + "confirm_swap": "Sí, cambiarlo", + "cancel_swap": "No, mantener el anterior", + "set_squadron": "Configurar escuadrón", + "same_as_logs": "Igual que registros", + "require_password": "🔒 Requerir contraseña", + "password_required": "🔒 Contraseña requerida", + "lock_data": "🔐 Vincular datos del escuadrón", + "data_locked": "🔐 Datos vinculados al servidor", + "allow_public": "👥 Permitir meta público", + "public_enabled": "👥 Meta público habilitado", + "update_accounts": "📋 Actualizar cuentas meta", + "change_password": "🔑 Cambiar contraseña", + "help": "❓ Ayuda", + "add_player": "➕ Agregar Jugador", + "update_all": "🔄 Actualizar Todos los Miembros", + "back_to_settings": "⬅ Volver a Configuración", + "manage_notifications": "Gestionar Notificaciones", + "diagnose_permissions": "Diagnosticar Permisos", + "enable": "Habilitar", + "disable": "Deshabilitar", + "change_channel": "Cambiar Canal", + "view_replay": "Ver Repetición", + "view_website": "Ver en el Sitio Web", + "view_video": "Ver Video", + "view_log": "Ver Registro", + "view_chat": "Ver Chat", + "subscribe_website": "Suscribirse desde el Sitio Web", + "yes_disband": "Sí, disolver", + "cancel": "Cancelar", + "transfer_leave": "Transferir y salir", + "accept_selected": "Aceptar seleccionados", + "accept_all": "Aceptar todos", + "decline_selected": "Rechazar seleccionados", + "back": "Volver", + "remove_all": "Eliminar todos", + "remove_active": "Eliminar activos", + "remove_queued": "Eliminar en espera", + "remove_selected": "Eliminar seleccionados", + "ping_all": "Notificar a todos", + "ping_active": "Notificar activos", + "ping_queued": "Notificar en espera", + "ping_selected": "Notificar seleccionados", + "accept_members": "Aceptar miembros", + "remove_members": "Eliminar miembros", + "ping_members": "Notificar miembros", + "rename_stack": "Renombrar stack", + "request_to_join": "Solicitar unirse", + "leave_withdraw": "Salir / Retirar", + "manage_stack": "Gestionar stack ⚙️", + "disband_stack": "Disolver stack", + "force_disband_create": "Forzar disolución y crear nuevo" + }, + "events": { + "guild_join_title": "¡Gracias por agregarme!", + "guild_join_desc": "Ejecuta `/setup` para configurar el bot en este servidor." + }, + "comp": { + "not_found_title": "Comps no encontradas", + "not_found_desc": "Sin datos para **{squadron}**, inténtalo más tarde.", + "error_loading_title": "Error al cargar comps", + "error_loading_desc": "Error al cargar datos de comp: {error}", + "title": "Comps de {squadron}", + "desc": "Comps vistas en los últimos {minutes} minutos", + "no_recent_title": "Sin comps recientes", + "no_recent_desc": "Sin comps en los últimos {minutes} minutos.", + "comp_title": "COMP {index}", + "last_seen_label": "**Visto por última vez** : {timestamp}{warning}", + "comp_label": "**Comp**: {notation}", + "no_players_recorded": "No hay jugadores registrados.", + "limit_reached_title": "Límite de comps alcanzado", + "limit_reached_desc": "Este servidor ha usado las {limit} consultas de comps para este horario. Suscríbete (con /unlock) para acceso ilimitado o espera al siguiente horario.", + "remaining_footer": "{remaining}/{limit} consultas de comps restantes en este horario" + }, + "quick_log": { + "invalid_type": "El tipo solo puede ser Logs, Puntos, Clasificación, BR Semanal o Ambos.", + "squadron_required": "Debes indicar el nombre del escuadrón para alarmas de Registros, Puntos o Ambos.", + "wildcard_logs_only": "Solo los Registros pueden configurarse con escuadrón comodín.", + "squadron_not_resolved": "No se pudo resolver el escuadrón `{squadron}`.", + "save_failed": "Error al guardar preferencias. Inténtalo de nuevo más tarde.", + "premium_warning": "\n\n> ⚠️ **Los registros de partidas requieren Premium.** Ejecuta `/unlock` para suscribirte ($2.99/mes) — los registros no se publicarán hasta entonces.", + "leaderboard_set": "La alarma de Clasificación Global se ha configurado en este canal.", + "both_set": "Las alarmas de Registros y Puntos para {squadron} se han configurado en este canal.{premium_note}", + "alarm_set": "La alarma de {alarm_type} para {squadron} se ha configurado en este canal.{premium_note}", + "weekly_br_wildcard_set": "Informe BR Semanal (top 20 escuadrones) configurado para este canal. Se envía al final de cada rotación de BR.", + "weekly_br_squadron_set": "Informe BR Semanal para {squadron} (top 15 jugadores) configurado para este canal. Se envía al final de cada rotación de BR." + }, + "diagnostics": { + "title": "Diagnóstico de Autolog", + "channel_permissions_header": "**Permisos del Canal** (<#{channel_id}>)", + "perms_needed": " ^ El autoregistro necesita todos los permisos anteriores para enviar marcadores.", + "server_squadron_header": "**Escuadrón del Servidor** (`/set-squadron`)", + "server_squadron_short": " Corto: `{short}`", + "server_squadron_long": " Largo: `{long}`", + "server_squadron_not_set": " No configurado (el color de la barra del marcador mostrará 'not_set')", + "autolog_prefs_header": "**Preferencias de Autolog** (`/quick-log`)", + "autolog_none_configured": " ❌ NINGUNO configurado - el autoregistro NO enviará nada a este servidor.", + "autolog_setup_hint": " Usa `/quick-log Logs` en el canal de destino para configurarlo.", + "autolog_no_logs_channels": " ❌ No hay canales de Registros configurados. Solo se encontraron canales de Puntos/Clasificación.", + "autolog_enable_hint": " Usa `/quick-log Logs` para habilitar el autoregistro.", + "selected_channel_tag": " **(canal seleccionado)**", + "missing_send_attach": " (sin permiso de envío/adjunto)", + "channel_not_found": " (canal no encontrado)", + "invalid_channel_id": " (ID de canal inválido)", + "premium_status_header": "**Estado Premium** (`/unlock`)", + "premium_active": " ✅ Este servidor tiene una suscripción Premium activa.", + "premium_not_subscribed": " ❌ Este servidor **no** tiene una suscripción Premium.", + "premium_autolog_required": " El autoregistro requiere Premium. Usa `/unlock` para suscribirte.", + "premium_not_subscribed_free": " ⚪ Sin suscripción — usa `/unlock` para suscribirte ($2.99/mes).", + "premium_free_note": " *(Los autologs son gratuitos para todos los servidores por ahora.)*" + }, + "sq_info": { + "title": "Info del escuadrón: {squadron}", + "placement_field": "Posición", + "total_points_field": "Puntos totales", + "total_members_field": "Total de miembros", + "members_field": "Miembros", + "fetch_failed": "Error al obtener información del escuadrón." + }, + "sq_info_graph": { + "title": "{squadron} — SQ-INFO (Temporada {season})", + "embed_title": "{squadron} — Composición de la plantilla", + "embed_desc": "Temporada **{season}** · Mediana de partidas: **{median}** · Núcleo: **{core}** · Activos: **{active}** · Débiles: **{weak}**\nBarras ordenadas por partidas desc; altura = tasa de victoria. Núcleo = top 30 % de TV y partidas ≥ mediana. Activos = top 30–45 % de TV y partidas ≈ mediana. Débiles = el resto.", + "core_threshold_line": "NÚCLEO ≥ {wr} %", + "weak_threshold_line": "DÉBILES < {wr} %", + "y_label": "Tasa de victoria", + "core_header": "NÚCLEO — {count} · TV {avg}%", + "active_header": "ACTIVOS — {count} · TV {avg}%", + "weak_header": "DÉBILES — {count} · TV {avg}%", + "no_active_season": "No se encontró ninguna temporada activa. Inténtalo de nuevo cuando comience la siguiente.", + "no_members": "No se encontraron miembros actuales para {squadron}." + }, + "recap_card": { + "unknown_season": "Temporada desconocida: `{season}`.", + "no_clan_id": "No se pudo resolver el ID del escuadrón `{squadron}`.", + "render_failed": "Error al generar la tarjeta de resumen de temporada. Inténtalo más tarde." + }, + "sq_stats": { + "no_data_title": "Sin datos", + "no_data_desc": "No se encontraron datos históricos para el escuadrón: {squadron}", + "title": "{squadron} // ESCUADRÓN", + "desc": "Tendencia de puntuación total (últimos {count} puntos de datos)", + "previous_score_field": "Puntuación anterior", + "current_score_field": "Puntuación actual", + "change_field": "Cambio", + "player_title": "{squadron} // JUGADORES", + "player_desc": "Tendencias de puntos individuales por jugador", + "comparison_title": "{squadron} // COMPARACIÓN EN CLASIFICACIÓN", + "comparison_desc": "Comparando con escuadrones clasificados {range}", + "current_position_field": "Posición actual", + "squadrons_shown_field": "Escuadrones mostrados", + "squadron_not_found_error": "Escuadrón no encontrado en la clasificación", + "no_nearby_error": "No se encontraron escuadrones cercanos", + "no_historical_error": "No se encontraron datos históricos para escuadrones cercanos", + "comparison_chart_failed": "Error al generar el gráfico de comparación", + "select_players_placeholder": "Selecciona jugadores (Página {page})" + }, + "loss_calc": { + "title": "Pérdida de puntos — {squadron}", + "players_leaving_field": "Jugadores que se van", + "share_of_total_field": "% del Total", + "points_lost_real_field": "Puntos perdidos (real)", + "points_lost_raw_field": "Puntos perdidos (bruto)", + "squadron_rating_field": "Clasificación del escuadrón", + "squadron_position_field": "Posición del escuadrón", + "positions_lost_field": "Posiciones perdidas", + "not_found_footer": "No encontrado en el escuadrón: {players}", + "fetch_failed": "Error al obtener datos del escuadrón: {error}", + "no_point_data": "No hay datos de puntos disponibles para este escuadrón.", + "no_matching_players": "No se encontraron jugadores coincidentes en **{squadron}**." + }, + "player": { + "select_player_placeholder": "Selecciona un jugador", + "no_stats_found": "❌ No se encontraron estadísticas para el UID: {uid}", + "no_vehicle_stats": "❌ No se encontraron estadísticas de vehículos para este jugador.", + "vehicles_found": "Se encontraron **{count}** vehículos para **{nick}**\nSelecciona un vehículo para ver estadísticas detalladas:", + "vehicle_select_placeholder": "Selecciona un vehículo (Página {page}/{total})", + "combat_stats_header": "**__ESTADÍSTICAS DE COMBATE__**", + "ground_kills_label": "**Elim. terrestres:** {value}", + "air_kills_label": "**Elim. aéreas:** {value}", + "total_kills_label": "**Eliminaciones totales:** {value}", + "assists_label": "**Asistencias:** {value}", + "deaths_label": "**Muertes:** {value}", + "kd_label": "**K/D:** {value}", + "captures_label": "**Capturas:** {value}", + "battle_record_header": "**__HISTORIAL DE BATALLAS__**", + "total_battles_label": "**Batallas totales:** {value}", + "wins_label": "**Victorias:** {value}", + "losses_label": "**Derrotas:** {value}", + "win_rate_label": "**% Victorias:** {value}%", + "stats_desc": "Estadísticas de **{nick}** (**{squadron}**)\nUID: `{uid}`", + "not_found_title": "Jugador no encontrado", + "not_found_desc": "No se encontró historial de partidas para `{player}`.", + "no_players_found": "No se encontraron jugadores que coincidan con **{username}**\nIntenta usar `/website` para buscar en el sitio web.", + "multiple_matches": "Se encontraron varias coincidencias, elige la correcta:", + "must_provide_input": "Debes proporcionar al menos un UID o nombre de usuario." + }, + "player_games": { + "no_recent_title": "Sin partidas recientes", + "no_recent_desc": "No se encontraron partidas para **{player}** en las últimas 8 horas.", + "squadron_label": "**Escuadrón:** {squadron}", + "record_label": "**V:** {wins} **D:** {losses} **%V:** {wr}%", + "comps_played_header": "\n\n**Comps jugadas**" + }, + "match": { + "missing_input_title": "Entrada faltante", + "missing_input_desc": "Proporciona un `match_id` o un `player_name`.", + "not_found_title": "Partida no encontrada", + "not_found_desc": "No se encontró una partida con el ID `{match_id}`.", + "invalid_data_title": "Datos de partida inválidos", + "invalid_data_desc": "No se pudieron analizar los datos de la repetición.", + "scoreboard_error_title": "Error en el marcador", + "scoreboard_error_desc": "Error al generar la imagen del marcador.", + "no_games_title": "Sin partidas", + "no_games_desc": "No se encontró historial de partidas para **{player}**.", + "recent_matches_title": "Partidas recientes de {player}", + "recent_matches_desc": "Mostrando hasta {count} partidas recientes. Selecciona una para ver el marcador completo.", + "select_match_placeholder": "Selecciona una partida para ver..." + }, + "compare": { + "no_players_found": "No se encontraron jugadores que coincidan con **{name}**.", + "multiple_matches": "Varias coincidencias para **{name}**: {matches}\nUsa un nombre más específico (las sugerencias del autocompletado son exactas).", + "could_not_resolve": "No se pudieron resolver los jugadores.", + "could_not_fetch": "❌ No se pudieron obtener estadísticas de **{name}**.", + "no_graph_data": "No hay datos disponibles de los últimos 90 días.", + "no_squadron_points_data": "No hay datos de puntos del escuadrón para {names} (jugador no encontrado en el historial del escuadrón rastreado).", + "graph_title": "Puntos del jugador — últimos 90 días", + "battles_label": "Batallas", + "wins_label": "Victorias", + "losses_label": "Derrotas", + "win_rate_label": "% Victorias", + "ground_kills_label": "Elim. terrestres", + "air_kills_label": "Elim. aéreas", + "total_kills_label": "Eliminaciones totales", + "assists_label": "Asistencias", + "deaths_label": "Muertes", + "kd_label": "K/D", + "captures_label": "Capturas" + }, + "squadron": { + "not_found_desc": "Escuadrón `{squadron}` no encontrado.", + "set_title": "✅ Escuadrón configurado", + "set_desc": "El escuadrón **{squadron}** ha sido configurado para este servidor.", + "short_name_field": "Nombre corto", + "long_name_field": "Nombre largo", + "swap_title": "✅ Escuadrón cambiado", + "swap_desc": "Se reemplazó **{old}** por **{new}** en este servidor.", + "already_set_title": "⚠️ Escuadrón ya configurado", + "already_set_desc": "Este servidor está configurado actualmente con **{old}**.\n¿Cambiarlo a **{new}**?", + "swap_cancelled": "❌ Cambio de escuadrón cancelado." + }, + "setup": { + "step1_title": "Configuración del servidor — Paso 1 de 3", + "step1_desc": "Este asistente te guiará para configurar el bot en tu servidor.\n\n**Paso 1** — Configura tu escuadrón\n**Paso 2** — Elige un canal de registros\n**Paso 3** — Elige un canal de puntos\n", + "step1_current_sq": "\nEscuadrón configurado actualmente: **[{short}] {long}**", + "step2_title": "Configuración del servidor — Paso 2 de 3", + "step2_desc": "Escuadrón configurado como **[{short}] {long}**.\n\n¿Dónde deben publicarse los **registros de batalla**?\nSelecciona un canal de texto a continuación, o salta este paso.", + "step3_title": "Configuración del servidor — Paso 3 de 3", + "step3_desc": "¿Dónde deben publicarse las **notificaciones de puntos**?\nSelecciona un canal de texto a continuación, o salta este paso.", + "step3_same_as_logs": "\n\nTambién puedes hacer clic en \"Igual que registros\" para reutilizar el canal de registros.", + "summary_title": "Configuración completa", + "summary_desc": "Puedes usar `/autolog-management` para cambiar estas opciones más tarde.", + "squadron_field": "Escuadrón", + "logs_channel_field": "Canal de registros", + "points_channel_field": "Canal de puntos", + "premium_required_field": "⚠️ Los registros de partidas requieren Premium", + "premium_required_value": "Los marcadores automáticos de partidas no se publicarán hasta que el servidor tenga una suscripción activa. Ejecuta `/unlock` para suscribirte ($2.99/mes).", + "modal_title": "Configurar escuadrón", + "modal_label": "Nombre corto del escuadrón", + "modal_placeholder": "ej. AXYS", + "squadron_not_found": "Escuadrón `{squadron}` no encontrado. Inténtalo de nuevo.", + "logs_channel_placeholder": "Selecciona un canal de registros...", + "points_channel_placeholder": "Selecciona un canal de puntos..." + }, + "meta_management": { + "squadron_not_found_title": "❌ Escuadrón no encontrado", + "squadron_not_found_desc": "No se encontró el ID de clan para el escuadrón: **{squadron}**", + "access_denied_title": "❌ Acceso denegado", + "access_denied_desc": "Contraseña incorrecta. Los datos meta de este escuadrón están protegidos.", + "data_locked_title": "🔐 Datos del escuadrón vinculados", + "data_locked_desc": "**{squadron}** tiene la vinculación de datos habilitada y no puede transferirse a otro servidor.\n\nEl propietario del escuadrón debe deshabilitar **Vincular datos del escuadrón** antes de que pueda trasladarse.", + "error_retrieving_settings": "❌ Error al obtener la configuración del servidor después de la transferencia. Inténtalo de nuevo.", + "error_retrieving_settings_retry": "❌ Error al obtener la configuración del servidor. Intenta ejecutar el comando de nuevo.", + "authenticated_title": "✅ Autenticado", + "authenticated_desc": "Contraseña verificada. Gestionando la configuración de **{squadron}**.", + "claimed_title": "✅ Escuadrón reclamado", + "claimed_desc": "**{squadron}** ha sido reclamado exitosamente para este servidor.", + "password_requirement_field": "🔒 Requisito de contraseña", + "data_lock_field": "🔐 Vinculación de datos del escuadrón", + "public_meta_field": "👥 Acceso meta público", + "access_password_field": "🔑 Contraseña de acceso", + "enabled_value": "✅ Habilitado", + "disabled_value": "❌ Deshabilitado", + "settings_title": "🔐 Configuración de gestión meta", + "settings_desc": "**Escuadrón:** {squadron}\n**ID de Clan:** {clan_id}", + "first_time_title": "🔐 Gestión meta - Primera configuración", + "first_time_owner_desc": "**Escuadrón:** {squadron}\n**ID de Clan:** {clan_id}\n\n🔑 Tu contraseña de acceso ha sido generada. **Guarda esta contraseña** — la necesitarás para autenticar el acceso a los datos meta en el futuro.\n\n**Contraseña:** `{password}`", + "first_time_non_owner_desc": "**Escuadrón:** {squadron}\n**ID de Clan:** {clan_id}\n\nEl escuadrón ha sido configurado. Pídele la contraseña de acceso al propietario del servidor.", + "settings_field": "Configuración", + "settings_hint": "Usa los botones de abajo para configurar los ajustes de acceso.", + "password_toggled": "✅ Requisito de contraseña: **{state}**", + "lock_toggled": "✅ Vinculación de datos del escuadrón: **{state}**", + "public_meta_toggled": "✅ Acceso meta público: **{state}**\n{detail}", + "public_meta_enabled_detail": "Los no administradores ahora pueden usar el comando `/meta`.", + "public_meta_disabled_detail": "Solo los administradores pueden usar el comando `/meta`.", + "owner_only_password": "❌ Solo el propietario del servidor puede cambiar la contraseña del escuadrón.", + "help_title": "📖 Ayuda de gestión meta", + "help_desc": "Explicación de cada ajuste y función:", + "help_password_field": "🔑 Contraseña de Acceso", + "help_password_value": "La contraseña de acceso de tu escuadrón. Solo el **propietario del servidor** puede ver la contraseña en el panel de configuración. Cualquier persona con la contraseña puede reclamar los datos meta de tu escuadrón en su servidor, así que mantenla segura.", + "help_require_field": "🔒 Requerir contraseña", + "help_require_value": "Cuando está habilitado, incluso los administradores de este servidor deben ingresar la contraseña del escuadrón para acceder a `/meta-management`. Agrega una capa extra de seguridad para evitar cambios accidentales.", + "help_lock_field": "🔐 Vincular datos del escuadrón", + "help_lock_value": "Cuando está habilitado, impide que el escuadrón sea transferido a otros servidores, incluso con la contraseña correcta. Debe deshabilitarse antes de que el escuadrón pueda transferirse.", + "help_public_field": "👥 Permitir meta público", + "help_public_value": "Cuando está habilitado, permite que los miembros no administradores usen el comando `/meta` para buscar vehículos del escuadrón. Cuando está deshabilitado, solo los administradores del servidor pueden usar `/meta`.", + "help_accounts_field": "📋 Actualizar cuentas meta", + "help_accounts_value": "Abre el gestor de lista de jugadores donde puedes agregar o eliminar jugadores del roster meta de tu escuadrón. Usa **Actualizar Todos los Miembros** para sincronizar todo tu escuadrón a la vez.", + "help_change_pw_field": "🔑 Cambiar contraseña", + "help_change_pw_value": "**Solo el propietario del servidor.** Cambia la contraseña de acceso del escuadrón y establece una pista opcional. La pista se muestra en el aviso de contraseña para ayudar a recordarla.", + "password_modal_title": "Contraseña de acceso del escuadrón", + "password_modal_label": "Ingresa la contraseña del escuadrón", + "password_modal_placeholder": "XXXX-XXXX-XXXX", + "change_pw_modal_title": "Cambiar contraseña del escuadrón", + "current_password_label": "Contraseña actual", + "current_password_placeholder": "Ingresa tu contraseña actual", + "new_password_label": "Nueva contraseña", + "new_password_placeholder": "Ingresa tu nueva contraseña", + "confirm_password_label": "Confirmar nueva contraseña", + "confirm_password_placeholder": "Vuelve a ingresar tu nueva contraseña", + "hint_label": "Pista de Contraseña (Opcional)", + "hint_placeholder": "Una pista para recordar la contraseña", + "pw_incorrect": "❌ La contraseña actual es incorrecta.", + "pw_mismatch": "❌ Las contraseñas nuevas no coinciden. Inténtalo de nuevo.", + "pw_empty": "❌ La nueva contraseña no puede estar vacía.", + "pw_changed": "✅ Contraseña actualizada correctamente para **{squadron}**.\n**Nueva Contraseña:** `{password}`", + "pw_changed_hint": "\n**Pista:** {hint}", + "player_add_modal_title": "Agregar Jugador al Roster Meta", + "player_add_label": "UID o Apodo del Jugador", + "player_add_placeholder": "Ingresa el UID del jugador (ej. 12345678) o su apodo", + "player_not_found": "❌ Jugador `{player}` no encontrado en la base de datos Players_Global.\n", + "roster_title": "📋 Gestión de Roster Meta - {squadron}", + "roster_desc": "**ID de Clan del Escuadrón:** {clan_id}\n**Total de Jugadores:** {count}", + "roster_page_field": "Jugadores (Página {page}/{total})", + "no_players_field": "Sin Jugadores", + "no_players_hint": "Aún no hay jugadores en el roster meta. Haz clic en **Agregar Jugador** para comenzar.", + "remove_player_placeholder": "Selecciona el jugador a eliminar...", + "fetch_members_failed": "❌ Error al obtener los miembros del escuadrón: {error}", + "no_members_found": "❌ No se encontraron miembros en el escuadrón o la llamada a la API falló.", + "roster_synced": "✅ Roster sincronizado con el escuadrón.", + "roster_added": "**+{count}** agregados", + "roster_removed": "**-{count}** eliminados (salieron del escuadrón)", + "roster_up_to_date": "**{count}** ya actualizados", + "refreshing_vehicles": "Actualizando datos de vehículos en segundo plano..." + }, + "meta": { + "not_configured": "❌ Datos meta no configurados para este servidor. Ejecuta `/meta-management` primero.", + "no_permission": "❌ Necesitas permisos de administrador para usar este comando.\nLos administradores pueden habilitar el acceso público desde `/meta-management`.", + "no_results": "❌ Ningún jugador en el roster de tu escuadrón tiene **{vehicle}**.", + "no_results_admin_hint": "\n*¿Esperas que alguien lo tenga? Haz clic en el botón de actualizar miembros en `/meta-management` y verifica.*", + "search_title": "🔍 Resultados de Búsqueda - {vehicle}", + "matches_found": "**Coincidencias Encontradas:** {count} jugador(es)", + "spawns_label": "Apariciones", + "deaths_label": "Muertes", + "gk_label": "ET", + "ak_label": "EA", + "points_label": "Puntos", + "kdr_label": "KDR", + "games_label": "Partidas", + "no_points": "—" + }, + "top": { + "title": "**Top 20 Escuadrones**", + "rating_label": "**Clasificación:** {value}", + "air_kills_label": "**Elim. Aéreas:** {value}", + "ground_kills_label": "**Elim. Terrestres:** {value}", + "deaths_label": "**Muertes:** {value}", + "kd_label": "**K/D:** {value}", + "win_rate_label": "**% Victorias:** {value}", + "playtime_label": "**Tiempo de Juego:** {value}", + "fetch_failed": "Error al obtener datos del escuadrón." + }, + "analytics": { + "no_data_title": "Sin Datos", + "no_matches_desc": "No se encontraron partidas.", + "no_comp_desc": "No se encontraron datos de composición.", + "no_consistency_desc": "Datos de jugadores insuficientes (mínimo 50 partidas).", + "no_time_desc": "No se encontraron datos de tiempo.", + "unknown_view": "Vista desconocida.", + "map_title": "% Victorias por Mapa: {squadron}", + "comp_title": "Composiciones de Equipo: {squadron}", + "consistency_title": "Consistencia de Jugadores: {squadron}", + "consistency_desc": "Ordenado por ratio K/D", + "time_title": "Rendimiento por Hora del Día: {squadron}", + "eu_timeslot": "\n**Franja Horaria EU**", + "na_timeslot": "\n**Franja Horaria NA**", + "off_peak": "\n**Horas Bajas**", + "matchups_title": "📜 {squadron} — Historial de Enfrentamientos", + "matchups_won_field": "🏆 Más Victorias Contra", + "matchups_lost_field": "💀 Más Derrotas Contra", + "no_matchups_desc": "No hay partidas registradas contra otros escuadrones." + }, + "recent": { + "title": "Partidas Recientes: {squadron}", + "no_matches_desc": "No se encontraron partidas para este escuadrón." + }, + "h2h": { + "two_required_title": "Se Requieren Dos Escuadrones", + "two_required_desc": "Proporciona al menos un escuadrón, o usa `/set-squadron` e indica el escuadrón oponente.", + "provide_a_desc": "Proporciona `squadron_a` o usa `/set-squadron` primero.", + "provide_b_desc": "Proporciona `squadron_b` o usa `/set-squadron` primero.", + "squadron_not_found_title": "Escuadrón No Encontrado", + "same_squadron_title": "Mismo Escuadrón", + "same_squadron_desc": "No puedes verificar el cara a cara contra ti mismo.", + "record_desc": "**Historial:** {a_wins}V - {b_wins}D ({total} partidas)", + "no_matches_desc": "No hay partidas registradas entre **{a}** y **{b}**." + }, + "autolog": { + "premium_active_line": "✅ **Premium:** Activo — el autoregistro está habilitado para este servidor.", + "premium_not_subscribed_line": "❌ **Premium:** Sin suscripción — usa `/unlock` para habilitar el autoregistro.", + "premium_free_line": "⚪ **Premium:** Sin suscripción — usa `/unlock` para suscribirte ($2.99/mes). *(Los autologs son gratuitos para todos los servidores por ahora.)*", + "what_to_do": "\n\n¿Qué deseas hacer?", + "select_notif_type": "Selecciona el tipo de notificación a gestionar:", + "select_notif_placeholder": "Selecciona el tipo de notificación", + "logs_option": "Registros", + "logs_option_desc": "Gestionar notificaciones de Registros", + "points_option": "Puntos", + "points_option_desc": "Gestionar notificaciones de Puntos", + "leaderboard_option": "Clasificación", + "leaderboard_option_desc": "Gestionar notificaciones de Clasificación", + "selected_type": "Seleccionado **{type}**. Ahora elige el escuadrón a gestionar:", + "select_squadron_placeholder": "Selecciona un escuadrón", + "select_squadron_page_placeholder": "Selecciona un escuadrón (Página {page})", + "no_squadrons_available": "No hay escuadrones disponibles para este tipo de notificación.", + "managing_global": "Gestionando **{type}** (global) en el canal **{channel}**.", + "managing_squadron": "Gestionando **{type}** para el escuadrón **{squadron}** en el canal **{channel}**.", + "select_channel": "Selecciona un nuevo canal:", + "select_channel_placeholder": "Selecciona un canal", + "select_channel_page_placeholder": "Selecciona un canal (Página {page})", + "global_toggled": "{type} (global) está ahora {state}.", + "squadron_toggled": "{type} para **{squadron}** está ahora {state}.", + "channel_updated_global": "Se actualizó {type} (global) a {channel}", + "channel_updated_squadron": "Se actualizó {type} para **{squadron}** a {channel}", + "diagnose_channel_placeholder": "Selecciona un canal para diagnosticar...", + "select_channel_diagnose": "Selecciona el canal a diagnosticar:", + "game_not_logged_title": "Partida No Registrada", + "game_not_logged_desc": "Usa `/unlock` para suscribirte al nivel **Standard** (o superior) y recibir marcadores automáticos de partidas.", + "server_not_upgraded_title": "⚠️ Servidor No Actualizado", + "server_not_upgraded_autolog_desc": "Este servidor no tiene una suscripción Premium activa.\n\n**Los marcadores automáticos de partidas dejarán de enviarse a servidores no actualizados después del .**\n\nUsa `/unlock` para suscribirte y seguir recibiendo registros automáticos de partidas.", + "replay_not_available": "Los datos de la repetición aún no están disponibles — ¡espera un momento e inténtalo de nuevo!", + "too_many_videos": "Demasiados videos en proceso ahora mismo — inténtalo de nuevo en un momento.", + "video_gen_failed": "Error al generar el video: `{error}`", + "video_missing": "Error al generar el video de repetición - el archivo de salida falta o está vacío.", + "video_too_large": "El video de repetición es demasiado grande para subir ({file_mb:.1f} MB). El límite del servidor es {limit_mb:.0f} MB.", + "video_web_fallback": "También puedes ver esta partida en {url}", + "video_upload_failed": "El video es demasiado grande para subir — véalo en el sitio web:\n{url}", + "video_unexpected_error": "Error inesperado al generar el video de repetición: `{error}`", + "replay_not_found": "Datos de repetición no encontrados para la sesión `{session_id}` en disco.", + "chat_log_title": "**Registro de Chat para la Partida [{session_id}]({url})**", + "chat_log_part_title": "**Registro de Chat para la Partida [{session_id}]({url}) (Parte {part}/{total})**", + "chat_log_part_only": "**Registro de Chat (Parte {part}/{total})**", + "no_chat_log": "No se encontró registro de chat para la sesión `{session_id}`.", + "chat_log_error": "Error inesperado al cargar el registro de chat: `{error}`", + "battle_log_title": "**Registro de Batalla para la Partida [{session_id}]({url})**", + "battle_log_part_title": "**Registro de Batalla para la Partida [{session_id}]({url}) (Parte {part}/{total})**", + "battle_log_part_only": "**Registro de Batalla (Parte {part}/{total})**", + "no_battle_log": "No se encontraron eventos de combate para la sesión `{session_id}`.", + "battle_log_error": "Error inesperado al cargar el registro de batalla: `{error}`", + "points_update_title": "**{squadron} {region} Actualización de Puntos**", + "points_update_desc": "# {old_total} -> {new_total} {chart}{wl_line}{placement_line}\n\n**Cambios de Jugadores:**", + "points_table_header": "Nombre Cambio Ahora\n", + "wl_line": "\n**{squadron}** fue **{wins}V-{losses}D** esta sesión", + "placement_rose": "\n**{squadron}** subió al **{new_place}** desde el **{old_place}**", + "placement_fell": "\n**{squadron}** bajó al **{new_place}** desde el **{old_place}**", + "points_not_logged_title": "Puntos No Registrados", + "points_not_logged_desc": "Usa `/unlock` para suscribirte al nivel **Standard** (o superior) y recibir actualizaciones automáticas de puntos.", + "server_not_upgraded_points_desc": "Este servidor no tiene una suscripción Premium activa.\n\n**Las actualizaciones automáticas dejarán de enviarse a servidores no actualizados después del .**\n\nUsa `/unlock` para suscribirte y seguir recibiendo actualizaciones automáticas.", + "leave_title": "⚠️ Jugador Abandonó {squadron}", + "leave_desc": "**{nick}** ({uid}) ha abandonado el escuadrón.\n\nÚltimos puntos registrados: **{points}**", + "no_squadrons_desc": "No squadrons configured", + "no_channels_desc": "No channels available", + "over_cap_title": "Escuadrón sobre el límite de tu nivel", + "over_cap_desc": "Tu servidor está en el nivel **{tier}**, que permite **{cap} {notif}** escuadrones. El escuadrón **{squadron}** supera el límite y no se está registrando. Mejora tu nivel para restaurarlo.", + "over_cap_footer": "Mejora en srebot-meow.ing/premium o con /unlock", + "wildcard_blocked_title": "Wildcard requiere un nivel superior", + "wildcard_blocked_desc": "Las entradas wildcard (*, all, everything) sólo están disponibles en Pro o Max. Tu servidor está en **{tier}** para {notif}. Mejora para habilitarlo.", + "cap_header": "{used}/{cap} {notif} activos — nivel {tier}" + }, + "track": { + "squadron_not_found": "Escuadrón no encontrado.", + "fetch_failed": "Error al obtener información del escuadrón." + }, + "unlock": { + "title": "SRE Bot Premium", + "desc": "**Desbloquea funciones premium para este servidor.**\n\nPremium incluye:\n> • Publicación automática de marcadores\n> • Registros de chat y batalla\n> • Búsqueda de repeticiones\n> • Consultas de /comp ilimitadas\n> • Soporte prioritario\n\n**$2.99 / mes · por servidor · cancela cuando quieras**\n\n⚠️ La facturación de Discord solo está disponible en países seleccionados. Si el botón de abajo muestra **\"Producto No Disponible\"**, puede ser por un país no compatible o el uso de un **dispositivo móvil**. Usa el botón **Suscribirse desde el Sitio Web** en su lugar.", + "already_subscribed_title": "SRE Bot Premium", + "already_subscribed_desc": "✅ **¡Este servidor ya está suscrito!**", + "manage_discord_field": "Gestionar Suscripción", + "manage_discord_value": "Tu suscripción es a través de **Discord**.\nPara cancelar, ve a **Configuración de Usuario → Suscripciones** en Discord.", + "manage_website_field": "Gestionar Suscripción", + "manage_website_value": "Tu suscripción es a través del **sitio web**.\nAdministrala en [whop.com/billing](https://whop.com/billing).", + "coming_soon_field": "Próximamente", + "coming_soon_value": "Las suscripciones Premium aún no están disponibles. ¡Vuelve pronto!", + "current_tier": "Estás en el plan **{tier}**.", + "upgrade_to": "Mejorar a {tier}", + "upgrade_to_value": "Más escuadrones y funciones mejorando a **{tier}**." + }, + "language": { + "prompt": "Selecciona el idioma de tu servidor:", + "select_placeholder": "Elige el idioma de tu servidor", + "language_set": "Idioma configurado a {language}.", + "translate_prompt": "Selecciona un idioma de destino abajo 👇", + "translate_placeholder": "Elige un idioma de destino…", + "translate_result": "**{author} → {language}:**\n{text}", + "translation_unavailable": "Traducción no disponible (DeepL no configurado)", + "translation_failed": "Error de traducción" + }, + "misc": { + "credits_title": "Créditos", + "credits_desc": "**Meowww**\n\n> **NotSoToothless** - Desarrollador Principal, Manager del Bot, Manager de Comunidad\n> **Z3R0** - Desarrollador, Desarrollador de Optimización, Ingeniero de Bases de Datos\n> **Clippii (Heidi)** - Desarrollador, Desarrollador de Sitio Web, Manager de Comunidad\n> **LivingTheDagor** - Desarrollador, Desarrollador de Parser, Consultor\n> **Lux_** - Ingeniero de API, Desarrollador de Spectra\n> **Konigallerwaffen** - Consultor de Feedback y Funcionalidades\n> **Žralok Tonda** - Traductor Checo\n> **Styevy**, **Lopais** - Traductores Alemanes\n> **Susogus**, **playforfun698** - Traductores Polacos\n> **Bobr** - Traductor Ruso\n\n\n[¿Te gustaría unirte?](https://discord.gg/BCvkK8JhPe)", + "schedule_title": "CALENDARIO DE TEMPORADA", + "schedule_not_found_title": "Calendario No Encontrado", + "schedule_not_found_desc": "Aún no hay datos del calendario disponibles.", + "news_no_news_title": "Sin Novedades", + "news_no_news_desc": "No hay anuncios en este momento. ¡Vuelve más tarde!", + "news_footer": "¡Gracias por tu apoyo! ᕙᘘᗢ", + "help_title": "Guía del Bot", + "donate_title": "Apoya a SRE Bot", + "donate_desc": "Si disfrutas usar SRE Bot y quieres apoyar su desarrollo, ¡considera invitarme un café!\n\n**[Donar en Ko-fi](https://ko-fi.com/notsotoothless)**\n\nCada contribución ayuda a mantener el bot en funcionamiento y apoya nuevas funciones. ¡Gracias!", + "status_title": "Estado del bot", + "status_last_received": "Última partida recibida", + "status_avg_ttl": "TTL promedio (últimas 30)", + "status_no_data": "Sin datos aún", + "status_gaijin_slow": "⚠️ Servidores de Gaijin lentos", + "help_commands_header": "**Resumen de comandos**", + "help_links": "Para más detalles, lee la documentación [aquí]({docs}) o pide soporte [aquí]({support}).", + "help_terms": "[Términos de servicio]({terms}) • [Política de privacidad]({terms})" + }, + "dev": { + "restricted_dev_team": "This command is restricted to the dev team.", + "restricted_bot_owner": "❌ This command is restricted to the bot owner.", + "invalid_server_id": "❌ Invalid server ID. Must be a 17-19 digit Discord server ID.", + "expiry_too_soon": "❌ Expiry timestamp must be at least 1 month from now.\n> Now: \n> Minimum: \n> You provided: ", + "entitlement_write_failed": "❌ Failed to write entitlement: {error}", + "entitlement_created_title": "✅ Manual Entitlement Created", + "entitlement_created_desc": "**Server:** {guild_name} (`{server_id}`)\n**Expires:** ()\n**Created:** ", + "query_failed": "Query failed: {error}", + "health_title": "Bot Health Dashboard", + "health_uptime": "Uptime", + "health_guilds": "Guilds", + "health_games_processed": "Games Processed", + "health_tasks": "Tasks", + "health_websocket": "WebSocket", + "health_never": "never", + "health_errors": "({count} errors)", + "health_last_msg": "last msg {ago} ({count} total)", + "health_avg_ttl": "Avg TTL (Last 30)", + "entitlements_title": "Active Entitlements ({count} total)", + "entitlements_no_entries": "No entitlements.", + "entitlements_empty_title": "Active Entitlements", + "entitlements_empty_desc": "No active entitlements found.", + "entitlements_tag_discord": "Discord", + "entitlements_tag_whop": "Whop", + "entitlements_tag_manual": "Manual", + "query_prefix": "Query: {name}" + }, + "leaderboard_alarm": { + "title": "🏆 Clasificación de Escuadrones", + "top15_desc": "Los 15 mejores escuadrones con estadísticas, enviado 35 minutos después del cierre de la franja horaria.\nEste se envió .", + "top30_desc": "Escuadrones del 16 al 30 con estadísticas.", + "not_logged_title": "Clasificación No Registrada", + "not_logged_desc": "Usa `/unlock` para suscribirte al nivel **Standard** (o superior) y recibir actualizaciones automáticas de clasificación.", + "server_not_upgraded_title": "⚠️ Servidor No Actualizado", + "server_not_upgraded_desc": "Este servidor no tiene una suscripción Premium activa.\n\n**Las actualizaciones automáticas dejarán de enviarse a servidores no actualizados después del .**\n\nUsa `/unlock` para suscribirte y seguir recibiendo actualizaciones automáticas." + }, + "stacks": { + "stack_title": "Stack de {leader}", + "stack_named_title": "{name}", + "no_members": "Sin miembros aún.", + "members_field": "Miembros ({count}/{max})", + "queue_field": "Cola ({count}/{max})", + "manage_title": "Gestionar Stack", + "no_pending_requests": "Sin solicitudes pendientes.", + "disbanded_title": "Stack [Disuelto]", + "disbanded_desc": "Este stack fue disuelto por el líder.", + "expired_title": "Stack [Expirado]", + "expired_desc": "Este stack ha expirado.", + "join_modal_title": "Solicitar unirse al stack", + "join_vehicle_label": "¿Con qué jugarás?", + "join_vehicle_placeholder": "ej. F-16C, WZ305...", + "ping_modal_title": "Mensaje de ping", + "ping_message_label": "Mensaje personalizado (opcional)", + "ping_message_placeholder": "ej. ¡Vengan ahora! ¡El stack empieza!", + "rename_modal_title": "Renombrar stack", + "rename_label": "Nombre del stack", + "rename_placeholder": "ej. Búhos Nocturnos, Escuadrón Alfa...", + "select_new_leader": "Seleccionar nuevo líder…", + "select_applicants": "Seleccionar solicitantes…", + "no_pending_applications": "Sin solicitudes pendientes.", + "select_to_remove": "Seleccionar personas a eliminar…", + "no_members_or_applicants": "Sin miembros ni solicitantes.", + "select_to_ping": "Seleccionar personas para notificar individualmente…", + "stack_not_found": "❌ Stack no encontrado.", + "no_longer_exists": "❌ Este stack ya no existe.", + "member_not_exists": "❌ Ese miembro ya no existe.", + "already_has_stack": "❌ Ese jugador ya tiene un stack activo.", + "already_member": "❌ Ya eres miembro de este stack.", + "already_applied": "❌ Ya tienes una solicitud pendiente para este stack.", + "queue_full": "❌ La cola está llena ({max}/{max}). Inténtalo más tarde.", + "application_sent": "✅ ¡Solicitud enviada! El líder del stack la revisará.", + "stack_disbanded": "✅ Stack disuelto.", + "cancelled": "Cancelado.", + "select_member_transfer": "❌ Selecciona un miembro para transferir el liderazgo.", + "ownership_transferred": "✅ Liderazgo transferido a {nick}. Has salido del stack.", + "select_applicant_first": "❌ Selecciona al menos un solicitante primero.", + "stack_full": "❌ El stack está lleno ({max}/{max} miembros).", + "select_person_first": "❌ Selecciona al menos una persona primero.", + "no_one_to_ping": "❌ No hay nadie a quien notificar.", + "ping_footer": "Notificado por {leader} para {stack}.", + "pinged": "✅ ¡Notificado!", + "select_from_dropdown": "❌ Selecciona al menos una persona del menú desplegable primero.", + "stack_renamed": "✅ Stack renombrado a **{name}**.", + "only_member_use_disband": "❌ Eres el único miembro. Usa **Disolver stack** para terminarlo.", + "select_transfer_prompt": "Selecciona un miembro para transferir el liderazgo antes de salir:", + "left_stack": "✅ Has salido del stack.", + "application_withdrawn": "✅ Tu solicitud ha sido retirada.", + "not_member_or_applicant": "❌ No eres miembro ni solicitante de este stack.", + "leader_only_manage": "❌ Solo el líder del stack puede gestionarlo.", + "leader_only_disband": "❌ Solo el líder del stack puede disolverlo.", + "confirm_disband": "¿Estás seguro de que quieres disolver este stack? Esta acción no se puede deshacer.", + "already_active_stack": "⚠️ Ya tienes un stack activo. Si el mensaje original desapareció (ej. tras reinicio del bot), puedes forzar la disolución y empezar de nuevo.", + "force_created": "✅ Stack anterior disuelto. Nuevo stack creado.", + "no_active_stack": "❌ No tienes un stack activo. Usa `/stack-create` para crear uno.", + "could_not_parse_channel": "⚠️ No se pudo procesar el ID del canal almacenado." + }, + "commands": { + "common": { + "season": "La temporada para generar la tarjeta", + "theme": "Tema de color de la tarjeta", + "squadron_short": "Nombre corto del escuadrón", + "player_username": "Nombre del jugador", + "choice_dark": "Oscuro", + "choice_light": "Claro" + }, + "comp": { + "description": "Buscar las últimas compos conocidas de un equipo", + "squadron_short": "Nombre corto del equipo enemigo" + }, + "quick_log": { + "description": "Configurar una alarma para este escuadrón en este canal", + "squadron_name": "Nombre CORTO del escuadrón a vigilar", + "type": "Elige Logs, Puntos, Clasificación, BR Semanal o Ambos", + "choice_logs": "Logs", + "choice_points": "Puntos", + "choice_leaderboard": "Clasificación", + "choice_both": "Ambos (Logs + Puntos)", + "choice_weekly_br": "BR Semanal" + }, + "sq_info": { + "description": "Obtener información de un escuadrón" + }, + "sq_info_graph": { + "description": "Mostrar un gráfico de la composición de la plantilla por actividad y tasa de victoria (temporada actual)" + }, + "sq_card": { + "description": "Generar una tarjeta de temporada para un escuadrón", + "squadron": "Nombre corto del escuadrón" + }, + "sq_stats": { + "description": "Mostrar los puntos de un escuadrón en el tiempo" + }, + "loss_calculator": { + "description": "Calcular pérdida de puntos si jugadores dejan un escuadrón", + "player1": "Jugador que se va", + "player_optional": "Jugador que se va (opcional)" + }, + "website": { + "description": "Obtener un enlace al sitio de SRE Bot" + }, + "card": { + "description": "Generar una tarjeta de temporada para un jugador" + }, + "player_stats": { + "description": "Ver estadísticas detalladas de vehículos de un jugador", + "username": "Usuario WT para pedir stats", + "uid": "UID WT para pedir stats" + }, + "view_player_games": { + "description": "Ver las últimas 20 partidas de un jugador" + }, + "view_match": { + "description": "Ver marcador de partida por ID o jugador", + "match_id": "ID hexadecimal de sesión de la partida", + "player_name": "Jugador para ver sus partidas recientes" + }, + "compare": { + "description": "Comparar estadísticas SQB acumuladas de jugadores", + "player1": "Primer jugador", + "player2": "Segundo jugador", + "player_optional": "Jugador adicional (opcional)" + }, + "leaderboard": { + "description": "Obtener la clasificación global de SRE Bot" + }, + "set_squadron": { + "description": "Definir el tag de escuadrón de este servidor", + "abbreviated_name": "Nombre corto del escuadrón a definir" + }, + "setup": { + "description": "Configurar el bot para este servidor" + }, + "meta_management": { + "description": "Gestionar acceso a datos meta de este servidor" + }, + "meta": { + "description": "Buscar roster meta por nombre de vehículo", + "vehicle": "Nombre del vehículo a buscar" + }, + "top": { + "description": "Ver los 20 mejores escuadrones con estadísticas" + }, + "language": { + "description": "Cambiar el idioma del bot." + }, + "translate_message": { + "name": "Traducir mensaje" + }, + "sq_track": { + "description": "Seguir un escuadrón y comparar desde la última revisión", + "squadron_short_name": "Nombre corto del escuadrón a seguir" + }, + "analytics": { + "description": "Ver análisis SQB avanzados de un escuadrón", + "view": "Qué vista de análisis mostrar", + "choice_maps": "Victorias por mapa", + "choice_comps": "Composiciones de equipo", + "choice_consistency": "Consistencia de jugadores", + "choice_time": "Hora del día", + "choice_matchups": "Historial de enfrentamientos" + }, + "recent": { + "description": "Mostrar batallas recientes de un escuadrón", + "length": "Número de partidas a mostrar" + }, + "vs": { + "description": "Historial cara a cara entre dos escuadrones", + "squadron_a": "Primer escuadrón", + "squadron_b": "Segundo escuadrón" + }, + "autolog_management": { + "description": "Gestionar notificaciones autolog y diagnosticar permisos" + }, + "diagnose_perms": { + "description": "Diagnosticar permisos autolog de este canal" + }, + "unlock": { + "description": "Desbloquear Premium para este servidor" + }, + "credits": { + "description": "Ver el equipo acreditado por este proyecto" + }, + "schedule": { + "description": "Ver el calendario BR de la temporada actual" + }, + "news": { + "description": "Ver últimas noticias y anuncios de SRE Bot" + }, + "help": { + "description": "Ver guía, ToS y enlaces de soporte" + }, + "donate": { + "description": "Apoyar el desarrollo de SRE Bot" + }, + "stack_create": { + "description": "Crear un stack de jugadores", + "vehicle": "¿Con qué vehículo empezarás?" + }, + "stack_manage": { + "description": "Volver a publicar tu stack activo en este canal" + }, + "bot_status": { + "description": "Ver estado del bot: última partida recibida y TTL promedio" + } + }, + "permission": { + "blacklisted_title": "❌ Bloqueado", + "blacklisted_desc": "No puedes usar este comando porque estás bloqueado.", + "reason_line": "**Motivo:** {reason}", + "access_denied_title": "⛔ Acceso denegado", + "no_permission_desc": "No tienes permiso para usar este comando.", + "unexpected_error_title": "❗ Error, repórtalo...." + }, + "weekly_br": { + "title_wildcard": "Informe BR Semanal — {br} BR", + "title_squadron": "Informe BR Semanal — [{tag}] {long} • {br} BR", + "window_label": "Periodo: {start} → {end}", + "wildcard_desc_first": "Top {count} escuadrones por ELO • Puestos {low}–{high}", + "wildcard_desc_second": "Top {count} escuadrones por ELO • Puestos {low}–{high}", + "squadron_stats_line": "- {games} partidas • K/D {kdr} • Victorias {wr}%", + "top_players_inline_header": "🥇 Mejores jugadores:", + "player_line_short": " {rank}. {nick} ⭐ {score} ({games}p)", + "top_players_header": "**Top {count} jugadores por ELO:**", + "player_line_full": "{rank}. **{nick}** ⭐ {score} • {games} partidas • K/D {kdr}", + "squadron_header_line": "ELO de escuadrón: {score} • {games} partidas • Victorias {wr}% • K/D {kdr}", + "squadron_header_no_aggregate": "ELO de escuadrón: poca actividad de equipo esta semana.", + "no_data": "No hay partidas registradas de [{tag}] en esta rotación de BR." + } +} diff --git a/BOT/locales/fr.json b/BOT/locales/fr.json new file mode 100644 index 0000000..528b079 --- /dev/null +++ b/BOT/locales/fr.json @@ -0,0 +1,856 @@ +{ + "common": { + "error_title": "Erreur", + "no_data_title": "Aucune donnée", + "access_denied_title": "Accès refusé", + "access_denied_desc": "Ce serveur a été mis sur liste noire.", + "no_players_selected": "Aucun joueur sélectionné. Veuillez sélectionner au moins un joueur.", + "must_use_in_server": "Cette commande doit être utilisée dans un serveur.", + "could_not_resolve_channel": "Impossible de résoudre le salon sélectionné.", + "failed_update_setting": "❌ Échec de la mise à jour du paramètre.", + "configuration_not_found": "Configuration introuvable.", + "no_channel_selected": "Aucun salon sélectionné.", + "no_selection_received": "Aucune sélection reçue.", + "database_error": "❌ Erreur de base de données : {error}", + "enabled": "Activé", + "disabled": "Désactivé", + "not_configured": "Non configuré", + "unknown": "Inconnu", + "rating_field": "Classement", + "battles_field": "Batailles", + "wins_field": "Victoires", + "losses_field": "Défaites", + "win_rate_field": "Taux de victoire", + "kills_field": "Éliminations", + "deaths_field": "Morts", + "kd_field": "K/D", + "members_field": "Membres", + "placement_field": "Position", + "points_field": "Points", + "ground_kills_field": "Éliminations terrestres", + "air_kills_field": "Éliminations aériennes", + "total_kills_field": "Éliminations totales", + "assists_field": "Assistances", + "captures_field": "Captures", + "none_option": "Aucun" + }, + "buttons": { + "skip": "Passer", + "previous": "Précédent", + "next": "Suivant", + "prev": "Préc.", + "prev_arrow": "◀ Précédent", + "next_arrow": "Suivant ▶", + "prev_arrow_only": "◀", + "next_arrow_only": "▶", + "generate_chart": "📊 Générer le graphique", + "show_graph": "Afficher le graphique", + "view_player_stats": "📊 Voir les stats des joueurs", + "compare_nearby": "📈 Comparer les escadrons proches", + "confirm_swap": "Oui, remplacer", + "cancel_swap": "Non, garder l'ancien", + "set_squadron": "Définir l'escadron", + "same_as_logs": "Même que Logs", + "require_password": "🔒 Exiger un mot de passe", + "password_required": "🔒 Mot de passe requis", + "lock_data": "🔐 Lier les données de l'escadron", + "data_locked": "🔐 Données liées à ce serveur", + "allow_public": "👥 Autoriser le meta public", + "public_enabled": "👥 Meta public activé", + "update_accounts": "📋 Mettre à jour les comptes meta", + "change_password": "🔑 Changer le mot de passe", + "help": "❓ Aide", + "add_player": "➕ Ajouter un Joueur", + "update_all": "🔄 Mettre à Jour Tous les Membres", + "back_to_settings": "⬅ Retour aux Paramètres", + "manage_notifications": "Gérer les notifications", + "diagnose_permissions": "Diagnostiquer les permissions", + "enable": "Activer", + "disable": "Désactiver", + "change_channel": "Changer de salon", + "view_replay": "Voir le Replay", + "view_website": "Voir sur le Site", + "view_video": "Voir la Vidéo", + "view_log": "Voir le Log", + "view_chat": "Voir le Chat", + "subscribe_website": "S'abonner via le Site", + "yes_disband": "Oui, dissoudre", + "cancel": "Annuler", + "transfer_leave": "Transférer et quitter", + "accept_selected": "Accepter la sélection", + "accept_all": "Tout accepter", + "decline_selected": "Refuser la sélection", + "back": "Retour", + "remove_all": "Tout retirer", + "remove_active": "Retirer les actifs", + "remove_queued": "Retirer en attente", + "remove_selected": "Retirer la sélection", + "ping_all": "Notifier tout le monde", + "ping_active": "Notifier les actifs", + "ping_queued": "Notifier en attente", + "ping_selected": "Notifier la sélection", + "accept_members": "Accepter des membres", + "remove_members": "Retirer des membres", + "ping_members": "Notifier les membres", + "rename_stack": "Renommer le stack", + "request_to_join": "Demander à rejoindre", + "leave_withdraw": "Quitter / Se retirer", + "manage_stack": "Gérer le stack ⚙️", + "disband_stack": "Dissoudre le stack", + "force_disband_create": "Forcer la dissolution et créer un nouveau" + }, + "events": { + "guild_join_title": "Merci de m'avoir ajouté !", + "guild_join_desc": "Lancez `/setup` pour configurer le bot sur ce serveur." + }, + "comp": { + "not_found_title": "Comps introuvables", + "not_found_desc": "Aucune donnée pour **{squadron}**, réessayez plus tard.", + "error_loading_title": "Erreur de chargement des comps", + "error_loading_desc": "Impossible de charger les données de comp : {error}", + "title": "Comps pour {squadron}", + "desc": "Comps vues dans les {minutes} dernières minutes", + "no_recent_title": "Aucune comp récente", + "no_recent_desc": "Aucune comp dans les {minutes} dernières minutes.", + "comp_title": "COMP {index}", + "last_seen_label": "**Vu pour la dernière fois** : {timestamp}{warning}", + "comp_label": "**Comp** : {notation}", + "no_players_recorded": "Aucun joueur enregistré.", + "limit_reached_title": "Limite de comps atteinte", + "limit_reached_desc": "Ce serveur a utilisé les {limit} recherches de comps pour ce créneau. Abonnez-vous (avec /unlock) pour un accès illimité ou attendez le prochain créneau.", + "remaining_footer": "{remaining}/{limit} recherches de comps restantes pour ce créneau" + }, + "quick_log": { + "invalid_type": "Le type ne peut être défini que sur Logs, Points, Classement, BR Hebdomadaire ou Les deux.", + "squadron_required": "Vous devez fournir un nom d'escadron pour les alarmes Logs, Points ou les deux.", + "wildcard_logs_only": "Seuls les Logs peuvent être définis sur un escadron générique.", + "squadron_not_resolved": "L'escadron `{squadron}` n'a pas pu être résolu.", + "save_failed": "Échec de la sauvegarde des préférences. Veuillez réessayer plus tard.", + "premium_warning": "\n\n> ⚠️ **Les logs de partie nécessitent Premium.** Lancez `/unlock` pour vous abonner (2,99 $/mois) — les logs ne seront pas publiés avant cela.", + "leaderboard_set": "L'alarme du Classement Global est définie sur ce salon.", + "both_set": "Les alarmes Logs et Points pour {squadron} sont définies sur ce salon.{premium_note}", + "alarm_set": "L'alarme {alarm_type} pour {squadron} est définie sur ce salon.{premium_note}", + "weekly_br_wildcard_set": "Rapport BR hebdomadaire (top 20 escadrons) configuré pour ce salon. Envoyé à la fin de chaque rotation BR.", + "weekly_br_squadron_set": "Rapport BR hebdomadaire pour {squadron} (top 15 joueurs) configuré pour ce salon. Envoyé à la fin de chaque rotation BR." + }, + "diagnostics": { + "title": "Diagnostics autolog", + "channel_permissions_header": "**Permissions du salon** (<#{channel_id}>)", + "perms_needed": " ^ L'autologging a besoin de tout ce qui précède pour envoyer des tableaux de scores.", + "server_squadron_header": "**Escadron du serveur** (`/set-squadron`)", + "server_squadron_short": " Court : `{short}`", + "server_squadron_long": " Long : `{long}`", + "server_squadron_not_set": " Non défini (la couleur de la barre du tableau de scores affichera 'not_set')", + "autolog_prefs_header": "**Préférences Autolog** (`/quick-log`)", + "autolog_none_configured": " ❌ AUCUN configuré - l'autologging n'enverra RIEN à ce serveur.", + "autolog_setup_hint": " Utilisez `/quick-log Logs` dans le salon cible pour configurer.", + "autolog_no_logs_channels": " ❌ Aucun salon Logs configuré. Seulement Points/Classement trouvés.", + "autolog_enable_hint": " Utilisez `/quick-log Logs` pour activer l'autologging.", + "selected_channel_tag": " **(salon sélectionné)**", + "missing_send_attach": " (envoi/pièce jointe manquant)", + "channel_not_found": " (salon introuvable)", + "invalid_channel_id": " (ID de salon invalide)", + "premium_status_header": "**Statut Premium** (`/unlock`)", + "premium_active": " ✅ Ce serveur dispose d'un abonnement Premium actif.", + "premium_not_subscribed": " ❌ Ce serveur n'a **pas** d'abonnement Premium.", + "premium_autolog_required": " L'autologging nécessite Premium. Utilisez `/unlock` pour vous abonner.", + "premium_not_subscribed_free": " ⚪ Non abonné — utilisez `/unlock` pour vous abonner (2,99 $/mois).", + "premium_free_note": " *(Les autologs sont gratuits pour tous les serveurs pour le moment.)*" + }, + "sq_info": { + "title": "Info escadron : {squadron}", + "placement_field": "Position", + "total_points_field": "Points totaux", + "total_members_field": "Membres totaux", + "members_field": "Membres", + "fetch_failed": "Échec de la récupération des informations de l'escadron." + }, + "sq_info_graph": { + "title": "{squadron} — SQ-INFO (Saison {season})", + "embed_title": "{squadron} — Composition de l'effectif", + "embed_desc": "Saison **{season}** · Médiane de parties : **{median}** · Noyau : **{core}** · Actifs : **{active}** · Faibles : **{weak}**\nBarres triées par parties décr. ; hauteur = taux de victoire. Noyau = top 30 % de TV et parties ≥ médiane. Actifs = top 30–45 % de TV et parties ≈ médiane. Faibles = tous les autres.", + "core_threshold_line": "NOYAU ≥ {wr} %", + "weak_threshold_line": "FAIBLES < {wr} %", + "y_label": "Taux de victoire", + "core_header": "NOYAU — {count} · TV {avg}%", + "active_header": "ACTIFS — {count} · TV {avg}%", + "weak_header": "FAIBLES — {count} · TV {avg}%", + "no_active_season": "Aucune saison active trouvée. Réessayez au début de la prochaine.", + "no_members": "Aucun membre actuel trouvé pour {squadron}." + }, + "recap_card": { + "unknown_season": "Saison inconnue : `{season}`.", + "no_clan_id": "Impossible de résoudre l'identifiant de l'escadron `{squadron}`.", + "render_failed": "Échec de la génération de la carte récapitulative de saison. Réessayez plus tard." + }, + "sq_stats": { + "no_data_title": "Aucune donnée", + "no_data_desc": "Aucune donnée historique trouvée pour l'escadron : {squadron}", + "title": "{squadron} // ESCADRON", + "desc": "Tendance du score total (Derniers {count} points de données)", + "previous_score_field": "Score précédent", + "current_score_field": "Score actuel", + "change_field": "Variation", + "player_title": "{squadron} // JOUEURS", + "player_desc": "Tendances des points par joueur", + "comparison_title": "{squadron} // COMPARAISON AU CLASSEMENT", + "comparison_desc": "Comparaison avec les escadrons classés {range}", + "current_position_field": "Position actuelle", + "squadrons_shown_field": "Escadrons affichés", + "squadron_not_found_error": "Escadron introuvable dans le classement", + "no_nearby_error": "Aucun escadron proche trouvé", + "no_historical_error": "Aucune donnée historique trouvée pour les escadrons proches", + "comparison_chart_failed": "Échec de la génération du graphique de comparaison", + "select_players_placeholder": "Sélectionner des joueurs (Page {page})" + }, + "loss_calc": { + "title": "Perte de points — {squadron}", + "players_leaving_field": "Joueurs partant", + "share_of_total_field": "% part du total", + "points_lost_real_field": "Points perdus (réel)", + "points_lost_raw_field": "Points perdus (brut)", + "squadron_rating_field": "Classement de l'escadron", + "squadron_position_field": "Position de l'escadron", + "positions_lost_field": "Positions perdues", + "not_found_footer": "Introuvable dans l'escadron : {players}", + "fetch_failed": "Échec de la récupération des données de l'escadron : {error}", + "no_point_data": "Aucune donnée de points disponible pour cet escadron.", + "no_matching_players": "Aucun joueur correspondant trouvé dans **{squadron}**." + }, + "player": { + "select_player_placeholder": "Sélectionner un joueur", + "no_stats_found": "❌ Aucune stat trouvée pour l'UID : {uid}", + "no_vehicle_stats": "❌ Aucune stat de véhicule trouvée pour ce joueur.", + "vehicles_found": "**{count}** véhicules trouvés pour **{nick}**\nSélectionnez un véhicule pour voir les stats détaillées :", + "vehicle_select_placeholder": "Sélectionner un véhicule (Page {page}/{total})", + "combat_stats_header": "**__STATS DE COMBAT__**", + "ground_kills_label": "**Éliminations terrestres :** {value}", + "air_kills_label": "**Éliminations aériennes :** {value}", + "total_kills_label": "**Éliminations totales :** {value}", + "assists_label": "**Assistances :** {value}", + "deaths_label": "**Morts :** {value}", + "kd_label": "**K/D :** {value}", + "captures_label": "**Captures :** {value}", + "battle_record_header": "**__BILAN DE BATAILLES__**", + "total_battles_label": "**Batailles totales :** {value}", + "wins_label": "**Victoires :** {value}", + "losses_label": "**Défaites :** {value}", + "win_rate_label": "**Taux de victoire :** {value}%", + "stats_desc": "Stats pour **{nick}** (**{squadron}**)\nUID : `{uid}`", + "not_found_title": "Joueur introuvable", + "not_found_desc": "Aucun historique de partie trouvé pour `{player}`.", + "no_players_found": "Aucun joueur trouvé correspondant à **{username}**\nEssayez d'utiliser `/website` pour rechercher sur le site.", + "multiple_matches": "Plusieurs correspondances trouvées, choisissez la bonne ci-dessous :", + "must_provide_input": "Vous devez fournir au moins un UID ou un nom d'utilisateur." + }, + "player_games": { + "no_recent_title": "Aucune partie récente", + "no_recent_desc": "Aucune partie trouvée pour **{player}** dans les 8 dernières heures.", + "squadron_label": "**Escadron :** {squadron}", + "record_label": "**V :** {wins} **D :** {losses} **TV :** {wr}%", + "comps_played_header": "\n\n**Comps Jouées**" + }, + "match": { + "missing_input_title": "Entrée manquante", + "missing_input_desc": "Fournissez soit un `match_id` soit un `player_name`.", + "not_found_title": "Match introuvable", + "not_found_desc": "Impossible de trouver un match avec l'ID `{match_id}`.", + "invalid_data_title": "Données de match invalides", + "invalid_data_desc": "Les données de replay n'ont pas pu être analysées.", + "scoreboard_error_title": "Erreur du tableau de scores", + "scoreboard_error_desc": "Échec de la génération de l'image du tableau de scores.", + "no_games_title": "Aucune partie trouvée", + "no_games_desc": "Aucun historique de partie trouvé pour **{player}**.", + "recent_matches_title": "Matchs récents pour {player}", + "recent_matches_desc": "Affichage de jusqu'à {count} parties récentes. Sélectionnez-en une pour voir le tableau de scores complet.", + "select_match_placeholder": "Sélectionner un match à voir..." + }, + "compare": { + "no_players_found": "Aucun joueur trouvé correspondant à **{name}**.", + "multiple_matches": "Plusieurs correspondances pour **{name}** : {matches}\nVeuillez utiliser un nom plus précis (les suggestions de saisie automatique sont exactes).", + "could_not_resolve": "Impossible de résoudre les joueurs.", + "could_not_fetch": "❌ Impossible de récupérer les stats pour **{name}**.", + "no_graph_data": "Aucune donnée disponible pour les 90 derniers jours.", + "no_squadron_points_data": "Aucune donnée de points d'escadron pour {names} (joueur introuvable dans l'historique de l'escadron suivi).", + "graph_title": "Points des joueurs — 90 derniers jours", + "battles_label": "Batailles", + "wins_label": "Victoires", + "losses_label": "Défaites", + "win_rate_label": "Taux de victoire", + "ground_kills_label": "Éliminations terrestres", + "air_kills_label": "Éliminations aériennes", + "total_kills_label": "Éliminations totales", + "assists_label": "Assistances", + "deaths_label": "Morts", + "kd_label": "K/D", + "captures_label": "Captures" + }, + "squadron": { + "not_found_desc": "Escadron `{squadron}` introuvable.", + "set_title": "✅ Escadron défini", + "set_desc": "L'escadron **{squadron}** a été défini pour ce serveur.", + "short_name_field": "Nom court", + "long_name_field": "Nom long", + "swap_title": "✅ Escadron remplacé", + "swap_desc": "Remplacé **{old}** par **{new}** pour ce serveur.", + "already_set_title": "⚠️ Escadron déjà défini", + "already_set_desc": "Ce serveur est actuellement défini sur **{old}**.\nLe remplacer par **{new}** ?", + "swap_cancelled": "❌ Changement d'escadron annulé." + }, + "setup": { + "step1_title": "Configuration du serveur — Étape 1 sur 3", + "step1_desc": "Cet assistant vous guidera dans la configuration du bot pour votre serveur.\n\n**Étape 1** — Définir votre escadron\n**Étape 2** — Choisir un salon de logs\n**Étape 3** — Choisir un salon de points\n", + "step1_current_sq": "\nEscadron actuellement configuré : **[{short}] {long}**", + "step2_title": "Configuration du serveur — Étape 2 sur 3", + "step2_desc": "Escadron défini sur **[{short}] {long}**.\n\nOù les **logs de batailles** doivent-ils être publiés ?\nSélectionnez un salon textuel ci-dessous, ou passez cette étape.", + "step3_title": "Configuration du serveur — Étape 3 sur 3", + "step3_desc": "Où les **notifications de points** doivent-elles être publiées ?\nSélectionnez un salon textuel ci-dessous, ou passez cette étape.", + "step3_same_as_logs": "\n\nVous pouvez aussi cliquer sur \"Même que Logs\" pour réutiliser le salon des logs.", + "summary_title": "Configuration terminée", + "summary_desc": "Vous pouvez utiliser `/autolog-management` pour modifier ces paramètres plus tard.", + "squadron_field": "Escadron", + "logs_channel_field": "Salon des logs", + "points_channel_field": "Salon des points", + "premium_required_field": "⚠️ Les logs de partie nécessitent Premium", + "premium_required_value": "Les tableaux de scores automatiques ne seront pas publiés tant que ce serveur n'aura pas un abonnement actif. Lancez `/unlock` pour vous abonner (2,99 $/mois).", + "modal_title": "Définir l'escadron", + "modal_label": "Nom court de l'escadron", + "modal_placeholder": "ex. AXYS", + "squadron_not_found": "Escadron `{squadron}` introuvable. Veuillez réessayer.", + "logs_channel_placeholder": "Sélectionner un salon de logs...", + "points_channel_placeholder": "Sélectionner un salon de points..." + }, + "meta_management": { + "squadron_not_found_title": "❌ Escadron introuvable", + "squadron_not_found_desc": "Impossible de trouver l'ID de clan pour l'escadron : **{squadron}**", + "access_denied_title": "❌ Accès Refusé", + "access_denied_desc": "Mot de passe incorrect. Les données meta de cet escadron sont protégées.", + "data_locked_title": "🔐 Données de l'escadron liées", + "data_locked_desc": "**{squadron}** a la liaison des données activée et ne peut pas être transféré vers un autre serveur.\n\nLe propriétaire de l'escadron doit désactiver **Lier les données de l'escadron** avant de pouvoir le déplacer.", + "error_retrieving_settings": "❌ Erreur lors de la récupération des paramètres du serveur après le transfert. Veuillez réessayer.", + "error_retrieving_settings_retry": "❌ Erreur lors de la récupération des paramètres du serveur. Veuillez relancer la commande.", + "authenticated_title": "✅ Authentifié", + "authenticated_desc": "Mot de passe vérifié. Gestion des paramètres pour **{squadron}**.", + "claimed_title": "✅ Escadron revendiqué", + "claimed_desc": "**{squadron}** a été revendiqué avec succès pour ce serveur !", + "password_requirement_field": "🔒 Exigence de mot de passe", + "data_lock_field": "🔐 Liaison des données de l'escadron", + "public_meta_field": "👥 Accès meta public", + "access_password_field": "🔑 Mot de passe d'accès", + "enabled_value": "✅ Activé", + "disabled_value": "❌ Désactivé", + "settings_title": "🔐 Paramètres de gestion meta", + "settings_desc": "**Escadron :** {squadron}\n**ID de Clan :** {clan_id}", + "first_time_title": "🔐 Gestion meta — Première configuration", + "first_time_owner_desc": "**Escadron :** {squadron}\n**ID de Clan :** {clan_id}\n\n🔑 Votre mot de passe d'accès a été généré. **Sauvegardez ce mot de passe** — vous en aurez besoin pour authentifier l'accès aux données meta à l'avenir.\n\n**Mot de Passe :** `{password}`", + "first_time_non_owner_desc": "**Escadron :** {squadron}\n**ID de Clan :** {clan_id}\n\nL'escadron a été configuré. Demandez le mot de passe d'accès au propriétaire du serveur.", + "settings_field": "Paramètres", + "settings_hint": "Utilisez les boutons ci-dessous pour configurer les paramètres d'accès.", + "password_toggled": "✅ Exigence de mot de passe : **{state}**", + "lock_toggled": "✅ Liaison des données de l'escadron : **{state}**", + "public_meta_toggled": "✅ Accès meta public : **{state}**\n{detail}", + "public_meta_enabled_detail": "Les non-administrateurs peuvent maintenant utiliser la commande `/meta`.", + "public_meta_disabled_detail": "Seuls les administrateurs peuvent utiliser la commande `/meta`.", + "owner_only_password": "❌ Seul le propriétaire du serveur peut changer le mot de passe de l'escadron.", + "help_title": "📖 Aide de gestion meta", + "help_desc": "Explication de chaque paramètre et fonctionnalité :", + "help_password_field": "🔑 Mot de passe d'accès", + "help_password_value": "Le mot de passe d'accès de votre escadron. Seul le **propriétaire du serveur** peut voir le mot de passe dans le panneau de paramètres. Toute personne ayant le mot de passe peut revendiquer les données meta de votre escadron sur son serveur, alors gardez-le sécurisé.", + "help_require_field": "🔒 Exiger un mot de passe", + "help_require_value": "Quand activé, même les administrateurs de ce serveur doivent saisir le mot de passe de l'escadron pour accéder à `/meta-management`. Ajoute une couche de sécurité supplémentaire pour prévenir les modifications accidentelles.", + "help_lock_field": "🔐 Lier les données de l'escadron", + "help_lock_value": "Quand activé, empêche le transfert de l'escadron vers d'autres serveurs, même avec le bon mot de passe. Doit être désactivé avant de pouvoir transférer l'escadron.", + "help_public_field": "👥 Autoriser le meta public", + "help_public_value": "Quand activé, permet aux membres non-administrateurs d'utiliser la commande `/meta` pour rechercher des véhicules d'escadron. Quand désactivé, seuls les administrateurs du serveur peuvent utiliser `/meta`.", + "help_accounts_field": "📋 Mettre à jour les comptes meta", + "help_accounts_value": "Ouvre le gestionnaire de liste de joueurs où vous pouvez ajouter ou supprimer des joueurs de la liste meta de votre escadron. Utilisez **Mettre à jour tous les membres** pour synchroniser tout votre escadron en une fois.", + "help_change_pw_field": "🔑 Changer le mot de passe", + "help_change_pw_value": "**Propriétaire du serveur uniquement.** Modifiez le mot de passe d'accès de l'escadron et définissez un indice optionnel. L'indice s'affiche dans l'invite de mot de passe pour aider à s'en souvenir.", + "password_modal_title": "Mot de passe d'accès de l'escadron", + "password_modal_label": "Entrez le mot de passe de l'escadron", + "password_modal_placeholder": "XXXX-XXXX-XXXX", + "change_pw_modal_title": "Changer le mot de passe de l'escadron", + "current_password_label": "Mot de passe actuel", + "current_password_placeholder": "Entrez votre mot de passe actuel", + "new_password_label": "Nouveau mot de passe", + "new_password_placeholder": "Entrez votre nouveau mot de passe", + "confirm_password_label": "Confirmer le nouveau mot de passe", + "confirm_password_placeholder": "Ressaisissez votre nouveau mot de passe", + "hint_label": "Indice de mot de passe (optionnel)", + "hint_placeholder": "Un indice pour se souvenir du mot de passe", + "pw_incorrect": "❌ Le mot de passe actuel est incorrect.", + "pw_mismatch": "❌ Les nouveaux mots de passe ne correspondent pas. Veuillez réessayer.", + "pw_empty": "❌ Le nouveau mot de passe ne peut pas être vide.", + "pw_changed": "✅ Mot de passe mis à jour avec succès pour **{squadron}**.\n**Nouveau mot de passe :** `{password}`", + "pw_changed_hint": "\n**Indice :** {hint}", + "player_add_modal_title": "Ajouter un Joueur à la Liste Meta", + "player_add_label": "UID ou Pseudo du Joueur", + "player_add_placeholder": "Entrez l'UID du joueur (ex. 12345678) ou son pseudo", + "player_not_found": "❌ Joueur `{player}` introuvable dans la base de données Players_Global.\n", + "roster_title": "📋 Gestion de la liste meta - {squadron}", + "roster_desc": "**ID de Clan de l'Escadron :** {clan_id}\n**Joueurs Totaux :** {count}", + "roster_page_field": "Joueurs (Page {page}/{total})", + "no_players_field": "Aucun joueur", + "no_players_hint": "Aucun joueur ajouté à la liste meta pour l'instant. Cliquez sur **Ajouter un Joueur** pour commencer.", + "remove_player_placeholder": "Sélectionner le joueur à supprimer...", + "fetch_members_failed": "❌ Échec de la récupération des membres de l'escadron : {error}", + "no_members_found": "❌ Aucun membre trouvé dans l'escadron ou l'appel API a échoué.", + "roster_synced": "✅ Liste synchronisée avec l'escadron.", + "roster_added": "**+{count}** ajouté(s)", + "roster_removed": "**-{count}** supprimé(s) (ont quitté l'escadron)", + "roster_up_to_date": "**{count}** déjà à jour", + "refreshing_vehicles": "Actualisation des données de véhicules en arrière-plan..." + }, + "meta": { + "not_configured": "❌ Données meta non configurées pour ce serveur. Lancez d'abord `/meta-management`.", + "no_permission": "❌ Vous devez avoir les permissions d'administrateur pour utiliser cette commande.\nLes administrateurs peuvent activer l'accès public via `/meta-management`.", + "no_results": "❌ Aucun joueur de votre liste d'escadron ne possède **{vehicle}**.", + "no_results_admin_hint": "\n*Vous vous attendiez à ce que quelqu'un l'ait ? Cliquez sur le bouton de mise à jour des membres dans `/meta-management` et vérifiez.*", + "search_title": "🔍 Résultats de Recherche - {vehicle}", + "matches_found": "**Correspondances Trouvées :** {count} joueur(s)", + "spawns_label": "Apparitions", + "deaths_label": "Morts", + "gk_label": "EL", + "ak_label": "EA", + "points_label": "Points", + "kdr_label": "KDR", + "games_label": "Parties", + "no_points": "—" + }, + "top": { + "title": "**Top 20 escadrons**", + "rating_label": "**Classement :** {value}", + "air_kills_label": "**Éliminations aériennes :** {value}", + "ground_kills_label": "**Éliminations terrestres :** {value}", + "deaths_label": "**Morts :** {value}", + "kd_label": "**K/D :** {value}", + "win_rate_label": "**Taux de victoire :** {value}", + "playtime_label": "**Temps de jeu :** {value}", + "fetch_failed": "Échec de la récupération des données de l'escadron." + }, + "analytics": { + "no_data_title": "Aucune donnée", + "no_matches_desc": "Aucun match trouvé.", + "no_comp_desc": "Aucune donnée de composition trouvée.", + "no_consistency_desc": "Pas assez de données de joueurs (minimum 50 matchs).", + "no_time_desc": "Aucune donnée temporelle trouvée.", + "unknown_view": "Vue inconnue.", + "map_title": "Taux de victoire par carte : {squadron}", + "comp_title": "Compositions d'équipe : {squadron}", + "consistency_title": "Constance des joueurs : {squadron}", + "consistency_desc": "Trié par ratio K/D", + "time_title": "Performance par heure de la journée : {squadron}", + "eu_timeslot": "\n**Plage Horaire EU**", + "na_timeslot": "\n**Plage Horaire NA**", + "off_peak": "\n**Hors Pic**", + "matchups_title": "📜 {squadron} — Historique des Affrontements", + "matchups_won_field": "🏆 Plus de Victoires Contre", + "matchups_lost_field": "💀 Plus de Défaites Contre", + "no_matchups_desc": "Aucun match enregistré contre d'autres escadrons." + }, + "recent": { + "title": "Matchs récents : {squadron}", + "no_matches_desc": "Aucun match trouvé pour cet escadron." + }, + "h2h": { + "two_required_title": "Deux escadrons requis", + "two_required_desc": "Fournissez au moins un escadron, ou utilisez `/set-squadron` et indiquez l'adversaire.", + "provide_a_desc": "Fournissez `squadron_a` ou utilisez d'abord `/set-squadron`.", + "provide_b_desc": "Fournissez `squadron_b` ou utilisez d'abord `/set-squadron`.", + "squadron_not_found_title": "Escadron introuvable", + "same_squadron_title": "Même escadron", + "same_squadron_desc": "Vous ne pouvez pas vérifier les face-à-face contre vous-même.", + "record_desc": "**Bilan :** {a_wins}V - {b_wins}D ({total} parties)", + "no_matches_desc": "Aucun match enregistré entre **{a}** et **{b}**." + }, + "autolog": { + "premium_active_line": "✅ **Premium :** Actif — l'autologging est activé pour ce serveur.", + "premium_not_subscribed_line": "❌ **Premium :** Non abonné — utilisez `/unlock` pour activer l'autologging.", + "premium_free_line": "⚪ **Premium :** Non abonné — utilisez `/unlock` pour vous abonner (2,99 $/mois). *(Les autologs sont gratuits pour tous les serveurs pour le moment.)*", + "what_to_do": "\n\nQue souhaitez-vous faire ?", + "select_notif_type": "Sélectionnez le type de notification à gérer :", + "select_notif_placeholder": "Sélectionner le type de notification", + "logs_option": "Logs", + "logs_option_desc": "Gérer les notifications de Logs", + "points_option": "Points", + "points_option_desc": "Gérer les notifications de Points", + "leaderboard_option": "Classement", + "leaderboard_option_desc": "Gérer les notifications de Classement", + "selected_type": "**{type}** sélectionné. Choisissez maintenant l'escadron à gérer :", + "select_squadron_placeholder": "Sélectionner un escadron", + "select_squadron_page_placeholder": "Sélectionner un escadron (Page {page})", + "no_squadrons_available": "Aucun escadron disponible pour ce type de notification.", + "managing_global": "Gestion de **{type}** (global) dans le salon **{channel}**.", + "managing_squadron": "Gestion de **{type}** pour l'escadron **{squadron}** dans le salon **{channel}**.", + "select_channel": "Sélectionnez un nouveau salon :", + "select_channel_placeholder": "Sélectionner un salon", + "select_channel_page_placeholder": "Sélectionner un salon (Page {page})", + "global_toggled": "{type} (global) est maintenant {state}.", + "squadron_toggled": "{type} pour **{squadron}** est maintenant {state}.", + "channel_updated_global": "{type} (global) mis à jour vers {channel}", + "channel_updated_squadron": "{type} pour **{squadron}** mis à jour vers {channel}", + "diagnose_channel_placeholder": "Sélectionner un salon à diagnostiquer...", + "select_channel_diagnose": "Sélectionnez le salon à diagnostiquer :", + "game_not_logged_title": "Partie non enregistrée", + "game_not_logged_desc": "Utilisez `/unlock` pour souscrire au tier **Standard** (ou supérieur) et recevoir les tableaux de scores automatiques.", + "server_not_upgraded_title": "⚠️ Serveur non mis à niveau", + "server_not_upgraded_autolog_desc": "Ce serveur ne dispose pas d'un abonnement Premium actif.\n\n**Les tableaux de scores automatiques cesseront d'être envoyés aux serveurs non mis à niveau après le .**\n\nUtilisez `/unlock` pour vous abonner et continuer à recevoir les logs de parties automatiques.", + "replay_not_available": "Les données de replay ne sont pas encore disponibles — attendez un peu puis réessayez !", + "too_many_videos": "Trop de vidéos en cours de rendu — veuillez réessayer dans un moment.", + "video_gen_failed": "Erreur lors de la génération de la vidéo : `{error}`", + "video_missing": "Échec de la génération de la vidéo de replay - fichier de sortie manquant ou vide.", + "video_too_large": "Vidéo de replay trop grande pour être téléchargée ({file_mb:.1f} Mo). La limite du serveur est de {limit_mb:.0f} Mo.", + "video_web_fallback": "Vous pouvez aussi voir ce match sur {url}", + "video_upload_failed": "Vidéo trop grande pour être téléchargée — voyez-la sur le site :\n{url}", + "video_unexpected_error": "Erreur inattendue lors de la génération de la vidéo de replay : `{error}`", + "replay_not_found": "Données de replay introuvables pour la session `{session_id}` sur le disque.", + "chat_log_title": "**Log de Chat pour la Partie [{session_id}]({url})**", + "chat_log_part_title": "**Log de Chat pour la Partie [{session_id}]({url}) (Partie {part}/{total})**", + "chat_log_part_only": "**Log de Chat (Partie {part}/{total})**", + "no_chat_log": "Aucun log de chat trouvé pour la session `{session_id}`.", + "chat_log_error": "Erreur inattendue lors du chargement du log de chat : `{error}`", + "battle_log_title": "**Log de Bataille pour la Partie [{session_id}]({url})**", + "battle_log_part_title": "**Log de Bataille pour la Partie [{session_id}]({url}) (Partie {part}/{total})**", + "battle_log_part_only": "**Log de Bataille (Partie {part}/{total})**", + "no_battle_log": "Aucun événement de combat trouvé pour la session `{session_id}`.", + "battle_log_error": "Erreur inattendue lors du chargement du log de bataille : `{error}`", + "points_update_title": "**{squadron} {region} Mise à jour des points**", + "points_update_desc": "# {old_total} -> {new_total} {chart}{wl_line}{placement_line}\n\n**Changements des joueurs :**", + "points_table_header": "Nom Changement Maintenant\n", + "wl_line": "\n**{squadron}** a terminé **{wins}V-{losses}D** cette session", + "placement_rose": "\n**{squadron}** est monté au **{new_place}** depuis le **{old_place}**", + "placement_fell": "\n**{squadron}** est descendu au **{new_place}** depuis le **{old_place}**", + "points_not_logged_title": "Points non enregistrés", + "points_not_logged_desc": "Utilisez `/unlock` pour souscrire au tier **Standard** (ou supérieur) et recevoir les mises à jour automatiques des points.", + "server_not_upgraded_points_desc": "Ce serveur ne dispose pas d'un abonnement Premium actif.\n\n**Les mises à jour automatiques cesseront d'être envoyées aux serveurs non mis à niveau après le .**\n\nUtilisez `/unlock` pour vous abonner et continuer à recevoir les mises à jour automatiques.", + "leave_title": "⚠️ Joueur ayant quitté {squadron}", + "leave_desc": "**{nick}** ({uid}) a quitté l'escadron.\n\nDerniers points enregistrés : **{points}**", + "no_squadrons_desc": "No squadrons configured", + "no_channels_desc": "No channels available", + "over_cap_title": "Escadron au-dessus de la limite du tier", + "over_cap_desc": "Votre serveur est sur le tier **{tier}**, qui autorise **{cap} {notif}** escadrons. L'escadron **{squadron}** dépasse la limite et n'est pas enregistré. Passez à un tier supérieur pour le restaurer.", + "over_cap_footer": "Mise à niveau sur srebot-meow.ing/premium ou via /unlock", + "wildcard_blocked_title": "Le logging wildcard nécessite un tier supérieur", + "wildcard_blocked_desc": "Les entrées wildcard (*, all, everything) ne sont disponibles que sur Pro ou Max. Votre serveur est sur **{tier}** pour {notif}. Mise à niveau requise.", + "cap_header": "{used}/{cap} {notif} activés — tier {tier}" + }, + "track": { + "squadron_not_found": "Escadron introuvable.", + "fetch_failed": "Échec de la récupération des informations de l'escadron." + }, + "unlock": { + "title": "SRE Bot Premium", + "desc": "**Débloquez les fonctionnalités premium pour ce serveur.**\n\nPremium inclut :\n> • Publications automatiques de tableaux de scores\n> • Logs de chat et de bataille\n> • Recherches de replays\n> • Recherches /comp illimitées\n> • Support prioritaire\n\n**2,99 $ / mois · par serveur · résiliable à tout moment**\n\n⚠️ La facturation Discord n'est disponible que dans certains pays. Si le bouton ci-dessous affiche **«Produit Indisponible»**, cela peut être dû à un pays non pris en charge ou à l'utilisation d'un **appareil mobile**. Utilisez le bouton **S'abonner via le Site** à la place.", + "already_subscribed_title": "SRE Bot Premium", + "already_subscribed_desc": "✅ **Ce serveur est déjà abonné !**", + "manage_discord_field": "Gérer l'Abonnement", + "manage_discord_value": "Votre abonnement est via **Discord**.\nPour annuler, allez dans **Paramètres Utilisateur → Abonnements** sur Discord.", + "manage_website_field": "Gérer l'Abonnement", + "manage_website_value": "Votre abonnement est via le **site web**.\nGérez-le sur [whop.com/billing](https://whop.com/billing).", + "coming_soon_field": "Bientôt disponible", + "coming_soon_value": "Les abonnements Premium ne sont pas encore disponibles. Revenez bientôt !", + "current_tier": "Vous êtes sur le plan **{tier}**.", + "upgrade_to": "Passer à {tier}", + "upgrade_to_value": "Plus d'escadrons et de fonctionnalités en passant à **{tier}**." + }, + "language": { + "prompt": "Veuillez sélectionner la langue de votre serveur :", + "select_placeholder": "Choisissez la langue de votre serveur", + "language_set": "Langue définie sur {language}.", + "translate_prompt": "Sélectionne une langue cible ci-dessous 👇", + "translate_placeholder": "Choisissez une langue cible…", + "translate_result": "**{author} → {language} :**\n{text}", + "translation_unavailable": "Traduction indisponible (DeepL non configuré)", + "translation_failed": "Traduction échouée" + }, + "misc": { + "credits_title": "Crédits", + "credits_desc": "**Meowww**\n\n> **NotSoToothless** - Développeur Principal, Gestionnaire du Bot, Gestionnaire de Communauté\n> **Z3R0** - Développeur, Développeur en Optimisation, Ingénieur Base de Données\n> **Clippii (Heidi)** - Développeur, Développeur Web, Gestionnaire de Communauté\n> **LivingTheDagor** - Développeur, Développeur de Parser, Consultant\n> **Lux_** - Ingénieur API, Développeur Spectra\n> **Konigallerwaffen** - Consultant Retours et Fonctionnalités\n> **Žralok Tonda** - Traducteur Tchèque\n> **Styevy**, **Lopais** - Traducteurs Allemands\n> **Susogus**, **playforfun698** - Traducteurs Polonais\n> **Bobr** - Traducteur Russe\n\n\n[Envie de nous rejoindre ?](https://discord.gg/BCvkK8JhPe)", + "schedule_title": "CALENDRIER DE SAISON", + "schedule_not_found_title": "Calendrier introuvable", + "schedule_not_found_desc": "Aucune donnée de calendrier n'est disponible pour l'instant.", + "news_no_news_title": "Aucune actualité", + "news_no_news_desc": "Il n'y a aucune annonce pour le moment. Revenez plus tard !", + "news_footer": "Merci pour votre soutien ! ᙙᙖᘢ", + "help_title": "Guide du Bot", + "donate_title": "Soutenir SRE Bot", + "donate_desc": "Si vous aimez utiliser SRE Bot et souhaitez soutenir son développement, pensez à m'offrir un café !\n\n**[Faire un don sur Ko-fi](https://ko-fi.com/notsotoothless)**\n\nChaque contribution aide à maintenir le bot en marche et soutient de nouvelles fonctionnalités. Merci !", + "status_title": "Statut du bot", + "status_last_received": "Dernière partie reçue", + "status_avg_ttl": "TTL moyen (30 dernières)", + "status_no_data": "Aucune donnée pour le moment", + "status_gaijin_slow": "⚠️ Serveurs Gaijin lents", + "help_commands_header": "**Aperçu des commandes**", + "help_links": "Pour les détails, lis la documentation [ici]({docs}) ou demande du support [ici]({support}).", + "help_terms": "[Conditions d'utilisation]({terms}) • [Politique de confidentialité]({terms})" + }, + "dev": { + "restricted_dev_team": "This command is restricted to the dev team.", + "restricted_bot_owner": "❌ This command is restricted to the bot owner.", + "invalid_server_id": "❌ Invalid server ID. Must be a 17-19 digit Discord server ID.", + "expiry_too_soon": "❌ Expiry timestamp must be at least 1 month from now.\n> Now: \n> Minimum: \n> You provided: ", + "entitlement_write_failed": "❌ Failed to write entitlement: {error}", + "entitlement_created_title": "✅ Manual Entitlement Created", + "entitlement_created_desc": "**Server:** {guild_name} (`{server_id}`)\n**Expires:** ()\n**Created:** ", + "query_failed": "Query failed: {error}", + "health_title": "Bot Health Dashboard", + "health_uptime": "Uptime", + "health_guilds": "Guilds", + "health_games_processed": "Games Processed", + "health_tasks": "Tasks", + "health_websocket": "WebSocket", + "health_never": "never", + "health_errors": "({count} errors)", + "health_last_msg": "last msg {ago} ({count} total)", + "health_avg_ttl": "Avg TTL (Last 30)", + "entitlements_title": "Active Entitlements ({count} total)", + "entitlements_no_entries": "No entitlements.", + "entitlements_empty_title": "Active Entitlements", + "entitlements_empty_desc": "No active entitlements found.", + "entitlements_tag_discord": "Discord", + "entitlements_tag_whop": "Whop", + "entitlements_tag_manual": "Manual", + "query_prefix": "Query: {name}" + }, + "leaderboard_alarm": { + "title": "🏆 Classement des Escadrons", + "top15_desc": "Top 15 escadrons avec statistiques, envoyé 35 minutes après la clôture du créneau.\nCelui-ci envoyé .", + "top30_desc": "Escadrons 16 à 30 avec statistiques.", + "not_logged_title": "Classement non enregistré", + "not_logged_desc": "Utilisez `/unlock` pour souscrire au tier **Standard** (ou supérieur) et recevoir les mises à jour automatiques du classement.", + "server_not_upgraded_title": "⚠️ Serveur non mis à niveau", + "server_not_upgraded_desc": "Ce serveur ne dispose pas d'un abonnement Premium actif.\n\n**Les mises à jour automatiques cesseront d'être envoyées aux serveurs non mis à niveau après le .**\n\nUtilisez `/unlock` pour vous abonner et continuer à recevoir les mises à jour automatiques." + }, + "stacks": { + "stack_title": "Stack de {leader}", + "stack_named_title": "{name}", + "no_members": "Aucun membre pour l'instant.", + "members_field": "Membres ({count}/{max})", + "queue_field": "File d'attente ({count}/{max})", + "manage_title": "Gérer le stack", + "no_pending_requests": "Aucune demande en attente.", + "disbanded_title": "Stack [Dissous]", + "disbanded_desc": "Ce stack a été dissous par le chef.", + "expired_title": "Stack [Expiré]", + "expired_desc": "Ce stack a expiré.", + "join_modal_title": "Demander à rejoindre le stack", + "join_vehicle_label": "Avec quoi allez-vous jouer ?", + "join_vehicle_placeholder": "ex. F-16C, WZ305...", + "ping_modal_title": "Message de notification", + "ping_message_label": "Message personnalisé (facultatif)", + "ping_message_placeholder": "ex. Venez maintenant ! Le stack commence !", + "rename_modal_title": "Renommer le stack", + "rename_label": "Nom du stack", + "rename_placeholder": "ex. Hiboux de nuit, Équipe Alpha...", + "select_new_leader": "Sélectionner un nouveau chef…", + "select_applicants": "Sélectionner des candidats…", + "no_pending_applications": "Aucune candidature en attente.", + "select_to_remove": "Sélectionner des personnes à retirer…", + "no_members_or_applicants": "Aucun membre ni candidat.", + "select_to_ping": "Sélectionner des personnes à notifier individuellement…", + "stack_not_found": "❌ Stack introuvable.", + "no_longer_exists": "❌ Ce stack n'existe plus.", + "member_not_exists": "❌ Ce membre n'existe plus.", + "already_has_stack": "❌ Ce joueur a déjà un stack actif.", + "already_member": "❌ Vous êtes déjà membre de ce stack.", + "already_applied": "❌ Vous avez déjà une candidature en attente pour ce stack.", + "queue_full": "❌ La file d'attente est pleine ({max}/{max}). Réessayez plus tard.", + "application_sent": "✅ Candidature envoyée ! Le chef du stack l'examinera.", + "stack_disbanded": "✅ Stack dissous.", + "cancelled": "Annulé.", + "select_member_transfer": "❌ Veuillez sélectionner un membre pour le transfert.", + "ownership_transferred": "✅ Direction transférée à {nick}. Vous avez quitté le stack.", + "select_applicant_first": "❌ Veuillez d'abord sélectionner au moins un candidat.", + "stack_full": "❌ Le stack est déjà plein ({max}/{max} membres).", + "select_person_first": "❌ Veuillez d'abord sélectionner au moins une personne.", + "no_one_to_ping": "❌ Personne à notifier.", + "ping_footer": "Notifié par {leader} pour {stack}.", + "pinged": "✅ Notifié !", + "select_from_dropdown": "❌ Veuillez d'abord sélectionner au moins une personne dans le menu déroulant.", + "stack_renamed": "✅ Stack renommé en **{name}**.", + "only_member_use_disband": "❌ Vous êtes le seul membre. Utilisez **Dissoudre le stack** pour terminer.", + "select_transfer_prompt": "Sélectionnez un membre à qui transférer la direction avant de partir :", + "left_stack": "✅ Vous avez quitté le stack.", + "application_withdrawn": "✅ Votre candidature a été retirée.", + "not_member_or_applicant": "❌ Vous n'êtes ni membre ni candidat de ce stack.", + "leader_only_manage": "❌ Seul le chef du stack peut le gérer.", + "leader_only_disband": "❌ Seul le chef du stack peut le dissoudre.", + "confirm_disband": "Êtes-vous sûr de vouloir dissoudre ce stack ? Cette action est irréversible.", + "already_active_stack": "⚠️ Vous avez déjà un stack actif. Si le message original a disparu (ex. après redémarrage du bot), vous pouvez forcer la dissolution et recommencer.", + "force_created": "✅ Stack précédent dissous. Nouveau stack créé.", + "no_active_stack": "❌ Vous n'avez pas de stack actif. Utilisez `/stack-create` pour en créer un.", + "could_not_parse_channel": "⚠️ Impossible de traiter l'ID du canal enregistré." + }, + "commands": { + "common": { + "season": "La saison pour générer la carte", + "theme": "Thème de couleur de la carte", + "squadron_short": "Le nom court de l'escadron", + "player_username": "Le pseudo du joueur", + "choice_dark": "Sombre", + "choice_light": "Clair" + }, + "comp": { + "description": "Trouver les dernières compos connues d'une équipe", + "squadron_short": "Nom court de l'équipe ennemie" + }, + "quick_log": { + "description": "Créer une alerte pour cet escadron dans ce salon", + "squadron_name": "Nom COURT de l'escadron à surveiller", + "type": "Choisissez Logs, Points, Classement, BR Hebdomadaire ou Les deux", + "choice_logs": "Logs", + "choice_points": "Points", + "choice_leaderboard": "Classement", + "choice_both": "Les deux (Logs + Points)", + "choice_weekly_br": "BR Hebdomadaire" + }, + "sq_info": { + "description": "Afficher les informations d'un escadron" + }, + "sq_info_graph": { + "description": "Afficher un graphique de la composition de l'effectif par activité et taux de victoire (saison actuelle)" + }, + "sq_card": { + "description": "Générer une carte de saison pour un escadron", + "squadron": "Nom court de l'escadron" + }, + "sq_stats": { + "description": "Afficher les points d'un escadron dans le temps" + }, + "loss_calculator": { + "description": "Calculer la perte de points si des joueurs quittent un escadron", + "player1": "Joueur qui part", + "player_optional": "Joueur qui part (facultatif)" + }, + "website": { + "description": "Obtenir le lien du site SRE Bot" + }, + "card": { + "description": "Générer une carte de saison pour un joueur" + }, + "player_stats": { + "description": "Voir les statistiques détaillées des véhicules d'un joueur", + "username": "Pseudo WT pour la demande de stats", + "uid": "UID WT pour la demande de stats" + }, + "view_player_games": { + "description": "Voir les 20 dernières parties d'un joueur" + }, + "view_match": { + "description": "Voir un score de match par ID ou joueur", + "match_id": "ID hex de session du match", + "player_name": "Pseudo d'un joueur pour parcourir ses matchs récents" + }, + "compare": { + "description": "Comparer les stats SQB globales de joueurs", + "player1": "Premier pseudo joueur", + "player2": "Deuxième pseudo joueur", + "player_optional": "Pseudo joueur supplémentaire (facultatif)" + }, + "leaderboard": { + "description": "Obtenir le classement global de SRE Bot" + }, + "set_squadron": { + "description": "Définir le tag d'escadron de ce serveur", + "abbreviated_name": "Nom court de l'escadron à définir" + }, + "setup": { + "description": "Configurer le bot pour ce serveur" + }, + "meta_management": { + "description": "Gérer l'accès aux données méta de ce serveur" + }, + "meta": { + "description": "Chercher le roster méta par nom de véhicule", + "vehicle": "Nom du véhicule à rechercher" + }, + "top": { + "description": "Voir le top 20 des escadrons avec stats détaillées" + }, + "language": { + "description": "Changer la langue du bot." + }, + "translate_message": { + "name": "Traduire le message" + }, + "sq_track": { + "description": "Suivre un escadron et comparer depuis la dernière vérification", + "squadron_short_name": "Nom court de l'escadron à suivre" + }, + "analytics": { + "description": "Voir les analyses SQB avancées d'un escadron", + "view": "Vue d'analyse à afficher", + "choice_maps": "Taux de victoire par carte", + "choice_comps": "Compositions d'équipe", + "choice_consistency": "Régularité des joueurs", + "choice_time": "Heure de la journée", + "choice_matchups": "Historique des duels" + }, + "recent": { + "description": "Afficher les batailles récentes d'un escadron", + "length": "Nombre de matchs à afficher" + }, + "vs": { + "description": "Face-à-face entre deux escadrons", + "squadron_a": "Premier escadron", + "squadron_b": "Deuxième escadron" + }, + "autolog_management": { + "description": "Gérer les notifications autolog et diagnostiquer les permissions" + }, + "diagnose_perms": { + "description": "Diagnostiquer les permissions autolog de ce salon" + }, + "unlock": { + "description": "Débloquer les fonctionnalités Premium pour ce serveur" + }, + "credits": { + "description": "Voir l'équipe créditée pour ce projet" + }, + "schedule": { + "description": "Voir le calendrier BR de la saison actuelle" + }, + "news": { + "description": "Voir les dernières nouvelles et annonces de SRE Bot" + }, + "help": { + "description": "Voir le guide, les CGU et les liens de support" + }, + "donate": { + "description": "Soutenir le développement de SRE Bot" + }, + "stack_create": { + "description": "Créer une stack de joueurs", + "vehicle": "Avec quel véhicule vas-tu commencer ?" + }, + "stack_manage": { + "description": "Republier ta stack active dans ce salon" + }, + "bot_status": { + "description": "Voir le statut du bot : dernière partie reçue et TTL moyen" + } + }, + "permission": { + "blacklisted_title": "❌ Liste noire", + "blacklisted_desc": "Tu es bloqué et ne peux pas utiliser cette commande.", + "reason_line": "**Raison :** {reason}", + "access_denied_title": "⛔ Accès refusé", + "no_permission_desc": "Tu n'as pas la permission d'utiliser cette commande.", + "unexpected_error_title": "❗ Erreur, signale-la...." + }, + "weekly_br": { + "title_wildcard": "Rapport BR hebdomadaire — {br} BR", + "title_squadron": "Rapport BR hebdomadaire — [{tag}] {long} • {br} BR", + "window_label": "Période : {start} → {end}", + "wildcard_desc_first": "Top {count} escadrons par ELO • Rangs {low}–{high}", + "wildcard_desc_second": "Top {count} escadrons par ELO • Rangs {low}–{high}", + "squadron_stats_line": "- {games} parties • K/D {kdr} • Victoires {wr}%", + "top_players_inline_header": "🥇 Meilleurs joueurs :", + "player_line_short": " {rank}. {nick} ⭐ {score} ({games}p)", + "top_players_header": "**Top {count} joueurs par ELO :**", + "player_line_full": "{rank}. **{nick}** ⭐ {score} • {games} parties • K/D {kdr}", + "squadron_header_line": "ELO escadron : {score} • {games} parties • Victoires {wr}% • K/D {kdr}", + "squadron_header_no_aggregate": "ELO escadron : pas assez d'activité d'équipe cette semaine.", + "no_data": "Aucun match enregistré pour [{tag}] cette rotation BR." + } +} diff --git a/BOT/locales/it.json b/BOT/locales/it.json new file mode 100644 index 0000000..7371af3 --- /dev/null +++ b/BOT/locales/it.json @@ -0,0 +1,856 @@ +{ + "common": { + "error_title": "Errore", + "no_data_title": "Nessun dato", + "access_denied_title": "Accesso negato", + "access_denied_desc": "Questo server è stato inserito nella lista nera.", + "no_players_selected": "Nessun giocatore selezionato. Seleziona almeno un giocatore.", + "must_use_in_server": "Questo comando deve essere usato in un server.", + "could_not_resolve_channel": "Impossibile trovare il canale selezionato.", + "failed_update_setting": "❌ Impossibile aggiornare l'impostazione.", + "configuration_not_found": "Configurazione non trovata.", + "no_channel_selected": "Nessun canale selezionato.", + "no_selection_received": "Nessuna selezione ricevuta.", + "database_error": "❌ Errore database: {error}", + "enabled": "Attivo", + "disabled": "Disattivato", + "not_configured": "Non configurato", + "unknown": "Sconosciuto", + "rating_field": "Valutazione", + "battles_field": "Battaglie", + "wins_field": "Vittorie", + "losses_field": "Sconfitte", + "win_rate_field": "Percentuale vittorie", + "kills_field": "Eliminazioni", + "deaths_field": "Morti", + "kd_field": "K/D", + "members_field": "Membri", + "placement_field": "Posizione", + "points_field": "Punti", + "ground_kills_field": "Eliminazioni terrestri", + "air_kills_field": "Eliminazioni aeree", + "total_kills_field": "Eliminazioni totali", + "assists_field": "Assistenze", + "captures_field": "Catture", + "none_option": "Nessuno" + }, + "buttons": { + "skip": "Salta", + "previous": "Precedente", + "next": "Successivo", + "prev": "Prec", + "prev_arrow": "◀ Precedente", + "next_arrow": "Successivo ▶", + "prev_arrow_only": "◀", + "next_arrow_only": "▶", + "generate_chart": "📊 Genera grafico", + "show_graph": "Mostra grafico", + "view_player_stats": "📊 Vedi statistiche giocatori", + "compare_nearby": "📈 Confronta squadriglie vicine", + "confirm_swap": "Sì, cambiala", + "cancel_swap": "No, mantieni quella vecchia", + "set_squadron": "Imposta Squadriglia", + "same_as_logs": "Come Registri", + "require_password": "🔒 Richiedi Password", + "password_required": "🔒 Password Richiesta", + "lock_data": "🔐 Vincola Dati Squadriglia", + "data_locked": "🔐 Dati Vincolati al Server", + "allow_public": "👥 Consenti Meta Pubblico", + "public_enabled": "👥 Meta Pubblico Attivo", + "update_accounts": "📋 Aggiorna Account Meta", + "change_password": "🔑 Cambia Password", + "help": "❓ Aiuto", + "add_player": "➕ Aggiungi Giocatore", + "update_all": "🔄 Aggiorna Tutti i Membri", + "back_to_settings": "⬅ Torna alle Impostazioni", + "manage_notifications": "Gestisci Notifiche", + "diagnose_permissions": "Diagnostica Permessi", + "enable": "Abilita", + "disable": "Disabilita", + "change_channel": "Cambia Canale", + "view_replay": "Visualizza Replay", + "view_website": "Visualizza sul Sito", + "view_video": "Visualizza Video", + "view_log": "Visualizza Registro", + "view_chat": "Visualizza Chat", + "subscribe_website": "Abbonati tramite Sito Web", + "yes_disband": "Sì, sciogli", + "cancel": "Annulla", + "transfer_leave": "Trasferisci e lascia", + "accept_selected": "Accetta selezionati", + "accept_all": "Accetta tutti", + "decline_selected": "Rifiuta selezionati", + "back": "Indietro", + "remove_all": "Rimuovi tutti", + "remove_active": "Rimuovi attivi", + "remove_queued": "Rimuovi in coda", + "remove_selected": "Rimuovi selezionati", + "ping_all": "Notifica tutti", + "ping_active": "Notifica attivi", + "ping_queued": "Notifica in coda", + "ping_selected": "Notifica selezionati", + "accept_members": "Accetta membri", + "remove_members": "Rimuovi membri", + "ping_members": "Notifica membri", + "rename_stack": "Rinomina stack", + "request_to_join": "Richiedi di unirti", + "leave_withdraw": "Lascia / Ritira", + "manage_stack": "Gestisci stack ⚙️", + "disband_stack": "Sciogli stack", + "force_disband_create": "Forza scioglimento e crea nuovo" + }, + "events": { + "guild_join_title": "Grazie per avermi aggiunto!", + "guild_join_desc": "Esegui `/setup` per configurare il bot per questo server." + }, + "comp": { + "not_found_title": "Composizioni non trovate", + "not_found_desc": "Nessun dato per **{squadron}**, riprova più tardi.", + "error_loading_title": "Errore nel caricamento delle composizioni", + "error_loading_desc": "Impossibile caricare i dati delle composizioni: {error}", + "title": "Composizioni per {squadron}", + "desc": "Composizioni viste negli ultimi {minutes} minuti", + "no_recent_title": "Nessuna composizione recente", + "no_recent_desc": "Nessuna composizione negli ultimi {minutes} minuti.", + "comp_title": "COMP {index}", + "last_seen_label": "**Ultima volta**: {timestamp}{warning}", + "comp_label": "**Comp**: {notation}", + "no_players_recorded": "Nessun giocatore registrato.", + "limit_reached_title": "Limite composizioni raggiunto", + "limit_reached_desc": "Questo server ha esaurito tutte le {limit} ricerche di composizioni per questo slot. Abbonati (con /unlock) per accesso illimitato o attendi il prossimo slot.", + "remaining_footer": "{remaining}/{limit} ricerche di composizioni rimanenti in questo slot" + }, + "quick_log": { + "invalid_type": "Il tipo può essere impostato solo su Log, Punti, Classifica, BR Settimanale o Entrambi.", + "squadron_required": "Devi fornire il nome di una Squadriglia per gli allarmi Registri, Punti o Entrambi.", + "wildcard_logs_only": "Solo i Registri possono essere impostati su Squadriglia wildcard.", + "squadron_not_resolved": "La Squadriglia `{squadron}` non è stata trovata.", + "save_failed": "Impossibile salvare le preferenze. Riprova più tardi.", + "premium_warning": "\n\n> ⚠️ **I registri di gioco richiedono Premium.** Esegui `/unlock` per abbonarti ($2.99/mese) — i registri non verranno inviati fino ad allora.", + "leaderboard_set": "Allarme Classifica Globale impostato su questo canale.", + "both_set": "Allarmi Registri e Punti per {squadron} impostati su questo canale.{premium_note}", + "alarm_set": "Allarme {alarm_type} per {squadron} impostato su questo canale.{premium_note}", + "weekly_br_wildcard_set": "Report BR Settimanale (top 20 squadroni) configurato per questo canale. Inviato alla fine di ogni rotazione BR.", + "weekly_br_squadron_set": "Report BR Settimanale per {squadron} (top 15 giocatori) configurato per questo canale. Inviato alla fine di ogni rotazione BR." + }, + "diagnostics": { + "title": "Diagnostica autolog", + "channel_permissions_header": "**Permessi Canale** (<#{channel_id}>)", + "perms_needed": " ^ L'autologging necessita di tutti i permessi sopra per inviare le classifiche.", + "server_squadron_header": "**Squadriglia del Server** (`/set-squadron`)", + "server_squadron_short": " Breve: `{short}`", + "server_squadron_long": " Esteso: `{long}`", + "server_squadron_not_set": " Non impostato (il colore della barra della classifica mostrerà 'not_set')", + "autolog_prefs_header": "**Preferenze Autolog** (`/quick-log`)", + "autolog_none_configured": " ❌ NESSUNO configurato - l'autologging NON invierà nulla a questo server.", + "autolog_setup_hint": " Usa `/quick-log Logs` nel canale di destinazione per configurarlo.", + "autolog_no_logs_channels": " ❌ Nessun canale Registri configurato. Trovati solo Punti/Classifica.", + "autolog_enable_hint": " Usa `/quick-log Logs` per abilitare l'autologging.", + "selected_channel_tag": " **(canale selezionato)**", + "missing_send_attach": " (permessi di invio/allegato mancanti)", + "channel_not_found": " (canale non trovato)", + "invalid_channel_id": " (ID canale non valido)", + "premium_status_header": "**Stato Premium** (`/unlock`)", + "premium_active": " ✅ Questo server ha un abbonamento Premium attivo.", + "premium_not_subscribed": " ❌ Questo server **non** ha un abbonamento Premium.", + "premium_autolog_required": " L'autologging richiede Premium. Usa `/unlock` per abbonarti.", + "premium_not_subscribed_free": " ⚪ Non abbonato — usa `/unlock` per abbonarti ($2.99/mese).", + "premium_free_note": " *(Gli autolog sono gratuiti per tutti i server al momento.)*" + }, + "sq_info": { + "title": "Info squadriglia: {squadron}", + "placement_field": "Posizione", + "total_points_field": "Punti totali", + "total_members_field": "Membri totali", + "members_field": "Membri", + "fetch_failed": "Impossibile recuperare le informazioni della Squadriglia." + }, + "sq_info_graph": { + "title": "{squadron} — SQ-INFO (Stagione {season})", + "embed_title": "{squadron} — Composizione del roster", + "embed_desc": "Stagione **{season}** · Mediana partite: **{median}** · Nucleo: **{core}** · Attivi: **{active}** · Deboli: **{weak}**\nBarre ordinate per partite desc; altezza = tasso di vittoria. Nucleo = top 30 % di TV e partite ≥ mediana. Attivi = top 30–45 % di TV e partite ≈ mediana. Deboli = tutti gli altri.", + "core_threshold_line": "NUCLEO ≥ {wr} %", + "weak_threshold_line": "DEBOLI < {wr} %", + "y_label": "Tasso di vittoria", + "core_header": "NUCLEO — {count} · TV {avg}%", + "active_header": "ATTIVI — {count} · TV {avg}%", + "weak_header": "DEBOLI — {count} · TV {avg}%", + "no_active_season": "Nessuna stagione attiva trovata. Riprova quando inizia la prossima.", + "no_members": "Nessun membro attuale trovato per {squadron}." + }, + "recap_card": { + "unknown_season": "Stagione sconosciuta: `{season}`.", + "no_clan_id": "Impossibile risolvere l'ID della squadriglia `{squadron}`.", + "render_failed": "Impossibile generare la card di riepilogo stagione. Riprova più tardi." + }, + "sq_stats": { + "no_data_title": "Nessun dato", + "no_data_desc": "Nessun dato storico trovato per la Squadriglia: {squadron}", + "title": "{squadron} // SQUADRIGLIA", + "desc": "Andamento punteggio totale (ultimi {count} dati)", + "previous_score_field": "Punteggio precedente", + "current_score_field": "Punteggio attuale", + "change_field": "Variazione", + "player_title": "{squadron} // GIOCATORI", + "player_desc": "Andamento punti individuali dei giocatori", + "comparison_title": "{squadron} // CONFRONTO CLASSIFICA", + "comparison_desc": "Confronto con Squadriglie classificate {range}", + "current_position_field": "Posizione attuale", + "squadrons_shown_field": "Squadriglie mostrate", + "squadron_not_found_error": "Squadriglia non trovata nella classifica", + "no_nearby_error": "Nessuna Squadriglia vicina trovata", + "no_historical_error": "Nessun dato storico trovato per le Squadriglie vicine", + "comparison_chart_failed": "Impossibile generare il grafico di confronto", + "select_players_placeholder": "Seleziona giocatori (Pagina {page})" + }, + "loss_calc": { + "title": "Perdita punti — {squadron}", + "players_leaving_field": "Giocatori in partenza", + "share_of_total_field": "% del totale", + "points_lost_real_field": "Punti persi (reali)", + "points_lost_raw_field": "Punti persi (grezzi)", + "squadron_rating_field": "Valutazione squadriglia", + "squadron_position_field": "Posizione squadriglia", + "positions_lost_field": "Posizioni perse", + "not_found_footer": "Non trovato nella Squadriglia: {players}", + "fetch_failed": "Impossibile recuperare i dati della Squadriglia: {error}", + "no_point_data": "Nessun dato punti disponibile per questa Squadriglia.", + "no_matching_players": "Nessun giocatore corrispondente trovato in **{squadron}**." + }, + "player": { + "select_player_placeholder": "Seleziona un giocatore", + "no_stats_found": "❌ Nessuna statistica trovata per UID: {uid}", + "no_vehicle_stats": "❌ Nessuna statistica veicolo trovata per questo giocatore.", + "vehicles_found": "Trovati **{count}** veicoli per **{nick}**\nSeleziona un veicolo per visualizzare le statistiche dettagliate:", + "vehicle_select_placeholder": "Seleziona un veicolo (Pagina {page}/{total})", + "combat_stats_header": "**__STATISTICHE COMBATTIMENTO__**", + "ground_kills_label": "**Eliminazioni terrestri:** {value}", + "air_kills_label": "**Eliminazioni aeree:** {value}", + "total_kills_label": "**Eliminazioni totali:** {value}", + "assists_label": "**Assistenze:** {value}", + "deaths_label": "**Morti:** {value}", + "kd_label": "**K/D:** {value}", + "captures_label": "**Catture:** {value}", + "battle_record_header": "**__STORICO BATTAGLIE__**", + "total_battles_label": "**Battaglie totali:** {value}", + "wins_label": "**Vittorie:** {value}", + "losses_label": "**Sconfitte:** {value}", + "win_rate_label": "**Percentuale vittorie:** {value}%", + "stats_desc": "Statistiche per **{nick}** (**{squadron}**)\nUID: `{uid}`", + "not_found_title": "Giocatore non trovato", + "not_found_desc": "Nessuna cronologia di gioco trovata per `{player}`.", + "no_players_found": "Nessun giocatore trovato corrispondente a **{username}**\nProva a usare `/website` per cercare sul sito.", + "multiple_matches": "Trovate più corrispondenze, scegli quella corretta qui sotto:", + "must_provide_input": "Devi fornire almeno un UID o un nome utente." + }, + "player_games": { + "no_recent_title": "Nessuna partita recente", + "no_recent_desc": "Nessuna partita trovata per **{player}** nelle ultime 8 ore.", + "squadron_label": "**Squadriglia:** {squadron}", + "record_label": "**V:** {wins} **S:** {losses} **%V:** {wr}%", + "comps_played_header": "\n\n**Composizioni giocate**" + }, + "match": { + "missing_input_title": "Input mancante", + "missing_input_desc": "Fornisci un `match_id` o un `player_name`.", + "not_found_title": "Partita non trovata", + "not_found_desc": "Impossibile trovare una partita con ID `{match_id}`.", + "invalid_data_title": "Dati partita non validi", + "invalid_data_desc": "I dati del replay non possono essere elaborati.", + "scoreboard_error_title": "Errore classifica", + "scoreboard_error_desc": "Impossibile generare l'immagine della classifica.", + "no_games_title": "Nessuna partita trovata", + "no_games_desc": "Nessuna cronologia di gioco trovata per **{player}**.", + "recent_matches_title": "Partite recenti per {player}", + "recent_matches_desc": "Mostra fino a {count} partite recenti. Selezionane una per visualizzare la classifica completa.", + "select_match_placeholder": "Seleziona una partita da visualizzare..." + }, + "compare": { + "no_players_found": "Nessun giocatore trovato corrispondente a **{name}**.", + "multiple_matches": "Più corrispondenze per **{name}**: {matches}\nUsa un nome più specifico (i suggerimenti di completamento automatico sono esatti).", + "could_not_resolve": "Impossibile trovare i giocatori.", + "could_not_fetch": "❌ Impossibile recuperare le statistiche per **{name}**.", + "no_graph_data": "Nessun dato disponibile per gli ultimi 90 giorni.", + "no_squadron_points_data": "Nessun dato punti Squadriglia per {names} (giocatore non trovato nella cronologia Squadriglia tracciata).", + "graph_title": "Punti giocatore — ultimi 90 giorni", + "battles_label": "Battaglie", + "wins_label": "Vittorie", + "losses_label": "Sconfitte", + "win_rate_label": "Percentuale vittorie", + "ground_kills_label": "Eliminazioni terrestri", + "air_kills_label": "Eliminazioni aeree", + "total_kills_label": "Eliminazioni totali", + "assists_label": "Assistenze", + "deaths_label": "Morti", + "kd_label": "K/D", + "captures_label": "Catture" + }, + "squadron": { + "not_found_desc": "Squadriglia `{squadron}` non trovata.", + "set_title": "✅ Squadriglia impostata", + "set_desc": "La squadriglia **{squadron}** è stata impostata per questo server.", + "short_name_field": "Nome breve", + "long_name_field": "Nome esteso", + "swap_title": "✅ Squadriglia cambiata", + "swap_desc": "**{old}** sostituito con **{new}** per questo server.", + "already_set_title": "⚠️ Squadriglia già impostata", + "already_set_desc": "Questo server è attualmente impostato su **{old}**.\nCambiarlo con **{new}**?", + "swap_cancelled": "❌ Cambio squadriglia annullato." + }, + "setup": { + "step1_title": "Configurazione Server — Passaggio 1 di 3", + "step1_desc": "Questa procedura guidata ti aiuterà a configurare il bot per il tuo server.\n\n**Passaggio 1** — Imposta la tua squadriglia\n**Passaggio 2** — Scegli un canale registri\n**Passaggio 3** — Scegli un canale punti\n", + "step1_current_sq": "\nSquadriglia attualmente configurata: **[{short}] {long}**", + "step2_title": "Configurazione Server — Passaggio 2 di 3", + "step2_desc": "Squadriglia impostata su **[{short}] {long}**.\n\nDove devono essere pubblicati i **registri di battaglia**?\nSeleziona un canale di testo qui sotto, oppure salta questo passaggio.", + "step3_title": "Configurazione Server — Passaggio 3 di 3", + "step3_desc": "Dove devono essere pubblicate le **notifiche punti**?\nSeleziona un canale di testo qui sotto, oppure salta questo passaggio.", + "step3_same_as_logs": "\n\nPuoi anche cliccare su \"Come Registri\" per riutilizzare il canale registri.", + "summary_title": "Configurazione Completata", + "summary_desc": "Puoi usare `/autolog-management` per modificare queste impostazioni in seguito.", + "squadron_field": "Squadriglia", + "logs_channel_field": "Canale Registri", + "points_channel_field": "Canale Punti", + "premium_required_field": "⚠️ I Registri di Gioco Richiedono Premium", + "premium_required_value": "Le classifiche automatiche non verranno inviate finché questo server non ha un abbonamento attivo. Esegui `/unlock` per abbonarti ($2.99/mese).", + "modal_title": "Imposta Squadriglia", + "modal_label": "Nome Breve Squadriglia", + "modal_placeholder": "es. AXYS", + "squadron_not_found": "Squadriglia `{squadron}` non trovata. Riprova.", + "logs_channel_placeholder": "Seleziona un canale registri...", + "points_channel_placeholder": "Seleziona un canale punti..." + }, + "meta_management": { + "squadron_not_found_title": "❌ Squadriglia Non Trovata", + "squadron_not_found_desc": "Impossibile trovare l'ID clan per la Squadriglia: **{squadron}**", + "access_denied_title": "❌ Accesso Negato", + "access_denied_desc": "Password errata. I dati meta di questa Squadriglia sono protetti.", + "data_locked_title": "🔐 Dati squadriglia vincolati", + "data_locked_desc": "**{squadron}** ha la vincolazione dati attivata e non può essere trasferita a un altro server.\n\nIl proprietario della Squadriglia deve disabilitare **Vincola Dati Squadriglia** prima che possa essere spostata.", + "error_retrieving_settings": "❌ Errore nel recupero delle impostazioni del server dopo il trasferimento. Riprova.", + "error_retrieving_settings_retry": "❌ Errore nel recupero delle impostazioni del server. Prova a eseguire nuovamente il comando.", + "authenticated_title": "✅ Autenticato", + "authenticated_desc": "Password verificata. Gestione impostazioni per **{squadron}**.", + "claimed_title": "✅ Squadriglia Rivendicata", + "claimed_desc": "**{squadron}** è stata rivendicata con successo per questo server!", + "password_requirement_field": "🔒 Requisito password", + "data_lock_field": "🔐 Vincolazione dati squadriglia", + "public_meta_field": "👥 Accesso meta pubblico", + "access_password_field": "🔑 Password di accesso", + "enabled_value": "✅ Attivo", + "disabled_value": "❌ Disattivo", + "settings_title": "🔐 Impostazioni gestione meta", + "settings_desc": "**Squadriglia:** {squadron}\n**ID Clan:** {clan_id}", + "first_time_title": "🔐 Gestione meta - Prima configurazione", + "first_time_owner_desc": "**Squadriglia:** {squadron}\n**ID Clan:** {clan_id}\n\n🔑 La tua password di accesso è stata generata. **Salva questa password** — ti servirà per autenticare l'accesso ai dati meta in futuro.\n\n**Password:** `{password}`", + "first_time_non_owner_desc": "**Squadriglia:** {squadron}\n**ID Clan:** {clan_id}\n\nLa Squadriglia è stata configurata. Chiedi al proprietario del server la password di accesso.", + "settings_field": "Impostazioni", + "settings_hint": "Usa i pulsanti qui sotto per configurare le impostazioni di accesso.", + "password_toggled": "✅ Requisito password: **{state}**", + "lock_toggled": "✅ Vincolazione dati Squadriglia: **{state}**", + "public_meta_toggled": "✅ Accesso meta pubblico: **{state}**\n{detail}", + "public_meta_enabled_detail": "I non-amministratori possono ora usare il comando `/meta`.", + "public_meta_disabled_detail": "Solo gli amministratori possono usare il comando `/meta`.", + "owner_only_password": "❌ Solo il proprietario del server può cambiare la password della Squadriglia.", + "help_title": "📖 Guida Gestione Meta", + "help_desc": "Spiegazione di ogni impostazione e funzione:", + "help_password_field": "🔑 Password di Accesso", + "help_password_value": "La password di accesso della tua Squadriglia. Solo il **proprietario del server** può vedere la password nel pannello impostazioni. Chiunque conosca la password può rivendicare i dati meta della tua Squadriglia sul proprio server, quindi tienila al sicuro.", + "help_require_field": "🔒 Richiedi Password", + "help_require_value": "Quando attivo, anche gli amministratori su questo server devono inserire la password della Squadriglia per accedere a `/meta-management`. Aggiunge un ulteriore livello di sicurezza per prevenire modifiche accidentali.", + "help_lock_field": "🔐 Vincola Dati Squadriglia", + "help_lock_value": "Quando attivo, impedisce il trasferimento della Squadriglia ad altri server, anche con la password corretta. Deve essere disabilitato prima che la Squadriglia possa essere trasferita.", + "help_public_field": "👥 Consenti Meta Pubblico", + "help_public_value": "Quando attivo, consente ai membri non-amministratori di usare il comando `/meta` per cercare i veicoli della Squadriglia. Quando disabilitato, solo gli amministratori del server possono usare `/meta`.", + "help_accounts_field": "📋 Aggiorna Account Meta", + "help_accounts_value": "Apre il gestore del registro giocatori dove puoi aggiungere o rimuovere giocatori dal registro meta della tua Squadriglia. Usa **Aggiorna Tutti i Membri** per sincronizzare l'intera Squadriglia in una volta sola.", + "help_change_pw_field": "🔑 Cambia Password", + "help_change_pw_value": "**Solo proprietario del server.** Cambia la password di accesso della Squadriglia e imposta un suggerimento opzionale. Il suggerimento viene mostrato nella richiesta di password per aiutare a ricordarla.", + "password_modal_title": "Password di Accesso Squadriglia", + "password_modal_label": "Inserisci la Password della Squadriglia", + "password_modal_placeholder": "XXXX-XXXX-XXXX", + "change_pw_modal_title": "Cambia Password Squadriglia", + "current_password_label": "Password Attuale", + "current_password_placeholder": "Inserisci la tua password attuale", + "new_password_label": "Nuova Password", + "new_password_placeholder": "Inserisci la tua nuova password", + "confirm_password_label": "Conferma Nuova Password", + "confirm_password_placeholder": "Reinserisci la tua nuova password", + "hint_label": "Suggerimento Password (Opzionale)", + "hint_placeholder": "Un suggerimento per ricordare la password", + "pw_incorrect": "❌ La password attuale non è corretta.", + "pw_mismatch": "❌ Le nuove password non corrispondono. Riprova.", + "pw_empty": "❌ La nuova password non può essere vuota.", + "pw_changed": "✅ Password aggiornata con successo per **{squadron}**.\n**Nuova Password:** `{password}`", + "pw_changed_hint": "\n**Suggerimento:** {hint}", + "player_add_modal_title": "Aggiungi Giocatore al Registro Meta", + "player_add_label": "UID o Nome del Giocatore", + "player_add_placeholder": "Inserisci l'UID del giocatore (es. 12345678) o il nickname", + "player_not_found": "❌ Giocatore `{player}` non trovato nel database Players_Global.\n", + "roster_title": "📋 Gestione Registro Meta - {squadron}", + "roster_desc": "**ID Clan Squadriglia:** {clan_id}\n**Giocatori Totali:** {count}", + "roster_page_field": "Giocatori (Pagina {page}/{total})", + "no_players_field": "Nessun Giocatore", + "no_players_hint": "Nessun giocatore aggiunto al registro meta. Clicca **Aggiungi Giocatore** per iniziare.", + "remove_player_placeholder": "Seleziona giocatore da rimuovere...", + "fetch_members_failed": "❌ Impossibile recuperare i membri della Squadriglia: {error}", + "no_members_found": "❌ Nessun membro trovato nella Squadriglia o chiamata API fallita.", + "roster_synced": "✅ Registro sincronizzato con la Squadriglia.", + "roster_added": "**+{count}** aggiunti", + "roster_removed": "**-{count}** rimossi (hanno lasciato la Squadriglia)", + "roster_up_to_date": "**{count}** già aggiornati", + "refreshing_vehicles": "Aggiornamento dati veicoli in background..." + }, + "meta": { + "not_configured": "❌ Dati meta non configurati per questo server. Esegui prima `/meta-management`.", + "no_permission": "❌ Hai bisogno dei permessi di amministratore per usare questo comando.\nGli amministratori possono abilitare l'accesso pubblico tramite `/meta-management`.", + "no_results": "❌ Nessun giocatore nel registro della tua Squadriglia possiede **{vehicle}**.", + "no_results_admin_hint": "\n*Ti aspetti che qualcuno lo abbia? Clicca il pulsante di aggiornamento membri in `/meta-management` e ricontrolla.*", + "search_title": "🔍 Risultati Ricerca - {vehicle}", + "matches_found": "**Corrispondenze trovate:** {count} giocatore/i", + "spawns_label": "Spawn", + "deaths_label": "Morti", + "gk_label": "GK", + "ak_label": "AK", + "points_label": "Punti", + "kdr_label": "KDR", + "games_label": "Partite", + "no_points": "—" + }, + "top": { + "title": "**Top 20 Squadriglie**", + "rating_label": "**Valutazione:** {value}", + "air_kills_label": "**Eliminazioni aeree:** {value}", + "ground_kills_label": "**Eliminazioni terrestri:** {value}", + "deaths_label": "**Morti:** {value}", + "kd_label": "**K/D:** {value}", + "win_rate_label": "**Percentuale vittorie:** {value}", + "playtime_label": "**Tempo di gioco:** {value}", + "fetch_failed": "Impossibile recuperare i dati della Squadriglia." + }, + "analytics": { + "no_data_title": "Nessun dato", + "no_matches_desc": "Nessuna partita trovata.", + "no_comp_desc": "Nessun dato composizione trovato.", + "no_consistency_desc": "Dati giocatore insufficienti (minimo 50 partite).", + "no_time_desc": "Nessun dato orario trovato.", + "unknown_view": "Vista sconosciuta.", + "map_title": "Percentuali vittorie per mappa: {squadron}", + "comp_title": "Composizioni di squadra: {squadron}", + "consistency_title": "Consistenza giocatori: {squadron}", + "consistency_desc": "Ordinato per rapporto K/D", + "time_title": "Prestazioni per fascia oraria: {squadron}", + "eu_timeslot": "\n**Fascia Europea**", + "na_timeslot": "\n**Fascia Nord Americana**", + "off_peak": "\n**Fuori orario di punta**", + "matchups_title": "📜 {squadron} — Storico Scontri", + "matchups_won_field": "🏆 Più Vittorie Contro", + "matchups_lost_field": "💀 Più Sconfitte Contro", + "no_matchups_desc": "Nessuna partita registrata contro altri squadroni." + }, + "recent": { + "title": "Partite recenti: {squadron}", + "no_matches_desc": "Nessuna partita trovata per questa Squadriglia." + }, + "h2h": { + "two_required_title": "Due squadriglie richieste", + "two_required_desc": "Fornisci almeno una Squadriglia, oppure usa `/set-squadron` e fornisci l'avversario.", + "provide_a_desc": "Fornisci `squadron_a` oppure usa prima `/set-squadron`.", + "provide_b_desc": "Fornisci `squadron_b` oppure usa prima `/set-squadron`.", + "squadron_not_found_title": "Squadriglia non trovata", + "same_squadron_title": "Stessa squadriglia", + "same_squadron_desc": "Non puoi controllare il testa a testa contro te stesso.", + "record_desc": "**Record:** {a_wins}V - {b_wins}S ({total} partite)", + "no_matches_desc": "Nessuna partita registrata tra **{a}** e **{b}**." + }, + "autolog": { + "premium_active_line": "✅ **Premium:** Attivo — l'autologging è abilitato per questo server.", + "premium_not_subscribed_line": "❌ **Premium:** Non abbonato — usa `/unlock` per abilitare l'autologging.", + "premium_free_line": "⚪ **Premium:** Non abbonato — usa `/unlock` per abbonarti ($2.99/mese). *(Gli autolog sono gratuiti per tutti i server al momento.)*", + "what_to_do": "\n\nCosa vorresti fare?", + "select_notif_type": "Seleziona il tipo di notifica da gestire:", + "select_notif_placeholder": "Seleziona tipo di notifica", + "logs_option": "Registri", + "logs_option_desc": "Gestisci le notifiche Registri", + "points_option": "Punti", + "points_option_desc": "Gestisci le notifiche Punti", + "leaderboard_option": "Classifica", + "leaderboard_option_desc": "Gestisci le notifiche Classifica", + "selected_type": "Selezionato **{type}**. Ora scegli la Squadriglia da gestire:", + "select_squadron_placeholder": "Seleziona una Squadriglia", + "select_squadron_page_placeholder": "Seleziona una Squadriglia (Pagina {page})", + "no_squadrons_available": "Nessuna Squadriglia disponibile per questo tipo di notifica.", + "managing_global": "Gestione **{type}** (globale) nel canale **{channel}**.", + "managing_squadron": "Gestione **{type}** per la Squadriglia **{squadron}** nel canale **{channel}**.", + "select_channel": "Seleziona un nuovo canale:", + "select_channel_placeholder": "Seleziona un canale", + "select_channel_page_placeholder": "Seleziona un canale (Pagina {page})", + "global_toggled": "{type} (globale) è ora {state}.", + "squadron_toggled": "{type} per **{squadron}** è ora {state}.", + "channel_updated_global": "Aggiornato {type} (globale) a {channel}", + "channel_updated_squadron": "Aggiornato {type} per **{squadron}** a {channel}", + "diagnose_channel_placeholder": "Seleziona un canale da diagnosticare...", + "select_channel_diagnose": "Seleziona il canale da diagnosticare:", + "game_not_logged_title": "Partita non registrata", + "game_not_logged_desc": "Usa `/unlock` per sottoscrivere il piano **Standard** (o superiore) e ricevere le classifiche automatiche.", + "server_not_upgraded_title": "⚠️ Server non aggiornato", + "server_not_upgraded_autolog_desc": "Questo server non ha un abbonamento Premium attivo.\n\n**Le classifiche automatiche smetteranno di essere inviate ai server non aggiornati dopo .**\n\nUsa `/unlock` per abbonarti e continuare a ricevere i registri di gioco automatici.", + "replay_not_available": "I dati del replay non sono ancora disponibili — aspetta un po' e riprova!", + "too_many_videos": "Troppi video in rendering al momento — riprova tra un attimo.", + "video_gen_failed": "Errore nella generazione del video: `{error}`", + "video_missing": "Impossibile generare il video del replay - file di output mancante o vuoto.", + "video_too_large": "Video replay troppo grande da caricare ({file_mb:.1f} MB). Il limite del server è {limit_mb:.0f} MB.", + "video_web_fallback": "Puoi anche visualizzare questa partita su {url}", + "video_upload_failed": "Video troppo grande da caricare — visualizzalo sul sito:\n{url}", + "video_unexpected_error": "Errore imprevisto nella generazione del video replay: `{error}`", + "replay_not_found": "Dati replay non trovati per la sessione `{session_id}` su disco.", + "chat_log_title": "**Registro Chat della Partita [{session_id}]({url})**", + "chat_log_part_title": "**Registro Chat della Partita [{session_id}]({url}) (Parte {part}/{total})**", + "chat_log_part_only": "**Registro Chat (Parte {part}/{total})**", + "no_chat_log": "Nessun registro chat trovato per la sessione `{session_id}`.", + "chat_log_error": "Errore imprevisto nel caricamento del registro chat: `{error}`", + "battle_log_title": "**Registro Battaglia della Partita [{session_id}]({url})**", + "battle_log_part_title": "**Registro Battaglia della Partita [{session_id}]({url}) (Parte {part}/{total})**", + "battle_log_part_only": "**Registro Battaglia (Parte {part}/{total})**", + "no_battle_log": "Nessun evento di combattimento trovato per la sessione `{session_id}`.", + "battle_log_error": "Errore imprevisto nel caricamento del registro battaglia: `{error}`", + "points_update_title": "**{squadron} {region} Aggiornamento Punti**", + "points_update_desc": "# {old_total} -> {new_total} {chart}{wl_line}{placement_line}\n\n**Variazioni giocatori:**", + "points_table_header": "Nome Variaz. Ora\n", + "wl_line": "\n**{squadron}** è andata **{wins}V-{losses}S** in questa sessione", + "placement_rose": "\n**{squadron}** è salito al **{new_place}** dal **{old_place}**", + "placement_fell": "\n**{squadron}** è sceso al **{new_place}** dal **{old_place}**", + "points_not_logged_title": "Punti non registrati", + "points_not_logged_desc": "Usa `/unlock` per sottoscrivere il piano **Standard** (o superiore) e ricevere gli aggiornamenti automatici dei punti.", + "server_not_upgraded_points_desc": "Questo server non ha un abbonamento Premium attivo.\n\n**Gli aggiornamenti automatici smetteranno di essere inviati ai server non aggiornati dopo .**\n\nUsa `/unlock` per abbonarti e continuare a ricevere gli aggiornamenti automatici.", + "leave_title": "⚠️ Giocatore Ha Lasciato {squadron}", + "leave_desc": "**{nick}** ({uid}) ha lasciato la Squadriglia.\n\nUltimi punti registrati: **{points}**", + "no_squadrons_desc": "No squadrons configured", + "no_channels_desc": "No channels available", + "over_cap_title": "Squadrone oltre il limite del tuo piano", + "over_cap_desc": "Il tuo server è sul piano **{tier}**, che consente **{cap} {notif}** squadroni. Lo squadrone **{squadron}** supera il limite e non viene registrato. Passa a un piano superiore per ripristinarlo.", + "over_cap_footer": "Aggiorna su srebot-meow.ing/premium o con /unlock", + "wildcard_blocked_title": "Il wildcard richiede un piano superiore", + "wildcard_blocked_desc": "Gli squadroni wildcard (*, all, everything) sono disponibili solo su Pro o Max. Il tuo server è su **{tier}** per {notif}. Aggiorna per abilitarli.", + "cap_header": "{used}/{cap} {notif} attivati — piano {tier}" + }, + "track": { + "squadron_not_found": "Squadriglia non trovata.", + "fetch_failed": "Impossibile recuperare le informazioni della Squadriglia." + }, + "unlock": { + "title": "SRE Bot Premium", + "desc": "**Sblocca le funzioni premium per questo server.**\n\nPremium include:\n> • Post automatici della classifica\n> • Registri chat e battaglia\n> • Ricerche replay\n> • Ricerche /comp illimitate\n> • Supporto prioritario\n\n**$2.99 / mese · per server · cancella quando vuoi**\n\n⚠️ La fatturazione Discord è disponibile solo in alcuni paesi. Se il pulsante qui sotto mostra **\"Prodotto Non Disponibile\"**, potrebbe essere a causa di un paese non supportato o dell'uso di un **dispositivo mobile**. Usa il pulsante **Abbonati tramite Sito Web** invece.", + "already_subscribed_title": "SRE Bot Premium", + "already_subscribed_desc": "✅ **Questo server è già abbonato!**", + "manage_discord_field": "Gestisci Abbonamento", + "manage_discord_value": "Il tuo abbonamento è tramite **Discord**.\nPer annullare, vai su **Impostazioni Utente → Abbonamenti** in Discord.", + "manage_website_field": "Gestisci Abbonamento", + "manage_website_value": "Il tuo abbonamento è tramite il **sito web**.\nGestiscilo su [whop.com/billing](https://whop.com/billing).", + "coming_soon_field": "Prossimamente", + "coming_soon_value": "Gli abbonamenti Premium non sono ancora disponibili. Ricontrolla presto!", + "current_tier": "Sei sul piano **{tier}**.", + "upgrade_to": "Passa a {tier}", + "upgrade_to_value": "Più squadroni e funzioni passando a **{tier}**." + }, + "language": { + "prompt": "Seleziona la lingua del tuo server:", + "select_placeholder": "Scegli la lingua del tuo server", + "language_set": "Lingua impostata su {language}.", + "translate_prompt": "Seleziona una lingua di destinazione qui sotto 👇", + "translate_placeholder": "Scegli una lingua di destinazione…", + "translate_result": "**{author} → {language}:**\n{text}", + "translation_unavailable": "Traduzione non disponibile (DeepL non configurato)", + "translation_failed": "Traduzione fallita" + }, + "misc": { + "credits_title": "Crediti", + "credits_desc": "**Meowww**\n\n> **NotSoToothless** - Lead Developer, Bot Manager, Community Manager\n> **Z3R0** - Developer, Optimization Developer, Database Engineer\n> **Clippii (Heidi)** - Developer, Website Developer, Community Manager\n> **LivingTheDagor** - Developer, Parser Developer, Consultant\n> **Lux_** - API Engineer, Spectra Developer\n> **Konigallerwaffen** - Consulente Feedback e Funzionalità\n> **Žralok Tonda** - Traduttore Ceco\n> **Styevy**, **Lopais** - Traduttori Tedeschi\n> **Susogus**, **playforfun698** - Traduttori Polacchi\n> **Bobr** - Traduttore Russo\n\n\n[Vuoi unirti a noi?](https://discord.gg/BCvkK8JhPe)", + "schedule_title": "PROGRAMMA DI STAGIONE", + "schedule_not_found_title": "Programma non trovato", + "schedule_not_found_desc": "Nessun dato del programma disponibile ancora.", + "news_no_news_title": "Nessuna notizia", + "news_no_news_desc": "Non ci sono annunci al momento. Ricontrolla più tardi!", + "news_footer": "Grazie per il tuo supporto! ᖙᘘᗢ", + "help_title": "Guida al Bot", + "donate_title": "Supporta SRE Bot", + "donate_desc": "Se ti piace usare SRE Bot e vuoi supportare il suo sviluppo, considera di offrirmi un caffè!\n\n**[Dona su Ko-fi](https://ko-fi.com/notsotoothless)**\n\nOgni contributo aiuta a mantenere il bot attivo e supporta nuove funzioni. Grazie!", + "status_title": "Stato del bot", + "status_last_received": "Ultima partita ricevuta", + "status_avg_ttl": "TTL medio (ultime 30)", + "status_no_data": "Nessun dato ancora", + "status_gaijin_slow": "⚠️ Server Gaijin lenti", + "help_commands_header": "**Panoramica comandi**", + "help_links": "Per i dettagli, leggi la documentazione [qui]({docs}) o chiedi supporto [qui]({support}).", + "help_terms": "[Termini di servizio]({terms}) • [Privacy Policy]({terms})" + }, + "dev": { + "restricted_dev_team": "This command is restricted to the dev team.", + "restricted_bot_owner": "❌ This command is restricted to the bot owner.", + "invalid_server_id": "❌ Invalid server ID. Must be a 17-19 digit Discord server ID.", + "expiry_too_soon": "❌ Expiry timestamp must be at least 1 month from now.\n> Now: \n> Minimum: \n> You provided: ", + "entitlement_write_failed": "❌ Failed to write entitlement: {error}", + "entitlement_created_title": "✅ Manual Entitlement Created", + "entitlement_created_desc": "**Server:** {guild_name} (`{server_id}`)\n**Expires:** ()\n**Created:** ", + "query_failed": "Query failed: {error}", + "health_title": "Bot Health Dashboard", + "health_uptime": "Uptime", + "health_guilds": "Guilds", + "health_games_processed": "Games Processed", + "health_tasks": "Tasks", + "health_websocket": "WebSocket", + "health_never": "never", + "health_errors": "({count} errors)", + "health_last_msg": "last msg {ago} ({count} total)", + "health_avg_ttl": "Avg TTL (Last 30)", + "entitlements_title": "Active Entitlements ({count} total)", + "entitlements_no_entries": "No entitlements.", + "entitlements_empty_title": "Active Entitlements", + "entitlements_empty_desc": "No active entitlements found.", + "entitlements_tag_discord": "Discord", + "entitlements_tag_whop": "Whop", + "entitlements_tag_manual": "Manual", + "query_prefix": "Query: {name}" + }, + "leaderboard_alarm": { + "title": "🏆 Classifica Squadriglie", + "top15_desc": "Top 15 Squadriglie con statistiche, inviato 35 minuti dopo la chiusura della fascia oraria.\nInviato .", + "top30_desc": "Squadriglie dalla 16ª alla 30ª con statistiche.", + "not_logged_title": "Classifica Non Registrata", + "not_logged_desc": "Usa `/unlock` per sottoscrivere il piano **Standard** (o superiore) e ricevere gli aggiornamenti automatici della classifica.", + "server_not_upgraded_title": "⚠️ Server non aggiornato", + "server_not_upgraded_desc": "Questo server non ha un abbonamento Premium attivo.\n\n**Gli aggiornamenti automatici smetteranno di essere inviati ai server non aggiornati dopo .**\n\nUsa `/unlock` per abbonarti e continuare a ricevere gli aggiornamenti automatici." + }, + "stacks": { + "stack_title": "Stack di {leader}", + "stack_named_title": "{name}", + "no_members": "Nessun membro ancora.", + "members_field": "Membri ({count}/{max})", + "queue_field": "Coda ({count}/{max})", + "manage_title": "Gestisci Stack", + "no_pending_requests": "Nessuna richiesta in sospeso.", + "disbanded_title": "Stack [Sciolto]", + "disbanded_desc": "Questo stack è stato sciolto dal leader.", + "expired_title": "Stack [Scaduto]", + "expired_desc": "Questo stack è scaduto.", + "join_modal_title": "Richiedi di unirti allo stack", + "join_vehicle_label": "Con cosa giocherai?", + "join_vehicle_placeholder": "es. F-16C, WZ305...", + "ping_modal_title": "Messaggio di notifica", + "ping_message_label": "Messaggio personalizzato (opzionale)", + "ping_message_placeholder": "es. Venite ora! Lo stack sta iniziando!", + "rename_modal_title": "Rinomina stack", + "rename_label": "Nome dello stack", + "rename_placeholder": "es. Gufi Notturni, Squadra Alfa...", + "select_new_leader": "Seleziona nuovo leader…", + "select_applicants": "Seleziona candidati…", + "no_pending_applications": "Nessuna candidatura in sospeso.", + "select_to_remove": "Seleziona persone da rimuovere…", + "no_members_or_applicants": "Nessun membro o candidato.", + "select_to_ping": "Seleziona persone da notificare individualmente…", + "stack_not_found": "❌ Stack non trovato.", + "no_longer_exists": "❌ Questo stack non esiste più.", + "member_not_exists": "❌ Quel membro non esiste più.", + "already_has_stack": "❌ Quel giocatore ha già uno stack attivo.", + "already_member": "❌ Sei già membro di questo stack.", + "already_applied": "❌ Hai già una candidatura in sospeso per questo stack.", + "queue_full": "❌ La coda è piena ({max}/{max}). Riprova più tardi.", + "application_sent": "✅ Candidatura inviata! Il leader dello stack la esaminerà.", + "stack_disbanded": "✅ Stack sciolto.", + "cancelled": "Annullato.", + "select_member_transfer": "❌ Seleziona un membro a cui trasferire la leadership.", + "ownership_transferred": "✅ Leadership trasferita a {nick}. Hai lasciato lo stack.", + "select_applicant_first": "❌ Seleziona almeno un candidato prima.", + "stack_full": "❌ Lo stack è già pieno ({max}/{max} membri).", + "select_person_first": "❌ Seleziona almeno una persona prima.", + "no_one_to_ping": "❌ Nessuno da notificare.", + "ping_footer": "Notificato da {leader} per {stack}.", + "pinged": "✅ Notificato!", + "select_from_dropdown": "❌ Seleziona almeno una persona dal menu a tendina prima.", + "stack_renamed": "✅ Stack rinominato in **{name}**.", + "only_member_use_disband": "❌ Sei l'unico membro. Usa **Sciogli stack** per terminare.", + "select_transfer_prompt": "Seleziona un membro a cui trasferire la leadership prima di uscire:", + "left_stack": "✅ Hai lasciato lo stack.", + "application_withdrawn": "✅ La tua candidatura è stata ritirata.", + "not_member_or_applicant": "❌ Non sei membro né candidato di questo stack.", + "leader_only_manage": "❌ Solo il leader dello stack può gestirlo.", + "leader_only_disband": "❌ Solo il leader dello stack può scioglierlo.", + "confirm_disband": "Sei sicuro di voler sciogliere questo stack? Questa azione non può essere annullata.", + "already_active_stack": "⚠️ Hai già uno stack attivo. Se il messaggio originale è scomparso (es. dopo riavvio del bot), puoi forzare lo scioglimento e ricominciare.", + "force_created": "✅ Stack precedente sciolto. Nuovo stack creato.", + "no_active_stack": "❌ Non hai uno stack attivo. Usa `/stack-create` per crearne uno.", + "could_not_parse_channel": "⚠️ Impossibile elaborare l'ID del canale memorizzato." + }, + "commands": { + "common": { + "season": "La stagione per generare la card", + "theme": "Tema colore della card", + "squadron_short": "Nome breve dello squadrone", + "player_username": "Nome del giocatore", + "choice_dark": "Scuro", + "choice_light": "Chiaro" + }, + "comp": { + "description": "Trova le ultime comp note di un team", + "squadron_short": "Nome breve del team nemico" + }, + "quick_log": { + "description": "Imposta un allarme per questo squadrone in questo canale", + "squadron_name": "Nome BREVE dello squadrone da monitorare", + "type": "Scegli Log, Punti, Classifica, BR Settimanale o Entrambi", + "choice_logs": "Logs", + "choice_points": "Punti", + "choice_leaderboard": "Classifica", + "choice_both": "Entrambi (Logs + Punti)", + "choice_weekly_br": "BR Settimanale" + }, + "sq_info": { + "description": "Mostra informazioni su uno squadrone" + }, + "sq_info_graph": { + "description": "Mostra un grafico della composizione del roster per attività e tasso di vittoria (stagione corrente)" + }, + "sq_card": { + "description": "Genera una card stagionale per uno squadrone", + "squadron": "Nome breve dello squadrone" + }, + "sq_stats": { + "description": "Mostra i punti di uno squadrone nel tempo" + }, + "loss_calculator": { + "description": "Calcola la perdita di punti se dei giocatori lasciano lo squadrone", + "player1": "Giocatore in uscita", + "player_optional": "Giocatore in uscita (opzionale)" + }, + "website": { + "description": "Ottieni un link al sito di SRE Bot" + }, + "card": { + "description": "Genera una card stagionale per un giocatore" + }, + "player_stats": { + "description": "Vedi statistiche veicolo dettagliate di un giocatore", + "username": "Username WT per la richiesta stats", + "uid": "UID WT per la richiesta stats" + }, + "view_player_games": { + "description": "Vedi le ultime 20 partite di un giocatore" + }, + "view_match": { + "description": "Vedi scoreboard partita per ID o giocatore", + "match_id": "ID sessione esadecimale della partita", + "player_name": "Giocatore per sfogliare partite recenti" + }, + "compare": { + "description": "Confronta statistiche SQB aggregate tra giocatori", + "player1": "Primo username", + "player2": "Secondo username", + "player_optional": "Username aggiuntivo (opzionale)" + }, + "leaderboard": { + "description": "Ottieni la classifica globale di SRE Bot" + }, + "set_squadron": { + "description": "Imposta il tag squadrone di questo server", + "abbreviated_name": "Nome breve dello squadrone da impostare" + }, + "setup": { + "description": "Configura il bot per questo server" + }, + "meta_management": { + "description": "Gestisci accesso ai dati meta per questo server" + }, + "meta": { + "description": "Cerca il roster meta per nome veicolo", + "vehicle": "Nome veicolo da cercare" + }, + "top": { + "description": "Vedi i top 20 squadroni con stats dettagliate" + }, + "language": { + "description": "Cambia la lingua del bot." + }, + "translate_message": { + "name": "Traduci messaggio" + }, + "sq_track": { + "description": "Traccia uno squadrone e confronta dall'ultimo controllo", + "squadron_short_name": "Nome breve dello squadrone da tracciare" + }, + "analytics": { + "description": "Vedi analisi SQB avanzate per uno squadrone", + "view": "Vista analisi da mostrare", + "choice_maps": "Win rate mappe", + "choice_comps": "Composizioni team", + "choice_consistency": "Costanza giocatori", + "choice_time": "Ora del giorno", + "choice_matchups": "Storico match-up" + }, + "recent": { + "description": "Mostra battaglie recenti di uno squadrone", + "length": "Numero di partite da mostrare" + }, + "vs": { + "description": "Record testa a testa tra due squadroni", + "squadron_a": "Primo squadrone", + "squadron_b": "Secondo squadrone" + }, + "autolog_management": { + "description": "Gestisci notifiche autolog e diagnostica permessi" + }, + "diagnose_perms": { + "description": "Diagnostica permessi autolog per questo canale" + }, + "unlock": { + "description": "Sblocca funzionalità Premium per questo server" + }, + "credits": { + "description": "Vedi il team accreditato per questo progetto" + }, + "schedule": { + "description": "Vedi programma BR della stagione attuale" + }, + "news": { + "description": "Vedi ultime news e annunci di SRE Bot" + }, + "help": { + "description": "Vedi guida, ToS e link supporto" + }, + "donate": { + "description": "Supporta lo sviluppo di SRE Bot" + }, + "stack_create": { + "description": "Crea uno stack di giocatori", + "vehicle": "Con quale veicolo inizierai?" + }, + "stack_manage": { + "description": "Ripubblica lo stack attivo in questo canale" + }, + "bot_status": { + "description": "Visualizza lo stato del bot: ultima partita ricevuta e TTL medio" + } + }, + "permission": { + "blacklisted_title": "❌ In blacklist", + "blacklisted_desc": "Non puoi usare questo comando perché sei in blacklist.", + "reason_line": "**Motivo:** {reason}", + "access_denied_title": "⛔ Accesso negato", + "no_permission_desc": "Non hai il permesso di usare questo comando.", + "unexpected_error_title": "❗ Errore, segnalalo...." + }, + "weekly_br": { + "title_wildcard": "Report BR Settimanale — {br} BR", + "title_squadron": "Report BR Settimanale — [{tag}] {long} • {br} BR", + "window_label": "Periodo: {start} → {end}", + "wildcard_desc_first": "Top {count} squadroni per ELO • Posizioni {low}–{high}", + "wildcard_desc_second": "Top {count} squadroni per ELO • Posizioni {low}–{high}", + "squadron_stats_line": "- {games} partite • K/D {kdr} • Vittorie {wr}%", + "top_players_inline_header": "🥇 Migliori giocatori:", + "player_line_short": " {rank}. {nick} ⭐ {score} ({games}p)", + "top_players_header": "**Top {count} giocatori per ELO:**", + "player_line_full": "{rank}. **{nick}** ⭐ {score} • {games} partite • K/D {kdr}", + "squadron_header_line": "ELO squadrone: {score} • {games} partite • Vittorie {wr}% • K/D {kdr}", + "squadron_header_no_aggregate": "ELO squadrone: troppa poca attività di squadra questa settimana.", + "no_data": "Nessuna partita registrata per [{tag}] in questa rotazione BR." + } +} diff --git a/BOT/locales/pl.json b/BOT/locales/pl.json new file mode 100644 index 0000000..f55afa8 --- /dev/null +++ b/BOT/locales/pl.json @@ -0,0 +1,856 @@ +{ + "common": { + "error_title": "Błąd", + "no_data_title": "Brak danych", + "access_denied_title": "Odmowa dostępu", + "access_denied_desc": "Ten serwer został zablokowany.", + "no_players_selected": "Nie wybrano graczy. Wybierz przynajmniej jednego gracza.", + "must_use_in_server": "To polecenie musi być użyte na serwerze.", + "could_not_resolve_channel": "Nie udało się znaleźć wybranego kanału.", + "failed_update_setting": "❌ Nie udało się zaktualizować ustawienia.", + "configuration_not_found": "Nie znaleziono konfiguracji.", + "no_channel_selected": "Nie wybrano kanału.", + "no_selection_received": "Nie otrzymano wyboru.", + "database_error": "❌ Błąd bazy danych: {error}", + "enabled": "Włączone", + "disabled": "Wyłączone", + "not_configured": "Nieskonfigurowane", + "unknown": "Nieznane", + "rating_field": "Ranking", + "battles_field": "Bitwy", + "wins_field": "Zwycięstwa", + "losses_field": "Porażki", + "win_rate_field": "Wskaźnik zwycięstw", + "kills_field": "Zabójstwa", + "deaths_field": "Śmierci", + "kd_field": "K/D", + "members_field": "Członkowie", + "placement_field": "Miejsce", + "points_field": "Punkty", + "ground_kills_field": "Zabójstwa naziemne", + "air_kills_field": "Zabójstwa powietrzne", + "total_kills_field": "Łączne zabójstwa", + "assists_field": "Asysty", + "captures_field": "Przejęcia", + "none_option": "Brak" + }, + "buttons": { + "skip": "Pomiń", + "previous": "Poprzedni", + "next": "Następny", + "prev": "Poprz.", + "prev_arrow": "◀ Poprzedni", + "next_arrow": "Następny ▶", + "prev_arrow_only": "◀", + "next_arrow_only": "▶", + "generate_chart": "📊 Generuj wykres", + "show_graph": "Pokaż wykres", + "view_player_stats": "📊 Zobacz statystyki graczy", + "compare_nearby": "📈 Porównaj pobliskie dywizjony", + "confirm_swap": "Tak, zmień", + "cancel_swap": "Nie, zostaw starą", + "set_squadron": "Ustaw dywizjon", + "same_as_logs": "Tak samo jak logi", + "require_password": "🔒 Wymagaj Hasła", + "password_required": "🔒 Hasło Wymagane", + "lock_data": "🔐 Powiąż Dane Dywizjonu", + "data_locked": "🔐 Dane Powiązane z Serwerem", + "allow_public": "👥 Zezwól na Publiczne Meta", + "public_enabled": "👥 Publiczne Meta Włączone", + "update_accounts": "📋 Zaktualizuj Konta Meta", + "change_password": "🔑 Zmień Hasło", + "help": "❓ Pomoc", + "add_player": "➕ Dodaj Gracza", + "update_all": "🔄 Zaktualizuj Wszystkich Członków", + "back_to_settings": "⬅ Powrót do Ustawień", + "manage_notifications": "Zarządzaj Powiadomieniami", + "diagnose_permissions": "Diagnozuj Uprawnienia", + "enable": "Włącz", + "disable": "Wyłącz", + "change_channel": "Zmień Kanał", + "view_replay": "Zobacz Powtórkę", + "view_website": "Zobacz na Stronie", + "view_video": "Zobacz Wideo", + "view_log": "Zobacz Log", + "view_chat": "Zobacz Czat", + "subscribe_website": "Subskrybuj przez Stronę", + "yes_disband": "Tak, rozwiąż", + "cancel": "Anuluj", + "transfer_leave": "Przekaż i odejdź", + "accept_selected": "Przyjmij wybranych", + "accept_all": "Przyjmij wszystkich", + "decline_selected": "Odrzuć wybranych", + "back": "Wróć", + "remove_all": "Usuń wszystkich", + "remove_active": "Usuń aktywnych", + "remove_queued": "Usuń oczekujących", + "remove_selected": "Usuń wybranych", + "ping_all": "Pinguj wszystkich", + "ping_active": "Pinguj aktywnych", + "ping_queued": "Pinguj oczekujących", + "ping_selected": "Pinguj wybranych", + "accept_members": "Przyjmij członków", + "remove_members": "Usuń członków", + "ping_members": "Pinguj członków", + "rename_stack": "Zmień nazwę stacka", + "request_to_join": "Poproś o dołączenie", + "leave_withdraw": "Odejdź / Wycofaj się", + "manage_stack": "Zarządzaj stackiem ⚙️", + "disband_stack": "Rozwiąż stack", + "force_disband_create": "Wymuś rozwiązanie i utwórz nowy" + }, + "events": { + "guild_join_title": "Dziękuję za dodanie mnie!", + "guild_join_desc": "Uruchom `/setup`, aby skonfigurować bota dla tego serwera." + }, + "comp": { + "not_found_title": "Nie znaleziono składów", + "not_found_desc": "Brak danych dla **{squadron}**, spróbuj ponownie później.", + "error_loading_title": "Błąd ładowania składów", + "error_loading_desc": "Nie udało się załadować danych składów: {error}", + "title": "Składy dla {squadron}", + "desc": "Składy widziane w ciągu ostatnich {minutes} minut", + "no_recent_title": "Brak ostatnich składów", + "no_recent_desc": "Brak składów w ciągu ostatnich {minutes} minut.", + "comp_title": "SKŁAD {index}", + "last_seen_label": "**Ostatnio widziany**: {timestamp}{warning}", + "comp_label": "**Skład**: {notation}", + "no_players_recorded": "Brak zarejestrowanych graczy.", + "limit_reached_title": "Limit składów osiągnięty", + "limit_reached_desc": "Ten serwer wykorzystał wszystkie {limit} wyszukiwań składów w tym slocie czasowym. Subskrybuj (za pomocą /unlock) aby uzyskać nieograniczony dostęp lub poczekaj na następny slot.", + "remaining_footer": "{remaining}/{limit} wyszukiwań składów pozostało w tym slocie czasowym" + }, + "quick_log": { + "invalid_type": "Typ można ustawić tylko na Logi, Punkty, Tabela liderów, Tygodniowy BR lub Oba.", + "squadron_required": "Musisz podać nazwę dywizjonu dla alarmów Logów, Punktów lub Obu.", + "wildcard_logs_only": "Tylko Logi można ustawić na dywizjon z symbolem wieloznacznym.", + "squadron_not_resolved": "Nie udało się rozwiązać dywizjonu `{squadron}`.", + "save_failed": "Nie udało się zapisać preferencji. Spróbuj ponownie później.", + "premium_warning": "\n\n> ⚠️ **Logi gry wymagają Premium.** Uruchom `/unlock`, aby subskrybować ($2.99/mies.) — logi nie będą wysyłane do tego czasu.", + "leaderboard_set": "Alarm globalnej tabeli wyników ustawiony na tym kanale.", + "both_set": "Alarmy Logów i Punktów dla {squadron} ustawione na tym kanale.{premium_note}", + "alarm_set": "Alarm {alarm_type} dla {squadron} ustawiony na tym kanale.{premium_note}", + "weekly_br_wildcard_set": "Tygodniowy raport BR (top 20 szwadronów) skonfigurowany dla tego kanału. Wysyłany na koniec każdej rotacji BR.", + "weekly_br_squadron_set": "Tygodniowy raport BR dla {squadron} (top 15 graczy) skonfigurowany dla tego kanału. Wysyłany na koniec każdej rotacji BR." + }, + "diagnostics": { + "title": "Diagnostyka autologu", + "channel_permissions_header": "**Uprawnienia kanału** (<#{channel_id}>)", + "perms_needed": " ^ Autologowanie wymaga wszystkich powyższych uprawnień do wysyłania tablic wyników.", + "server_squadron_header": "**Dywizjon serwera** (`/set-squadron`)", + "server_squadron_short": " Skrót: `{short}`", + "server_squadron_long": " Pełna nazwa: `{long}`", + "server_squadron_not_set": " Nie ustawiony (kolor paska tablicy wyników będzie wyświetlany jako 'not_set')", + "autolog_prefs_header": "**Preferencje autologu** (`/quick-log`)", + "autolog_none_configured": " ❌ BRAK konfiguracji - autologowanie NIE będzie wysyłać niczego na ten serwer.", + "autolog_setup_hint": " Użyj `/quick-log Logs` na docelowym kanale, aby skonfigurować.", + "autolog_no_logs_channels": " ❌ Brak skonfigurowanych kanałów Logów. Znaleziono tylko Punkty/Tabelę wyników.", + "autolog_enable_hint": " Użyj `/quick-log Logs`, aby włączyć autologowanie.", + "selected_channel_tag": " **(wybrany kanał)**", + "missing_send_attach": " (brak uprawnień do wysyłania/załączania)", + "channel_not_found": " (kanał nie znaleziony)", + "invalid_channel_id": " (nieprawidłowe ID kanału)", + "premium_status_header": "**Status Premium** (`/unlock`)", + "premium_active": " ✅ Ten serwer ma aktywną subskrypcję Premium.", + "premium_not_subscribed": " ❌ Ten serwer **nie** posiada subskrypcji Premium.", + "premium_autolog_required": " Autologowanie wymaga Premium. Użyj `/unlock`, aby subskrybować.", + "premium_not_subscribed_free": " ⚪ Brak subskrypcji — użyj `/unlock`, aby subskrybować ($2.99/mies.).", + "premium_free_note": " *(Autologi są teraz darmowe dla wszystkich serwerów.)*" + }, + "sq_info": { + "title": "Informacje o dywizjonie: {squadron}", + "placement_field": "Miejsce", + "total_points_field": "Łączne punkty", + "total_members_field": "Łączna liczba członków", + "members_field": "Członkowie", + "fetch_failed": "Nie udało się pobrać informacji o dywizjonie." + }, + "sq_info_graph": { + "title": "{squadron} — SQ-INFO (Sezon {season})", + "embed_title": "{squadron} — Skład dywizjonu", + "embed_desc": "Sezon **{season}** · Mediana meczów: **{median}** · Trzon: **{core}** · Aktywni: **{active}** · Słabi: **{weak}**\nSłupki posortowane wg meczów malejąco; wysokość = wsp. wygranych. Trzon = top 30 % WR i mecze ≥ mediana. Aktywni = top 30–45 % WR i mecze ≈ mediana. Słabi = pozostali.", + "core_threshold_line": "TRZON ≥ {wr} %", + "weak_threshold_line": "SŁABI < {wr} %", + "y_label": "Współczynnik wygranych", + "core_header": "TRZON — {count} · WR {avg}%", + "active_header": "AKTYWNI — {count} · WR {avg}%", + "weak_header": "SŁABI — {count} · WR {avg}%", + "no_active_season": "Nie znaleziono aktywnego sezonu. Spróbuj ponownie po rozpoczęciu następnego.", + "no_members": "Nie znaleziono aktualnych członków dla {squadron}." + }, + "recap_card": { + "unknown_season": "Nieznany sezon: `{season}`.", + "no_clan_id": "Nie udało się ustalić ID dywizjonu `{squadron}`.", + "render_failed": "Nie udało się wygenerować karty podsumowania sezonu. Spróbuj ponownie później." + }, + "sq_stats": { + "no_data_title": "Brak danych", + "no_data_desc": "Nie znaleziono danych historycznych dla dywizjonu: {squadron}", + "title": "{squadron} // DYWIZJON", + "desc": "Trend łącznego wyniku (ostatnie {count} punktów danych)", + "previous_score_field": "Poprzedni wynik", + "current_score_field": "Bieżący wynik", + "change_field": "Zmiana", + "player_title": "{squadron} // GRACZE", + "player_desc": "Trendy punktowe poszczególnych graczy", + "comparison_title": "{squadron} // PORÓWNANIE TABELI WYNIKÓW", + "comparison_desc": "Porównanie z dywizjonami z rankingu {range}", + "current_position_field": "Aktualna pozycja", + "squadrons_shown_field": "Wyświetlone dywizjony", + "squadron_not_found_error": "Nie znaleziono dywizjonu w tabeli wyników", + "no_nearby_error": "Nie znaleziono pobliskich dywizjonów", + "no_historical_error": "Nie znaleziono danych historycznych dla pobliskich dywizjonów", + "comparison_chart_failed": "Nie udało się wygenerować wykresu porównawczego", + "select_players_placeholder": "Wybierz graczy (Strona {page})" + }, + "loss_calc": { + "title": "Utrata punktów — {squadron}", + "players_leaving_field": "Opuszczający gracze", + "share_of_total_field": "% udział w całości", + "points_lost_real_field": "Utracone punkty (rzeczywiste)", + "points_lost_raw_field": "Utracone punkty (surowe)", + "squadron_rating_field": "Ranking dywizjonu", + "squadron_position_field": "Pozycja dywizjonu", + "positions_lost_field": "Utracone pozycje", + "not_found_footer": "Nie znaleziono w dywizjonie: {players}", + "fetch_failed": "Nie udało się pobrać danych dywizjonu: {error}", + "no_point_data": "Brak danych punktowych dla tego dywizjonu.", + "no_matching_players": "Nie znaleziono pasujących graczy w **{squadron}**." + }, + "player": { + "select_player_placeholder": "Wybierz gracza", + "no_stats_found": "❌ Nie znaleziono statystyk dla UID: {uid}", + "no_vehicle_stats": "❌ Nie znaleziono statystyk pojazdów dla tego gracza.", + "vehicles_found": "Znaleziono **{count}** pojazdów dla **{nick}**\nWybierz pojazd, aby zobaczyć szczegółowe statystyki:", + "vehicle_select_placeholder": "Wybierz pojazd (Strona {page}/{total})", + "combat_stats_header": "**__STATYSTYKI BOJOWE__**", + "ground_kills_label": "**Zabójstwa naziemne:** {value}", + "air_kills_label": "**Zabójstwa powietrzne:** {value}", + "total_kills_label": "**Łączne zabójstwa:** {value}", + "assists_label": "**Asysty:** {value}", + "deaths_label": "**Śmierci:** {value}", + "kd_label": "**K/D:** {value}", + "captures_label": "**Przejęcia:** {value}", + "battle_record_header": "**__WYNIKI BITEW__**", + "total_battles_label": "**Łączne bitwy:** {value}", + "wins_label": "**Zwycięstwa:** {value}", + "losses_label": "**Porażki:** {value}", + "win_rate_label": "**Wskaźnik zwycięstw:** {value}%", + "stats_desc": "Statystyki dla **{nick}** (**{squadron}**)\nUID: `{uid}`", + "not_found_title": "Nie znaleziono gracza", + "not_found_desc": "Nie znaleziono historii gier dla `{player}`.", + "no_players_found": "Nie znaleziono graczy pasujących do **{username}**\nSpróbuj użyć `/website`, aby wyszukać na stronie.", + "multiple_matches": "Znaleziono wiele dopasowań, wybierz właściwe poniżej:", + "must_provide_input": "Musisz podać przynajmniej UID lub nazwę użytkownika." + }, + "player_games": { + "no_recent_title": "Brak ostatnich gier", + "no_recent_desc": "Nie znaleziono gier dla **{player}** w ciągu ostatnich 8 godzin.", + "squadron_label": "**Dywizjon:** {squadron}", + "record_label": "**Z:** {wins} **P:** {losses} **WS:** {wr}%", + "comps_played_header": "\n\n**Grane składy**" + }, + "match": { + "missing_input_title": "Brakujące dane", + "missing_input_desc": "Podaj `match_id` lub `player_name`.", + "not_found_title": "Nie znaleziono meczu", + "not_found_desc": "Nie udało się znaleźć meczu o ID `{match_id}`.", + "invalid_data_title": "Nieprawidłowe dane meczu", + "invalid_data_desc": "Nie udało się przetworzyć danych powtórki.", + "scoreboard_error_title": "Błąd tablicy wyników", + "scoreboard_error_desc": "Nie udało się wygenerować obrazu tablicy wyników.", + "no_games_title": "Nie znaleziono gier", + "no_games_desc": "Nie znaleziono historii gier dla **{player}**.", + "recent_matches_title": "Ostatnie mecze dla {player}", + "recent_matches_desc": "Wyświetlanie do {count} ostatnich gier. Wybierz jedną, aby zobaczyć pełną tablicę wyników.", + "select_match_placeholder": "Wybierz mecz do wyświetlenia..." + }, + "compare": { + "no_players_found": "Nie znaleziono graczy pasujących do **{name}**.", + "multiple_matches": "Wiele dopasowań dla **{name}**: {matches}\nProszę użyć bardziej szczegółowej nazwy (sugestie autouzupełniania są dokładne).", + "could_not_resolve": "Nie udało się rozwiązać graczy.", + "could_not_fetch": "❌ Nie udało się pobrać statystyk dla **{name}**.", + "no_graph_data": "Brak danych za ostatnie 90 dni.", + "no_squadron_points_data": "Brak danych punktowych dywizjonu dla {names} (gracz nie znaleziony w śledzonej historii dywizjonu).", + "graph_title": "Punkty gracza — ostatnie 90 dni", + "battles_label": "Bitwy", + "wins_label": "Zwycięstwa", + "losses_label": "Porażki", + "win_rate_label": "Wskaźnik zwycięstw", + "ground_kills_label": "Zabójstwa naziemne", + "air_kills_label": "Zabójstwa powietrzne", + "total_kills_label": "Łączne zabójstwa", + "assists_label": "Asysty", + "deaths_label": "Śmierci", + "kd_label": "K/D", + "captures_label": "Przejęcia" + }, + "squadron": { + "not_found_desc": "Nie znaleziono dywizjonu `{squadron}`.", + "set_title": "✅ Dywizjon ustawiony", + "set_desc": "Dywizjon **{squadron}** został ustawiony dla tego serwera.", + "short_name_field": "Skrócona nazwa", + "long_name_field": "Pełna nazwa", + "swap_title": "✅ Dywizjon zmieniony", + "swap_desc": "Zastąpiono **{old}** przez **{new}** dla tego serwera.", + "already_set_title": "⚠️ Dywizjon już ustawiony", + "already_set_desc": "Ten serwer jest aktualnie ustawiony na **{old}**.\nZmienić na **{new}**?", + "swap_cancelled": "❌ Zmiana dywizjonu anulowana." + }, + "setup": { + "step1_title": "Konfiguracja serwera — Krok 1 z 3", + "step1_desc": "Ten kreator przeprowadzi Cię przez konfigurację bota dla Twojego serwera.\n\n**Krok 1** — Ustaw dywizjon\n**Krok 2** — Wybierz kanał logów\n**Krok 3** — Wybierz kanał punktów\n", + "step1_current_sq": "\nAktualnie skonfigurowany dywizjon: **[{short}] {long}**", + "step2_title": "Konfiguracja serwera — Krok 2 z 3", + "step2_desc": "Dywizjon ustawiony na **[{short}] {long}**.\n\nGdzie powinny być wysyłane **logi bitew**?\nWybierz kanał tekstowy poniżej lub pomiń ten krok.", + "step3_title": "Konfiguracja serwera — Krok 3 z 3", + "step3_desc": "Gdzie powinny być wysyłane **powiadomienia o punktach**?\nWybierz kanał tekstowy poniżej lub pomiń ten krok.", + "step3_same_as_logs": "\n\nMożesz też kliknąć \"Tak samo jak Logi\", aby ponownie użyć kanału logów.", + "summary_title": "Konfiguracja zakończona", + "summary_desc": "Możesz użyć `/autolog-management`, aby zmienić te ustawienia później.", + "squadron_field": "Dywizjon", + "logs_channel_field": "Kanał logów", + "points_channel_field": "Kanał punktów", + "premium_required_field": "⚠️ Logi gry wymagają Premium", + "premium_required_value": "Automatyczne tablice wyników nie będą wysyłane, dopóki ten serwer nie będzie miał aktywnej subskrypcji. Uruchom `/unlock`, aby subskrybować ($2.99/mies.).", + "modal_title": "Ustaw dywizjon", + "modal_label": "Skrócona nazwa dywizjonu", + "modal_placeholder": "np. AXYS", + "squadron_not_found": "Nie znaleziono dywizjonu `{squadron}`. Spróbuj ponownie.", + "logs_channel_placeholder": "Wybierz kanał logów...", + "points_channel_placeholder": "Wybierz kanał punktów..." + }, + "meta_management": { + "squadron_not_found_title": "❌ Nie znaleziono dywizjonu", + "squadron_not_found_desc": "Nie udało się znaleźć ID klanu dla dywizjonu: **{squadron}**", + "access_denied_title": "❌ Odmowa dostępu", + "access_denied_desc": "Nieprawidłowe hasło. Metadane tego dywizjonu są chronione.", + "data_locked_title": "🔐 Dane dywizjonu powiązane", + "data_locked_desc": "**{squadron}** ma włączone powiązanie danych i nie może zostać przeniesiony na inny serwer.\n\nWłaściciel dywizjonu musi wyłączyć **Powiązanie Danych Dywizjonu** przed jego przeniesieniem.", + "error_retrieving_settings": "❌ Błąd pobierania ustawień serwera po przeniesieniu. Spróbuj ponownie.", + "error_retrieving_settings_retry": "❌ Błąd pobierania ustawień serwera. Spróbuj uruchomić polecenie ponownie.", + "authenticated_title": "✅ Uwierzytelniono", + "authenticated_desc": "Hasło zweryfikowane. Zarządzanie ustawieniami dla **{squadron}**.", + "claimed_title": "✅ Dywizjon Przejęty", + "claimed_desc": "**{squadron}** został pomyślnie przejęty dla tego serwera!", + "password_requirement_field": "🔒 Wymaganie Hasła", + "data_lock_field": "🔐 Powiązanie Danych Dywizjonu", + "public_meta_field": "👥 Publiczny Dostęp do Meta", + "access_password_field": "🔑 Hasło Dostępu", + "enabled_value": "✅ Włączone", + "disabled_value": "❌ Wyłączone", + "settings_title": "🔐 Ustawienia Zarządzania Meta", + "settings_desc": "**Dywizjon:** {squadron}\n**ID Klanu:** {clan_id}", + "first_time_title": "🔐 Zarządzanie Meta - Pierwsze Uruchomienie", + "first_time_owner_desc": "**Dywizjon:** {squadron}\n**ID Klanu:** {clan_id}\n\n🔑 Twoje hasło dostępu zostało wygenerowane. **Zapisz to hasło** — będzie Ci potrzebne do uwierzytelniania dostępu do metadanych w przyszłości.\n\n**Hasło:** `{password}`", + "first_time_non_owner_desc": "**Dywizjon:** {squadron}\n**ID Klanu:** {clan_id}\n\nDywizjon został skonfigurowany. Zapytaj właściciela serwera o hasło dostępu.", + "settings_field": "Ustawienia", + "settings_hint": "Użyj przycisków poniżej, aby skonfigurować ustawienia dostępu.", + "password_toggled": "✅ Wymaganie hasła: **{state}**", + "lock_toggled": "✅ Powiązanie danych dywizjonu: **{state}**", + "public_meta_toggled": "✅ Publiczny dostęp do meta: **{state}**\n{detail}", + "public_meta_enabled_detail": "Osoby niebędące administratorami mogą teraz używać polecenia `/meta`.", + "public_meta_disabled_detail": "Tylko administratorzy mogą używać polecenia `/meta`.", + "owner_only_password": "❌ Tylko właściciel serwera może zmienić hasło dywizjonu.", + "help_title": "📖 Pomoc Zarządzania Meta", + "help_desc": "Wyjaśnienie każdego ustawienia i funkcji:", + "help_password_field": "🔑 Hasło Dostępu", + "help_password_value": "Hasło dostępu Twojego dywizjonu. Tylko **właściciel serwera** może zobaczyć hasło w panelu ustawień. Każdy, kto ma hasło, może przejąć metadane dywizjonu na swoim serwerze, więc przechowuj je bezpiecznie.", + "help_require_field": "🔒 Wymagaj Hasła", + "help_require_value": "Po włączeniu, nawet administratorzy na tym serwerze muszą wprowadzić hasło dywizjonu, aby uzyskać dostęp do `/meta-management`. Dodaje dodatkową warstwę zabezpieczeń przed przypadkowymi zmianami.", + "help_lock_field": "🔐 Powiąż Dane Dywizjonu", + "help_lock_value": "Po włączeniu, wiąże dane dywizjonu z tym serwerem, uniemożliwiając przeniesienie nawet przy poprawnym haśle. Musi być wyłączone przed przeniesieniem dywizjonu.", + "help_public_field": "👥 Zezwól na Publiczne Meta", + "help_public_value": "Po włączeniu, pozwala osobom niebędącym administratorami używać polecenia `/meta` do wyszukiwania pojazdów dywizjonu. Po wyłączeniu, tylko administratorzy serwera mogą używać `/meta`.", + "help_accounts_field": "📋 Zaktualizuj Konta Meta", + "help_accounts_value": "Otwiera menedżera składu graczy, gdzie możesz dodawać lub usuwać graczy z listy meta dywizjonu. Użyj **Zaktualizuj Wszystkich Członków**, aby zsynchronizować cały dywizjon naraz.", + "help_change_pw_field": "🔑 Zmień Hasło", + "help_change_pw_value": "**Tylko właściciel serwera.** Zmień hasło dostępu dywizjonu i ustaw opcjonalną podpowiedź. Podpowiedź jest wyświetlana w oknie hasła, aby pomóc je zapamiętać.", + "password_modal_title": "Hasło Dostępu Dywizjonu", + "password_modal_label": "Wprowadź Hasło Dywizjonu", + "password_modal_placeholder": "XXXX-XXXX-XXXX", + "change_pw_modal_title": "Zmień Hasło Dywizjonu", + "current_password_label": "Bieżące Hasło", + "current_password_placeholder": "Wprowadź swoje bieżące hasło", + "new_password_label": "Nowe Hasło", + "new_password_placeholder": "Wprowadź nowe hasło", + "confirm_password_label": "Potwierdź Nowe Hasło", + "confirm_password_placeholder": "Wprowadź ponownie nowe hasło", + "hint_label": "Podpowiedź do Hasła (Opcjonalne)", + "hint_placeholder": "Podpowiedź pomagająca zapamiętać hasło", + "pw_incorrect": "❌ Bieżące hasło jest nieprawidłowe.", + "pw_mismatch": "❌ Nowe hasła nie są zgodne. Spróbuj ponownie.", + "pw_empty": "❌ Nowe hasło nie może być puste.", + "pw_changed": "✅ Hasło zaktualizowane pomyślnie dla **{squadron}**.\n**Nowe Hasło:** `{password}`", + "pw_changed_hint": "\n**Podpowiedź:** {hint}", + "player_add_modal_title": "Dodaj Gracza do Listy Meta", + "player_add_label": "UID lub Pseudonim Gracza", + "player_add_placeholder": "Wprowadź UID gracza (np. 12345678) lub pseudonim", + "player_not_found": "❌ Gracz `{player}` nie znaleziony w bazie danych Players_Global.\n", + "roster_title": "📋 Zarządzanie Listą Meta - {squadron}", + "roster_desc": "**ID Klanu Dywizjonu:** {clan_id}\n**Łączna Liczba Graczy:** {count}", + "roster_page_field": "Gracze (Strona {page}/{total})", + "no_players_field": "Brak Graczy", + "no_players_hint": "Nie dodano jeszcze żadnych graczy do listy meta. Kliknij **Dodaj Gracza**, aby rozpocząć.", + "remove_player_placeholder": "Wybierz gracza do usunięcia...", + "fetch_members_failed": "❌ Nie udało się pobrać członków dywizjonu: {error}", + "no_members_found": "❌ Nie znaleziono członków w dywizjonie lub wywołanie API nie powiodło się.", + "roster_synced": "✅ Lista zsynchronizowana z dywizjonem.", + "roster_added": "**+{count}** dodanych", + "roster_removed": "**-{count}** usuniętych (opuścili dywizjon)", + "roster_up_to_date": "**{count}** już aktualnych", + "refreshing_vehicles": "Odświeżanie danych pojazdów w tle..." + }, + "meta": { + "not_configured": "❌ Metadane nie skonfigurowane dla tego serwera. Najpierw uruchom `/meta-management`.", + "no_permission": "❌ Potrzebujesz uprawnień administratora, aby użyć tego polecenia.\nAdministratorzy mogą włączyć dostęp publiczny przez `/meta-management`.", + "no_results": "❌ Żaden gracz z listy dywizjonu nie posiada **{vehicle}**.", + "no_results_admin_hint": "\n*Spodziewasz się, że ktoś powinien to mieć? Kliknij przycisk aktualizacji członków w `/meta-management` i sprawdź ponownie.*", + "search_title": "🔍 Wyniki Wyszukiwania - {vehicle}", + "matches_found": "**Znalezione Dopasowania:** {count} gracz(y)", + "spawns_label": "Spawny", + "deaths_label": "Śmierci", + "gk_label": "ZN", + "ak_label": "ZP", + "points_label": "Punkty", + "kdr_label": "KDR", + "games_label": "Gry", + "no_points": "—" + }, + "top": { + "title": "**Top 20 Dywizjonów**", + "rating_label": "**Ranking:** {value}", + "air_kills_label": "**Zabójstwa powietrzne:** {value}", + "ground_kills_label": "**Zabójstwa naziemne:** {value}", + "deaths_label": "**Śmierci:** {value}", + "kd_label": "**K/D:** {value}", + "win_rate_label": "**Wskaźnik zwycięstw:** {value}", + "playtime_label": "**Czas Gry:** {value}", + "fetch_failed": "Nie udało się pobrać danych dywizjonu." + }, + "analytics": { + "no_data_title": "Brak danych", + "no_matches_desc": "Nie znaleziono meczów.", + "no_comp_desc": "Nie znaleziono danych składu.", + "no_consistency_desc": "Niewystarczające dane graczy (minimum 50 meczów).", + "no_time_desc": "Nie znaleziono danych czasowych.", + "unknown_view": "Nieznany widok.", + "map_title": "Wskaźniki Zwycięstw na Mapach: {squadron}", + "comp_title": "Składy Drużyny: {squadron}", + "consistency_title": "Stałość graczy: {squadron}", + "consistency_desc": "Posortowane według wskaźnika K/D", + "time_title": "Wyniki w Zależności od Pory Dnia: {squadron}", + "eu_timeslot": "\n**Slot EU**", + "na_timeslot": "\n**Slot NA**", + "off_peak": "\n**Poza Szczytem**", + "matchups_title": "📜 {squadron} — Historia Starć", + "matchups_won_field": "🏆 Najwięcej Wygranych Przeciw", + "matchups_lost_field": "💀 Najwięcej Przegranych Z", + "no_matchups_desc": "Brak zarejestrowanych meczów przeciwko innym klanom." + }, + "recent": { + "title": "Ostatnie Mecze: {squadron}", + "no_matches_desc": "Nie znaleziono meczów dla tego dywizjonu." + }, + "h2h": { + "two_required_title": "Wymagane Dwa Dywizjony", + "two_required_desc": "Podaj przynajmniej jeden dywizjon lub użyj `/set-squadron` i podaj przeciwnika.", + "provide_a_desc": "Podaj `squadron_a` lub najpierw użyj `/set-squadron`.", + "provide_b_desc": "Podaj `squadron_b` lub najpierw użyj `/set-squadron`.", + "squadron_not_found_title": "Nie Znaleziono Dywizjonu", + "same_squadron_title": "Ten Sam Dywizjon", + "same_squadron_desc": "Nie możesz sprawdzić bezpośredniej konfrontacji z samym sobą.", + "record_desc": "**Wynik:** {a_wins}Z - {b_wins}P ({total} gier)", + "no_matches_desc": "Brak zarejestrowanych meczów między **{a}** a **{b}**." + }, + "autolog": { + "premium_active_line": "✅ **Premium:** Aktywne — autologowanie jest włączone dla tego serwera.", + "premium_not_subscribed_line": "❌ **Premium:** Brak subskrypcji — użyj `/unlock`, aby włączyć autologowanie.", + "premium_free_line": "⚪ **Premium:** Brak subskrypcji — użyj `/unlock`, aby subskrybować ($2.99/mies.). *(Autologi są teraz darmowe dla wszystkich serwerów.)*", + "what_to_do": "\n\nCo chcesz zrobić?", + "select_notif_type": "Wybierz typ powiadomienia do zarządzania:", + "select_notif_placeholder": "Wybierz typ powiadomienia", + "logs_option": "Logi", + "logs_option_desc": "Zarządzaj powiadomieniami Logów", + "points_option": "Punkty", + "points_option_desc": "Zarządzaj powiadomieniami Punktów", + "leaderboard_option": "Tabela Wyników", + "leaderboard_option_desc": "Zarządzaj powiadomieniami Tabeli Wyników", + "selected_type": "Wybrano **{type}**. Teraz wybierz dywizjon do zarządzania:", + "select_squadron_placeholder": "Wybierz dywizjon", + "select_squadron_page_placeholder": "Wybierz dywizjon (Strona {page})", + "no_squadrons_available": "Brak dostępnych dywizjonów dla tego typu powiadomień.", + "managing_global": "Zarządzanie **{type}** (globalne) na kanale **{channel}**.", + "managing_squadron": "Zarządzanie **{type}** dla dywizjonu **{squadron}** na kanale **{channel}**.", + "select_channel": "Wybierz nowy kanał:", + "select_channel_placeholder": "Wybierz kanał", + "select_channel_page_placeholder": "Wybierz kanał (Strona {page})", + "global_toggled": "{type} (globalne) jest teraz {state}.", + "squadron_toggled": "{type} dla **{squadron}** jest teraz {state}.", + "channel_updated_global": "Zaktualizowano {type} (globalne) na {channel}", + "channel_updated_squadron": "Zaktualizowano {type} dla **{squadron}** na {channel}", + "diagnose_channel_placeholder": "Wybierz kanał do diagnostyki...", + "select_channel_diagnose": "Wybierz kanał do diagnostyki:", + "game_not_logged_title": "Gra niezapisana", + "game_not_logged_desc": "Użyj `/unlock`, aby wykupić plan **Standard** (lub wyższy) i otrzymywać automatyczne tablice wyników.", + "server_not_upgraded_title": "⚠️ Serwer Niezaktualizowany", + "server_not_upgraded_autolog_desc": "Ten serwer nie ma aktywnej subskrypcji Premium.\n\n**Automatyczne tablice wyników gier przestaną być wysyłane na serwery bez aktualizacji po .**\n\nUżyj `/unlock`, aby subskrybować i nadal otrzymywać automatyczne logi gier.", + "replay_not_available": "Dane powtórki nie są jeszcze dostępne — poczekaj chwilę i spróbuj ponownie!", + "too_many_videos": "Zbyt wiele filmów jest teraz renderowanych — spróbuj ponownie za chwilę.", + "video_gen_failed": "Błąd generowania wideo: `{error}`", + "video_missing": "Nie udało się wygenerować wideo powtórki - brak pliku wyjściowego lub jest on pusty.", + "video_too_large": "Wideo powtórki jest zbyt duże do przesłania ({file_mb:.1f} MB). Limit serwera wynosi {limit_mb:.0f} MB.", + "video_web_fallback": "Możesz też obejrzeć ten mecz na {url}", + "video_upload_failed": "Wideo zbyt duże do przesłania — obejrzyj je na stronie:\n{url}", + "video_unexpected_error": "Nieoczekiwany błąd podczas generowania wideo powtórki: `{error}`", + "replay_not_found": "Nie znaleziono danych powtórki dla sesji `{session_id}` na dysku.", + "chat_log_title": "**Log Czatu dla Gry [{session_id}]({url})**", + "chat_log_part_title": "**Log Czatu dla Gry [{session_id}]({url}) (Część {part}/{total})**", + "chat_log_part_only": "**Log Czatu (Część {part}/{total})**", + "no_chat_log": "Nie znaleziono logu czatu dla sesji `{session_id}`.", + "chat_log_error": "Nieoczekiwany błąd podczas ładowania logu czatu: `{error}`", + "battle_log_title": "**Log Bitwy dla Gry [{session_id}]({url})**", + "battle_log_part_title": "**Log Bitwy dla Gry [{session_id}]({url}) (Część {part}/{total})**", + "battle_log_part_only": "**Log Bitwy (Część {part}/{total})**", + "no_battle_log": "Nie znaleziono zdarzeń bojowych dla sesji `{session_id}`.", + "battle_log_error": "Nieoczekiwany błąd podczas ładowania logu bitwy: `{error}`", + "points_update_title": "**{squadron} {region} Aktualizacja Punktów**", + "points_update_desc": "# {old_total} -> {new_total} {chart}{wl_line}{placement_line}\n\n**Zmiany Graczy:**", + "points_table_header": "Nazwa Zmiana Teraz\n", + "wl_line": "\n**{squadron}** osiągnął **{wins}Z-{losses}P** w tej sesji", + "placement_rose": "\n**{squadron}** awansował na **{new_place}** z **{old_place}**", + "placement_fell": "\n**{squadron}** spadł na **{new_place}** z **{old_place}**", + "points_not_logged_title": "Punkty niezapisane", + "points_not_logged_desc": "Użyj `/unlock`, aby wykupić plan **Standard** (lub wyższy) i otrzymywać automatyczne aktualizacje punktów.", + "server_not_upgraded_points_desc": "Ten serwer nie ma aktywnej subskrypcji Premium.\n\n**Automatyczne aktualizacje przestaną być wysyłane na serwery bez aktualizacji po .**\n\nUżyj `/unlock`, aby subskrybować i nadal otrzymywać automatyczne aktualizacje.", + "leave_title": "⚠️ Gracz Opuścił {squadron}", + "leave_desc": "**{nick}** ({uid}) opuścił dywizjon.\n\nOstatnie zarejestrowane punkty: **{points}**", + "no_squadrons_desc": "No squadrons configured", + "no_channels_desc": "No channels available", + "over_cap_title": "Klan powyżej limitu Twojego planu", + "over_cap_desc": "Twój serwer ma plan **{tier}**, który pozwala na **{cap} {notif}** klanów. Klan **{squadron}** jest powyżej limitu i nie jest rejestrowany. Zmień plan, aby przywrócić.", + "over_cap_footer": "Zmień plan na srebot-meow.ing/premium lub /unlock", + "wildcard_blocked_title": "Wildcard wymaga wyższego planu", + "wildcard_blocked_desc": "Wpisy wildcard (*, all, everything) są dostępne tylko w planach Pro i Max. Twój serwer ma **{tier}** dla {notif}. Zaktualizuj, aby włączyć.", + "cap_header": "{used}/{cap} {notif} włączonych — plan {tier}" + }, + "track": { + "squadron_not_found": "Nie znaleziono dywizjonu.", + "fetch_failed": "Nie udało się pobrać informacji o dywizjonie." + }, + "unlock": { + "title": "SRE Bot Premium", + "desc": "**Odblokuj funkcje premium dla tego serwera.**\n\nPremium zawiera:\n> • Automatyczne posty z tablicami wyników\n> • Logi czatu i bitwy\n> • Wyszukiwanie powtórek\n> • Nieograniczone wyszukiwania /comp\n> • Priorytetowe wsparcie\n\n**$2.99 / miesiąc · na serwer · anuluj w dowolnym momencie**\n\n⚠️ Płatności Discord są dostępne tylko w wybranych krajach. Jeśli przycisk poniżej pokazuje **\"Produkt Niedostępny\"**, może to być spowodowane nieobsługiwanym krajem lub użyciem **urządzenia mobilnego**. Zamiast tego użyj przycisku **Subskrybuj przez Stronę**.", + "already_subscribed_title": "SRE Bot Premium", + "already_subscribed_desc": "✅ **Ten serwer jest już subskrybowany!**", + "manage_discord_field": "Zarządzaj Subskrypcją", + "manage_discord_value": "Twoja subskrypcja jest przez **Discord**.\nAby anulować, przejdź do **Ustawień Użytkownika → Subskrypcje** w Discord.", + "manage_website_field": "Zarządzaj Subskrypcją", + "manage_website_value": "Twoja subskrypcja jest przez **stronę**.\nZarządzaj nią na [whop.com/billing](https://whop.com/billing).", + "coming_soon_field": "Już Wkrótce", + "coming_soon_value": "Subskrypcje Premium nie są jeszcze dostępne. Sprawdź wkrótce!", + "current_tier": "Masz plan **{tier}**.", + "upgrade_to": "Przejdź na {tier}", + "upgrade_to_value": "Więcej klanów i funkcji po przejściu na **{tier}**." + }, + "language": { + "prompt": "Proszę wybrać język serwera:", + "select_placeholder": "Wybierz język serwera", + "language_set": "Język ustawiony na {language}.", + "translate_prompt": "Wybierz język docelowy poniżej 👇", + "translate_placeholder": "Wybierz język docelowy…", + "translate_result": "**{author} → {language}:**\n{text}", + "translation_unavailable": "Tłumaczenie niedostępne (DeepL nieskonfigurowany)", + "translation_failed": "Tłumaczenie nie powiodło się" + }, + "misc": { + "credits_title": "Twórcy", + "credits_desc": "**Meowww**\n\n> **NotSoToothless** - Główny Programista, Menedżer Bota, Menedżer Społeczności\n> **Z3R0** - Programista, Programista Optymalizacji, Inżynier Baz Danych\n> **Clippii (Heidi)i (Heidi)** - Programista, Programista Strony, Menedżer Społeczności\n> **LivingTheDagor** - Programista, Programista Parsera, Konsultant\n> **Lux_** - Inżynier API, Programista Spectra\n> **Konigallerwaffen** - Konsultant ds. opinii i funkcji\n> **Žralok Tonda** - Tłumacz Czeski\n> **Styevy**, **Lopais** - Tłumacze Niemieccy\n> **Susogus**, **playforfun698** - Tłumacze Polscy\n> **Bobr** - Tłumacz Rosyjski\n\n\n[Chcesz do nas dołączyć?](https://discord.gg/BCvkK8JhPe)", + "schedule_title": "HARMONOGRAM SEZONU", + "schedule_not_found_title": "Nie Znaleziono Harmonogramu", + "schedule_not_found_desc": "Dane harmonogramu nie są jeszcze dostępne.", + "news_no_news_title": "Brak Wiadomości", + "news_no_news_desc": "Nie ma teraz żadnych ogłoszeń. Sprawdź ponownie później!", + "news_footer": "Dziękujemy za wsparcie! ᕙᘘᗢ", + "help_title": "Przewodnik po Bocie", + "donate_title": "Wesprzyj SRE Bot", + "donate_desc": "Jeśli lubisz używać SRE Bot i chcesz wesprzeć jego rozwój, rozważ kupienie mi kawy!\n\n**[Przekaż darowiznę na Ko-fi](https://ko-fi.com/notsotoothless)**\n\nKażda wpłata pomaga utrzymać bota i wspiera nowe funkcje. Dziękuję!", + "status_title": "Status bota", + "status_last_received": "Ostatnia odebrana gra", + "status_avg_ttl": "Średni TTL (ostatnie 30)", + "status_no_data": "Brak danych", + "status_gaijin_slow": "⚠️ Serwery Gaijin są wolne", + "help_commands_header": "**Przegląd komend**", + "help_links": "Szczegóły znajdziesz w dokumentacji [tutaj]({docs}) albo na supporcie [tutaj]({support}).", + "help_terms": "[Regulamin]({terms}) • [Polityka prywatności]({terms})" + }, + "dev": { + "restricted_dev_team": "This command is restricted to the dev team.", + "restricted_bot_owner": "❌ This command is restricted to the bot owner.", + "invalid_server_id": "❌ Invalid server ID. Must be a 17-19 digit Discord server ID.", + "expiry_too_soon": "❌ Expiry timestamp must be at least 1 month from now.\n> Now: \n> Minimum: \n> You provided: ", + "entitlement_write_failed": "❌ Failed to write entitlement: {error}", + "entitlement_created_title": "✅ Manual Entitlement Created", + "entitlement_created_desc": "**Server:** {guild_name} (`{server_id}`)\n**Expires:** ()\n**Created:** ", + "query_failed": "Query failed: {error}", + "health_title": "Bot Health Dashboard", + "health_uptime": "Uptime", + "health_guilds": "Guilds", + "health_games_processed": "Games Processed", + "health_tasks": "Tasks", + "health_websocket": "WebSocket", + "health_never": "never", + "health_errors": "({count} errors)", + "health_last_msg": "last msg {ago} ({count} total)", + "health_avg_ttl": "Avg TTL (Last 30)", + "entitlements_title": "Active Entitlements ({count} total)", + "entitlements_no_entries": "No entitlements.", + "entitlements_empty_title": "Active Entitlements", + "entitlements_empty_desc": "No active entitlements found.", + "entitlements_tag_discord": "Discord", + "entitlements_tag_whop": "Whop", + "entitlements_tag_manual": "Manual", + "query_prefix": "Query: {name}" + }, + "leaderboard_alarm": { + "title": "🏆 Tabela Wyników Dywizjonów", + "top15_desc": "Top 15 dywizjonów ze statystykami, wysyłane 35 minut po zamknięciu slotu czasowego.\nWysłana .", + "top30_desc": "Dywizjony 16-30 ze statystykami.", + "not_logged_title": "Tabela Wyników Niezalogowana", + "not_logged_desc": "Użyj `/unlock`, aby wykupić plan **Standard** (lub wyższy) i otrzymywać automatyczne aktualizacje tabeli wyników.", + "server_not_upgraded_title": "⚠️ Serwer Niezaktualizowany", + "server_not_upgraded_desc": "Ten serwer nie ma aktywnej subskrypcji Premium.\n\n**Automatyczne aktualizacje przestaną być wysyłane na serwery bez aktualizacji po .**\n\nUżyj `/unlock`, aby subskrybować i nadal otrzymywać automatyczne aktualizacje." + }, + "stacks": { + "stack_title": "Stack gracza {leader}", + "stack_named_title": "{name}", + "no_members": "Brak członków.", + "members_field": "Członkowie ({count}/{max})", + "queue_field": "Kolejka ({count}/{max})", + "manage_title": "Zarządzaj stackiem", + "no_pending_requests": "Brak oczekujących próśb.", + "disbanded_title": "Stack [Rozwiązany]", + "disbanded_desc": "Ten stack został rozwiązany przez lidera.", + "expired_title": "Stack [Wygasły]", + "expired_desc": "Ten stack wygasł.", + "join_modal_title": "Prośba o dołączenie do stacka", + "join_vehicle_label": "Czym będziesz grać?", + "join_vehicle_placeholder": "np. F-16C, WZ305...", + "ping_modal_title": "Wiadomość pingu", + "ping_message_label": "Własna wiadomość (opcjonalnie)", + "ping_message_placeholder": "np. Chodźcie! Stack startuje!", + "rename_modal_title": "Zmień nazwę stacka", + "rename_label": "Nazwa stacka", + "rename_placeholder": "np. Nocne Sowy, Drużyna Alfa...", + "select_new_leader": "Wybierz nowego lidera…", + "select_applicants": "Wybierz kandydatów…", + "no_pending_applications": "Brak oczekujących aplikacji.", + "select_to_remove": "Wybierz osoby do usunięcia…", + "no_members_or_applicants": "Brak członków lub kandydatów.", + "select_to_ping": "Wybierz osoby do indywidualnego pingu…", + "stack_not_found": "❌ Stack nie znaleziony.", + "no_longer_exists": "❌ Ten stack już nie istnieje.", + "member_not_exists": "❌ Ten członek już nie istnieje.", + "already_has_stack": "❌ Ten gracz już ma aktywny stack.", + "already_member": "❌ Już jesteś członkiem tego stacka.", + "already_applied": "❌ Już masz oczekującą aplikację do tego stacka.", + "queue_full": "❌ Kolejka jest pełna ({max}/{max}). Spróbuj później.", + "application_sent": "✅ Aplikacja wysłana! Lider stacka ją rozpatrzy.", + "stack_disbanded": "✅ Stack rozwiązany.", + "cancelled": "Anulowano.", + "select_member_transfer": "❌ Wybierz członka, na którego chcesz przekazać prowadzenie.", + "ownership_transferred": "✅ Prowadzenie przekazane do {nick}. Opuściłeś stack.", + "select_applicant_first": "❌ Najpierw wybierz co najmniej jednego kandydata.", + "stack_full": "❌ Stack jest już pełny ({max}/{max} członków).", + "select_person_first": "❌ Najpierw wybierz co najmniej jedną osobę.", + "no_one_to_ping": "❌ Nie ma kogo pingować.", + "ping_footer": "Pingowane przez {leader} dla {stack}.", + "pinged": "✅ Pingowano!", + "select_from_dropdown": "❌ Najpierw wybierz co najmniej jedną osobę z listy rozwijanej.", + "stack_renamed": "✅ Stack zmieniony na **{name}**.", + "only_member_use_disband": "❌ Jesteś jedynym członkiem. Użyj **Rozwiąż stack** aby zakończyć.", + "select_transfer_prompt": "Wybierz członka, na którego chcesz przekazać prowadzenie przed odejściem:", + "left_stack": "✅ Opuściłeś stack.", + "application_withdrawn": "✅ Twoja aplikacja została wycofana.", + "not_member_or_applicant": "❌ Nie jesteś członkiem ani kandydatem tego stacka.", + "leader_only_manage": "❌ Tylko lider stacka może nim zarządzać.", + "leader_only_disband": "❌ Tylko lider stacka może go rozwiązać.", + "confirm_disband": "Czy na pewno chcesz rozwiązać ten stack? Tej akcji nie można cofnąć.", + "already_active_stack": "⚠️ Masz już aktywny stack. Jeśli oryginalna wiadomość zniknęła (np. po restarcie bota), możesz wymusić rozwiązanie i zacząć od nowa.", + "force_created": "✅ Poprzedni stack rozwiązany. Nowy stack utworzony.", + "no_active_stack": "❌ Nie masz aktywnego stacka. Użyj `/stack-create` aby utworzyć.", + "could_not_parse_channel": "⚠️ Nie można przetworzyć zapisanego ID kanału." + }, + "commands": { + "common": { + "season": "Sezon do wygenerowania karty", + "theme": "Motyw kolorystyczny karty", + "squadron_short": "Krótka nazwa dywizjonu", + "player_username": "Nazwa gracza", + "choice_dark": "Ciemny", + "choice_light": "Jasny" + }, + "comp": { + "description": "Znajdź ostatnie znane składy drużyny", + "squadron_short": "Krótka nazwa wrogiej drużyny" + }, + "quick_log": { + "description": "Ustaw alarm dla tego dywizjonu w tym kanale", + "squadron_name": "KRÓTKA nazwa dywizjonu do monitorowania", + "type": "Wybierz Logi, Punkty, Tabela liderów, Tygodniowy BR lub Oba", + "choice_logs": "Logi", + "choice_points": "Punkty", + "choice_leaderboard": "Ranking", + "choice_both": "Oba (Logi + Punkty)", + "choice_weekly_br": "Tygodniowy BR" + }, + "sq_info": { + "description": "Pobierz informacje o dywizjonie" + }, + "sq_info_graph": { + "description": "Pokaż wykres składu dywizjonu według aktywności i współczynnika wygranych (bieżący sezon)" + }, + "sq_card": { + "description": "Wygeneruj kartę sezonu dla dywizjonu", + "squadron": "Krótka nazwa dywizjonu" + }, + "sq_stats": { + "description": "Pokaż punkty dywizjonu w czasie" + }, + "loss_calculator": { + "description": "Oblicz stratę punktów, jeśli gracze opuszczą dywizjon", + "player1": "Gracz odchodzący", + "player_optional": "Gracz odchodzący (opcjonalnie)" + }, + "website": { + "description": "Pobierz link do strony SRE Bot" + }, + "card": { + "description": "Wygeneruj kartę sezonu dla gracza" + }, + "player_stats": { + "description": "Zobacz szczegółowe statystyki pojazdów gracza", + "username": "Nazwa WT do statystyk", + "uid": "UID WT do statystyk" + }, + "view_player_games": { + "description": "Zobacz ostatnie 20 gier gracza" + }, + "view_match": { + "description": "Zobacz tabelę meczu po ID lub graczu", + "match_id": "Szesnastkowe ID sesji meczu", + "player_name": "Gracz do przeglądania ostatnich meczów" + }, + "compare": { + "description": "Porównaj łączne statystyki SQB graczy", + "player1": "Pierwszy gracz", + "player2": "Drugi gracz", + "player_optional": "Dodatkowy gracz (opcjonalnie)" + }, + "leaderboard": { + "description": "Otwórz globalny ranking SRE Bot" + }, + "set_squadron": { + "description": "Ustaw tag dywizjonu dla tego serwera", + "abbreviated_name": "Krótka nazwa dywizjonu do ustawienia" + }, + "setup": { + "description": "Skonfiguruj bota dla tego serwera" + }, + "meta_management": { + "description": "Zarządzaj dostępem do danych meta dla tego serwera" + }, + "meta": { + "description": "Szukaj w rosterze meta po nazwie pojazdu", + "vehicle": "Nazwa pojazdu do wyszukania" + }, + "top": { + "description": "Pokaż top 20 dywizjonów ze szczegółowymi statystykami" + }, + "language": { + "description": "Zmień język bota." + }, + "translate_message": { + "name": "Przetłumacz wiadomość" + }, + "sq_track": { + "description": "Śledź dywizjon i porównaj z ostatnim sprawdzeniem", + "squadron_short_name": "Krótka nazwa dywizjonu do śledzenia" + }, + "analytics": { + "description": "Zobacz zaawansowane analizy SQB dywizjonu", + "view": "Widok analizy do pokazania", + "choice_maps": "Win rate map", + "choice_comps": "Składy drużyny", + "choice_consistency": "Regularność graczy", + "choice_time": "Pora dnia", + "choice_matchups": "Historia pojedynków" + }, + "recent": { + "description": "Pokaż ostatnie bitwy dywizjonu", + "length": "Liczba meczów do pokazania" + }, + "vs": { + "description": "Bilans bezpośredni dwóch dywizjonów", + "squadron_a": "Pierwszy dywizjon", + "squadron_b": "Drugi dywizjon" + }, + "autolog_management": { + "description": "Zarządzaj powiadomieniami autolog i diagnozuj uprawnienia" + }, + "diagnose_perms": { + "description": "Diagnozuj uprawnienia autolog w tym kanale" + }, + "unlock": { + "description": "Odblokuj funkcje Premium dla tego serwera" + }, + "credits": { + "description": "Zobacz zespół stojący za tym projektem" + }, + "schedule": { + "description": "Zobacz obecny harmonogram BR sezonu" + }, + "news": { + "description": "Zobacz najnowsze newsy i ogłoszenia SRE Bot" + }, + "help": { + "description": "Zobacz poradnik, ToS i linki wsparcia" + }, + "donate": { + "description": "Wesprzyj rozwój SRE Bot" + }, + "stack_create": { + "description": "Utwórz stack graczy", + "vehicle": "Jakim pojazdem zaczniesz?" + }, + "stack_manage": { + "description": "Opublikuj ponownie aktywny stack w tym kanale" + }, + "bot_status": { + "description": "Pokaż status bota: ostatnia odebrana gra i średni TTL" + } + }, + "permission": { + "blacklisted_title": "❌ Zablokowano", + "blacklisted_desc": "Nie możesz używać tej komendy, bo jesteś na czarnej liście.", + "reason_line": "**Powód:** {reason}", + "access_denied_title": "⛔ Odmowa dostępu", + "no_permission_desc": "Nie masz uprawnień do użycia tej komendy.", + "unexpected_error_title": "❗ Błąd, zgłoś to...." + }, + "weekly_br": { + "title_wildcard": "Tygodniowy raport BR — {br} BR", + "title_squadron": "Tygodniowy raport BR — [{tag}] {long} • {br} BR", + "window_label": "Okres: {start} → {end}", + "wildcard_desc_first": "Top {count} szwadronów wg ELO • Miejsca {low}–{high}", + "wildcard_desc_second": "Top {count} szwadronów wg ELO • Miejsca {low}–{high}", + "squadron_stats_line": "- {games} bitew • K/D {kdr} • Zwycięstw {wr}%", + "top_players_inline_header": "🥇 Najlepsi gracze:", + "player_line_short": " {rank}. {nick} ⭐ {score} ({games}b)", + "top_players_header": "**Top {count} graczy wg ELO:**", + "player_line_full": "{rank}. **{nick}** ⭐ {score} • {games} bitew • K/D {kdr}", + "squadron_header_line": "ELO szwadronu: {score} • {games} bitew • Zwycięstw {wr}% • K/D {kdr}", + "squadron_header_no_aggregate": "ELO szwadronu: zbyt mała aktywność drużyny w tym tygodniu.", + "no_data": "Brak meczów dla [{tag}] w tej rotacji BR." + } +} diff --git a/BOT/locales/pt.json b/BOT/locales/pt.json new file mode 100644 index 0000000..a0cbb2e --- /dev/null +++ b/BOT/locales/pt.json @@ -0,0 +1,856 @@ +{ + "common": { + "error_title": "Erro", + "no_data_title": "Sem dados", + "access_denied_title": "Acesso negado", + "access_denied_desc": "Este servidor foi bloqueado.", + "no_players_selected": "Nenhum jogador selecionado. Selecione pelo menos um jogador.", + "must_use_in_server": "Este comando deve ser usado em um servidor.", + "could_not_resolve_channel": "Não foi possível resolver o canal selecionado.", + "failed_update_setting": "❌ Falha ao atualizar configuração.", + "configuration_not_found": "Configuração não encontrada.", + "no_channel_selected": "Nenhum canal selecionado.", + "no_selection_received": "Nenhuma seleção recebida.", + "database_error": "❌ Erro no banco de dados: {error}", + "enabled": "Ativado", + "disabled": "Desativado", + "not_configured": "Não configurado", + "unknown": "Desconhecido", + "rating_field": "Classificação", + "battles_field": "Batalhas", + "wins_field": "Vitórias", + "losses_field": "Derrotas", + "win_rate_field": "Taxa de Vitórias", + "kills_field": "Abates", + "deaths_field": "Mortes", + "kd_field": "K/D", + "members_field": "Membros", + "placement_field": "Colocação", + "points_field": "Pontos", + "ground_kills_field": "Abates terrestres", + "air_kills_field": "Abates aéreos", + "total_kills_field": "Total de Abates", + "assists_field": "Assistências", + "captures_field": "Capturas", + "none_option": "Nenhum" + }, + "buttons": { + "skip": "Pular", + "previous": "Anterior", + "next": "Próximo", + "prev": "Ant.", + "prev_arrow": "◀ Anterior", + "next_arrow": "Próximo ▶", + "prev_arrow_only": "◀", + "next_arrow_only": "▶", + "generate_chart": "📊 Gerar Gráfico", + "show_graph": "Mostrar gráfico", + "view_player_stats": "📊 Ver Estatísticas dos Jogadores", + "compare_nearby": "📈 Comparar Esquadrões Próximos", + "confirm_swap": "Sim, trocar", + "cancel_swap": "Não, manter o anterior", + "set_squadron": "Definir esquadrão", + "same_as_logs": "Mesmo que Logs", + "require_password": "🔒 Exigir senha", + "password_required": "🔒 Senha obrigatória", + "lock_data": "🔐 Vincular dados do esquadrão", + "data_locked": "🔐 Dados vinculados ao servidor", + "allow_public": "👥 Permitir meta público", + "public_enabled": "👥 Meta público ativado", + "update_accounts": "📋 Atualizar contas meta", + "change_password": "🔑 Alterar senha", + "help": "❓ Ajuda", + "add_player": "➕ Adicionar jogador", + "update_all": "🔄 Atualizar todos os membros", + "back_to_settings": "⬅ Voltar às configurações", + "manage_notifications": "Gerenciar notificações", + "diagnose_permissions": "Diagnosticar permissões", + "enable": "Ativar", + "disable": "Desativar", + "change_channel": "Alterar canal", + "view_replay": "Ver replay", + "view_website": "Ver no Site", + "view_video": "Ver vídeo", + "view_log": "Ver log", + "view_chat": "Ver chat", + "subscribe_website": "Assinar pelo Site", + "yes_disband": "Sim, dissolver", + "cancel": "Cancelar", + "transfer_leave": "Transferir e sair", + "accept_selected": "Aceitar selecionados", + "accept_all": "Aceitar todos", + "decline_selected": "Recusar selecionados", + "back": "Voltar", + "remove_all": "Remover todos", + "remove_active": "Remover ativos", + "remove_queued": "Remover em espera", + "remove_selected": "Remover selecionados", + "ping_all": "Notificar todos", + "ping_active": "Notificar ativos", + "ping_queued": "Notificar em espera", + "ping_selected": "Notificar selecionados", + "accept_members": "Aceitar membros", + "remove_members": "Remover membros", + "ping_members": "Notificar membros", + "rename_stack": "Renomear stack", + "request_to_join": "Pedir para entrar", + "leave_withdraw": "Sair / Retirar", + "manage_stack": "Gerenciar stack ⚙️", + "disband_stack": "Dissolver stack", + "force_disband_create": "Forçar dissolução e criar novo" + }, + "events": { + "guild_join_title": "Obrigado por me adicionar!", + "guild_join_desc": "Execute `/setup` para configurar o bot neste servidor." + }, + "comp": { + "not_found_title": "Composições não encontradas", + "not_found_desc": "Sem dados para **{squadron}**, tente novamente mais tarde.", + "error_loading_title": "Erro ao carregar composições", + "error_loading_desc": "Falha ao carregar dados de composição: {error}", + "title": "Composições de {squadron}", + "desc": "Composições vistas nos últimos {minutes} minutos", + "no_recent_title": "Sem composições recentes", + "no_recent_desc": "Nenhuma composição nos últimos {minutes} minutos.", + "comp_title": "COMP {index}", + "last_seen_label": "**Visto pela última vez**: {timestamp}{warning}", + "comp_label": "**Comp**: {notation}", + "no_players_recorded": "Nenhum jogador registrado.", + "limit_reached_title": "Limite de composições atingido", + "limit_reached_desc": "Este servidor usou todas as {limit} consultas de composições para este horário. Assine (com /unlock) para acesso ilimitado ou aguarde o próximo horário.", + "remaining_footer": "{remaining}/{limit} consultas de composições restantes neste horário" + }, + "quick_log": { + "invalid_type": "O tipo só pode ser definido como Logs, Pontos, Classificação, BR Semanal ou Ambos.", + "squadron_required": "Você deve fornecer um nome de esquadrão para alarmes de Logs, Pontos ou Ambos.", + "wildcard_logs_only": "Apenas Logs podem ser configurados para esquadrão curinga.", + "squadron_not_resolved": "O esquadrão `{squadron}` não pôde ser resolvido.", + "save_failed": "Falha ao salvar preferências. Tente novamente mais tarde.", + "premium_warning": "\n\n> ⚠️ **Logs de partidas requerem Premium.** Execute `/unlock` para assinar ($2.99/mês) — os logs não serão postados até então.", + "leaderboard_set": "Alarme do Placar Global definido para este canal.", + "both_set": "Alarmes de Logs e Pontos para {squadron} definidos para este canal.{premium_note}", + "alarm_set": "Alarme de {alarm_type} para {squadron} definido para este canal.{premium_note}", + "weekly_br_wildcard_set": "Relatório BR Semanal (top 20 esquadrões) configurado para este canal. Envia ao final de cada rotação de BR.", + "weekly_br_squadron_set": "Relatório BR Semanal para {squadron} (top 15 jogadores) configurado para este canal. Envia ao final de cada rotação de BR." + }, + "diagnostics": { + "title": "Diagnóstico do Autolog", + "channel_permissions_header": "**Permissões do Canal** (<#{channel_id}>)", + "perms_needed": " ^ O autolog precisa de todas as permissões acima para enviar placares.", + "server_squadron_header": "**Esquadrão do Servidor** (`/set-squadron`)", + "server_squadron_short": " Curto: `{short}`", + "server_squadron_long": " Longo: `{long}`", + "server_squadron_not_set": " Não definido (a cor da barra do placar aparecerá como 'not_set')", + "autolog_prefs_header": "**Preferências do Autolog** (`/quick-log`)", + "autolog_none_configured": " ❌ NENHUM configurado — o autolog NÃO enviará nada para este servidor.", + "autolog_setup_hint": " Use `/quick-log Logs` no canal desejado para configurar.", + "autolog_no_logs_channels": " ❌ Nenhum canal de Logs configurado. Apenas Pontos/Placar encontrados.", + "autolog_enable_hint": " Use `/quick-log Logs` para ativar o autolog.", + "selected_channel_tag": " **(canal selecionado)**", + "missing_send_attach": " (falta permissão de envio/anexo)", + "channel_not_found": " (canal não encontrado)", + "invalid_channel_id": " (ID de canal inválido)", + "premium_status_header": "**Status Premium** (`/unlock`)", + "premium_active": " ✅ Este servidor possui uma assinatura Premium ativa.", + "premium_not_subscribed": " ❌ Este servidor **não** possui uma assinatura Premium.", + "premium_autolog_required": " O autolog requer Premium. Use `/unlock` para assinar.", + "premium_not_subscribed_free": " ⚪ Não assinado — use `/unlock` para assinar ($2.99/mês).", + "premium_free_note": " *(Os autologs estão gratuitos para todos os servidores por enquanto.)*" + }, + "sq_info": { + "title": "Informações do Esquadrão: {squadron}", + "placement_field": "Colocação", + "total_points_field": "Total de Pontos", + "total_members_field": "Total de Membros", + "members_field": "Membros", + "fetch_failed": "Falha ao buscar informações do esquadrão." + }, + "sq_info_graph": { + "title": "{squadron} — SQ-INFO (Temporada {season})", + "embed_title": "{squadron} — Composição do plantel", + "embed_desc": "Temporada **{season}** · Mediana de partidas: **{median}** · Núcleo: **{core}** · Ativos: **{active}** · Fracos: **{weak}**\nBarras ordenadas por partidas desc; altura = taxa de vitória. Núcleo = ≥ mediana e TV ≥ 1,5× TV do esquadrão. Fracos = abaixo da mediana ou TV < TV do esquadrão ÷ 2. Ativos = os demais.", + "core_threshold_line": "NÚCLEO ≥ {wr} %", + "weak_threshold_line": "FRACOS < {wr} %", + "y_label": "Taxa de vitória", + "core_header": "NÚCLEO — {count} · TV {avg}%", + "active_header": "ATIVOS — {count} · TV {avg}%", + "weak_header": "FRACOS — {count} · TV {avg}%", + "no_active_season": "Nenhuma temporada ativa encontrada. Tente novamente quando a próxima começar.", + "no_members": "Nenhum membro atual encontrado para {squadron}." + }, + "recap_card": { + "unknown_season": "Temporada desconhecida: `{season}`.", + "no_clan_id": "Não foi possível resolver o ID do esquadrão `{squadron}`.", + "render_failed": "Falha ao gerar o card de resumo da temporada. Tente novamente mais tarde." + }, + "sq_stats": { + "no_data_title": "Sem dados", + "no_data_desc": "Nenhum dado histórico encontrado para o esquadrão: {squadron}", + "title": "{squadron} // ESQUADRÃO", + "desc": "Tendência de Pontuação Total (Últimos {count} pontos de dados)", + "previous_score_field": "Pontuação anterior", + "current_score_field": "Pontuação atual", + "change_field": "Variação", + "player_title": "{squadron} // JOGADORES", + "player_desc": "Tendências individuais de pontos dos jogadores", + "comparison_title": "{squadron} // COMPARAÇÃO NO PLACAR", + "comparison_desc": "Comparando com esquadrões classificados {range}", + "current_position_field": "Posição atual", + "squadrons_shown_field": "Esquadrões exibidos", + "squadron_not_found_error": "Esquadrão não encontrado no placar", + "no_nearby_error": "Nenhum esquadrão próximo encontrado", + "no_historical_error": "Nenhum dado histórico encontrado para esquadrões próximos", + "comparison_chart_failed": "Falha ao gerar gráfico de comparação", + "select_players_placeholder": "Selecionar jogadores (Página {page})" + }, + "loss_calc": { + "title": "Perda de Pontos — {squadron}", + "players_leaving_field": "Jogadores saindo", + "share_of_total_field": "% do Total", + "points_lost_real_field": "Pontos perdidos (real)", + "points_lost_raw_field": "Pontos perdidos (bruto)", + "squadron_rating_field": "Classificação do Esquadrão", + "squadron_position_field": "Posição do Esquadrão", + "positions_lost_field": "Posições perdidas", + "not_found_footer": "Não encontrado no esquadrão: {players}", + "fetch_failed": "Falha ao buscar dados do esquadrão: {error}", + "no_point_data": "Nenhum dado de pontos disponível para este esquadrão.", + "no_matching_players": "Nenhum jogador correspondente encontrado em **{squadron}**." + }, + "player": { + "select_player_placeholder": "Selecionar um jogador", + "no_stats_found": "❌ Nenhuma estatística encontrada para o UID: {uid}", + "no_vehicle_stats": "❌ Nenhuma estatística de veículo encontrada para este jogador.", + "vehicles_found": "Encontrados **{count}** veículos para **{nick}**\nSelecione um veículo para ver as estatísticas detalhadas:", + "vehicle_select_placeholder": "Selecionar um veículo (Página {page}/{total})", + "combat_stats_header": "**__ESTATÍSTICAS DE COMBATE__**", + "ground_kills_label": "**Abates Terrestres:** {value}", + "air_kills_label": "**Abates Aéreos:** {value}", + "total_kills_label": "**Total de Abates:** {value}", + "assists_label": "**Assistências:** {value}", + "deaths_label": "**Mortes:** {value}", + "kd_label": "**K/D:** {value}", + "captures_label": "**Capturas:** {value}", + "battle_record_header": "**__HISTÓRICO DE BATALHAS__**", + "total_battles_label": "**Total de Batalhas:** {value}", + "wins_label": "**Vitórias:** {value}", + "losses_label": "**Derrotas:** {value}", + "win_rate_label": "**Taxa de Vitórias:** {value}%", + "stats_desc": "Estatísticas de **{nick}** (**{squadron}**)\nUID: `{uid}`", + "not_found_title": "Jogador não encontrado", + "not_found_desc": "Nenhum histórico de partidas encontrado para `{player}`.", + "no_players_found": "Nenhum jogador encontrado correspondendo a **{username}**\nTente usar `/website` para pesquisar no site.", + "multiple_matches": "Múltiplas correspondências encontradas, escolha a correta abaixo:", + "must_provide_input": "Você deve fornecer pelo menos um UID ou nome de usuário." + }, + "player_games": { + "no_recent_title": "Sem partidas recentes", + "no_recent_desc": "Nenhuma partida encontrada para **{player}** nas últimas 8 horas.", + "squadron_label": "**Esquadrão:** {squadron}", + "record_label": "**V:** {wins} **D:** {losses} **TV:** {wr}%", + "comps_played_header": "\n\n**Composições Jogadas**" + }, + "match": { + "missing_input_title": "Entrada ausente", + "missing_input_desc": "Forneça um `match_id` ou um `player_name`.", + "not_found_title": "Partida não encontrada", + "not_found_desc": "Não foi possível encontrar uma partida com o ID `{match_id}`.", + "invalid_data_title": "Dados de Partida Inválidos", + "invalid_data_desc": "Os dados do replay não puderam ser processados.", + "scoreboard_error_title": "Erro no Placar", + "scoreboard_error_desc": "Falha ao gerar a imagem do placar.", + "no_games_title": "Nenhuma partida encontrada", + "no_games_desc": "Nenhum histórico de partidas encontrado para **{player}**.", + "recent_matches_title": "Partidas recentes de {player}", + "recent_matches_desc": "Exibindo até {count} partidas recentes. Selecione uma para ver o placar completo.", + "select_match_placeholder": "Selecione uma partida para visualizar..." + }, + "compare": { + "no_players_found": "Nenhum jogador encontrado correspondendo a **{name}**.", + "multiple_matches": "Múltiplas correspondências para **{name}**: {matches}\nPor favor, use um nome mais específico (as sugestões de autocompletar são exatas).", + "could_not_resolve": "Não foi possível resolver os jogadores.", + "could_not_fetch": "❌ Não foi possível buscar estatísticas de **{name}**.", + "no_graph_data": "Nenhum dado disponível para os últimos 90 dias.", + "no_squadron_points_data": "Nenhum dado de pontos do esquadrão para {names} (jogador não encontrado no histórico de esquadrão rastreado).", + "graph_title": "Pontos dos Jogadores — Últimos 90 Dias", + "battles_label": "Batalhas", + "wins_label": "Vitórias", + "losses_label": "Derrotas", + "win_rate_label": "Taxa de Vitórias", + "ground_kills_label": "Abates terrestres", + "air_kills_label": "Abates aéreos", + "total_kills_label": "Total de Abates", + "assists_label": "Assistências", + "deaths_label": "Mortes", + "kd_label": "K/D", + "captures_label": "Capturas" + }, + "squadron": { + "not_found_desc": "Esquadrão `{squadron}` não encontrado.", + "set_title": "✅ Esquadrão definido", + "set_desc": "O esquadrão **{squadron}** foi definido para este servidor.", + "short_name_field": "Nome curto", + "long_name_field": "Nome completo", + "swap_title": "✅ Esquadrão trocado", + "swap_desc": "Substituído **{old}** por **{new}** neste servidor.", + "already_set_title": "⚠️ Esquadrão já definido", + "already_set_desc": "Este servidor está atualmente configurado para **{old}**.\nTrocar para **{new}**?", + "swap_cancelled": "❌ Alteração de esquadrão cancelada." + }, + "setup": { + "step1_title": "Configuração do Servidor — Passo 1 de 3", + "step1_desc": "Este assistente vai guiá-lo pela configuração do bot para o seu servidor.\n\n**Passo 1** — Defina seu esquadrão\n**Passo 2** — Escolha um canal de logs\n**Passo 3** — Escolha um canal de pontos\n", + "step1_current_sq": "\nEsquadrão configurado atualmente: **[{short}] {long}**", + "step2_title": "Configuração do Servidor — Passo 2 de 3", + "step2_desc": "Esquadrão definido como **[{short}] {long}**.\n\nOnde os **logs de batalha** devem ser postados?\nSelecione um canal de texto abaixo ou pule esta etapa.", + "step3_title": "Configuração do Servidor — Passo 3 de 3", + "step3_desc": "Onde as **notificações de pontos** devem ser postadas?\nSelecione um canal de texto abaixo ou pule esta etapa.", + "step3_same_as_logs": "\n\nVocê também pode clicar em \"Mesmo que Logs\" para reutilizar o canal de logs.", + "summary_title": "Configuração concluída", + "summary_desc": "Você pode usar `/autolog-management` para alterar essas configurações mais tarde.", + "squadron_field": "Esquadrão", + "logs_channel_field": "Canal de Logs", + "points_channel_field": "Canal de Pontos", + "premium_required_field": "⚠️ Logs de Partidas Requerem Premium", + "premium_required_value": "Os placares automáticos de partidas não serão postados até que este servidor tenha uma assinatura ativa. Execute `/unlock` para assinar ($2.99/mês).", + "modal_title": "Definir esquadrão", + "modal_label": "Nome curto do esquadrão", + "modal_placeholder": "ex. AXYS", + "squadron_not_found": "Esquadrão `{squadron}` não encontrado. Por favor, tente novamente.", + "logs_channel_placeholder": "Selecione um canal de logs...", + "points_channel_placeholder": "Selecione um canal de pontos..." + }, + "meta_management": { + "squadron_not_found_title": "❌ Esquadrão não encontrado", + "squadron_not_found_desc": "Não foi possível encontrar o ID do clã para o esquadrão: **{squadron}**", + "access_denied_title": "❌ Acesso negado", + "access_denied_desc": "Senha incorreta. Os dados meta deste esquadrão estão protegidos.", + "data_locked_title": "🔐 Dados do esquadrão vinculados", + "data_locked_desc": "**{squadron}** tem a vinculação de dados ativada e não pode ser transferido para outro servidor.\n\nO dono do esquadrão deve desativar **Vincular Dados do Esquadrão** antes de movê-lo.", + "error_retrieving_settings": "❌ Erro ao recuperar as configurações do servidor após a transferência. Por favor, tente novamente.", + "error_retrieving_settings_retry": "❌ Erro ao recuperar as configurações do servidor. Por favor, execute o comando novamente.", + "authenticated_title": "✅ Autenticado", + "authenticated_desc": "Senha verificada. Gerenciando configurações para **{squadron}**.", + "claimed_title": "✅ Esquadrão reivindicado", + "claimed_desc": "**{squadron}** foi reivindicado com sucesso para este servidor!", + "password_requirement_field": "🔒 Requisito de senha", + "data_lock_field": "🔐 Vinculação de dados do esquadrão", + "public_meta_field": "👥 Acesso meta público", + "access_password_field": "🔑 Senha de acesso", + "enabled_value": "✅ Ativado", + "disabled_value": "❌ Desativado", + "settings_title": "🔐 Configurações de gerenciamento meta", + "settings_desc": "**Esquadrão:** {squadron}\n**ID do Clã:** {clan_id}", + "first_time_title": "🔐 Gerenciamento meta - Configuração inicial", + "first_time_owner_desc": "**Esquadrão:** {squadron}\n**ID do Clã:** {clan_id}\n\n🔑 Sua senha de acesso foi gerada. **Salve esta senha** — você precisará dela para autenticar o acesso a dados meta no futuro.\n\n**Senha:** `{password}`", + "first_time_non_owner_desc": "**Esquadrão:** {squadron}\n**ID do Clã:** {clan_id}\n\nO esquadrão foi configurado. Peça a senha de acesso ao dono do servidor.", + "settings_field": "Configurações", + "settings_hint": "Use os botões abaixo para definir as configurações de acesso.", + "password_toggled": "✅ Requisito de senha: **{state}**", + "lock_toggled": "✅ Vinculação de dados do esquadrão: **{state}**", + "public_meta_toggled": "✅ Acesso meta público: **{state}**\n{detail}", + "public_meta_enabled_detail": "Não-administradores agora podem usar o comando `/meta`.", + "public_meta_disabled_detail": "Apenas administradores podem usar o comando `/meta`.", + "owner_only_password": "❌ Apenas o dono do servidor pode alterar a senha do esquadrão.", + "help_title": "📖 Ajuda do gerenciamento meta", + "help_desc": "Explicação de cada configuração e funcionalidade:", + "help_password_field": "🔑 Senha de acesso", + "help_password_value": "A senha de acesso do seu esquadrão. Apenas o **dono do servidor** pode ver a senha no painel de configurações. Qualquer pessoa com a senha pode reivindicar os dados meta do seu esquadrão em outro servidor, portanto mantenha-a segura.", + "help_require_field": "🔒 Exigir senha", + "help_require_value": "Quando ativado, mesmo os administradores deste servidor devem inserir a senha do esquadrão para acessar `/meta-management`. Adiciona uma camada extra de segurança para evitar alterações acidentais.", + "help_lock_field": "🔐 Vincular dados do esquadrão", + "help_lock_value": "Quando ativado, impede que o esquadrão seja transferido para outros servidores, mesmo com a senha correta. Deve ser desativado antes que o esquadrão possa ser transferido.", + "help_public_field": "👥 Permitir meta público", + "help_public_value": "Quando ativado, permite que membros não-administradores usem o comando `/meta` para pesquisar veículos do esquadrão. Quando desativado, apenas administradores do servidor podem usar `/meta`.", + "help_accounts_field": "📋 Atualizar contas meta", + "help_accounts_value": "Abre o gerenciador de lista de jogadores onde você pode adicionar ou remover jogadores da lista meta do seu esquadrão. Use **Atualizar Todos os Membros** para sincronizar todo o seu esquadrão de uma vez.", + "help_change_pw_field": "🔑 Alterar senha", + "help_change_pw_value": "**Apenas o dono do servidor.** Altere a senha de acesso do esquadrão e defina uma dica opcional. A dica é exibida no prompt de senha para ajudar a lembrá-la.", + "password_modal_title": "Senha de acesso do esquadrão", + "password_modal_label": "Inserir senha do esquadrão", + "password_modal_placeholder": "XXXX-XXXX-XXXX", + "change_pw_modal_title": "Alterar senha do esquadrão", + "current_password_label": "Senha atual", + "current_password_placeholder": "Digite sua senha atual", + "new_password_label": "Nova senha", + "new_password_placeholder": "Digite sua nova senha", + "confirm_password_label": "Confirmar nova senha", + "confirm_password_placeholder": "Re-insira sua nova senha", + "hint_label": "Dica de senha (opcional)", + "hint_placeholder": "Uma dica para ajudar a lembrar a senha", + "pw_incorrect": "❌ A senha atual está incorreta.", + "pw_mismatch": "❌ As novas senhas não coincidem. Por favor, tente novamente.", + "pw_empty": "❌ A nova senha não pode ser vazia.", + "pw_changed": "✅ Senha atualizada com sucesso para **{squadron}**.\n**Nova senha:** `{password}`", + "pw_changed_hint": "\n**Dica:** {hint}", + "player_add_modal_title": "Adicionar jogador à lista meta", + "player_add_label": "UID ou apelido do jogador", + "player_add_placeholder": "Insira o UID do jogador (ex., 12345678) ou apelido", + "player_not_found": "❌ Jogador `{player}` não encontrado no banco de dados Players_Global.\n", + "roster_title": "📋 Gerenciamento da lista meta - {squadron}", + "roster_desc": "**ID do Clã do Esquadrão:** {clan_id}\n**Total de Jogadores:** {count}", + "roster_page_field": "Jogadores (Página {page}/{total})", + "no_players_field": "Sem jogadores", + "no_players_hint": "Nenhum jogador adicionado à lista meta ainda. Clique em **Adicionar jogador** para começar.", + "remove_player_placeholder": "Selecionar jogador para remover...", + "fetch_members_failed": "❌ Falha ao buscar membros do esquadrão: {error}", + "no_members_found": "❌ Nenhum membro encontrado no esquadrão ou a chamada à API falhou.", + "roster_synced": "✅ Lista sincronizada com o esquadrão.", + "roster_added": "**+{count}** adicionado(s)", + "roster_removed": "**-{count}** removido(s) (saiu do esquadrão)", + "roster_up_to_date": "**{count}** já atualizados", + "refreshing_vehicles": "Atualizando dados de veículos em segundo plano..." + }, + "meta": { + "not_configured": "❌ Dados meta não configurados para este servidor. Execute `/meta-management` primeiro.", + "no_permission": "❌ Você precisa de permissões de administrador para usar este comando.\nAdmins podem ativar o acesso público via `/meta-management`.", + "no_results": "❌ Nenhum jogador na lista do seu esquadrão possui **{vehicle}**.", + "no_results_admin_hint": "\n*Esperando que alguém tenha isso? Clique no botão de atualizar membros em `/meta-management` e verifique novamente.*", + "search_title": "🔍 Resultados da Busca - {vehicle}", + "matches_found": "**Correspondências Encontradas:** {count} jogador(es)", + "spawns_label": "Spawns", + "deaths_label": "Mortes", + "gk_label": "GK", + "ak_label": "AK", + "points_label": "Pontos", + "kdr_label": "KDR", + "games_label": "Partidas", + "no_points": "—" + }, + "top": { + "title": "**Top 20 Esquadrões**", + "rating_label": "**Classificação:** {value}", + "air_kills_label": "**Abates Aéreos:** {value}", + "ground_kills_label": "**Abates Terrestres:** {value}", + "deaths_label": "**Mortes:** {value}", + "kd_label": "**K/D:** {value}", + "win_rate_label": "**Taxa de Vitórias:** {value}", + "playtime_label": "**Tempo de Jogo:** {value}", + "fetch_failed": "Falha ao recuperar dados do esquadrão." + }, + "analytics": { + "no_data_title": "Sem dados", + "no_matches_desc": "Nenhuma partida encontrada.", + "no_comp_desc": "Nenhum dado de composição encontrado.", + "no_consistency_desc": "Dados de jogadores insuficientes (mínimo de 50 partidas).", + "no_time_desc": "Nenhum dado de tempo encontrado.", + "unknown_view": "Visualização desconhecida.", + "map_title": "Taxas de Vitória por Mapa: {squadron}", + "comp_title": "Composições de Time: {squadron}", + "consistency_title": "Consistência dos Jogadores: {squadron}", + "consistency_desc": "Ordenado por proporção K/D", + "time_title": "Desempenho por Hora do Dia: {squadron}", + "eu_timeslot": "\n**Horário EU**", + "na_timeslot": "\n**Horário NA**", + "off_peak": "\n**Fora do Pico**", + "matchups_title": "📜 {squadron} — Histórico de Confrontos", + "matchups_won_field": "🏆 Mais Vitórias Contra", + "matchups_lost_field": "💀 Mais Derrotas Contra", + "no_matchups_desc": "Sem partidas registadas contra outros esquadrões." + }, + "recent": { + "title": "Partidas recentes: {squadron}", + "no_matches_desc": "Nenhuma partida encontrada para este esquadrão." + }, + "h2h": { + "two_required_title": "Dois esquadrões necessários", + "two_required_desc": "Forneça pelo menos um esquadrão, ou use `/set-squadron` e forneça o adversário.", + "provide_a_desc": "Forneça `squadron_a` ou use `/set-squadron` primeiro.", + "provide_b_desc": "Forneça `squadron_b` ou use `/set-squadron` primeiro.", + "squadron_not_found_title": "Esquadrão não encontrado", + "same_squadron_title": "Mesmo esquadrão", + "same_squadron_desc": "Você não pode verificar o confronto direto contra si mesmo.", + "record_desc": "**Confronto:** {a_wins}V - {b_wins}D ({total} partidas)", + "no_matches_desc": "Nenhuma partida registrada entre **{a}** e **{b}**." + }, + "autolog": { + "premium_active_line": "✅ **Premium:** Ativo — o autolog está ativado para este servidor.", + "premium_not_subscribed_line": "❌ **Premium:** Não assinado — use `/unlock` para ativar o autolog.", + "premium_free_line": "⚪ **Premium:** Não assinado — use `/unlock` para assinar ($2.99/mês). *(Os autologs estão gratuitos para todos os servidores por enquanto.)*", + "what_to_do": "\n\nO que você gostaria de fazer?", + "select_notif_type": "Selecione o tipo de notificação a gerenciar:", + "select_notif_placeholder": "Selecionar tipo de notificação", + "logs_option": "Logs", + "logs_option_desc": "Gerenciar notificações de Logs", + "points_option": "Pontos", + "points_option_desc": "Gerenciar notificações de Pontos", + "leaderboard_option": "Placar", + "leaderboard_option_desc": "Gerenciar notificações do Placar", + "selected_type": "Selecionado **{type}**. Agora escolha o esquadrão a gerenciar:", + "select_squadron_placeholder": "Selecionar um esquadrão", + "select_squadron_page_placeholder": "Selecionar um esquadrão (Página {page})", + "no_squadrons_available": "Nenhum esquadrão disponível para este tipo de notificação.", + "managing_global": "Gerenciando **{type}** (global) no canal **{channel}**.", + "managing_squadron": "Gerenciando **{type}** para o esquadrão **{squadron}** no canal **{channel}**.", + "select_channel": "Selecione um novo canal:", + "select_channel_placeholder": "Selecionar um canal", + "select_channel_page_placeholder": "Selecionar um canal (Página {page})", + "global_toggled": "{type} (global) agora está {state}.", + "squadron_toggled": "{type} para **{squadron}** agora está {state}.", + "channel_updated_global": "{type} (global) atualizado para {channel}", + "channel_updated_squadron": "{type} para **{squadron}** atualizado para {channel}", + "diagnose_channel_placeholder": "Selecionar um canal para diagnosticar...", + "select_channel_diagnose": "Selecione o canal para diagnosticar:", + "game_not_logged_title": "Partida não registrada", + "game_not_logged_desc": "Use `/unlock` para assinar o plano **Standard** (ou superior) e receber placares automáticos de partidas.", + "server_not_upgraded_title": "⚠️ Servidor não atualizado", + "server_not_upgraded_autolog_desc": "Este servidor não possui uma assinatura Premium ativa.\n\n**Os placares automáticos de partidas deixarão de ser enviados para servidores não atualizados após .**\n\nUse `/unlock` para assinar e continuar recebendo logs automáticos de partidas.", + "replay_not_available": "Os dados do replay ainda não estão disponíveis — aguarde um momento e tente novamente!", + "too_many_videos": "Muitos vídeos sendo gerados agora — por favor, tente novamente em instantes.", + "video_gen_failed": "Erro ao gerar vídeo: `{error}`", + "video_missing": "Falha ao gerar o vídeo do replay - arquivo de saída ausente ou vazio.", + "video_too_large": "Vídeo do replay muito grande para enviar ({file_mb:.1f} MB). O limite do servidor é {limit_mb:.0f} MB.", + "video_web_fallback": "Você também pode ver esta partida em {url}", + "video_upload_failed": "Vídeo muito grande para enviar — veja-o no site:\n{url}", + "video_unexpected_error": "Erro inesperado ao gerar o vídeo do replay: `{error}`", + "replay_not_found": "Dados do replay não encontrados para a sessão `{session_id}` no disco.", + "chat_log_title": "**Log de Chat da Partida [{session_id}]({url})**", + "chat_log_part_title": "**Log de Chat da Partida [{session_id}]({url}) (Parte {part}/{total})**", + "chat_log_part_only": "**Log de Chat (Parte {part}/{total})**", + "no_chat_log": "Nenhum log de chat encontrado para a sessão `{session_id}`.", + "chat_log_error": "Erro inesperado ao carregar o log de chat: `{error}`", + "battle_log_title": "**Log de Batalha da Partida [{session_id}]({url})**", + "battle_log_part_title": "**Log de Batalha da Partida [{session_id}]({url}) (Parte {part}/{total})**", + "battle_log_part_only": "**Log de Batalha (Parte {part}/{total})**", + "no_battle_log": "Nenhum evento de combate encontrado para a sessão `{session_id}`.", + "battle_log_error": "Erro inesperado ao carregar o log de batalha: `{error}`", + "points_update_title": "**{squadron} {region} Atualização de Pontos**", + "points_update_desc": "# {old_total} -> {new_total} {chart}{wl_line}{placement_line}\n\n**Alterações dos Jogadores:**", + "points_table_header": "Nome Alteração Agora\n", + "wl_line": "\n**{squadron}** foi **{wins}V-{losses}D** nesta sessão", + "placement_rose": "\n**{squadron}** subiu para o **{new_place}** do **{old_place}**", + "placement_fell": "\n**{squadron}** caiu para o **{new_place}** do **{old_place}**", + "points_not_logged_title": "Pontos não registrados", + "points_not_logged_desc": "Use `/unlock` para assinar o plano **Standard** (ou superior) e receber atualizações automáticas de pontos.", + "server_not_upgraded_points_desc": "Este servidor não possui uma assinatura Premium ativa.\n\n**As atualizações automáticas deixarão de ser enviadas para servidores não atualizados após .**\n\nUse `/unlock` para assinar e continuar recebendo atualizações automáticas.", + "leave_title": "⚠️ Jogador Saiu de {squadron}", + "leave_desc": "**{nick}** ({uid}) saiu do esquadrão.\n\nÚltimos pontos registrados: **{points}**", + "no_squadrons_desc": "No squadrons configured", + "no_channels_desc": "No channels available", + "over_cap_title": "Esquadrão acima do limite do seu plano", + "over_cap_desc": "Seu servidor está no plano **{tier}**, que permite **{cap} {notif}** esquadrões. O esquadrão **{squadron}** está acima do limite e não está sendo registrado. Atualize para um plano maior.", + "over_cap_footer": "Atualize em srebot-meow.ing/premium ou via /unlock", + "wildcard_blocked_title": "Wildcard requer um plano superior", + "wildcard_blocked_desc": "Entradas wildcard (*, all, everything) só estão disponíveis nos planos Pro ou Max. Seu servidor está em **{tier}** para {notif}. Atualize para habilitar.", + "cap_header": "{used}/{cap} {notif} ativos — plano {tier}" + }, + "track": { + "squadron_not_found": "Esquadrão não encontrado.", + "fetch_failed": "Falha ao buscar informações do esquadrão." + }, + "unlock": { + "title": "SRE Bot Premium", + "desc": "**Desbloqueie recursos premium para este servidor.**\n\nO Premium inclui:\n> • Publicações automáticas de placar\n> • Logs de chat e batalha\n> • Consultas de replay\n> • Consultas /comp ilimitadas\n> • Suporte prioritário\n\n**$2.99 / mês · por servidor · cancele quando quiser**\n\n⚠️ A cobrança pelo Discord está disponível apenas em países selecionados. Se o botão abaixo exibir **\"Produto Indisponível\"**, pode ser devido a um país não suportado ou ao uso de um **dispositivo móvel**. Use o botão **Assinar pelo Site** em vez disso.", + "already_subscribed_title": "SRE Bot Premium", + "already_subscribed_desc": "✅ **Este servidor já está inscrito!**", + "manage_discord_field": "Gerenciar assinatura", + "manage_discord_value": "Sua assinatura é pelo **Discord**.\nPara cancelar, vá em **Configurações do Usuário → Assinaturas** no Discord.", + "manage_website_field": "Gerenciar assinatura", + "manage_website_value": "Sua assinatura é pelo **site**.\nGerencie-a em [whop.com/billing](https://whop.com/billing).", + "coming_soon_field": "Em breve", + "coming_soon_value": "As assinaturas Premium ainda não estão disponíveis. Volte em breve!", + "current_tier": "Você está no plano **{tier}**.", + "upgrade_to": "Atualizar para {tier}", + "upgrade_to_value": "Mais esquadrões e recursos atualizando para **{tier}**." + }, + "language": { + "prompt": "Por favor, selecione o idioma do seu servidor:", + "select_placeholder": "Escolha o idioma do seu servidor", + "language_set": "Idioma definido para {language}.", + "translate_prompt": "Selecione abaixo o idioma de destino 👇", + "translate_placeholder": "Escolha um idioma de destino…", + "translate_result": "**{author} → {language}:**\n{text}", + "translation_unavailable": "Tradução indisponível (DeepL não configurado)", + "translation_failed": "Falha na tradução" + }, + "misc": { + "credits_title": "Créditos", + "credits_desc": "**Meowww**\n\n> **NotSoToothless** - Desenvolvedor Principal, Gerente do Bot, Gerente da Comunidade\n> **Z3R0** - Desenvolvedor, Desenvolvedor de Otimização, Engenheiro de Banco de Dados\n> **Clippii (Heidi)** - Desenvolvedor, Desenvolvedor do Site, Gerente da Comunidade\n> **LivingTheDagor** - Desenvolvedor, Desenvolvedor de Parser, Consultor\n> **Lux_** - Engenheiro de API, Desenvolvedor Spectra\n> **Konigallerwaffen** - Consultor de Feedback e Funcionalidades\n> **Žralok Tonda** - Tradutor Tcheco\n> **Styevy**, **Lopais** - Tradutores Alemães\n> **Susogus**, **playforfun698** - Tradutores Poloneses\n> **Bobr** - Tradutor Russo\n\n\n[Quer se juntar a nós?](https://discord.gg/BCvkK8JhPe)", + "schedule_title": "CALENDÁRIO DA TEMPORADA", + "schedule_not_found_title": "Calendário não encontrado", + "schedule_not_found_desc": "Nenhum dado de calendário disponível ainda.", + "news_no_news_title": "Sem notícias", + "news_no_news_desc": "Não há anúncios no momento. Volte mais tarde!", + "news_footer": "Obrigado pelo seu apoio! ᕙᘘᗢ", + "help_title": "Guia do Bot", + "donate_title": "Apoie o SRE Bot", + "donate_desc": "Se você gosta de usar o SRE Bot e quer apoiar seu desenvolvimento, considere me pagar um café!\n\n**[Doar no Ko-fi](https://ko-fi.com/notsotoothless)**\n\nCada contribuição ajuda a manter o bot funcionando e apoia novos recursos. Obrigado!", + "status_title": "Status do bot", + "status_last_received": "Última partida recebida", + "status_avg_ttl": "TTL médio (últimas 30)", + "status_no_data": "Sem dados ainda", + "status_gaijin_slow": "⚠️ Servidores da Gaijin lentos", + "help_commands_header": "**Visão geral dos comandos**", + "help_links": "Para detalhes, leia a documentação [aqui]({docs}) ou peça suporte [aqui]({support}).", + "help_terms": "[Termos de Serviço]({terms}) • [Política de Privacidade]({terms})" + }, + "dev": { + "restricted_dev_team": "This command is restricted to the dev team.", + "restricted_bot_owner": "❌ This command is restricted to the bot owner.", + "invalid_server_id": "❌ Invalid server ID. Must be a 17-19 digit Discord server ID.", + "expiry_too_soon": "❌ Expiry timestamp must be at least 1 month from now.\n> Now: \n> Minimum: \n> You provided: ", + "entitlement_write_failed": "❌ Failed to write entitlement: {error}", + "entitlement_created_title": "✅ Manual Entitlement Created", + "entitlement_created_desc": "**Server:** {guild_name} (`{server_id}`)\n**Expires:** ()\n**Created:** ", + "query_failed": "Query failed: {error}", + "health_title": "Bot Health Dashboard", + "health_uptime": "Uptime", + "health_guilds": "Guilds", + "health_games_processed": "Games Processed", + "health_tasks": "Tasks", + "health_websocket": "WebSocket", + "health_never": "never", + "health_errors": "({count} errors)", + "health_last_msg": "last msg {ago} ({count} total)", + "health_avg_ttl": "Avg TTL (Last 30)", + "entitlements_title": "Active Entitlements ({count} total)", + "entitlements_no_entries": "No entitlements.", + "entitlements_empty_title": "Active Entitlements", + "entitlements_empty_desc": "No active entitlements found.", + "entitlements_tag_discord": "Discord", + "entitlements_tag_whop": "Whop", + "entitlements_tag_manual": "Manual", + "query_prefix": "Query: {name}" + }, + "leaderboard_alarm": { + "title": "🏆 Placar do Esquadrão", + "top15_desc": "Top 15 esquadrões com estatísticas, enviado 35 minutos após o fechamento do horário.\nEste foi enviado .", + "top30_desc": "Esquadrões 16-30 com estatísticas.", + "not_logged_title": "Placar não registrado", + "not_logged_desc": "Use `/unlock` para assinar o plano **Standard** (ou superior) e receber atualizações automáticas do placar.", + "server_not_upgraded_title": "⚠️ Servidor não atualizado", + "server_not_upgraded_desc": "Este servidor não possui uma assinatura Premium ativa.\n\n**As atualizações automáticas deixarão de ser enviadas para servidores não atualizados após .**\n\nUse `/unlock` para assinar e continuar recebendo atualizações automáticas." + }, + "stacks": { + "stack_title": "Stack de {leader}", + "stack_named_title": "{name}", + "no_members": "Sem membros ainda.", + "members_field": "Membros ({count}/{max})", + "queue_field": "Fila ({count}/{max})", + "manage_title": "Gerenciar Stack", + "no_pending_requests": "Sem pedidos pendentes.", + "disbanded_title": "Stack [Dissolvido]", + "disbanded_desc": "Este stack foi dissolvido pelo líder.", + "expired_title": "Stack [Expirado]", + "expired_desc": "Este stack expirou.", + "join_modal_title": "Pedir para entrar no stack", + "join_vehicle_label": "Com o que vai jogar?", + "join_vehicle_placeholder": "ex. F-16C, WZ305...", + "ping_modal_title": "Mensagem de notificação", + "ping_message_label": "Mensagem personalizada (opcional)", + "ping_message_placeholder": "ex. Venham agora! O stack está começando!", + "rename_modal_title": "Renomear stack", + "rename_label": "Nome do stack", + "rename_placeholder": "ex. Corujas Noturnas, Esquadrão Alfa...", + "select_new_leader": "Selecionar novo líder…", + "select_applicants": "Selecionar candidatos…", + "no_pending_applications": "Sem candidaturas pendentes.", + "select_to_remove": "Selecionar pessoas para remover…", + "no_members_or_applicants": "Sem membros ou candidatos.", + "select_to_ping": "Selecionar pessoas para notificar individualmente…", + "stack_not_found": "❌ Stack não encontrado.", + "no_longer_exists": "❌ Este stack não existe mais.", + "member_not_exists": "❌ Esse membro não existe mais.", + "already_has_stack": "❌ Esse jogador já tem um stack ativo.", + "already_member": "❌ Você já é membro deste stack.", + "already_applied": "❌ Você já tem uma candidatura pendente para este stack.", + "queue_full": "❌ A fila está cheia ({max}/{max}). Tente novamente mais tarde.", + "application_sent": "✅ Candidatura enviada! O líder do stack irá analisá-la.", + "stack_disbanded": "✅ Stack dissolvido.", + "cancelled": "Cancelado.", + "select_member_transfer": "❌ Selecione um membro para transferir a liderança.", + "ownership_transferred": "✅ Liderança transferida para {nick}. Você saiu do stack.", + "select_applicant_first": "❌ Selecione pelo menos um candidato primeiro.", + "stack_full": "❌ O stack está cheio ({max}/{max} membros).", + "select_person_first": "❌ Selecione pelo menos uma pessoa primeiro.", + "no_one_to_ping": "❌ Ninguém para notificar.", + "ping_footer": "Notificado por {leader} para {stack}.", + "pinged": "✅ Notificado!", + "select_from_dropdown": "❌ Selecione pelo menos uma pessoa do menu suspenso primeiro.", + "stack_renamed": "✅ Stack renomeado para **{name}**.", + "only_member_use_disband": "❌ Você é o único membro. Use **Dissolver stack** para encerrar.", + "select_transfer_prompt": "Selecione um membro para transferir a liderança antes de sair:", + "left_stack": "✅ Você saiu do stack.", + "application_withdrawn": "✅ Sua candidatura foi retirada.", + "not_member_or_applicant": "❌ Você não é membro nem candidato deste stack.", + "leader_only_manage": "❌ Apenas o líder do stack pode gerenciá-lo.", + "leader_only_disband": "❌ Apenas o líder do stack pode dissolvê-lo.", + "confirm_disband": "Tem certeza de que deseja dissolver este stack? Esta ação não pode ser desfeita.", + "already_active_stack": "⚠️ Você já tem um stack ativo. Se a mensagem original desapareceu (ex. após reinício do bot), você pode forçar a dissolução e começar de novo.", + "force_created": "✅ Stack anterior dissolvido. Novo stack criado.", + "no_active_stack": "❌ Você não tem um stack ativo. Use `/stack-create` para criar um.", + "could_not_parse_channel": "⚠️ Não foi possível processar o ID do canal armazenado." + }, + "commands": { + "common": { + "season": "A temporada para gerar o cartão", + "theme": "Tema de cor do cartão", + "squadron_short": "Nome curto do esquadrão", + "player_username": "Nome do jogador", + "choice_dark": "Escuro", + "choice_light": "Claro" + }, + "comp": { + "description": "Encontrar as últimas composições conhecidas de uma equipe", + "squadron_short": "Nome curto da equipe inimiga" + }, + "quick_log": { + "description": "Configurar um alarme para este esquadrão neste canal", + "squadron_name": "Nome CURTO do esquadrão a monitorar", + "type": "Escolha Logs, Pontos, Classificação, BR Semanal ou Ambos", + "choice_logs": "Logs", + "choice_points": "Pontos", + "choice_leaderboard": "Ranking", + "choice_both": "Ambos (Logs + Pontos)", + "choice_weekly_br": "BR Semanal" + }, + "sq_info": { + "description": "Buscar informações de um esquadrão" + }, + "sq_info_graph": { + "description": "Mostrar um gráfico da composição do plantel por atividade e taxa de vitória (temporada atual)" + }, + "sq_card": { + "description": "Gerar cartão de temporada para um esquadrão", + "squadron": "Nome curto do esquadrão" + }, + "sq_stats": { + "description": "Mostrar pontos de um esquadrão ao longo do tempo" + }, + "loss_calculator": { + "description": "Calcular perda de pontos se jogadores saírem do esquadrão", + "player1": "Jogador saindo", + "player_optional": "Jogador saindo (opcional)" + }, + "website": { + "description": "Obter link do site do SRE Bot" + }, + "card": { + "description": "Gerar cartão de temporada para um jogador" + }, + "player_stats": { + "description": "Ver estatísticas detalhadas de veículos de um jogador", + "username": "Nome WT para solicitar stats", + "uid": "UID WT para solicitar stats" + }, + "view_player_games": { + "description": "Ver os últimos 20 jogos de um jogador" + }, + "view_match": { + "description": "Ver placar de partida por ID ou jogador", + "match_id": "ID hex da sessão da partida", + "player_name": "Jogador para navegar partidas recentes" + }, + "compare": { + "description": "Comparar stats SQB agregadas entre jogadores", + "player1": "Primeiro jogador", + "player2": "Segundo jogador", + "player_optional": "Jogador adicional (opcional)" + }, + "leaderboard": { + "description": "Obter ranking global do SRE Bot" + }, + "set_squadron": { + "description": "Definir tag de esquadrão deste servidor", + "abbreviated_name": "Nome curto do esquadrão a definir" + }, + "setup": { + "description": "Configurar o bot para este servidor" + }, + "meta_management": { + "description": "Gerenciar acesso aos dados meta deste servidor" + }, + "meta": { + "description": "Pesquisar roster meta por nome de veículo", + "vehicle": "Nome do veículo a pesquisar" + }, + "top": { + "description": "Ver top 20 esquadrões com stats detalhadas" + }, + "language": { + "description": "Alterar o idioma do bot." + }, + "translate_message": { + "name": "Traduzir mensagem" + }, + "sq_track": { + "description": "Acompanhar um esquadrão e comparar desde a última verificação", + "squadron_short_name": "Nome curto do esquadrão a acompanhar" + }, + "analytics": { + "description": "Ver análises SQB avançadas de um esquadrão", + "view": "Qual análise mostrar", + "choice_maps": "Taxa de vitória por mapa", + "choice_comps": "Composições de equipe", + "choice_consistency": "Consistência dos jogadores", + "choice_time": "Hora do dia", + "choice_matchups": "Histórico de confrontos" + }, + "recent": { + "description": "Mostrar batalhas recentes de um esquadrão", + "length": "Número de partidas a mostrar" + }, + "vs": { + "description": "Histórico direto entre dois esquadrões", + "squadron_a": "Primeiro esquadrão", + "squadron_b": "Segundo esquadrão" + }, + "autolog_management": { + "description": "Gerenciar notificações autolog e diagnosticar permissões" + }, + "diagnose_perms": { + "description": "Diagnosticar permissões autolog deste canal" + }, + "unlock": { + "description": "Desbloquear recursos Premium para este servidor" + }, + "credits": { + "description": "Ver a equipe creditada por este projeto" + }, + "schedule": { + "description": "Ver o calendário BR da temporada atual" + }, + "news": { + "description": "Ver últimas notícias e anúncios do SRE Bot" + }, + "help": { + "description": "Ver guia, ToS e links de suporte" + }, + "donate": { + "description": "Apoiar o desenvolvimento do SRE Bot" + }, + "stack_create": { + "description": "Criar um stack de jogadores", + "vehicle": "Com qual veículo você vai começar?" + }, + "stack_manage": { + "description": "Repostar seu stack ativo neste canal" + }, + "bot_status": { + "description": "Ver status do bot: última partida recebida e TTL médio" + } + }, + "permission": { + "blacklisted_title": "❌ Bloqueado", + "blacklisted_desc": "Você está bloqueado de usar este comando.", + "reason_line": "**Motivo:** {reason}", + "access_denied_title": "⛔ Acesso negado", + "no_permission_desc": "Você não tem permissão para usar este comando.", + "unexpected_error_title": "❗ Erro, reporte isso...." + }, + "weekly_br": { + "title_wildcard": "Relatório BR Semanal — {br} BR", + "title_squadron": "Relatório BR Semanal — [{tag}] {long} • {br} BR", + "window_label": "Período: {start} → {end}", + "wildcard_desc_first": "Top {count} esquadrões por ELO • Posições {low}–{high}", + "wildcard_desc_second": "Top {count} esquadrões por ELO • Posições {low}–{high}", + "squadron_stats_line": "- {games} partidas • K/D {kdr} • Vitórias {wr}%", + "top_players_inline_header": "🥇 Melhores jogadores:", + "player_line_short": " {rank}. {nick} ⭐ {score} ({games}p)", + "top_players_header": "**Top {count} jogadores por ELO:**", + "player_line_full": "{rank}. **{nick}** ⭐ {score} • {games} partidas • K/D {kdr}", + "squadron_header_line": "ELO do esquadrão: {score} • {games} partidas • Vitórias {wr}% • K/D {kdr}", + "squadron_header_no_aggregate": "ELO do esquadrão: pouca atividade da equipe esta semana.", + "no_data": "Nenhuma partida registrada para [{tag}] nesta rotação de BR." + } +} diff --git a/BOT/locales/ru.json b/BOT/locales/ru.json new file mode 100644 index 0000000..c813201 --- /dev/null +++ b/BOT/locales/ru.json @@ -0,0 +1,856 @@ +{ + "common": { + "error_title": "Ошибка", + "no_data_title": "Нет данных", + "access_denied_title": "Доступ запрещён", + "access_denied_desc": "Этот сервер заблокирован.", + "no_players_selected": "Игроки не выбраны. Выберите хотя бы одного игрока.", + "must_use_in_server": "Эта команда доступна только на сервере.", + "could_not_resolve_channel": "Не удалось определить выбранный канал.", + "failed_update_setting": "❌ Не удалось обновить настройку.", + "configuration_not_found": "Конфигурация не найдена.", + "no_channel_selected": "Канал не выбран.", + "no_selection_received": "Выбор не получен.", + "database_error": "❌ Ошибка базы данных: {error}", + "enabled": "Включено", + "disabled": "Отключено", + "not_configured": "Не настроено", + "unknown": "Неизвестно", + "rating_field": "Рейтинг", + "battles_field": "Бои", + "wins_field": "Победы", + "losses_field": "Поражения", + "win_rate_field": "Процент побед", + "kills_field": "Уничтожения", + "deaths_field": "Гибели", + "kd_field": "K/D", + "members_field": "Участники", + "placement_field": "Место", + "points_field": "Очки", + "ground_kills_field": "Наземные уничтожения", + "air_kills_field": "Воздушные уничтожения", + "total_kills_field": "Всего уничтожений", + "assists_field": "Помощь", + "captures_field": "Захваты", + "none_option": "Нет" + }, + "buttons": { + "skip": "Пропустить", + "previous": "Назад", + "next": "Далее", + "prev": "Назад", + "prev_arrow": "◀ Назад", + "next_arrow": "Далее ▶", + "prev_arrow_only": "◀", + "next_arrow_only": "▶", + "generate_chart": "📊 Построить график", + "show_graph": "Показать график", + "view_player_stats": "📊 Статистика игроков", + "compare_nearby": "📈 Сравнить соседние полки", + "confirm_swap": "Да, заменить", + "cancel_swap": "Нет, оставить прежний", + "set_squadron": "Установить полк", + "same_as_logs": "Как в логах", + "require_password": "🔒 Требовать пароль", + "password_required": "🔒 Пароль обязателен", + "lock_data": "🔐 Привязать данные полка", + "data_locked": "🔐 Данные привязаны к серверу", + "allow_public": "👥 Разрешить публичную мету", + "public_enabled": "👥 Публичная мета включена", + "update_accounts": "📋 Обновить мета-аккаунты", + "change_password": "🔑 Изменить пароль", + "help": "❓ Помощь", + "add_player": "➕ Добавить игрока", + "update_all": "🔄 Обновить всех участников", + "back_to_settings": "⬅ К настройкам", + "manage_notifications": "Управление уведомлениями", + "diagnose_permissions": "Диагностика прав", + "enable": "Включить", + "disable": "Отключить", + "change_channel": "Сменить канал", + "view_replay": "Просмотреть реплей", + "view_website": "Открыть на сайте", + "view_video": "Просмотреть видео", + "view_log": "Просмотреть лог", + "view_chat": "Просмотреть чат", + "subscribe_website": "Подписаться через сайт", + "yes_disband": "Да, распустить", + "cancel": "Отмена", + "transfer_leave": "Передать и выйти", + "accept_selected": "Принять выбранных", + "accept_all": "Принять всех", + "decline_selected": "Отклонить выбранных", + "back": "Назад", + "remove_all": "Удалить всех", + "remove_active": "Удалить активных", + "remove_queued": "Удалить в очереди", + "remove_selected": "Удалить выбранных", + "ping_all": "Пинг всех", + "ping_active": "Пинг активных", + "ping_queued": "Пинг в очереди", + "ping_selected": "Пинг выбранных", + "accept_members": "Принять участников", + "remove_members": "Удалить участников", + "ping_members": "Пинг участников", + "rename_stack": "Переименовать стак", + "request_to_join": "Запросить вступление", + "leave_withdraw": "Выйти / Отозвать", + "manage_stack": "Управление стаком ⚙️", + "disband_stack": "Распустить стак", + "force_disband_create": "Принудительно распустить и создать новый" + }, + "events": { + "guild_join_title": "Спасибо, что добавили меня!", + "guild_join_desc": "Запустите `/setup`, чтобы настроить бота для этого сервера." + }, + "comp": { + "not_found_title": "Составы не найдены", + "not_found_desc": "Нет данных для **{squadron}**, попробуйте позже.", + "error_loading_title": "Ошибка загрузки составов", + "error_loading_desc": "Не удалось загрузить данные составов: {error}", + "title": "Составы полка {squadron}", + "desc": "Составы, замеченные за последние {minutes} минут", + "no_recent_title": "Нет недавних составов", + "no_recent_desc": "Нет составов за последние {minutes} минут.", + "comp_title": "СОСТАВ {index}", + "last_seen_label": "**Последний раз замечен** : {timestamp}{warning}", + "comp_label": "**Состав**: {notation}", + "no_players_recorded": "Нет зафиксированных игроков.", + "limit_reached_title": "Лимит составов достигнут", + "limit_reached_desc": "Этот сервер использовал все {limit} запросов составов для этого таймслота. Подпишитесь (через /unlock) для безлимитного доступа или дождитесь следующего таймслота.", + "remaining_footer": "{remaining}/{limit} запросов составов осталось в этом таймслоте" + }, + "quick_log": { + "invalid_type": "Тип может быть только Логи, Очки, Таблица лидеров, Еженедельный BR или Все.", + "squadron_required": "Для типов Логи, Очки или Оба необходимо указать название полка.", + "wildcard_logs_only": "Только тип Логи поддерживает универсальный полк.", + "squadron_not_resolved": "Не удалось определить полк `{squadron}`.", + "save_failed": "Не удалось сохранить настройки. Попробуйте позже.", + "premium_warning": "\n\n> ⚠️ **Игровые логи требуют Premium.** Запустите `/unlock` для подписки ($2.99/мес.) — логи не будут публиковаться до её активации.", + "leaderboard_set": "Оповещение глобального рейтинга установлено для этого канала.", + "both_set": "Оповещения о логах и очках для {squadron} установлены для этого канала.{premium_note}", + "alarm_set": "Оповещение {alarm_type} для {squadron} установлено для этого канала.{premium_note}", + "weekly_br_wildcard_set": "Еженедельный отчёт BR (топ-20 полков) настроен на этот канал. Отправляется в конце каждой ротации BR.", + "weekly_br_squadron_set": "Еженедельный отчёт BR для {squadron} (топ-15 игроков) настроен на этот канал. Отправляется в конце каждой ротации BR." + }, + "diagnostics": { + "title": "Диагностика автологирования", + "channel_permissions_header": "**Права канала** (<#{channel_id}>)", + "perms_needed": " ^ Автологирование требует всех указанных прав для отправки таблиц результатов.", + "server_squadron_header": "**Полк сервера** (`/set-squadron`)", + "server_squadron_short": " Короткое: `{short}`", + "server_squadron_long": " Длинное: `{long}`", + "server_squadron_not_set": " Не задано (цвет полосы таблицы результатов будет 'not_set')", + "autolog_prefs_header": "**Настройки автологирования** (`/quick-log`)", + "autolog_none_configured": " ❌ Ничего НЕ настроено — автологирование НЕ будет отправлять ничего на этот сервер.", + "autolog_setup_hint": " Используйте `/quick-log Logs` в нужном канале для настройки.", + "autolog_no_logs_channels": " ❌ Каналы для логов не настроены. Найдены только каналы для Очков/Рейтинга.", + "autolog_enable_hint": " Используйте `/quick-log Logs` для включения автологирования.", + "selected_channel_tag": " **(выбранный канал)**", + "missing_send_attach": " (отсутствуют права отправки/прикрепления)", + "channel_not_found": " (канал не найден)", + "invalid_channel_id": " (некорректный ID канала)", + "premium_status_header": "**Статус Premium** (`/unlock`)", + "premium_active": " ✅ На этом сервере активна подписка Premium.", + "premium_not_subscribed": " ❌ На этом сервере **нет** подписки Premium.", + "premium_autolog_required": " Автологирование требует Premium. Используйте `/unlock` для подписки.", + "premium_not_subscribed_free": " ⚪ Подписка отсутствует — используйте `/unlock` для подписки ($2.99/мес.).", + "premium_free_note": " *(Автологи сейчас бесплатны для всех серверов.)*" + }, + "sq_info": { + "title": "Информация о полке: {squadron}", + "placement_field": "Место", + "total_points_field": "Всего очков", + "total_members_field": "Всего участников", + "members_field": "Участники", + "fetch_failed": "Не удалось получить информацию о полке." + }, + "sq_info_graph": { + "title": "{squadron} — SQ-INFO (Сезон {season})", + "embed_title": "{squadron} — Состав полка", + "embed_desc": "Сезон **{season}** · Медиана боёв: **{median}** · Костяк: **{core}** · Активные: **{active}** · Слабые: **{weak}**\nСтолбцы по убыванию боёв; высота = винрейт. Костяк = ≥ медианы и WR ≥ 1,5× WR полка. Слабые = меньше медианы или WR < WR полка ÷ 2. Активные = все остальные.", + "core_threshold_line": "КОСТЯК ≥ {wr} %", + "weak_threshold_line": "СЛАБЫЕ < {wr} %", + "y_label": "Винрейт", + "core_header": "КОСТЯК — {count} · WR {avg}%", + "active_header": "АКТИВНЫЕ — {count} · WR {avg}%", + "weak_header": "СЛАБЫЕ — {count} · WR {avg}%", + "no_active_season": "Активный сезон не найден. Попробуйте позже, когда начнётся следующий.", + "no_members": "Текущие участники для {squadron} не найдены." + }, + "recap_card": { + "unknown_season": "Неизвестный сезон: `{season}`.", + "no_clan_id": "Не удалось определить ID полка `{squadron}`.", + "render_failed": "Не удалось сгенерировать сезонную карточку. Попробуйте позже." + }, + "sq_stats": { + "no_data_title": "Нет данных", + "no_data_desc": "Исторические данные для полка {squadron} не найдены", + "title": "{squadron} // ПОЛК", + "desc": "Тренд общего счёта (последние {count} точек данных)", + "previous_score_field": "Предыдущий счёт", + "current_score_field": "Текущий счёт", + "change_field": "Изменение", + "player_title": "{squadron} // ИГРОКИ", + "player_desc": "Тренды очков отдельных игроков", + "comparison_title": "{squadron} // СРАВНЕНИЕ РЕЙТИНГА", + "comparison_desc": "Сравнение с полками на позициях {range}", + "current_position_field": "Текущая позиция", + "squadrons_shown_field": "Полков отображено", + "squadron_not_found_error": "Полк не найден в рейтинге", + "no_nearby_error": "Соседние полки не найдены", + "no_historical_error": "Исторические данные для соседних полков не найдены", + "comparison_chart_failed": "Не удалось построить сравнительный график", + "select_players_placeholder": "Выберите игроков (Стр. {page})" + }, + "loss_calc": { + "title": "Потеря очков — {squadron}", + "players_leaving_field": "Уходящие игроки", + "share_of_total_field": "% от общего", + "points_lost_real_field": "Потеряно очков (реальных)", + "points_lost_raw_field": "Потеряно очков (сырых)", + "squadron_rating_field": "Рейтинг полка", + "squadron_position_field": "Позиция полка", + "positions_lost_field": "Потеряно позиций", + "not_found_footer": "Не найдено в полке: {players}", + "fetch_failed": "Не удалось получить данные полка: {error}", + "no_point_data": "Данные об очках для этого полка недоступны.", + "no_matching_players": "В полке **{squadron}** не найдено подходящих игроков." + }, + "player": { + "select_player_placeholder": "Выберите игрока", + "no_stats_found": "❌ Статистика не найдена для UID: {uid}", + "no_vehicle_stats": "❌ Статистика техники для этого игрока не найдена.", + "vehicles_found": "Найдено **{count}** единиц техники для **{nick}**\nВыберите технику для просмотра подробной статистики:", + "vehicle_select_placeholder": "Выберите технику (Стр. {page}/{total})", + "combat_stats_header": "**__БОЕВАЯ СТАТИСТИКА__**", + "ground_kills_label": "**Наземные уничтожения:** {value}", + "air_kills_label": "**Воздушные уничтожения:** {value}", + "total_kills_label": "**Всего уничтожений:** {value}", + "assists_label": "**Помощь:** {value}", + "deaths_label": "**Гибели:** {value}", + "kd_label": "**K/D:** {value}", + "captures_label": "**Захваты:** {value}", + "battle_record_header": "**__БОЕВОЙ СЧЁТ__**", + "total_battles_label": "**Всего боёв:** {value}", + "wins_label": "**Победы:** {value}", + "losses_label": "**Поражения:** {value}", + "win_rate_label": "**Процент побед:** {value}%", + "stats_desc": "Статистика **{nick}** (**{squadron}**)\nUID: `{uid}`", + "not_found_title": "Игрок не найден", + "not_found_desc": "История игр для `{player}` не найдена.", + "no_players_found": "Игроки, соответствующие **{username}**, не найдены\nПопробуйте использовать `/website` для поиска на сайте.", + "multiple_matches": "Найдено несколько совпадений, выберите нужное ниже:", + "must_provide_input": "Необходимо указать хотя бы UID или никнейм." + }, + "player_games": { + "no_recent_title": "Нет недавних игр", + "no_recent_desc": "Игры для **{player}** за последние 8 часов не найдены.", + "squadron_label": "**Полк:** {squadron}", + "record_label": "**П:** {wins} **Пор:** {losses} **ПП:** {wr}%", + "comps_played_header": "\n\n**Сыгранные составы**" + }, + "match": { + "missing_input_title": "Отсутствуют данные", + "missing_input_desc": "Укажите `match_id` или `player_name`.", + "not_found_title": "Матч не найден", + "not_found_desc": "Не удалось найти матч с ID `{match_id}`.", + "invalid_data_title": "Некорректные данные матча", + "invalid_data_desc": "Не удалось обработать данные реплея.", + "scoreboard_error_title": "Ошибка таблицы результатов", + "scoreboard_error_desc": "Не удалось создать изображение таблицы результатов.", + "no_games_title": "Игры не найдены", + "no_games_desc": "История игр для **{player}** не найдена.", + "recent_matches_title": "Недавние матчи: {player}", + "recent_matches_desc": "Отображается до {count} недавних игр. Выберите одну для просмотра полной таблицы результатов.", + "select_match_placeholder": "Выберите матч для просмотра..." + }, + "compare": { + "no_players_found": "Игроки, соответствующие **{name}**, не найдены.", + "multiple_matches": "Найдено несколько совпадений для **{name}**: {matches}\nИспользуйте более точное имя (предложения автодополнения точны).", + "could_not_resolve": "Не удалось определить игроков.", + "could_not_fetch": "❌ Не удалось получить статистику для **{name}**.", + "no_graph_data": "Данные за последние 90 дней недоступны.", + "no_squadron_points_data": "Нет данных об очках полка для {names} (игрок не найден в истории отслеживаемых полков).", + "graph_title": "Очки игрока — последние 90 дней", + "battles_label": "Бои", + "wins_label": "Победы", + "losses_label": "Поражения", + "win_rate_label": "Процент побед", + "ground_kills_label": "Наземные уничтожения", + "air_kills_label": "Воздушные уничтожения", + "total_kills_label": "Всего уничтожений", + "assists_label": "Помощь", + "deaths_label": "Гибели", + "kd_label": "K/D", + "captures_label": "Захваты" + }, + "squadron": { + "not_found_desc": "Полк `{squadron}` не найден.", + "set_title": "✅ Полк установлен", + "set_desc": "Полк **{squadron}** установлен для этого сервера.", + "short_name_field": "Короткое название", + "long_name_field": "Длинное название", + "swap_title": "✅ Полк заменён", + "swap_desc": "Заменено **{old}** на **{new}** для этого сервера.", + "already_set_title": "⚠️ Полк уже установлен", + "already_set_desc": "На этом сервере сейчас установлено **{old}**.\nЗаменить на **{new}**?", + "swap_cancelled": "❌ Смена полка отменена." + }, + "setup": { + "step1_title": "Настройка сервера — Шаг 1 из 3", + "step1_desc": "Этот мастер поможет настроить бота для вашего сервера.\n\n**Шаг 1** — Установите полк\n**Шаг 2** — Выберите канал для логов\n**Шаг 3** — Выберите канал для очков\n", + "step1_current_sq": "\nТекущий настроенный полк: **[{short}] {long}**", + "step2_title": "Настройка сервера — Шаг 2 из 3", + "step2_desc": "Полк установлен: **[{short}] {long}**.\n\nКуда публиковать **боевые логи**?\nВыберите текстовый канал ниже или пропустите этот шаг.", + "step3_title": "Настройка сервера — Шаг 3 из 3", + "step3_desc": "Куда публиковать **уведомления об очках**?\nВыберите текстовый канал ниже или пропустите этот шаг.", + "step3_same_as_logs": "\n\nВы также можете нажать «Как в логах» для использования канала логов.", + "summary_title": "Настройка завершена", + "summary_desc": "Вы можете использовать `/autolog-management` для изменения этих настроек позже.", + "squadron_field": "Полк", + "logs_channel_field": "Канал логов", + "points_channel_field": "Канал очков", + "premium_required_field": "⚠️ Игровые логи требуют Premium", + "premium_required_value": "Автоматические таблицы результатов не будут публиковаться, пока у сервера нет активной подписки. Запустите `/unlock` для подписки ($2.99/мес.).", + "modal_title": "Установить полк", + "modal_label": "Короткое название полка", + "modal_placeholder": "напр. AXYS", + "squadron_not_found": "Полк `{squadron}` не найден. Попробуйте снова.", + "logs_channel_placeholder": "Выберите канал для логов...", + "points_channel_placeholder": "Выберите канал для очков..." + }, + "meta_management": { + "squadron_not_found_title": "❌ Полк не найден", + "squadron_not_found_desc": "Не удалось найти ID клана для полка: **{squadron}**", + "access_denied_title": "❌ Доступ запрещён", + "access_denied_desc": "Неверный пароль. Мета-данные этого полка защищены.", + "data_locked_title": "🔐 Данные полка привязаны", + "data_locked_desc": "**{squadron}** имеет включённую привязку данных и не может быть перенесён на другой сервер.\n\nВладелец полка должен отключить **Привязку данных полка** перед переносом.", + "error_retrieving_settings": "❌ Ошибка получения настроек сервера после переноса. Попробуйте снова.", + "error_retrieving_settings_retry": "❌ Ошибка получения настроек сервера. Попробуйте запустить команду снова.", + "authenticated_title": "✅ Аутентификация успешна", + "authenticated_desc": "Пароль подтверждён. Управление настройками **{squadron}**.", + "claimed_title": "✅ Полк закреплён", + "claimed_desc": "**{squadron}** успешно закреплён за этим сервером!", + "password_requirement_field": "🔒 Требование пароля", + "data_lock_field": "🔐 Привязка данных полка", + "public_meta_field": "👥 Публичный доступ к мета", + "access_password_field": "🔑 Пароль доступа", + "enabled_value": "✅ Включено", + "disabled_value": "❌ Отключено", + "settings_title": "🔐 Настройки управления мета", + "settings_desc": "**Полк:** {squadron}\n**ID клана:** {clan_id}", + "first_time_title": "🔐 Управление мета — Первоначальная настройка", + "first_time_owner_desc": "**Полк:** {squadron}\n**ID клана:** {clan_id}\n\n🔑 Ваш пароль доступа сгенерирован. **Сохраните этот пароль** — он потребуется для аутентификации при доступе к мета-данным в будущем.\n\n**Пароль:** `{password}`", + "first_time_non_owner_desc": "**Полк:** {squadron}\n**ID клана:** {clan_id}\n\nПолк настроен. Запросите пароль доступа у владельца сервера.", + "settings_field": "Настройки", + "settings_hint": "Используйте кнопки ниже для настройки прав доступа.", + "password_toggled": "✅ Требование пароля: **{state}**", + "lock_toggled": "✅ Привязка данных полка: **{state}**", + "public_meta_toggled": "✅ Публичный доступ к мета: **{state}**\n{detail}", + "public_meta_enabled_detail": "Теперь не-администраторы могут использовать команду `/meta`.", + "public_meta_disabled_detail": "Команду `/meta` могут использовать только администраторы.", + "owner_only_password": "❌ Только владелец сервера может изменить пароль полка.", + "help_title": "📖 Справка по управлению мета", + "help_desc": "Описание каждой настройки и функции:", + "help_password_field": "🔑 Пароль доступа", + "help_password_value": "Пароль доступа вашего полка. Пароль в панели настроек виден только **владельцу сервера**. Любой, у кого есть пароль, может закрепить мета-данные вашего полка на своём сервере, поэтому храните его в безопасности.", + "help_require_field": "🔒 Требовать пароль", + "help_require_value": "При включении даже администраторы этого сервера должны вводить пароль полка для доступа к `/meta-management`. Добавляет дополнительный уровень защиты от случайных изменений.", + "help_lock_field": "🔐 Привязка данных полка", + "help_lock_value": "При включении привязывает данные полка к этому серверу, предотвращая перенос даже при наличии правильного пароля. Необходимо отключить перед переносом полка.", + "help_public_field": "👥 Разрешить публичную мета", + "help_public_value": "При включении позволяет участникам без прав администратора использовать команду `/meta` для поиска техники полка. При отключении команду `/meta` могут использовать только администраторы сервера.", + "help_accounts_field": "📋 Обновить мета-аккаунты", + "help_accounts_value": "Открывает менеджер ростера игроков, где можно добавлять или удалять игроков из мета-ростера полка. Используйте **Обновить всех участников** для синхронизации всего полка сразу.", + "help_change_pw_field": "🔑 Изменить пароль", + "help_change_pw_value": "**Только для владельца сервера.** Изменить пароль доступа полка и задать необязательную подсказку. Подсказка отображается в запросе пароля для помощи в его запоминании.", + "password_modal_title": "Пароль доступа к полку", + "password_modal_label": "Введите пароль полка", + "password_modal_placeholder": "XXXX-XXXX-XXXX", + "change_pw_modal_title": "Изменить пароль полка", + "current_password_label": "Текущий пароль", + "current_password_placeholder": "Введите текущий пароль", + "new_password_label": "Новый пароль", + "new_password_placeholder": "Введите новый пароль", + "confirm_password_label": "Подтвердите новый пароль", + "confirm_password_placeholder": "Повторно введите новый пароль", + "hint_label": "Подсказка к паролю (необязательно)", + "hint_placeholder": "Подсказка для запоминания пароля", + "pw_incorrect": "❌ Текущий пароль неверен.", + "pw_mismatch": "❌ Новые пароли не совпадают. Попробуйте снова.", + "pw_empty": "❌ Новый пароль не может быть пустым.", + "pw_changed": "✅ Пароль для **{squadron}** успешно изменён.\n**Новый пароль:** `{password}`", + "pw_changed_hint": "\n**Подсказка:** {hint}", + "player_add_modal_title": "Добавить игрока в мета-ростер", + "player_add_label": "UID или никнейм игрока", + "player_add_placeholder": "Введите UID (напр., 12345678) или никнейм игрока", + "player_not_found": "❌ Игрок `{player}` не найден в базе данных Players_Global.\n", + "roster_title": "📋 Управление мета-ростером — {squadron}", + "roster_desc": "**ID клана полка:** {clan_id}\n**Всего игроков:** {count}", + "roster_page_field": "Игроки (Стр. {page}/{total})", + "no_players_field": "Нет игроков", + "no_players_hint": "В мета-ростер ещё не добавлено ни одного игрока. Нажмите **Добавить игрока**, чтобы начать.", + "remove_player_placeholder": "Выберите игрока для удаления...", + "fetch_members_failed": "❌ Не удалось получить список участников полка: {error}", + "no_members_found": "❌ Участники полка не найдены или API-запрос не выполнен.", + "roster_synced": "✅ Ростер синхронизирован с полком.", + "roster_added": "**+{count}** добавлено", + "roster_removed": "**-{count}** удалено (покинули полк)", + "roster_up_to_date": "**{count}** уже актуальны", + "refreshing_vehicles": "Обновление данных о технике в фоновом режиме..." + }, + "meta": { + "not_configured": "❌ Мета-данные не настроены для этого сервера. Сначала запустите `/meta-management`.", + "no_permission": "❌ Для использования этой команды необходимы права администратора.\nАдминистраторы могут включить публичный доступ через `/meta-management`.", + "no_results": "❌ Ни один игрок в ростере вашего полка не имеет **{vehicle}**.", + "no_results_admin_hint": "\n*Ожидаете, что кто-то должен иметь это? Нажмите кнопку обновления участников в `/meta-management` и проверьте снова.*", + "search_title": "🔍 Результаты поиска — {vehicle}", + "matches_found": "**Найдено совпадений:** {count} игрок(ов)", + "spawns_label": "Появления", + "deaths_label": "Гибели", + "gk_label": "НУ", + "ak_label": "ВУ", + "points_label": "Очки", + "kdr_label": "K/D", + "games_label": "Бои", + "no_points": "—" + }, + "top": { + "title": "**Топ 20 полков**", + "rating_label": "**Рейтинг:** {value}", + "air_kills_label": "**Воздушные уничтожения:** {value}", + "ground_kills_label": "**Наземные уничтожения:** {value}", + "deaths_label": "**Гибели:** {value}", + "kd_label": "**K/D:** {value}", + "win_rate_label": "**Процент побед:** {value}", + "playtime_label": "**Время в игре:** {value}", + "fetch_failed": "Не удалось получить данные о полках." + }, + "analytics": { + "no_data_title": "Нет данных", + "no_matches_desc": "Матчи не найдены.", + "no_comp_desc": "Данные о составах не найдены.", + "no_consistency_desc": "Недостаточно данных об игроках (минимум 50 матчей).", + "no_time_desc": "Данные о времени не найдены.", + "unknown_view": "Неизвестный вид.", + "map_title": "Процент побед по картам: {squadron}", + "comp_title": "Составы команд: {squadron}", + "consistency_title": "Стабильность игроков: {squadron}", + "consistency_desc": "Отсортировано по K/D", + "time_title": "Результативность по времени суток: {squadron}", + "eu_timeslot": "\n**EU Timeslot**", + "na_timeslot": "\n**NA Timeslot**", + "off_peak": "\n**Off-Peak**", + "matchups_title": "📜 {squadron} — История Противостояний", + "matchups_won_field": "🏆 Чаще Всего Побеждали", + "matchups_lost_field": "💀 Чаще Всего Проигрывали", + "no_matchups_desc": "Нет записанных матчей против других полков." + }, + "recent": { + "title": "Недавние матчи: {squadron}", + "no_matches_desc": "Матчи для этого полка не найдены." + }, + "h2h": { + "two_required_title": "Необходимо два полка", + "two_required_desc": "Укажите хотя бы один полк или используйте `/set-squadron` и укажите соперника.", + "provide_a_desc": "Укажите `squadron_a` или сначала используйте `/set-squadron`.", + "provide_b_desc": "Укажите `squadron_b` или сначала используйте `/set-squadron`.", + "squadron_not_found_title": "Полк не найден", + "same_squadron_title": "Одинаковый полк", + "same_squadron_desc": "Нельзя проверить статистику встреч против самих себя.", + "record_desc": "**Счёт:** {a_wins}П - {b_wins}Пор ({total} игр)", + "no_matches_desc": "Нет зафиксированных матчей между **{a}** и **{b}**." + }, + "autolog": { + "premium_active_line": "✅ **Premium:** Активно — автологирование включено для этого сервера.", + "premium_not_subscribed_line": "❌ **Premium:** Подписка отсутствует — используйте `/unlock` для включения автологирования.", + "premium_free_line": "⚪ **Premium:** Подписка отсутствует — используйте `/unlock` для подписки ($2.99/мес.). *(Автологи сейчас бесплатны для всех серверов.)*", + "what_to_do": "\n\nЧто вы хотите сделать?", + "select_notif_type": "Выберите тип уведомления для управления:", + "select_notif_placeholder": "Выберите тип уведомления", + "logs_option": "Логи", + "logs_option_desc": "Управление уведомлениями о логах", + "points_option": "Очки", + "points_option_desc": "Управление уведомлениями об очках", + "leaderboard_option": "Рейтинг", + "leaderboard_option_desc": "Управление уведомлениями о рейтинге", + "selected_type": "Выбрано **{type}**. Теперь выберите полк для управления:", + "select_squadron_placeholder": "Выберите полк", + "select_squadron_page_placeholder": "Выберите полк (Стр. {page})", + "no_squadrons_available": "Нет доступных полков для этого типа уведомлений.", + "managing_global": "Управление **{type}** (глобально) в канале **{channel}**.", + "managing_squadron": "Управление **{type}** для полка **{squadron}** в канале **{channel}**.", + "select_channel": "Выберите новый канал:", + "select_channel_placeholder": "Выберите канал", + "select_channel_page_placeholder": "Выберите канал (Стр. {page})", + "global_toggled": "{type} (глобально) теперь {state}.", + "squadron_toggled": "{type} для **{squadron}** теперь {state}.", + "channel_updated_global": "Обновлён {type} (глобально) — канал {channel}", + "channel_updated_squadron": "Обновлён {type} для **{squadron}** — канал {channel}", + "diagnose_channel_placeholder": "Выберите канал для диагностики...", + "select_channel_diagnose": "Выберите канал для диагностики:", + "game_not_logged_title": "Игра не залогирована", + "game_not_logged_desc": "Используйте `/unlock`, чтобы оформить подписку уровня **Standard** (или выше) и получать автоматические таблицы результатов.", + "server_not_upgraded_title": "⚠️ Сервер не обновлён", + "server_not_upgraded_autolog_desc": "На этом сервере нет активной подписки Premium.\n\n**Автоматические таблицы результатов перестанут отправляться на серверы без обновления после .**\n\nИспользуйте `/unlock` для подписки и продолжения получения автоматических игровых логов.", + "replay_not_available": "Данные реплея пока недоступны — подождите немного и попробуйте снова!", + "too_many_videos": "Сейчас обрабатывается слишком много видео — попробуйте снова через мгновение.", + "video_gen_failed": "Ошибка создания видео: `{error}`", + "video_missing": "Не удалось создать видео реплея — выходной файл отсутствует или пуст.", + "video_too_large": "Видео реплея слишком большое для загрузки ({file_mb:.1f} МБ). Лимит сервера: {limit_mb:.0f} МБ.", + "video_web_fallback": "Вы также можете просмотреть этот матч на {url}", + "video_upload_failed": "Видео слишком большое для загрузки — просмотрите его на сайте:\n{url}", + "video_unexpected_error": "Неожиданная ошибка при создании видео реплея: `{error}`", + "replay_not_found": "Данные реплея для сессии `{session_id}` не найдены на диске.", + "chat_log_title": "**Лог чата для игры [{session_id}]({url})**", + "chat_log_part_title": "**Лог чата для игры [{session_id}]({url}) (Часть {part}/{total})**", + "chat_log_part_only": "**Лог чата (Часть {part}/{total})**", + "no_chat_log": "Лог чата для сессии `{session_id}` не найден.", + "chat_log_error": "Неожиданная ошибка при загрузке лога чата: `{error}`", + "battle_log_title": "**Боевой лог для игры [{session_id}]({url})**", + "battle_log_part_title": "**Боевой лог для игры [{session_id}]({url}) (Часть {part}/{total})**", + "battle_log_part_only": "**Боевой лог (Часть {part}/{total})**", + "no_battle_log": "Боевые события для сессии `{session_id}` не найдены.", + "battle_log_error": "Неожиданная ошибка при загрузке боевого лога: `{error}`", + "points_update_title": "**{squadron} {region} Обновление очков**", + "points_update_desc": "# {old_total} -> {new_total} {chart}{wl_line}{placement_line}\n\n**Изменения игроков:**", + "points_table_header": "Имя Изменение Теперь\n", + "wl_line": "\n**{squadron}** сыграл **{wins}П-{losses}Пор** за эту сессию", + "placement_rose": "\n**{squadron}** поднялся на **{new_place}** с **{old_place}**", + "placement_fell": "\n**{squadron}** опустился на **{new_place}** с **{old_place}**", + "points_not_logged_title": "Очки не залогированы", + "points_not_logged_desc": "Используйте `/unlock`, чтобы оформить подписку уровня **Standard** (или выше) и получать автоматические обновления очков.", + "server_not_upgraded_points_desc": "На этом сервере нет активной подписки Premium.\n\n**Автоматические обновления перестанут отправляться на серверы без обновления после .**\n\nИспользуйте `/unlock` для подписки и продолжения получения автоматических обновлений.", + "leave_title": "⚠️ Игрок покинул {squadron}", + "leave_desc": "**{nick}** ({uid}) покинул полк.\n\nПоследние зафиксированные очки: **{points}**", + "no_squadrons_desc": "No squadrons configured", + "no_channels_desc": "No channels available", + "over_cap_title": "Полк превышает лимит вашего тарифа", + "over_cap_desc": "Ваш сервер на тарифе **{tier}**, который разрешает **{cap}** полков для **{notif}**. Полк **{squadron}** сейчас сверх лимита и не логируется. Перейдите на более высокий тариф, чтобы восстановить.", + "over_cap_footer": "Повысить тариф: srebot-meow.ing/premium или /unlock", + "wildcard_blocked_title": "Для подстановочных символов нужен тариф выше", + "wildcard_blocked_desc": "Подстановочные полки (*, all, everything) доступны только на тарифах Pro и Max. Ваш сервер на **{tier}** для {notif}. Повысьте тариф, чтобы включить их.", + "cap_header": "{used}/{cap} {notif} включено — тариф {tier}" + }, + "track": { + "squadron_not_found": "Полк не найден.", + "fetch_failed": "Не удалось получить информацию о полке." + }, + "unlock": { + "title": "SRE Bot Premium", + "desc": "**Разблокируйте премиум-функции для этого сервера.**\n\nPremium включает:\n> • Автоматическая публикация таблиц результатов\n> • Логи чата и боя\n> • Просмотр реплеев\n> • Безлимитные запросы /comp\n> • Приоритетная поддержка\n\n**$2.99 / месяц · за сервер · отмена в любое время**\n\n⚠️ Биллинг Discord доступен только в отдельных странах. Если кнопка ниже показывает **«Продукт недоступен»**, это может быть связано с неподдерживаемой страной или использованием **мобильного устройства**. Используйте вместо этого кнопку **Подписаться через сайт**.", + "already_subscribed_title": "SRE Bot Premium", + "already_subscribed_desc": "✅ **Этот сервер уже подписан!**", + "manage_discord_field": "Управление подпиской", + "manage_discord_value": "Ваша подписка оформлена через **Discord**.\nДля отмены перейдите в **Настройки пользователя → Подписки** в Discord.", + "manage_website_field": "Управление подпиской", + "manage_website_value": "Ваша подписка оформлена через **сайт**.\nУправляйте ей на [whop.com/billing](https://whop.com/billing).", + "coming_soon_field": "Скоро", + "coming_soon_value": "Подписки Premium пока недоступны. Загляните позже!", + "current_tier": "Ваш текущий тариф: **{tier}**.", + "upgrade_to": "Повысить до {tier}", + "upgrade_to_value": "Увеличьте лимит полков и получите больше функций с тарифом **{tier}**." + }, + "language": { + "prompt": "Пожалуйста, выберите язык сервера:", + "select_placeholder": "Выберите язык сервера", + "language_set": "Язык установлен: {language}.", + "translate_prompt": "Выберите целевой язык ниже 👇", + "translate_placeholder": "Выберите целевой язык…", + "translate_result": "**{author} → {language}:**\n{text}", + "translation_unavailable": "Перевод недоступен (DeepL не настроен)", + "translation_failed": "Ошибка перевода" + }, + "misc": { + "credits_title": "Авторы", + "credits_desc": "**Meowww**\n\n> **NotSoToothless** - Ведущий разработчик, менеджер бота, менеджер сообщества\n> **Z3R0** - Разработчик, разработчик оптимизации, инженер баз данных\n> **Clippii (Heidi)** - Разработчик, веб-разработчик, менеджер сообщества\n> **LivingTheDagor** - Разработчик, разработчик парсера, консультант\n> **Lux_** - API-инженер, разработчик Spectra\n> **Konigallerwaffen** - Консультант по обратной связи и функциям\n> **Žralok Tonda** - Чешский переводчик\n> **Styevy**, **Lopais** - Немецкие переводчики\n> **Susogus**, **playforfun698** - Польские переводчики\n> **Bobr** - Русский переводчик\n\n\n[Хотите присоединиться к нам?](https://discord.gg/BCvkK8JhPe)", + "schedule_title": "РАСПИСАНИЕ СЕЗОНА", + "schedule_not_found_title": "Расписание не найдено", + "schedule_not_found_desc": "Данные расписания пока недоступны.", + "news_no_news_title": "Нет новостей", + "news_no_news_desc": "Объявлений пока нет. Загляните позже!", + "news_footer": "Спасибо за вашу поддержку! ᖙᘘᗢ", + "help_title": "Руководство по боту", + "donate_title": "Поддержать SRE Bot", + "donate_desc": "Если вам нравится SRE Bot и вы хотите поддержать его развитие, угостите меня кофе!\n\n**[Пожертвовать на Ko-fi](https://ko-fi.com/notsotoothless)**\n\nКаждый взнос помогает поддерживать работу бота и развивать новые функции. Спасибо!", + "status_title": "Статус бота", + "status_last_received": "Последняя полученная игра", + "status_avg_ttl": "Среднее TTL (последние 30)", + "status_no_data": "Пока нет данных", + "status_gaijin_slow": "⚠️ Серверы Gaijin медленные", + "help_commands_header": "**Обзор команд**", + "help_links": "Подробности в документации [здесь]({docs}) или поддержка [здесь]({support}).", + "help_terms": "[Условия использования]({terms}) • [Политика конфиденциальности]({terms})" + }, + "dev": { + "restricted_dev_team": "This command is restricted to the dev team.", + "restricted_bot_owner": "❌ This command is restricted to the bot owner.", + "invalid_server_id": "❌ Invalid server ID. Must be a 17-19 digit Discord server ID.", + "expiry_too_soon": "❌ Expiry timestamp must be at least 1 month from now.\n> Now: \n> Minimum: \n> You provided: ", + "entitlement_write_failed": "❌ Failed to write entitlement: {error}", + "entitlement_created_title": "✅ Manual Entitlement Created", + "entitlement_created_desc": "**Server:** {guild_name} (`{server_id}`)\n**Expires:** ()\n**Created:** ", + "query_failed": "Query failed: {error}", + "health_title": "Bot Health Dashboard", + "health_uptime": "Uptime", + "health_guilds": "Guilds", + "health_games_processed": "Games Processed", + "health_tasks": "Tasks", + "health_websocket": "WebSocket", + "health_never": "never", + "health_errors": "({count} errors)", + "health_last_msg": "last msg {ago} ({count} total)", + "health_avg_ttl": "Avg TTL (Last 30)", + "entitlements_title": "Active Entitlements ({count} total)", + "entitlements_no_entries": "No entitlements.", + "entitlements_empty_title": "Active Entitlements", + "entitlements_empty_desc": "No active entitlements found.", + "entitlements_tag_discord": "Discord", + "entitlements_tag_whop": "Whop", + "entitlements_tag_manual": "Manual", + "query_prefix": "Query: {name}" + }, + "leaderboard_alarm": { + "title": "🏆 Рейтинг полков", + "top15_desc": "Топ 15 полков со статистикой, публикуется через 35 минут после закрытия таймслота.\nЭто сообщение отправлено .", + "top30_desc": "Полки 16-30 со статистикой.", + "not_logged_title": "Рейтинг не залогирован", + "not_logged_desc": "Используйте `/unlock`, чтобы оформить подписку уровня **Standard** (или выше) и получать автоматические обновления рейтинга.", + "server_not_upgraded_title": "⚠️ Сервер не обновлён", + "server_not_upgraded_desc": "На этом сервере нет активной подписки Premium.\n\n**Автоматические обновления перестанут отправляться на серверы без обновления после .**\n\nИспользуйте `/unlock` для подписки и продолжения получения автоматических обновлений." + }, + "stacks": { + "stack_title": "Стак {leader}", + "stack_named_title": "{name}", + "no_members": "Пока нет участников.", + "members_field": "Участники ({count}/{max})", + "queue_field": "Очередь ({count}/{max})", + "manage_title": "Управление стаком", + "no_pending_requests": "Нет ожидающих заявок.", + "disbanded_title": "Стак [Распущен]", + "disbanded_desc": "Этот стак был распущен лидером.", + "expired_title": "Стак [Истёк]", + "expired_desc": "Этот стак истёк.", + "join_modal_title": "Заявка на вступление в стак", + "join_vehicle_label": "На чём будете играть?", + "join_vehicle_placeholder": "напр. F-16C, WZ305...", + "ping_modal_title": "Сообщение пинга", + "ping_message_label": "Своё сообщение (необязательно)", + "ping_message_placeholder": "напр. Заходите! Стак начинается!", + "rename_modal_title": "Переименовать стак", + "rename_label": "Название стака", + "rename_placeholder": "напр. Ночные совы, Альфа отряд...", + "select_new_leader": "Выберите нового лидера…", + "select_applicants": "Выберите кандидатов…", + "no_pending_applications": "Нет ожидающих заявок.", + "select_to_remove": "Выберите людей для удаления…", + "no_members_or_applicants": "Нет участников или кандидатов.", + "select_to_ping": "Выберите людей для индивидуального пинга…", + "stack_not_found": "❌ Стак не найден.", + "no_longer_exists": "❌ Этот стак больше не существует.", + "member_not_exists": "❌ Этот участник больше не существует.", + "already_has_stack": "❌ У этого игрока уже есть активный стак.", + "already_member": "❌ Вы уже участник этого стака.", + "already_applied": "❌ У вас уже есть ожидающая заявка в этот стак.", + "queue_full": "❌ Очередь заполнена ({max}/{max}). Попробуйте позже.", + "application_sent": "✅ Заявка отправлена! Лидер стака её рассмотрит.", + "stack_disbanded": "✅ Стак распущен.", + "cancelled": "Отменено.", + "select_member_transfer": "❌ Выберите участника для передачи руководства.", + "ownership_transferred": "✅ Руководство передано {nick}. Вы покинули стак.", + "select_applicant_first": "❌ Сначала выберите хотя бы одного кандидата.", + "stack_full": "❌ Стак уже заполнен ({max}/{max} участников).", + "select_person_first": "❌ Сначала выберите хотя бы одного человека.", + "no_one_to_ping": "❌ Некого пинговать.", + "ping_footer": "Пинг от {leader} для {stack}.", + "pinged": "✅ Пинг отправлен!", + "select_from_dropdown": "❌ Сначала выберите хотя бы одного человека из выпадающего меню.", + "stack_renamed": "✅ Стак переименован в **{name}**.", + "only_member_use_disband": "❌ Вы единственный участник. Используйте **Распустить стак** для завершения.", + "select_transfer_prompt": "Выберите участника для передачи руководства перед уходом:", + "left_stack": "✅ Вы покинули стак.", + "application_withdrawn": "✅ Ваша заявка отозвана.", + "not_member_or_applicant": "❌ Вы не являетесь участником или кандидатом этого стака.", + "leader_only_manage": "❌ Только лидер стака может им управлять.", + "leader_only_disband": "❌ Только лидер стака может его распустить.", + "confirm_disband": "Вы уверены, что хотите распустить этот стак? Это действие нельзя отменить.", + "already_active_stack": "⚠️ У вас уже есть активный стак. Если исходное сообщение исчезло (напр. после перезапуска бота), вы можете принудительно распустить его и начать заново.", + "force_created": "✅ Предыдущий стак распущен. Новый стак создан.", + "no_active_stack": "❌ У вас нет активного стака. Используйте `/stack-create` для создания.", + "could_not_parse_channel": "⚠️ Не удалось обработать сохранённый ID канала." + }, + "commands": { + "common": { + "season": "Сезон для создания карточки", + "theme": "Цветовая тема карточки", + "squadron_short": "Короткое название эскадрильи", + "player_username": "Имя игрока", + "choice_dark": "Тёмная", + "choice_light": "Светлая" + }, + "comp": { + "description": "Найти последние известные составы команды", + "squadron_short": "Короткое название команды противника" + }, + "quick_log": { + "description": "Настроить уведомление для этой эскадрильи в этом канале", + "squadron_name": "КОРОТКОЕ название эскадрильи для отслеживания", + "type": "Выберите Логи, Очки, Таблица лидеров, Еженедельный BR или Все", + "choice_logs": "Logs", + "choice_points": "Очки", + "choice_leaderboard": "Рейтинг", + "choice_both": "Оба (Logs + Очки)", + "choice_weekly_br": "Еженедельный BR" + }, + "sq_info": { + "description": "Получить информацию об эскадрилье" + }, + "sq_info_graph": { + "description": "Показать график состава полка по активности и винрейту (текущий сезон)" + }, + "sq_card": { + "description": "Создать сезонную карточку эскадрильи", + "squadron": "Короткое название эскадрильи" + }, + "sq_stats": { + "description": "Показать очки эскадрильи во времени" + }, + "loss_calculator": { + "description": "Рассчитать потерю очков, если игроки уйдут из эскадрильи", + "player1": "Уходящий игрок", + "player_optional": "Уходящий игрок (необязательно)" + }, + "website": { + "description": "Получить ссылку на сайт SRE Bot" + }, + "card": { + "description": "Создать сезонную карточку игрока" + }, + "player_stats": { + "description": "Показать подробную статистику техники игрока", + "username": "Имя WT для запроса статистики", + "uid": "UID WT для запроса статистики" + }, + "view_player_games": { + "description": "Показать последние 20 игр игрока" + }, + "view_match": { + "description": "Показать таблицу матча по ID или игроку", + "match_id": "Hex ID сессии матча", + "player_name": "Игрок для просмотра недавних матчей" + }, + "compare": { + "description": "Сравнить суммарную SQB-статистику игроков", + "player1": "Первый игрок", + "player2": "Второй игрок", + "player_optional": "Дополнительный игрок (необязательно)" + }, + "leaderboard": { + "description": "Открыть глобальный рейтинг SRE Bot" + }, + "set_squadron": { + "description": "Задать тег эскадрильи для этого сервера", + "abbreviated_name": "Короткое название эскадрильи" + }, + "setup": { + "description": "Настроить бота для этого сервера" + }, + "meta_management": { + "description": "Управлять доступом к мета-данным этого сервера" + }, + "meta": { + "description": "Искать мета-ростер по названию техники", + "vehicle": "Название техники для поиска" + }, + "top": { + "description": "Показать топ-20 эскадрилий с подробной статистикой" + }, + "language": { + "description": "Изменить язык бота." + }, + "translate_message": { + "name": "Перевести сообщение" + }, + "sq_track": { + "description": "Отслеживать эскадрилью и сравнить с прошлой проверкой", + "squadron_short_name": "Короткое название эскадрильи" + }, + "analytics": { + "description": "Показать расширенную SQB-аналитику эскадрильи", + "view": "Какой вид аналитики показать", + "choice_maps": "Винрейт по картам", + "choice_comps": "Составы команд", + "choice_consistency": "Стабильность игроков", + "choice_time": "Время суток", + "choice_matchups": "История встреч" + }, + "recent": { + "description": "Показать недавние бои эскадрильи", + "length": "Количество матчей" + }, + "vs": { + "description": "Личная статистика двух эскадрилий", + "squadron_a": "Первая эскадрилья", + "squadron_b": "Вторая эскадрилья" + }, + "autolog_management": { + "description": "Управлять autolog-уведомлениями и проверять права" + }, + "diagnose_perms": { + "description": "Проверить права autolog в этом канале" + }, + "unlock": { + "description": "Открыть Premium-функции для этого сервера" + }, + "credits": { + "description": "Показать команду, создавшую проект" + }, + "schedule": { + "description": "Показать текущий сезонный BR-график" + }, + "news": { + "description": "Показать последние новости и объявления SRE Bot" + }, + "help": { + "description": "Показать руководство, ToS и ссылки поддержки" + }, + "donate": { + "description": "Поддержать разработку SRE Bot" + }, + "stack_create": { + "description": "Создать стак игроков", + "vehicle": "На какой технике начнёте?" + }, + "stack_manage": { + "description": "Повторно отправить активный стак в этот канал" + }, + "bot_status": { + "description": "Статус бота: последняя полученная игра и среднее TTL" + } + }, + "permission": { + "blacklisted_title": "❌ В чёрном списке", + "blacklisted_desc": "Вы не можете использовать эту команду.", + "reason_line": "**Причина:** {reason}", + "access_denied_title": "⛔ Доступ запрещён", + "no_permission_desc": "У вас нет прав для использования этой команды.", + "unexpected_error_title": "❗ Ошибка, сообщите о ней...." + }, + "weekly_br": { + "title_wildcard": "Еженедельный отчёт BR — {br} BR", + "title_squadron": "Еженедельный отчёт BR — [{tag}] {long} • {br} BR", + "window_label": "Период: {start} → {end}", + "wildcard_desc_first": "Топ-{count} полков по ELO • Места {low}–{high}", + "wildcard_desc_second": "Топ-{count} полков по ELO • Места {low}–{high}", + "squadron_stats_line": "- {games} боёв • K/D {kdr} • Побед {wr}%", + "top_players_inline_header": "🥇 Лучшие игроки:", + "player_line_short": " {rank}. {nick} ⭐ {score} ({games}б)", + "top_players_header": "**Топ-{count} игроков по ELO:**", + "player_line_full": "{rank}. **{nick}** ⭐ {score} • {games} боёв • K/D {kdr}", + "squadron_header_line": "ELO полка: {score} • {games} боёв • Побед {wr}% • K/D {kdr}", + "squadron_header_no_aggregate": "ELO полка: недостаточно активности команды на этой неделе.", + "no_data": "Нет матчей для [{tag}] в этой ротации BR." + } +} diff --git a/BOT/locales/uk.json b/BOT/locales/uk.json new file mode 100644 index 0000000..3fc8f72 --- /dev/null +++ b/BOT/locales/uk.json @@ -0,0 +1,856 @@ +{ + "common": { + "error_title": "Помилка", + "no_data_title": "Немає даних", + "access_denied_title": "Доступ заборонено", + "access_denied_desc": "Цей сервер заблоковано.", + "no_players_selected": "Гравців не вибрано. Будь ласка, виберіть хоча б одного гравця.", + "must_use_in_server": "Ця команда може використовуватись лише на сервері.", + "could_not_resolve_channel": "Не вдалося визначити вибраний канал.", + "failed_update_setting": "❌ Не вдалося оновити налаштування.", + "configuration_not_found": "Конфігурацію не знайдено.", + "no_channel_selected": "Канал не вибрано.", + "no_selection_received": "Нічого не вибрано.", + "database_error": "❌ Помилка бази даних: {error}", + "enabled": "Увімкнено", + "disabled": "Вимкнено", + "not_configured": "Не налаштовано", + "unknown": "Невідомо", + "rating_field": "Рейтинг", + "battles_field": "Бої", + "wins_field": "Перемоги", + "losses_field": "Поразки", + "win_rate_field": "Відсоток перемог", + "kills_field": "Знищення", + "deaths_field": "Загибелі", + "kd_field": "K/D", + "members_field": "Учасники", + "placement_field": "Місце", + "points_field": "Очки", + "ground_kills_field": "Знищення наземних", + "air_kills_field": "Знищення повітряних", + "total_kills_field": "Всього знищень", + "assists_field": "Допомога", + "captures_field": "Захоплення", + "none_option": "Немає" + }, + "buttons": { + "skip": "Пропустити", + "previous": "Попередня", + "next": "Наступна", + "prev": "Назад", + "prev_arrow": "◀ Попередня", + "next_arrow": "Наступна ▶", + "prev_arrow_only": "◀", + "next_arrow_only": "▶", + "generate_chart": "📊 Згенерувати графік", + "show_graph": "Показати графік", + "view_player_stats": "📊 Переглянути Статистику Гравців", + "compare_nearby": "📈 Порівняти Сусідні Ескадрильї", + "confirm_swap": "Так, замінити", + "cancel_swap": "Ні, залишити стару", + "set_squadron": "Встановити Ескадрилью", + "same_as_logs": "Як канал логів", + "require_password": "🔒 Вимагати Пароль", + "password_required": "🔒 Пароль Обов'язковий", + "lock_data": "🔐 Прив'язати Дані Ескадрильї", + "data_locked": "🔐 Дані Прив'язані до Сервера", + "allow_public": "👥 Дозволити Публічний Мета", + "public_enabled": "👥 Публічний Мета Увімкнено", + "update_accounts": "📋 Оновити Мета Акаунти", + "change_password": "🔑 Змінити Пароль", + "help": "❓ Допомога", + "add_player": "➕ Додати Гравця", + "update_all": "🔄 Оновити Всіх Учасників", + "back_to_settings": "⬅ Назад до Налаштувань", + "manage_notifications": "Керувати Сповіщеннями", + "diagnose_permissions": "Діагностувати Дозволи", + "enable": "Увімкнути", + "disable": "Вимкнути", + "change_channel": "Змінити Канал", + "view_replay": "Переглянути Повтор", + "view_website": "Переглянути на Сайті", + "view_video": "Переглянути Відео", + "view_log": "Переглянути Лог", + "view_chat": "Переглянути Чат", + "subscribe_website": "Підписатись через Сайт", + "yes_disband": "Так, розпустити", + "cancel": "Скасувати", + "transfer_leave": "Передати і вийти", + "accept_selected": "Прийняти обраних", + "accept_all": "Прийняти всіх", + "decline_selected": "Відхилити обраних", + "back": "Назад", + "remove_all": "Видалити всіх", + "remove_active": "Видалити активних", + "remove_queued": "Видалити в черзі", + "remove_selected": "Видалити обраних", + "ping_all": "Пінг всіх", + "ping_active": "Пінг активних", + "ping_queued": "Пінг у черзі", + "ping_selected": "Пінг обраних", + "accept_members": "Прийняти учасників", + "remove_members": "Видалити учасників", + "ping_members": "Пінг учасників", + "rename_stack": "Перейменувати стак", + "request_to_join": "Запит на вступ", + "leave_withdraw": "Вийти / Відкликати", + "manage_stack": "Керувати стаком ⚙️", + "disband_stack": "Розпустити стак", + "force_disband_create": "Примусово розпустити і створити новий" + }, + "events": { + "guild_join_title": "Дякуємо за додавання!", + "guild_join_desc": "Виконайте `/setup`, щоб налаштувати бота для цього сервера." + }, + "comp": { + "not_found_title": "Склади не знайдено", + "not_found_desc": "Немає даних для **{squadron}**, спробуйте пізніше.", + "error_loading_title": "Помилка завантаження складів", + "error_loading_desc": "Не вдалося завантажити дані складу: {error}", + "title": "Склади для {squadron}", + "desc": "Склади, помічені за останні {minutes} хвилин", + "no_recent_title": "Немає нещодавніх складів", + "no_recent_desc": "Немає складів за останні {minutes} хвилин.", + "comp_title": "СКЛАД {index}", + "last_seen_label": "**Останній раз помічено**: {timestamp}{warning}", + "comp_label": "**Склад**: {notation}", + "no_players_recorded": "Гравців не зафіксовано.", + "limit_reached_title": "Ліміт складів досягнуто", + "limit_reached_desc": "Цей сервер використав усі {limit} запитів складів для цього таймслоту. Підпишіться (через /unlock) для безлімітного доступу або зачекайте наступного таймслоту.", + "remaining_footer": "{remaining}/{limit} запитів складів залишилось у цьому таймслоті" + }, + "quick_log": { + "invalid_type": "Тип може бути тільки Логи, Очки, Таблиця лідерів, Тижневий BR або Усі.", + "squadron_required": "Необхідно вказати назву ескадрильї для сигналів Логів, Очків або Обох.", + "wildcard_logs_only": "Лише Логи можна налаштувати на довільну ескадрилью.", + "squadron_not_resolved": "Ескадрилью `{squadron}` не вдалося знайти.", + "save_failed": "Не вдалося зберегти налаштування. Будь ласка, спробуйте пізніше.", + "premium_warning": "\n\n> ⚠️ **Ігрові логи потребують Premium.** Виконайте `/unlock` для підписки ($2.99/міс) — логи не будуть надсилатися до цього.", + "leaderboard_set": "Сигнал глобальної таблиці лідерів налаштовано на цей канал.", + "both_set": "Сигнали логів та очків для {squadron} налаштовано на цей канал.{premium_note}", + "alarm_set": "Сигнал {alarm_type} для {squadron} налаштовано на цей канал.{premium_note}", + "weekly_br_wildcard_set": "Тижневий звіт BR (топ-20 полків) налаштовано на цей канал. Надсилається в кінці кожної ротації BR.", + "weekly_br_squadron_set": "Тижневий звіт BR для {squadron} (топ-15 гравців) налаштовано на цей канал. Надсилається в кінці кожної ротації BR." + }, + "diagnostics": { + "title": "Діагностика автологів", + "channel_permissions_header": "**Дозволи Каналу** (<#{channel_id}>)", + "perms_needed": " ^ Автологування потребує всіх перерахованих дозволів для надсилання таблиць результатів.", + "server_squadron_header": "**Ескадрилья Сервера** (`/set-squadron`)", + "server_squadron_short": " Скорочена: `{short}`", + "server_squadron_long": " Повна: `{long}`", + "server_squadron_not_set": " Не налаштовано (колір рядка таблиці відображатиметься як 'not_set')", + "autolog_prefs_header": "**Налаштування Автологів** (`/quick-log`)", + "autolog_none_configured": " ❌ НІЧОГО не налаштовано — автологування НЕ надсилатиме нічого на цей сервер.", + "autolog_setup_hint": " Використайте `/quick-log Logs` у цільовому каналі для налаштування.", + "autolog_no_logs_channels": " ❌ Канали логів не налаштовано. Знайдено лише канали Очків/Таблиці Лідерів.", + "autolog_enable_hint": " Використайте `/quick-log Logs` для увімкнення автологування.", + "selected_channel_tag": " **(вибраний канал)**", + "missing_send_attach": " (бракує дозволів надсилання/прикріплення)", + "channel_not_found": " (канал не знайдено)", + "invalid_channel_id": " (невірний ID каналу)", + "premium_status_header": "**Статус Premium** (`/unlock`)", + "premium_active": " ✅ Цей сервер має активну підписку Premium.", + "premium_not_subscribed": " ❌ Цей сервер **не має** підписки Premium.", + "premium_autolog_required": " Автологування потребує Premium. Використайте `/unlock` для підписки.", + "premium_not_subscribed_free": " ⚫ Немає підписки — використайте `/unlock` для підписки ($2.99/міс).", + "premium_free_note": " *(Автологи зараз безкоштовні для всіх серверів.)*" + }, + "sq_info": { + "title": "Інформація про Ескадрилью: {squadron}", + "placement_field": "Місце", + "total_points_field": "Всього очків", + "total_members_field": "Всього учасників", + "members_field": "Учасники", + "fetch_failed": "Не вдалося отримати інформацію про ескадрилью." + }, + "sq_info_graph": { + "title": "{squadron} — SQ-INFO (Сезон {season})", + "embed_title": "{squadron} — Склад ескадрильї", + "embed_desc": "Сезон **{season}** · Медіана боїв: **{median}** · Кістяк: **{core}** · Активні: **{active}** · Слабкі: **{weak}**\nСтовпці за спаданням боїв; висота = відсоток перемог. Кістяк = ≥ медіани і WR ≥ 1,5× WR ескадрильї. Слабкі = менше медіани або WR < WR ескадрильї ÷ 2. Активні = всі інші.", + "core_threshold_line": "КІСТЯК ≥ {wr} %", + "weak_threshold_line": "СЛАБКІ < {wr} %", + "y_label": "Відсоток перемог", + "core_header": "КІСТЯК — {count} · WR {avg}%", + "active_header": "АКТИВНІ — {count} · WR {avg}%", + "weak_header": "СЛАБКІ — {count} · WR {avg}%", + "no_active_season": "Активний сезон не знайдено. Спробуйте пізніше, коли почнеться наступний.", + "no_members": "Поточних учасників для {squadron} не знайдено." + }, + "recap_card": { + "unknown_season": "Невідомий сезон: `{season}`.", + "no_clan_id": "Не вдалося визначити ID ескадрильї `{squadron}`.", + "render_failed": "Не вдалося згенерувати сезонну картку. Спробуйте пізніше." + }, + "sq_stats": { + "no_data_title": "Немає даних", + "no_data_desc": "Не знайдено історичних даних для ескадрильї: {squadron}", + "title": "{squadron} // ЕСКАДРИЛЬЯ", + "desc": "Тенденція Загального Рахунку (Останні {count} точок даних)", + "previous_score_field": "Попередній Рахунок", + "current_score_field": "Поточний Рахунок", + "change_field": "Зміна", + "player_title": "{squadron} // ГРАВЦІ", + "player_desc": "Тенденції очків окремих гравців", + "comparison_title": "{squadron} // ПОРІВНЯННЯ ТАБЛИЦІ ЛІДЕРІВ", + "comparison_desc": "Порівняння з ескадрильями, що займають місця {range}", + "current_position_field": "Поточна Позиція", + "squadrons_shown_field": "Показано Ескадрилей", + "squadron_not_found_error": "Ескадрилью не знайдено в таблиці лідерів", + "no_nearby_error": "Не знайдено сусідніх ескадрилей", + "no_historical_error": "Не знайдено історичних даних для сусідніх ескадрилей", + "comparison_chart_failed": "Не вдалося згенерувати графік порівняння", + "select_players_placeholder": "Виберіть гравців (Сторінка {page})" + }, + "loss_calc": { + "title": "Втрата Очків — {squadron}", + "players_leaving_field": "Гравці, що Виходять", + "share_of_total_field": "% від Загального", + "points_lost_real_field": "Втрачено Очків (Реально)", + "points_lost_raw_field": "Втрачено Очків (Сирих)", + "squadron_rating_field": "Рейтинг Ескадрильї", + "squadron_position_field": "Позиція Ескадрильї", + "positions_lost_field": "Втрачено Позицій", + "not_found_footer": "Не знайдено в ескадрильї: {players}", + "fetch_failed": "Не вдалося отримати дані ескадрильї: {error}", + "no_point_data": "Немає даних про очки для цієї ескадрильї.", + "no_matching_players": "Не знайдено відповідних гравців у **{squadron}**." + }, + "player": { + "select_player_placeholder": "Виберіть гравця", + "no_stats_found": "❌ Статистику не знайдено для UID: {uid}", + "no_vehicle_stats": "❌ Статистику техніки не знайдено для цього гравця.", + "vehicles_found": "Знайдено **{count}** одиниць техніки для **{nick}**\nВиберіть техніку для перегляду детальної статистики:", + "vehicle_select_placeholder": "Виберіть техніку (Сторінка {page}/{total})", + "combat_stats_header": "**__БОЙОВА СТАТИСТИКА__**", + "ground_kills_label": "**Знищення Наземних:** {value}", + "air_kills_label": "**Знищення Повітряних:** {value}", + "total_kills_label": "**Всього Знищень:** {value}", + "assists_label": "**Допомога:** {value}", + "deaths_label": "**Загибелі:** {value}", + "kd_label": "**K/D:** {value}", + "captures_label": "**Захоплення:** {value}", + "battle_record_header": "**__БОЙОВИЙ ЗАПИС__**", + "total_battles_label": "**Всього Боїв:** {value}", + "wins_label": "**Перемоги:** {value}", + "losses_label": "**Поразки:** {value}", + "win_rate_label": "**Відсоток Перемог:** {value}%", + "stats_desc": "Статистика для **{nick}** (**{squadron}**)\nUID: `{uid}`", + "not_found_title": "Гравця Не Знайдено", + "not_found_desc": "Не знайдено ігрової історії для `{player}`.", + "no_players_found": "Не знайдено гравців, що відповідають **{username}**\nСпробуйте скористатися `/website` для пошуку на сайті.", + "multiple_matches": "Знайдено декілька збігів, виберіть правильний нижче:", + "must_provide_input": "Необхідно вказати хоча б UID або ім'я користувача." + }, + "player_games": { + "no_recent_title": "Немає Нещодавніх Ігор", + "no_recent_desc": "Не знайдено ігор для **{player}** за останні 8 годин.", + "squadron_label": "**Ескадрилья:** {squadron}", + "record_label": "**П:** {wins} **Пор:** {losses} **ВП:** {wr}%", + "comps_played_header": "\n\n**Зіграні Склади**" + }, + "match": { + "missing_input_title": "Відсутні Вхідні Дані", + "missing_input_desc": "Вкажіть `match_id` або `player_name`.", + "not_found_title": "Матч Не Знайдено", + "not_found_desc": "Не вдалося знайти матч з ID `{match_id}`.", + "invalid_data_title": "Невірні Дані Матчу", + "invalid_data_desc": "Не вдалося розібрати дані повтору.", + "scoreboard_error_title": "Помилка Таблиці Результатів", + "scoreboard_error_desc": "Не вдалося згенерувати зображення таблиці результатів.", + "no_games_title": "Ігор Не Знайдено", + "no_games_desc": "Не знайдено ігрової історії для **{player}**.", + "recent_matches_title": "Нещодавні матчі для {player}", + "recent_matches_desc": "Показано до {count} нещодавніх ігор. Виберіть одну для перегляду повної таблиці результатів.", + "select_match_placeholder": "Виберіть матч для перегляду..." + }, + "compare": { + "no_players_found": "Не знайдено гравців, що відповідають **{name}**.", + "multiple_matches": "Декілька збігів для **{name}**: {matches}\nБудь ласка, використайте точніше ім'я (підказки автодоповнення є точними).", + "could_not_resolve": "Не вдалося визначити гравців.", + "could_not_fetch": "❌ Не вдалося отримати статистику для **{name}**.", + "no_graph_data": "Немає даних за останні 90 днів.", + "no_squadron_points_data": "Немає даних про очки ескадрильї для {names} (гравця не знайдено в відстежуваній історії ескадрильї).", + "graph_title": "Очки Гравця — Останні 90 Днів", + "battles_label": "Бої", + "wins_label": "Перемоги", + "losses_label": "Поразки", + "win_rate_label": "Відсоток Перемог", + "ground_kills_label": "Знищення Наземних", + "air_kills_label": "Знищення Повітряних", + "total_kills_label": "Всього Знищень", + "assists_label": "Допомога", + "deaths_label": "Загибелі", + "kd_label": "K/D", + "captures_label": "Захоплення" + }, + "squadron": { + "not_found_desc": "Ескадрилью `{squadron}` не знайдено.", + "set_title": "✅ Ескадрилью Встановлено", + "set_desc": "Ескадрилью **{squadron}** встановлено для цього сервера.", + "short_name_field": "Скорочена Назва", + "long_name_field": "Повна Назва", + "swap_title": "✅ Ескадрилью Замінено", + "swap_desc": "Замінено **{old}** на **{new}** для цього сервера.", + "already_set_title": "⚠️ Ескадрилью Вже Встановлено", + "already_set_desc": "Наразі для цього сервера встановлено **{old}**.\nЗамінити на **{new}**?", + "swap_cancelled": "❌ Зміну ескадрильї скасовано." + }, + "setup": { + "step1_title": "Налаштування Сервера — Крок 1 з 3", + "step1_desc": "Цей майстер допоможе вам налаштувати бота для вашого сервера.\n\n**Крок 1** — Встановіть вашу ескадрилью\n**Крок 2** — Виберіть канал логів\n**Крок 3** — Виберіть канал очків\n", + "step1_current_sq": "\nПоточна налаштована ескадрилья: **[{short}] {long}**", + "step2_title": "Налаштування Сервера — Крок 2 з 3", + "step2_desc": "Ескадрилью встановлено на **[{short}] {long}**.\n\nКуди надсилати **ігрові логи**?\nВиберіть текстовий канал нижче або пропустіть цей крок.", + "step3_title": "Налаштування Сервера — Крок 3 з 3", + "step3_desc": "Куди надсилати **сповіщення про очки**?\nВиберіть текстовий канал нижче або пропустіть цей крок.", + "step3_same_as_logs": "\n\nТакож можна натиснути «Як канал логів», щоб використати той самий канал.", + "summary_title": "Налаштування Завершено", + "summary_desc": "Ви можете використовувати `/autolog-management` для зміни цих налаштувань пізніше.", + "squadron_field": "Ескадрилья", + "logs_channel_field": "Канал Логів", + "points_channel_field": "Канал Очків", + "premium_required_field": "⚠️ Ігрові Логи Потребують Premium", + "premium_required_value": "Автоматичні таблиці результатів не надсилатимуться, поки цей сервер не матиме активної підписки. Виконайте `/unlock` для підписки ($2.99/міс).", + "modal_title": "Встановити Ескадрилью", + "modal_label": "Скорочена Назва Ескадрильї", + "modal_placeholder": "наприклад, AXYS", + "squadron_not_found": "Ескадрилью `{squadron}` не знайдено. Будь ласка, спробуйте знову.", + "logs_channel_placeholder": "Виберіть канал логів...", + "points_channel_placeholder": "Виберіть канал очків..." + }, + "meta_management": { + "squadron_not_found_title": "❌ Ескадрилью Не Знайдено", + "squadron_not_found_desc": "Не вдалося знайти ID клану для ескадрильї: **{squadron}**", + "access_denied_title": "❌ Доступ Заборонено", + "access_denied_desc": "Невірний пароль. Мета-дані цієї ескадрильї захищено.", + "data_locked_title": "🔐 Дані Ескадрильї Прив'язано", + "data_locked_desc": "**{squadron}** має увімкнену прив'язку даних і не може бути передана на інший сервер.\n\nВласник ескадрильї повинен вимкнути **Прив'язку Даних Ескадрильї** перед переміщенням.", + "error_retrieving_settings": "❌ Помилка отримання налаштувань сервера після передачі. Будь ласка, спробуйте знову.", + "error_retrieving_settings_retry": "❌ Помилка отримання налаштувань сервера. Будь ласка, запустіть команду знову.", + "authenticated_title": "✅ Автентифіковано", + "authenticated_desc": "Пароль підтверджено. Керування налаштуваннями для **{squadron}**.", + "claimed_title": "✅ Ескадрилью закріплено", + "claimed_desc": "**{squadron}** успішно закріплено за цим сервером!", + "password_requirement_field": "🔒 Вимога Пароля", + "data_lock_field": "🔐 Прив'язка Даних Ескадрильї", + "public_meta_field": "👥 Публічний Доступ до Мета", + "access_password_field": "🔑 Пароль Доступу", + "enabled_value": "✅ Увімкнено", + "disabled_value": "❌ Вимкнено", + "settings_title": "🔐 Налаштування Керування Мета", + "settings_desc": "**Ескадрилья:** {squadron}\n**ID Клану:** {clan_id}", + "first_time_title": "🔐 Керування Мета - Початкове Налаштування", + "first_time_owner_desc": "**Ескадрилья:** {squadron}\n**ID Клану:** {clan_id}\n\n🔑 Ваш пароль доступу було згенеровано. **Збережіть цей пароль** — він знадобиться для автентифікації доступу до мета-даних у майбутньому.\n\n**Пароль:** `{password}`", + "first_time_non_owner_desc": "**Ескадрилья:** {squadron}\n**ID Клану:** {clan_id}\n\nЕскадрилью налаштовано. Попросіть власника сервера надати пароль доступу.", + "settings_field": "Налаштування", + "settings_hint": "Використайте кнопки нижче для налаштування параметрів доступу.", + "password_toggled": "✅ Вимога пароля: **{state}**", + "lock_toggled": "✅ Прив'язка даних ескадрильї: **{state}**", + "public_meta_toggled": "✅ Публічний доступ до мета: **{state}**\n{detail}", + "public_meta_enabled_detail": "Не-адміністратори тепер можуть використовувати команду `/meta`.", + "public_meta_disabled_detail": "Лише адміністратори можуть використовувати команду `/meta`.", + "owner_only_password": "❌ Лише власник сервера може змінити пароль ескадрильї.", + "help_title": "📖 Довідка з Керування Мета", + "help_desc": "Пояснення кожного налаштування та функції:", + "help_password_field": "🔑 Пароль Доступу", + "help_password_value": "Пароль доступу вашої ескадрильї. Лише **власник сервера** може бачити пароль на панелі налаштувань. Будь-хто з паролем може захопити мета-дані вашої ескадрильї на своєму сервері, тому зберігайте його в безпеці.", + "help_require_field": "🔒 Вимагати Пароль", + "help_require_value": "Якщо увімкнено, навіть адміністратори цього сервера повинні вводити пароль ескадрильї для доступу до `/meta-management`. Забезпечує додатковий рівень захисту від випадкових змін.", + "help_lock_field": "🔐 Прив'язка Даних Ескадрильї", + "help_lock_value": "Якщо увімкнено, прив'язує дані ескадрильї до цього серверу, забороняючи передачу навіть з правильним паролем. Необхідно вимкнути перед передачею ескадрильї.", + "help_public_field": "👥 Дозволити Публічний Мета", + "help_public_value": "Якщо увімкнено, дозволяє не-адміністраторам використовувати команду `/meta` для пошуку техніки ескадрильї. Якщо вимкнено, лише адміністратори сервера можуть використовувати `/meta`.", + "help_accounts_field": "📋 Оновити Мета Акаунти", + "help_accounts_value": "Відкриває менеджер списку гравців, де можна додавати або видаляти гравців із мета-ростеру ескадрильї. Використайте **Оновити Всіх Учасників** для синхронізації всієї ескадрильї одразу.", + "help_change_pw_field": "🔑 Змінити Пароль", + "help_change_pw_value": "**Лише для власника сервера.** Змінює пароль доступу ескадрильї та встановлює необов'язкову підказку. Підказка відображається у запиті пароля для допомоги з пам'яттю.", + "password_modal_title": "Пароль Доступу до Ескадрильї", + "password_modal_label": "Введіть Пароль Ескадрильї", + "password_modal_placeholder": "XXXX-XXXX-XXXX", + "change_pw_modal_title": "Змінити Пароль Ескадрильї", + "current_password_label": "Поточний Пароль", + "current_password_placeholder": "Введіть ваш поточний пароль", + "new_password_label": "Новий Пароль", + "new_password_placeholder": "Введіть ваш новий пароль", + "confirm_password_label": "Підтвердіть Новий Пароль", + "confirm_password_placeholder": "Повторно введіть ваш новий пароль", + "hint_label": "Підказка до Пароля (Необов'язково)", + "hint_placeholder": "Підказка для запам'ятовування пароля", + "pw_incorrect": "❌ Поточний пароль невірний.", + "pw_mismatch": "❌ Нові паролі не збігаються. Будь ласка, спробуйте знову.", + "pw_empty": "❌ Новий пароль не може бути порожнім.", + "pw_changed": "✅ Пароль успішно оновлено для **{squadron}**.\n**Новий Пароль:** `{password}`", + "pw_changed_hint": "\n**Підказка:** {hint}", + "player_add_modal_title": "Додати Гравця до Мета Ростеру", + "player_add_label": "UID або Нікнейм Гравця", + "player_add_placeholder": "Введіть UID гравця (наприклад, 12345678) або нікнейм", + "player_not_found": "❌ Гравця `{player}` не знайдено в базі даних Players_Global.\n", + "roster_title": "📋 Керування Мета Ростером - {squadron}", + "roster_desc": "**ID Клану Ескадрильї:** {clan_id}\n**Всього Гравців:** {count}", + "roster_page_field": "Гравці (Сторінка {page}/{total})", + "no_players_field": "Немає Гравців", + "no_players_hint": "Гравців ще не додано до мета ростеру. Натисніть **Додати Гравця** для початку.", + "remove_player_placeholder": "Виберіть гравця для видалення...", + "fetch_members_failed": "❌ Не вдалося отримати учасників ескадрильї: {error}", + "no_members_found": "❌ Учасників ескадрильї не знайдено або виклик API зазнав невдачі.", + "roster_synced": "✅ Ростер синхронізовано з ескадрильєю.", + "roster_added": "**+{count}** додано", + "roster_removed": "**-{count}** видалено (покинули ескадрилью)", + "roster_up_to_date": "**{count}** вже актуально", + "refreshing_vehicles": "Оновлення даних техніки у фоновому режимі..." + }, + "meta": { + "not_configured": "❌ Мета-дані не налаштовано для цього сервера. Спочатку виконайте `/meta-management`.", + "no_permission": "❌ Для використання цієї команди потрібні права адміністратора.\nАдміністратори можуть увімкнути публічний доступ через `/meta-management`.", + "no_results": "❌ Жоден гравець у ростері вашої ескадрильї не має **{vehicle}**.", + "no_results_admin_hint": "\n*Очікуєте, що хтось має це? Натисніть кнопку оновлення учасників у `/meta-management` та перевірте ще раз.*", + "search_title": "🔍 Результати Пошуку - {vehicle}", + "matches_found": "**Знайдено Збігів:** {count} гравець(ів)", + "spawns_label": "Спауни", + "deaths_label": "Загибелі", + "gk_label": "GK", + "ak_label": "AK", + "points_label": "Очки", + "kdr_label": "K/D", + "games_label": "Бої", + "no_points": "—" + }, + "top": { + "title": "**Топ 20 Ескадрилей**", + "rating_label": "**Рейтинг:** {value}", + "air_kills_label": "**Знищення Повітряних:** {value}", + "ground_kills_label": "**Знищення Наземних:** {value}", + "deaths_label": "**Загибелі:** {value}", + "kd_label": "**K/D:** {value}", + "win_rate_label": "**Відсоток Перемог:** {value}", + "playtime_label": "**Час Гри:** {value}", + "fetch_failed": "Не вдалося отримати дані ескадрильї." + }, + "analytics": { + "no_data_title": "Немає даних", + "no_matches_desc": "Матчів не знайдено.", + "no_comp_desc": "Дані про склади не знайдено.", + "no_consistency_desc": "Недостатньо даних гравців (мінімум 50 матчів).", + "no_time_desc": "Дані про час не знайдено.", + "unknown_view": "Невідомий режим.", + "map_title": "Відсоток Перемог на Картах: {squadron}", + "comp_title": "Склади Команди: {squadron}", + "consistency_title": "Стабільність Гравців: {squadron}", + "consistency_desc": "Відсортовано за K/D", + "time_title": "Результативність за Часом Доби: {squadron}", + "eu_timeslot": "\n**Часовий Слот ЄС**", + "na_timeslot": "\n**Часовий Слот ПА**", + "off_peak": "\n**Поза Піком**", + "matchups_title": "📜 {squadron} — Історія Протистоянь", + "matchups_won_field": "🏆 Найбільше Перемог Проти", + "matchups_lost_field": "💀 Найбільше Поразок Від", + "no_matchups_desc": "Немає записаних матчів проти інших полків." + }, + "recent": { + "title": "Нещодавні Матчі: {squadron}", + "no_matches_desc": "Для цієї ескадрильї матчів не знайдено." + }, + "h2h": { + "two_required_title": "Потрібні Дві Ескадрильї", + "two_required_desc": "Вкажіть хоча б одну ескадрилью або використайте `/set-squadron` та вкажіть суперника.", + "provide_a_desc": "Вкажіть `squadron_a` або спочатку використайте `/set-squadron`.", + "provide_b_desc": "Вкажіть `squadron_b` або спочатку використайте `/set-squadron`.", + "squadron_not_found_title": "Ескадрилью Не Знайдено", + "same_squadron_title": "Однакові Ескадрильї", + "same_squadron_desc": "Не можна порівнювати ескадрилью з самою собою.", + "record_desc": "**Рахунок:** {a_wins}П - {b_wins}Пор ({total} ігор)", + "no_matches_desc": "Немає зафіксованих матчів між **{a}** та **{b}**." + }, + "autolog": { + "premium_active_line": "✅ **Premium:** Активно — автологування увімкнено для цього сервера.", + "premium_not_subscribed_line": "❌ **Premium:** Немає підписки — використайте `/unlock` для увімкнення автологування.", + "premium_free_line": "⚫ **Premium:** Немає підписки — використайте `/unlock` для підписки ($2.99/міс). *(Автологи зараз безкоштовні для всіх серверів.)*", + "what_to_do": "\n\nЩо ви хочете зробити?", + "select_notif_type": "Виберіть тип сповіщення для керування:", + "select_notif_placeholder": "Виберіть тип сповіщення", + "logs_option": "Логи", + "logs_option_desc": "Керувати сповіщеннями Логів", + "points_option": "Очки", + "points_option_desc": "Керувати сповіщеннями Очків", + "leaderboard_option": "Таблиця Лідерів", + "leaderboard_option_desc": "Керувати сповіщеннями Таблиці Лідерів", + "selected_type": "Вибрано **{type}**. Тепер виберіть ескадрилью для керування:", + "select_squadron_placeholder": "Виберіть ескадрилью", + "select_squadron_page_placeholder": "Виберіть ескадрилью (Сторінка {page})", + "no_squadrons_available": "Для цього типу сповіщення немає доступних ескадрилей.", + "managing_global": "Керування **{type}** (глобально) в каналі **{channel}**.", + "managing_squadron": "Керування **{type}** для ескадрильї **{squadron}** в каналі **{channel}**.", + "select_channel": "Виберіть новий канал:", + "select_channel_placeholder": "Виберіть канал", + "select_channel_page_placeholder": "Виберіть канал (Сторінка {page})", + "global_toggled": "{type} (глобально) тепер {state}.", + "squadron_toggled": "{type} для **{squadron}** тепер {state}.", + "channel_updated_global": "Оновлено {type} (глобально) на {channel}", + "channel_updated_squadron": "Оновлено {type} для **{squadron}** на {channel}", + "diagnose_channel_placeholder": "Виберіть канал для діагностики...", + "select_channel_diagnose": "Виберіть канал для діагностики:", + "game_not_logged_title": "Гру Не Зафіксовано", + "game_not_logged_desc": "Виконайте `/unlock`, щоб оформити підписку рівня **Standard** (або вище) та отримувати автоматичні таблиці результатів.", + "server_not_upgraded_title": "⚠️ Сервер Не Оновлено", + "server_not_upgraded_autolog_desc": "Цей сервер не має активної підписки Premium.\n\n**Автоматичні таблиці результатів перестануть надсилатись на не оновлені сервери після .**\n\nВиконайте `/unlock` для підписки та продовжуйте отримувати автоматичні ігрові логи.", + "replay_not_available": "Дані повтору ще недоступні — почекайте трохи і спробуйте знову!", + "too_many_videos": "Зараз рендериться забагато відео — будь ласка, спробуйте за мить.", + "video_gen_failed": "Помилка генерації відео: `{error}`", + "video_missing": "Не вдалося згенерувати відео повтору — вихідний файл відсутній або порожній.", + "video_too_large": "Відео повтору занадто велике для завантаження ({file_mb:.1f} МБ). Ліміт сервера — {limit_mb:.0f} МБ.", + "video_web_fallback": "Також можна переглянути цей матч за адресою {url}", + "video_upload_failed": "Відео занадто велике для завантаження — перегляньте на сайті:\n{url}", + "video_unexpected_error": "Непередбачена помилка при генерації відео повтору: `{error}`", + "replay_not_found": "Дані повтору для сесії `{session_id}` не знайдено на диску.", + "chat_log_title": "**Лог Чату для Гри [{session_id}]({url})**", + "chat_log_part_title": "**Лог Чату для Гри [{session_id}]({url}) (Частина {part}/{total})**", + "chat_log_part_only": "**Лог Чату (Частина {part}/{total})**", + "no_chat_log": "Лог чату для сесії `{session_id}` не знайдено.", + "chat_log_error": "Непередбачена помилка при завантаженні лога чату: `{error}`", + "battle_log_title": "**Бойовий Лог для Гри [{session_id}]({url})**", + "battle_log_part_title": "**Бойовий Лог для Гри [{session_id}]({url}) (Частина {part}/{total})**", + "battle_log_part_only": "**Бойовий Лог (Частина {part}/{total})**", + "no_battle_log": "Бойових подій для сесії `{session_id}` не знайдено.", + "battle_log_error": "Непередбачена помилка при завантаженні бойового лога: `{error}`", + "points_update_title": "**{squadron} {region} Оновлення Очків**", + "points_update_desc": "# {old_total} -> {new_total} {chart}{wl_line}{placement_line}\n\n**Зміни Гравців:**", + "points_table_header": "Ім'я Зміна Тепер\n", + "wl_line": "\n**{squadron}** зіграла **{wins}П-{losses}Пор** цієї сесії", + "placement_rose": "\n**{squadron}** піднявся на **{new_place}** з **{old_place}**", + "placement_fell": "\n**{squadron}** опустився на **{new_place}** з **{old_place}**", + "points_not_logged_title": "Очки Не Зафіксовано", + "points_not_logged_desc": "Виконайте `/unlock`, щоб оформити підписку рівня **Standard** (або вище) та отримувати автоматичні оновлення очків.", + "server_not_upgraded_points_desc": "Цей сервер не має активної підписки Premium.\n\n**Автоматичні оновлення перестануть надсилатись на не оновлені сервери після .**\n\nВиконайте `/unlock` для підписки та продовжуйте отримувати автоматичні оновлення.", + "leave_title": "⚠️ Гравець Покинув {squadron}", + "leave_desc": "**{nick}** ({uid}) покинув ескадрилью.\n\nОстанні зафіксовані очки: **{points}**", + "no_squadrons_desc": "No squadrons configured", + "no_channels_desc": "No channels available", + "over_cap_title": "Полк перевищує ліміт вашого тарифу", + "over_cap_desc": "Ваш сервер на тарифі **{tier}**, який дозволяє **{cap} {notif}** полків. Полк **{squadron}** зараз перевищує ліміт і не логується. Перейдіть на вищий тариф для відновлення.", + "over_cap_footer": "Оновити на srebot-meow.ing/premium або через /unlock", + "wildcard_blocked_title": "Для wildcard потрібен вищий тариф", + "wildcard_blocked_desc": "Wildcard-записи (*, all, everything) доступні лише на тарифах Pro або Max. Ваш сервер на **{tier}** для {notif}. Оновіть, щоб увімкнути.", + "cap_header": "{used}/{cap} {notif} активних — тариф {tier}" + }, + "track": { + "squadron_not_found": "Ескадрилью не знайдено.", + "fetch_failed": "Не вдалося отримати інформацію про ескадрилью." + }, + "unlock": { + "title": "SRE Bot Premium", + "desc": "**Розблокуйте преміум-функції для цього сервера.**\n\nPremium включає:\n> • Автоматичні публікації таблиць результатів\n> • Логи чату та бою\n> • Перегляд повторів\n> • Безлімітні запити /comp\n> • Пріоритетна підтримка\n\n**$2.99 / місяць · за сервер · скасування у будь-який час**\n\n⚠️ Білінг Discord доступний лише в окремих країнах. Якщо кнопка нижче показує **«Продукт Недоступний»**, це може бути через непідтримувану країну або використання **мобільного пристрою**. Натомість скористайтесь кнопкою **Підписатись через Сайт**.", + "already_subscribed_title": "SRE Bot Premium", + "already_subscribed_desc": "✅ **Цей сервер вже підписаний!**", + "manage_discord_field": "Керувати Підпискою", + "manage_discord_value": "Ваша підписка оформлена через **Discord**.\nДля скасування перейдіть до **Налаштувань Користувача → Підписки** в Discord.", + "manage_website_field": "Керувати Підпискою", + "manage_website_value": "Ваша підписка оформлена через **сайт**.\nКеруйте нею на [whop.com/billing](https://whop.com/billing).", + "coming_soon_field": "Незабаром", + "coming_soon_value": "Підписки Premium ще недоступні. Перевірте пізніше!", + "current_tier": "Ваш поточний тариф: **{tier}**.", + "upgrade_to": "Перейти на {tier}", + "upgrade_to_value": "Більше полків і функцій із тарифом **{tier}**." + }, + "language": { + "prompt": "Будь ласка, виберіть мову сервера:", + "select_placeholder": "Оберіть мову сервера", + "language_set": "Мову встановлено на {language}.", + "translate_prompt": "Виберіть цільову мову нижче 👇", + "translate_placeholder": "Оберіть цільову мову…", + "translate_result": "**{author} → {language}:**\n{text}", + "translation_unavailable": "Переклад недоступний (DeepL не налаштовано)", + "translation_failed": "Переклад не вдався" + }, + "misc": { + "credits_title": "Подяки", + "credits_desc": "**Meowww**\n\n> **NotSoToothless** - Провідний Розробник, Менеджер Бота, Менеджер Спільноти\n> **Z3R0** - Розробник, Розробник Оптимізації, Інженер Баз Даних\n> **Clippii (Heidi)** - Розробник, Розробник Сайту, Менеджер Спільноти\n> **LivingTheDagor** - Розробник, Розробник Парсера, Консультант\n> **Lux_** - Інженер API, Розробник Spectra\n> **Konigallerwaffen** - Консультант з відгуків та функцій\n> **Žralok Tonda** - Чеський перекладач\n> **Styevy**, **Lopais** - Німецькі перекладачі\n> **Susogus**, **playforfun698** - Польські перекладачі\n> **Bobr** - Російський перекладач\n\n\n[Хочете приєднатися до нас?](https://discord.gg/BCvkK8JhPe)", + "schedule_title": "РОЗКЛАД СЕЗОНУ", + "schedule_not_found_title": "Розклад Не Знайдено", + "schedule_not_found_desc": "Дані розкладу ще недоступні.", + "news_no_news_title": "Немає Новин", + "news_no_news_desc": "Зараз немає оголошень. Перевірте пізніше!", + "news_footer": "Дякуємо за вашу підтримку! ᖙᘚᗢ", + "help_title": "Довідник Бота", + "donate_title": "Підтримати SRE Bot", + "donate_desc": "Якщо вам подобається SRE Bot і ви хочете підтримати його розробку, розгляньте можливість купити мені каву!\n\n**[Донат на Ko-fi](https://ko-fi.com/notsotoothless)**\n\nКожен внесок допомагає підтримувати роботу бота та розробку нових функцій. Дякуємо!", + "status_title": "Статус бота", + "status_last_received": "Остання отримана гра", + "status_avg_ttl": "Середній TTL (останні 30)", + "status_no_data": "Поки немає даних", + "status_gaijin_slow": "⚠️ Сервери Gaijin повільні", + "help_commands_header": "**Огляд команд**", + "help_links": "Деталі в документації [тут]({docs}) або підтримка [тут]({support}).", + "help_terms": "[Умови використання]({terms}) • [Політика конфіденційності]({terms})" + }, + "dev": { + "restricted_dev_team": "This command is restricted to the dev team.", + "restricted_bot_owner": "❌ This command is restricted to the bot owner.", + "invalid_server_id": "❌ Invalid server ID. Must be a 17-19 digit Discord server ID.", + "expiry_too_soon": "❌ Expiry timestamp must be at least 1 month from now.\n> Now: \n> Minimum: \n> You provided: ", + "entitlement_write_failed": "❌ Failed to write entitlement: {error}", + "entitlement_created_title": "✅ Manual Entitlement Created", + "entitlement_created_desc": "**Server:** {guild_name} (`{server_id}`)\n**Expires:** ()\n**Created:** ", + "query_failed": "Query failed: {error}", + "health_title": "Bot Health Dashboard", + "health_uptime": "Uptime", + "health_guilds": "Guilds", + "health_games_processed": "Games Processed", + "health_tasks": "Tasks", + "health_websocket": "WebSocket", + "health_never": "never", + "health_errors": "({count} errors)", + "health_last_msg": "last msg {ago} ({count} total)", + "health_avg_ttl": "Avg TTL (Last 30)", + "entitlements_title": "Active Entitlements ({count} total)", + "entitlements_no_entries": "No entitlements.", + "entitlements_empty_title": "Active Entitlements", + "entitlements_empty_desc": "No active entitlements found.", + "entitlements_tag_discord": "Discord", + "entitlements_tag_whop": "Whop", + "entitlements_tag_manual": "Manual", + "query_prefix": "Query: {name}" + }, + "leaderboard_alarm": { + "title": "🏆 Таблиця Лідерів Ескадрилей", + "top15_desc": "Топ 15 ескадрилей зі статистикою, надсилається через 35 хвилин після закриття часового слоту.\nЦе надіслано .", + "top30_desc": "Ескадрильї 16-30 зі статистикою.", + "not_logged_title": "Таблицю Лідерів Не Зафіксовано", + "not_logged_desc": "Виконайте `/unlock`, щоб оформити підписку рівня **Standard** (або вище) та отримувати автоматичні оновлення таблиці лідерів.", + "server_not_upgraded_title": "⚠️ Сервер Не Оновлено", + "server_not_upgraded_desc": "Цей сервер не має активної підписки Premium.\n\n**Автоматичні оновлення перестануть надсилатись на не оновлені сервери після .**\n\nВиконайте `/unlock` для підписки та продовжуйте отримувати автоматичні оновлення." + }, + "stacks": { + "stack_title": "Стак {leader}", + "stack_named_title": "{name}", + "no_members": "Ще немає учасників.", + "members_field": "Учасники ({count}/{max})", + "queue_field": "Черга ({count}/{max})", + "manage_title": "Керування стаком", + "no_pending_requests": "Немає очікуваних запитів.", + "disbanded_title": "Стак [Розпущений]", + "disbanded_desc": "Цей стак був розпущений лідером.", + "expired_title": "Стак [Закінчився]", + "expired_desc": "Цей стак закінчився.", + "join_modal_title": "Запит на вступ до стаку", + "join_vehicle_label": "На чому будете грати?", + "join_vehicle_placeholder": "напр. F-16C, WZ305...", + "ping_modal_title": "Повідомлення пінгу", + "ping_message_label": "Власне повідомлення (необов'язково)", + "ping_message_placeholder": "напр. Заходьте! Стак починається!", + "rename_modal_title": "Перейменувати стак", + "rename_label": "Назва стаку", + "rename_placeholder": "напр. Нічні сови, Альфа загін...", + "select_new_leader": "Оберіть нового лідера…", + "select_applicants": "Оберіть кандидатів…", + "no_pending_applications": "Немає очікуваних заявок.", + "select_to_remove": "Оберіть людей для видалення…", + "no_members_or_applicants": "Немає учасників або кандидатів.", + "select_to_ping": "Оберіть людей для індивідуального пінгу…", + "stack_not_found": "❌ Стак не знайдено.", + "no_longer_exists": "❌ Цей стак більше не існує.", + "member_not_exists": "❌ Цей учасник більше не існує.", + "already_has_stack": "❌ Цей гравець вже має активний стак.", + "already_member": "❌ Ви вже є учасником цього стаку.", + "already_applied": "❌ У вас вже є очікувана заявка до цього стаку.", + "queue_full": "❌ Черга заповнена ({max}/{max}). Спробуйте пізніше.", + "application_sent": "✅ Заявку надіслано! Лідер стаку її розгляне.", + "stack_disbanded": "✅ Стак розпущено.", + "cancelled": "Скасовано.", + "select_member_transfer": "❌ Оберіть учасника для передачі керівництва.", + "ownership_transferred": "✅ Керівництво передано {nick}. Ви покинули стак.", + "select_applicant_first": "❌ Спочатку оберіть хоча б одного кандидата.", + "stack_full": "❌ Стак вже заповнений ({max}/{max} учасників).", + "select_person_first": "❌ Спочатку оберіть хоча б одну людину.", + "no_one_to_ping": "❌ Нікого пінгувати.", + "ping_footer": "Пінг від {leader} для {stack}.", + "pinged": "✅ Пінг надіслано!", + "select_from_dropdown": "❌ Спочатку оберіть хоча б одну людину з випадаючого меню.", + "stack_renamed": "✅ Стак перейменовано на **{name}**.", + "only_member_use_disband": "❌ Ви єдиний учасник. Використайте **Розпустити стак** для завершення.", + "select_transfer_prompt": "Оберіть учасника для передачі керівництва перед виходом:", + "left_stack": "✅ Ви покинули стак.", + "application_withdrawn": "✅ Вашу заявку відкликано.", + "not_member_or_applicant": "❌ Ви не є учасником чи кандидатом цього стаку.", + "leader_only_manage": "❌ Тільки лідер стаку може ним керувати.", + "leader_only_disband": "❌ Тільки лідер стаку може його розпустити.", + "confirm_disband": "Ви впевнені, що хочете розпустити цей стак? Цю дію неможливо скасувати.", + "already_active_stack": "⚠️ У вас вже є активний стак. Якщо оригінальне повідомлення зникло (напр. після перезапуску бота), ви можете примусово розпустити його і почати заново.", + "force_created": "✅ Попередній стак розпущено. Новий стак створено.", + "no_active_stack": "❌ У вас немає активного стаку. Використайте `/stack-create` для створення.", + "could_not_parse_channel": "⚠️ Не вдалося обробити збережений ID каналу." + }, + "commands": { + "common": { + "season": "Сезон для створення картки", + "theme": "Колірна тема картки", + "squadron_short": "Коротка назва ескадрильї", + "player_username": "Ім'я гравця", + "choice_dark": "Темна", + "choice_light": "Світла" + }, + "comp": { + "description": "Знайти останні відомі склади команди", + "squadron_short": "Коротка назва команди суперника" + }, + "quick_log": { + "description": "Налаштувати сповіщення для цієї ескадрильї в цьому каналі", + "squadron_name": "КОРОТКА назва ескадрильї для відстеження", + "type": "Оберіть Логи, Очки, Таблиця лідерів, Тижневий BR або Усі", + "choice_logs": "Logs", + "choice_points": "Очки", + "choice_leaderboard": "Рейтинг", + "choice_both": "Обидва (Logs + Очки)", + "choice_weekly_br": "Тижневий BR" + }, + "sq_info": { + "description": "Отримати інформацію про ескадрилью" + }, + "sq_info_graph": { + "description": "Показати графік складу ескадрильї за активністю та відсотком перемог (поточний сезон)" + }, + "sq_card": { + "description": "Створити сезонну картку ескадрильї", + "squadron": "Коротка назва ескадрильї" + }, + "sq_stats": { + "description": "Показати очки ескадрильї з часом" + }, + "loss_calculator": { + "description": "Розрахувати втрату очок, якщо гравці підуть з ескадрильї", + "player1": "Гравець, що йде", + "player_optional": "Гравець, що йде (необов'язково)" + }, + "website": { + "description": "Отримати посилання на сайт SRE Bot" + }, + "card": { + "description": "Створити сезонну картку гравця" + }, + "player_stats": { + "description": "Показати детальну статистику техніки гравця", + "username": "WT-ім'я для запиту статистики", + "uid": "WT UID для запиту статистики" + }, + "view_player_games": { + "description": "Показати останні 20 ігор гравця" + }, + "view_match": { + "description": "Показати таблицю матчу за ID або гравцем", + "match_id": "Hex ID сесії матчу", + "player_name": "Гравець для перегляду недавніх матчів" + }, + "compare": { + "description": "Порівняти сумарну SQB-статистику гравців", + "player1": "Перший гравець", + "player2": "Другий гравець", + "player_optional": "Додатковий гравець (необов'язково)" + }, + "leaderboard": { + "description": "Відкрити глобальний рейтинг SRE Bot" + }, + "set_squadron": { + "description": "Задати тег ескадрильї для цього сервера", + "abbreviated_name": "Коротка назва ескадрильї" + }, + "setup": { + "description": "Налаштувати бота для цього сервера" + }, + "meta_management": { + "description": "Керувати доступом до мета-даних цього сервера" + }, + "meta": { + "description": "Шукати мета-ростер за назвою техніки", + "vehicle": "Назва техніки для пошуку" + }, + "top": { + "description": "Показати топ-20 ескадрилій з детальною статистикою" + }, + "language": { + "description": "Змінити мову бота." + }, + "translate_message": { + "name": "Перекласти повідомлення" + }, + "sq_track": { + "description": "Відстежувати ескадрилью і порівняти з минулою перевіркою", + "squadron_short_name": "Коротка назва ескадрильї" + }, + "analytics": { + "description": "Показати розширену SQB-аналітику ескадрильї", + "view": "Який вид аналітики показати", + "choice_maps": "Вінрейт за мапами", + "choice_comps": "Склади команд", + "choice_consistency": "Стабільність гравців", + "choice_time": "Час доби", + "choice_matchups": "Історія зустрічей" + }, + "recent": { + "description": "Показати недавні бої ескадрильї", + "length": "Кількість матчів" + }, + "vs": { + "description": "Особиста статистика двох ескадрилій", + "squadron_a": "Перша ескадрилья", + "squadron_b": "Друга ескадрилья" + }, + "autolog_management": { + "description": "Керувати autolog-сповіщеннями і перевіряти права" + }, + "diagnose_perms": { + "description": "Перевірити права autolog у цьому каналі" + }, + "unlock": { + "description": "Відкрити Premium-функції для цього сервера" + }, + "credits": { + "description": "Показати команду, що створила цей проєкт" + }, + "schedule": { + "description": "Показати поточний сезонний BR-графік" + }, + "news": { + "description": "Показати останні новини й оголошення SRE Bot" + }, + "help": { + "description": "Показати гайд, ToS і посилання підтримки" + }, + "donate": { + "description": "Підтримати розробку SRE Bot" + }, + "stack_create": { + "description": "Створити стак гравців", + "vehicle": "На якій техніці почнеш?" + }, + "stack_manage": { + "description": "Повторно надіслати активний стак у цей канал" + }, + "bot_status": { + "description": "Статус бота: остання отримана гра та середній TTL" + } + }, + "permission": { + "blacklisted_title": "❌ У чорному списку", + "blacklisted_desc": "Ви не можете використовувати цю команду.", + "reason_line": "**Причина:** {reason}", + "access_denied_title": "⛔ Доступ заборонено", + "no_permission_desc": "У вас немає прав для використання цієї команди.", + "unexpected_error_title": "❗ Помилка, повідомте про неї...." + }, + "weekly_br": { + "title_wildcard": "Тижневий звіт BR — {br} BR", + "title_squadron": "Тижневий звіт BR — [{tag}] {long} • {br} BR", + "window_label": "Період: {start} → {end}", + "wildcard_desc_first": "Топ-{count} полків за ELO • Місця {low}–{high}", + "wildcard_desc_second": "Топ-{count} полків за ELO • Місця {low}–{high}", + "squadron_stats_line": "- {games} боїв • K/D {kdr} • Перемог {wr}%", + "top_players_inline_header": "🥇 Найкращі гравці:", + "player_line_short": " {rank}. {nick} ⭐ {score} ({games}б)", + "top_players_header": "**Топ-{count} гравців за ELO:**", + "player_line_full": "{rank}. **{nick}** ⭐ {score} • {games} боїв • K/D {kdr}", + "squadron_header_line": "ELO полку: {score} • {games} боїв • Перемог {wr}% • K/D {kdr}", + "squadron_header_no_aggregate": "ELO полку: недостатньо активності команди цього тижня.", + "no_data": "Немає матчів для [{tag}] у цій ротації BR." + } +} diff --git a/BOT/locales/zh-CN.json b/BOT/locales/zh-CN.json new file mode 100644 index 0000000..d325549 --- /dev/null +++ b/BOT/locales/zh-CN.json @@ -0,0 +1,858 @@ +{ + "common": { + "error_title": "错误", + "no_data_title": "没有数据", + "access_denied_title": "访问被拒绝", + "access_denied_desc": "此服务器已被加入黑名单。", + "no_players_selected": "未选择玩家。请至少选择一名玩家。", + "must_use_in_server": "此命令必须在服务器中使用。", + "could_not_resolve_channel": "无法识别所选频道。", + "failed_update_setting": "❌ 设置更新失败。", + "configuration_not_found": "未找到配置。", + "no_channel_selected": "未选择频道。", + "no_selection_received": "未收到选择。", + "database_error": "❌ 数据库错误:{error}", + "enabled": "已启用", + "disabled": "已禁用", + "not_configured": "未配置", + "unknown": "未知", + "rating_field": "评分", + "battles_field": "战斗", + "wins_field": "胜场", + "losses_field": "负场", + "win_rate_field": "胜率", + "kills_field": "击杀", + "deaths_field": "死亡", + "kd_field": "K/D", + "members_field": "成员", + "placement_field": "排名", + "points_field": "点数", + "ground_kills_field": "地面击杀", + "air_kills_field": "空中击杀", + "total_kills_field": "总击杀", + "assists_field": "助攻", + "captures_field": "占点", + "none_option": "无" + }, + "buttons": { + "skip": "跳过", + "previous": "上一页", + "next": "下一页", + "prev": "上一页", + "prev_arrow": "◀ 上一页", + "next_arrow": "下一页 ▶", + "prev_arrow_only": "◀", + "next_arrow_only": "▶", + "generate_chart": "📊 生成图表", + "show_graph": "显示图表", + "view_player_stats": "📊 查看玩家统计", + "compare_nearby": "📈 对比附近中队", + "confirm_swap": "是,替换", + "cancel_swap": "否,保留原设置", + "set_squadron": "设置中队", + "same_as_logs": "与日志相同", + "require_password": "🔒 需要密码", + "password_required": "🔒 需要密码", + "lock_data": "🔐 绑定中队数据", + "data_locked": "🔐 数据已绑定到服务器", + "allow_public": "👥 允许公开 Meta", + "public_enabled": "👥 公开 Meta 已启用", + "update_accounts": "📋 更新 Meta 账号", + "change_password": "🔑 修改密码", + "help": "❓ 帮助", + "add_player": "➕ 添加玩家", + "update_all": "🔄 更新所有成员", + "back_to_settings": "⬅ 返回设置", + "manage_notifications": "管理通知", + "diagnose_permissions": "诊断权限", + "enable": "启用", + "disable": "禁用", + "change_channel": "更改频道", + "view_replay": "查看回放", + "view_website": "在网站查看", + "view_video": "查看视频", + "view_log": "查看战斗日志", + "view_chat": "查看聊天", + "subscribe_website": "通过网站订阅", + "cancel": "取消", + "back": "返回", + "yes_disband": "是,解散", + "transfer_leave": "转让并离开", + "accept_selected": "接受所选", + "accept_all": "全部接受", + "decline_selected": "拒绝所选", + "remove_all": "全部移除", + "remove_active": "移除正式成员", + "remove_queued": "移除排队成员", + "remove_selected": "移除所选", + "ping_all": "提醒全部", + "ping_active": "提醒正式成员", + "ping_queued": "提醒排队成员", + "ping_selected": "提醒所选", + "accept_members": "接受成员", + "remove_members": "移除成员", + "ping_members": "提醒成员", + "rename_stack": "重命名车队", + "request_to_join": "申请加入", + "leave_withdraw": "离开 / 撤回申请", + "manage_stack": "管理车队 ⚙️", + "disband_stack": "解散车队", + "force_disband_create": "强制解散并新建" + }, + "events": { + "guild_join_title": "感谢添加我!", + "guild_join_desc": "该项目的详细说明。" + }, + "comp": { + "not_found_title": "未找到阵容", + "not_found_desc": "没有 **{squadron}** 的数据,请稍后再试。", + "error_loading_title": "加载阵容时出错", + "error_loading_desc": "加载阵容数据失败:{error}", + "title": "{squadron} 的阵容", + "desc": "最近 {minutes} 分钟内出现过的阵容", + "no_recent_title": "没有近期阵容", + "no_recent_desc": "最近 {minutes} 分钟内没有阵容记录。", + "comp_title": "阵容 {index}", + "last_seen_label": "**最后出现**:{timestamp}{warning}", + "comp_label": "**阵容**:{notation}", + "no_players_recorded": "没有记录到玩家。", + "limit_reached_title": "阵容查询次数已用完", + "limit_reached_desc": "此服务器已用完本时段的 {limit} 次阵容查询。订阅(使用 /unlock)可获得无限访问,或等待下一个时段。", + "remaining_footer": "本时段剩余 {remaining}/{limit} 次免费阵容查询" + }, + "quick_log": { + "invalid_type": "类型只能设置为 Logs、Points、排行榜、周BR 或 全部。", + "squadron_required": "此通知类型需要中队名称。", + "wildcard_logs_only": "通配符只适用于日志通知。", + "squadron_not_resolved": "无法识别中队 `{squadron}`。", + "save_failed": "保存通知设置失败。", + "premium_warning": "\n\n注意:高级功能可能需要有效订阅。", + "leaderboard_set": "排行榜通知已设置。", + "both_set": "已为 **{squadron}** 设置日志和点数通知。{premium_note}", + "alarm_set": "已为 **{squadron}** 设置 {alarm_type} 通知。{premium_note}", + "weekly_br_wildcard_set": "周BR报告(前20中队)已设置到该频道。在每次BR轮换结束时发送。", + "weekly_br_squadron_set": "{squadron} 的周BR报告(前15玩家)已设置到该频道。在每次BR轮换结束时发送。" + }, + "autolog": { + "game_not_logged_title": "比赛未记录", + "game_not_logged_desc": "此频道未配置自动日志,或该比赛不符合当前服务器的记录规则。", + "wildcard_blocked_title": "通配符日志不可用", + "wildcard_blocked_desc": "通配符中队条目(*, all, everything)仅 Pro 或 Max 档位可用。你的服务器在 {notif} 上当前为 **{tier}** 档位。升级后可重新启用通配符日志。", + "over_cap_title": "已达到中队数量上限", + "over_cap_desc": "你的服务器当前为 **{tier}** 档位,可启用 **{cap} 个 {notif}** 中队。中队 **{squadron}** 当前超出上限,因此不会被记录。升级到更高档位可恢复记录。", + "over_cap_footer": "升级套餐可提高或取消此限制。", + "server_not_upgraded_title": "此服务器未升级", + "server_not_upgraded_autolog_desc": "此服务器没有有效的高级订阅。\n\n**未升级服务器将在 后停止接收自动比赛计分板。**\n\n使用 `/unlock` 订阅以继续接收自动比赛日志。", + "server_not_upgraded_points_desc": "此服务器没有有效的高级订阅。\n\n**未升级服务器将在 后停止接收自动更新。**\n\n使用 `/unlock` 订阅以继续接收自动更新。", + "points_not_logged_title": "点数未记录", + "points_not_logged_desc": "没有为此服务器配置点数通知。", + "wl_line": "战绩:**{wins}胜 / {losses}负**,中队:**{squadron}**", + "placement_rose": "\n**{squadron}** 从 **{old_place}** 上升到 **{new_place}**", + "placement_fell": "\n**{squadron}** 从 **{old_place}** 下降到 **{new_place}**", + "points_update_title": "**{squadron} {region} 点数更新**", + "points_update_desc": "# {old_total} -> {new_total} {chart}{wl_line}{placement_line}\n\n**玩家变化:**", + "leave_title": "⚠️ 玩家离开 {squadron}", + "leave_desc": "**{nick}**({uid})已离开中队。\n\n最后记录点数:**{points}**", + "replay_not_available": "此比赛的回放暂不可用。", + "too_many_videos": "正在生成的视频过多,请稍后再试。", + "video_gen_failed": "视频生成失败:{error}", + "video_missing": "视频文件不存在。", + "video_too_large": "回放视频过大,无法上传({file_mb:.1f} MB)。服务器限制为 {limit_mb:.0f} MB。", + "video_web_fallback": "视频太大,无法直接上传。可在这里查看:{url}", + "video_upload_failed": "视频上传失败。可在网站查看:{url}", + "video_unexpected_error": "处理视频时出现意外错误:{error}", + "replay_not_found": "未找到回放:{session_id}", + "no_chat_log": "此回放没有可用聊天记录:{session_id}", + "chat_log_title": "**比赛 [{session_id}]({url}) 的聊天记录**", + "chat_log_part_title": "**比赛 [{session_id}]({url}) 的聊天记录(第 {part}/{total} 段)**", + "chat_log_part_only": "**聊天记录(第 {part}/{total} 段)**", + "chat_log_error": "读取聊天记录失败:{error}", + "no_battle_log": "此回放没有可用战斗日志:{session_id}", + "battle_log_title": "**比赛 [{session_id}]({url}) 的战斗日志**", + "battle_log_part_title": "**比赛 [{session_id}]({url}) 的战斗日志(第 {part}/{total} 段)**", + "battle_log_part_only": "**战斗日志(第 {part}/{total} 段)**", + "battle_log_error": "读取战斗日志失败:{error}", + "premium_active_line": "✅ **高级版:** 已启用 — autologging is 已启用 for this 服务器.", + "premium_not_subscribed_line": "❌ **高级版:** 未订阅 — 使用 `/unlock` to enable autologging.", + "premium_free_line": "⚪ **高级版:** 未订阅 — 使用 `/unlock` to 订阅 ($2.99/mo). *(Autologs are free for all 服务器s right now.)*", + "what_to_do": "\n\n你想做什么?", + "select_notif_type": "选择the notification type to manage:", + "select_notif_placeholder": "选择通知类型", + "logs_option": "日志", + "logs_option_desc": "该项目的详细说明。", + "points_option": "点数", + "points_option_desc": "该项目的详细说明。", + "leaderboard_option": "排行榜", + "leaderboard_option_desc": "该项目的详细说明。", + "selected_type": "已选择 **{type}**。现在请选择要管理的中队:", + "select_squadron_placeholder": "选择中队", + "select_squadron_page_placeholder": "选择中队(第 {page} 页)", + "no_squadrons_available": "没有squadron available for this notification type.", + "managing_global": "正在管理频道 **{channel}** 中的 **{type}**(全局)。", + "managing_squadron": "正在管理频道 **{channel}** 中 **{squadron}** 的 **{type}**。", + "select_channel": "选择a new channel:", + "select_channel_placeholder": "选择频道", + "select_channel_page_placeholder": "选择频道(第 {page} 页)", + "global_toggled": "{type}(全局)现在为 {state}。", + "squadron_toggled": "**{squadron}** 的 {type} 现在为 {state}。", + "channel_updated_global": "已将 {type}(全局)更新到 {channel}", + "channel_updated_squadron": "已将 **{squadron}** 的 {type} 更新到 {channel}", + "diagnose_channel_placeholder": "请输入或选择…", + "select_channel_diagnose": "选择the channel to diagnose:", + "points_table_header": "名称 变化 当前\n", + "no_squadrons_desc": "未配置中队", + "no_channels_desc": "没有可用频道", + "cap_header": "已启用 {used}/{cap} 个 {notif} — {tier} 档位" + }, + "language": { + "prompt": "选择此服务器的机器人显示语言。", + "select_placeholder": "选择语言", + "language_set": "语言已设置为 {language}。", + "translate_prompt": "在下方选择目标语言 👇", + "translate_result": "**{author} → {language}:**\n{text}", + "translate_failed": "翻译失败:{error}", + "translate_placeholder": "选择目标语言…", + "translation_unavailable": "翻译不可用(未配置 DeepL)", + "translation_failed": "翻译失败" + }, + "diagnostics": { + "title": "诊断", + "channel_permissions_header": "频道权限:{channel_id}", + "perms_needed": "需要权限:发送消息、嵌入链接、附加文件。", + "server_squadron_header": "服务器中队", + "server_squadron_short": "简称:{short}", + "server_squadron_long": "全称:{long}", + "server_squadron_not_set": "未设置服务器中队。", + "autolog_prefs_header": "自动日志设置", + "autolog_none_configured": "尚未配置自动日志。", + "autolog_setup_hint": "使用 `/quick-log` 或 `/setup` 开始配置。", + "missing_send_attach": "缺少发送消息或附加文件权限", + "channel_not_found": "找不到频道", + "invalid_channel_id": "无效频道 ID", + "selected_channel_tag": "(当前选择)", + "autolog_no_logs_channels": "未配置日志频道。", + "autolog_enable_hint": "启用日志通知后,这里会显示频道。", + "premium_status_header": "高级状态", + "premium_active": "高级功能:已激活", + "premium_not_subscribed": "高级功能:未订阅", + "premium_autolog_required": "自动日志需要订阅。", + "premium_not_subscribed_free": " ⚪ 未订阅 — 使用 `/unlock` 订阅($2.99/月)。", + "premium_free_note": " *(Autologs are free for all 服务器s right now.)*" + }, + "sq_info": { + "title": "中队信息:{squadron}", + "placement_field": "排名", + "total_points_field": "总点数", + "total_members_field": "成员数", + "members_field": "成员", + "fetch_failed": "无法获取中队信息。" + }, + "sq_info_graph": { + "title": "{squadron} — SQ-INFO(赛季 {season})", + "embed_title": "{squadron} — 阵容构成", + "embed_desc": "赛季 **{season}** · 中位场次:**{median}** · 核心:**{core}** · 活跃:**{active}** · 边缘:**{weak}**\n按场次降序排列;高度 = 胜率。核心 = 场次 ≥ 中位 且 胜率 ≥ 中队胜率。活跃 = 场次 ≥ 中位 且 胜率 < 中队胜率。边缘 = 场次低于中位。", + "squadron_wr_line": "中队胜率 {wr}%", + "y_label": "胜率", + "core_header": "核心 — {count} · 胜率 {avg}%", + "active_header": "活跃 — {count} · 胜率 {avg}%", + "weak_header": "边缘 — {count} · 胜率 {avg}%", + "no_active_season": "未找到进行中的赛季,请在下一个赛季开始后再试。", + "no_members": "未找到 {squadron} 的当前成员。" + }, + "recap_card": { + "unknown_season": "未知赛季:{season}", + "no_clan_id": "找不到 **{squadron}** 的中队 ID。", + "render_failed": "回顾卡渲染失败。" + }, + "leaderboard_alarm": { + "not_logged_title": "排行榜未记录", + "not_logged_desc": "此服务器未配置排行榜通知。", + "title": "中队排行榜更新", + "top15_desc": "含统计数据的前 15 名中队,将在时段结束 35 分钟后发送。\n本条发送于 。", + "top30_desc": "前 30 名中队", + "server_not_upgraded_title": "此服务器未升级", + "server_not_upgraded_desc": "此服务器没有有效的高级订阅。\n\n**未升级服务器将在 后停止接收自动更新。**\n\n使用 `/unlock` 订阅以继续接收自动更新。" + }, + "misc": { + "schedule_title": "赛季日程", + "schedule_timeslot_label": "{region} 时段", + "schedule_not_found_title": "未找到日程", + "schedule_not_found_desc": "目前还没有可用的日程数据。", + "credits_title": "鸣谢", + "credits_desc": "该项目的详细说明。", + "news_no_news_title": "暂无公告", + "news_no_news_desc": "目前没有公告,请稍后再查看!", + "news_footer": "感谢你的支持!ᕙᘘᗢ", + "help_title": "机器人指南", + "donate_title": "支持 SRE Bot", + "donate_desc": "该项目的详细说明。", + "status_title": "机器人状态", + "status_last_received": "最近接收的对局", + "status_avg_ttl": "平均 TTL (最近 30 局)", + "status_no_data": "暂无数据", + "status_gaijin_slow": "⚠️ Gaijin 服务器较慢", + "help_commands_header": "**命令概览**", + "help_links": "详细信息请阅读[文档]({docs}),或在[支持服务器]({support})获取帮助。", + "help_terms": "[服务条款]({terms}) • [隐私政策]({terms})" + }, + "sq_stats": { + "no_data_title": "没有数据", + "no_data_desc": "未找到中队 {squadron} 的历史数据。", + "title": "{squadron} // 中队", + "desc": "总分趋势(最近 {count} 个数据点)", + "previous_score_field": "上次分数", + "current_score_field": "当前分数", + "change_field": "变化", + "player_title": "{squadron} // 玩家", + "player_desc": "单个玩家点数趋势", + "comparison_title": "{squadron} // 排行榜对比", + "comparison_desc": "正在与排名 {range} 的中队对比", + "current_position_field": "当前排名", + "squadrons_shown_field": "显示的中队", + "squadron_not_found_error": "排行榜中找不到该中队", + "no_nearby_error": "未找到附近排名的中队", + "no_historical_error": "未找到附近中队的历史数据", + "comparison_chart_failed": "生成对比图表失败", + "select_players_placeholder": "选择玩家(第 {page} 页)" + }, + "loss_calc": { + "title": "点数损失 — {squadron}", + "players_leaving_field": "离队玩家", + "share_of_total_field": "占总数比例", + "points_lost_real_field": "损失点数(实际)", + "points_lost_raw_field": "损失点数(原始)", + "squadron_rating_field": "中队评分", + "squadron_position_field": "中队排名", + "positions_lost_field": "下降名次", + "not_found_footer": "未在中队中找到:{players}", + "fetch_failed": "获取中队数据失败:{error}", + "no_point_data": "此中队没有可用点数数据。", + "no_matching_players": "在 **{squadron}** 中未找到匹配玩家。" + }, + "player": { + "select_player_placeholder": "选择玩家", + "no_stats_found": "❌ 未找到 UID {uid} 的统计数据。", + "no_vehicle_stats": "❌ 未找到此玩家的载具统计。", + "vehicles_found": "找到 **{nick}** 的 **{count}** 个载具\n选择一个载具查看详细统计:", + "vehicle_select_placeholder": "选择载具(第 {page}/{total} 页)", + "combat_stats_header": "**__战斗统计__**", + "ground_kills_label": "**地面击杀:** {value}", + "air_kills_label": "**空中击杀:** {value}", + "total_kills_label": "**总击杀:** {value}", + "assists_label": "**助攻:** {value}", + "deaths_label": "**死亡:** {value}", + "kd_label": "**K/D:** {value}", + "captures_label": "**占点:** {value}", + "battle_record_header": "**__战绩记录__**", + "total_battles_label": "**总战斗:** {value}", + "wins_label": "**胜场:** {value}", + "losses_label": "**负场:** {value}", + "win_rate_label": "**胜率:** {value}%", + "stats_desc": "**{nick}**(**{squadron}**)的统计\nUID:`{uid}`", + "not_found_title": "未找到玩家", + "not_found_desc": "未找到 `{player}` 的比赛历史。", + "no_players_found": "未找到匹配 **{username}** 的玩家\n可尝试使用 `/website` 在网站搜索。", + "multiple_matches": "找到多个匹配项,请在下方选择正确的玩家:", + "must_provide_input": "你必须至少提供 UID 或用户名。" + }, + "player_games": { + "no_recent_title": "没有近期比赛", + "no_recent_desc": "最近 8 小时内未找到 **{player}** 的比赛。", + "squadron_label": "**中队:** {squadron}", + "record_label": "**胜:** {wins} **负:** {losses} **胜率:** {wr}%", + "comps_played_header": "\n\n**使用过的阵容**" + }, + "match": { + "missing_input_title": "缺少输入", + "missing_input_desc": "该项目的详细说明。", + "not_found_title": "未找到比赛", + "not_found_desc": "找不到 ID 为 `{match_id}` 的比赛。", + "invalid_data_title": "比赛数据无效", + "invalid_data_desc": "无法解析回放数据。", + "scoreboard_error_title": "计分板错误", + "scoreboard_error_desc": "生成计分板图片失败。", + "no_games_title": "未找到比赛", + "no_games_desc": "未找到 **{player}** 的比赛历史。", + "recent_matches_title": "{player} 的近期比赛", + "recent_matches_desc": "最多显示 {count} 场近期比赛。选择一场查看完整计分板。", + "select_match_placeholder": "选择要查看的比赛..." + }, + "compare": { + "no_players_found": "未找到匹配 **{name}** 的玩家。", + "multiple_matches": "**{name}** 有多个匹配项:{matches}\n请使用更具体的名称(自动补全建议为精确匹配)。", + "could_not_resolve": "无法识别玩家。", + "could_not_fetch": "❌ 无法获取 **{name}** 的统计。", + "no_graph_data": "最近 90 天没有可用数据。", + "no_squadron_points_data": "{names} 没有中队点数数据(在已追踪的中队历史中找不到该玩家)。", + "graph_title": "玩家点数 — 最近 90 天", + "battles_label": "战斗", + "wins_label": "胜场", + "losses_label": "负场", + "win_rate_label": "胜率", + "ground_kills_label": "地面击杀", + "air_kills_label": "空中击杀", + "total_kills_label": "总击杀", + "assists_label": "助攻", + "deaths_label": "死亡", + "kd_label": "K/D", + "captures_label": "占点" + }, + "squadron": { + "not_found_desc": "未找到中队 `{squadron}`。", + "set_title": "✅ 中队已设置", + "set_desc": "此服务器的中队已设置为 **{squadron}**。", + "short_name_field": "简称", + "long_name_field": "全称", + "swap_title": "✅ 中队已替换", + "swap_desc": "已将此服务器的 **{old}** 替换为 **{new}**。", + "already_set_title": "⚠️ 中队已设置", + "already_set_desc": "此服务器当前设置为 **{old}**。\n是否替换为 **{new}**?", + "swap_cancelled": "❌ 中队更改已取消。" + }, + "setup": { + "step1_title": "服务器设置 — 第 1/3 步", + "step1_desc": "此向导将引导你为服务器配置机器人。\n\n**第 1 步** — 设置中队\n**第 2 步** — 选择日志频道\n**第 3 步** — 选择点数频道\n", + "step1_current_sq": "\n当前配置的中队:**[{short}] {long}**", + "step2_title": "服务器设置 — 第 2/3 步", + "step2_desc": "中队已设置为 **[{short}] {long}**。\n\n**战斗日志** 应发布到哪里?\n请在下方选择文字频道,或跳过此步骤。", + "step3_title": "服务器设置 — 第 3/3 步", + "step3_desc": "**点数通知** 应发布到哪里?\n请在下方选择文字频道,或跳过此步骤。", + "step3_same_as_logs": "\n\n你也可以点击“与日志相同”来复用日志频道。", + "summary_title": "设置完成", + "summary_desc": "该项目的详细说明。", + "squadron_field": "中队", + "logs_channel_field": "日志频道", + "points_channel_field": "点数频道", + "premium_required_field": "⚠️ 比赛日志需要高级版", + "premium_required_value": "在此服务器拥有有效订阅之前,自动比赛计分板不会发布。运行 `/unlock` 订阅($2.99/月)。", + "modal_title": "设置中队", + "modal_label": "中队 Short 名称", + "modal_placeholder": "请输入或选择…", + "squadron_not_found": "未找到中队 `{squadron}`。请重试。", + "logs_channel_placeholder": "选择日志频道...", + "points_channel_placeholder": "选择点数频道..." + }, + "meta_management": { + "squadron_not_found_title": "❌ 中队 Not 已找到", + "squadron_not_found_desc": "找不到中队 **{squadron}** 的 clan ID。", + "access_denied_title": "标题", + "access_denied_desc": "密码错误。此中队的 Meta 数据受保护。", + "data_locked_title": "标题", + "data_locked_desc": "**{squadron}** 已启用数据绑定,不能转移到其他服务器。\n\n移动前,中队所有者必须先禁用 **绑定中队数据**。", + "error_retrieving_settings": "❌ 错误 retrieving 服务器 settings after transfer. Please try again.", + "error_retrieving_settings_retry": "❌ 错误 retrieving 服务器 settings. Please try running the 命令 again.", + "authenticated_title": "✅ 已验证", + "authenticated_desc": "密码已验证。正在管理 **{squadron}** 的设置。", + "claimed_title": "标题", + "claimed_desc": "**{squadron}** 已成功绑定到此服务器!", + "password_requirement_field": "🔒 密码 Requirement", + "data_lock_field": "🔐 中队 数据 Binding", + "public_meta_field": "👥 公开 Meta 访问", + "access_password_field": "🔑 访问 密码", + "enabled_value": "✅ 已启用", + "disabled_value": "❌ 已禁用", + "settings_title": "标题", + "settings_desc": "**中队:** {squadron}\n**Clan ID:** {clan_id}", + "first_time_title": "🔐 Meta 管理 - 首次设置", + "first_time_owner_desc": "**中队:** {squadron}\n**Clan ID:** {clan_id}\n\n🔑 已生成访问密码。**请保存此密码**,之后验证 Meta 数据访问时会用到。\n\n**密码:** `{password}`", + "first_time_non_owner_desc": "**中队:** {squadron}\n**Clan ID:** {clan_id}\n\n中队已设置。请向服务器所有者索要访问密码。", + "settings_field": "设置", + "settings_hint": "使用下方按钮配置访问设置。", + "password_toggled": "✅ 密码 requirement: **{state}**", + "lock_toggled": "✅ 中队 data binding: **{state}**", + "public_meta_toggled": "✅ 公开 meta access: **{state}**\n{detail}", + "public_meta_enabled_detail": "Non-admins can now 使用 `/meta` 命令.", + "public_meta_disabled_detail": "Only admins can 使用 `/meta` 命令.", + "owner_only_password": "❌ Only the 服务器 owner can change the squadron password.", + "help_title": "📖 Meta 管理帮助", + "help_desc": "各项设置和功能说明:", + "help_password_field": "🔑 访问 密码", + "help_password_value": "Your squadron's access password. Only the **服务器 owner** can see the password in the settings panel. Anyone with the password can claim your squadron's meta data on their 服务器, so keep it secure.", + "help_require_field": "🔒 Require 密码", + "help_require_value": "When 已启用, even admins on this 服务器 must enter the squadron password to access `/meta-management`. Adds an extra layer of security to prevent accidental changes.", + "help_lock_field": "🔐 Bind 中队 数据", + "help_lock_value": "When 已启用, prevents the squadron from being transferred to other 服务器s, even with the correct password. Must be disabled before the squadron can be transferred.", + "help_public_field": "👥 Allow 公开 Meta", + "help_public_value": "When 已启用, allows non-admin 成员s to 使用 the `/meta` 命令 to search squadron vehicles. When disabled, only 服务器 administrators can 使用 `/meta`.", + "help_accounts_field": "📋 更新Meta Accounts", + "help_accounts_value": "Opens the player roster manager where you can add or remove players from your squadron's meta roster. Use **更新All 成员** to sync your entire squadron at once.", + "help_change_pw_field": "🔑 Change 密码", + "help_change_pw_value": "**服务器 owner only.** Change the squadron's access password and set an optional hint. The hint is shown in the password prompt to help re成员 it.", + "password_modal_title": "中队 访问 密码", + "password_modal_label": "Enter 中队 密码", + "password_modal_placeholder": "请输入或选择…", + "change_pw_modal_title": "标题", + "current_password_label": "当前密码", + "current_password_placeholder": "请输入或选择…", + "new_password_label": "新密码", + "new_password_placeholder": "请输入或选择…", + "confirm_password_label": "确认新密码", + "confirm_password_placeholder": "请输入或选择…", + "hint_label": "密码提示(可选)", + "hint_placeholder": "请输入或选择…", + "pw_incorrect": "❌ 当前password is incorrect.", + "pw_mismatch": "❌ 两次输入的新密码不一致。请重试。", + "pw_empty": "❌ 新密码不能为空。", + "pw_changed": "✅ 密码 updated successfully for **{squadron}**.\n**New 密码:** `{password}`", + "pw_changed_hint": "\n**提示:** {hint}", + "player_add_modal_title": "标题", + "player_add_label": "玩家 UID 或昵称", + "player_add_placeholder": "请输入或选择…", + "player_not_found": "❌ 玩家 `{player}` not found in 玩家s_Global database.\n", + "roster_title": "📋 Meta 名单管理 - {squadron}", + "roster_desc": "**中队 Clan ID:** {clan_id}\n**总玩家:** {count}", + "roster_page_field": "玩家(第 {page}/{total} 页)", + "no_players_field": "没有玩家", + "no_players_hint": "没有players added to meta roster yet. Click **添加玩家** to get started.", + "remove_player_placeholder": "请输入或选择…", + "fetch_members_failed": "❌ 无法fetch squadron 成员s: {error}", + "no_members_found": "❌ 没有成员s found in squadron or API call failed.", + "roster_synced": "✅ 已与中队同步名单。", + "roster_added": "已添加 **+{count}**", + "roster_removed": "已移除 **-{count}**(已离开中队)", + "roster_up_to_date": "**{count}** 已是最新", + "refreshing_vehicles": "正在后台刷新载具数据..." + }, + "meta": { + "not_configured": "❌ Meta data not configured for this 服务器. Run `/meta-management` first.", + "no_permission": "❌ You need administrator permissions to 使用 this 命令.\nAdmins can enable public access via `/meta-management`.", + "no_results": "❌ 没有players in your squadron roster have **{vehicle}**.", + "no_results_admin_hint": "\n*如果你认为有人应该拥有它,请在 `/meta-management` 中点击更新成员按钮并再次检查。*", + "search_title": "🔍 搜索结果 - {vehicle}", + "matches_found": "**比赛es 已找到:** {count} player(s)", + "spawns_label": "出场", + "deaths_label": "死亡", + "gk_label": "GK", + "ak_label": "AK", + "points_label": "点数", + "kdr_label": "战损比", + "games_label": "场次", + "no_points": "—" + }, + "top": { + "title": "**Top 20 中队s**", + "rating_label": "**评分:** {value}", + "air_kills_label": "**空战 击杀:** {value}", + "ground_kills_label": "**陆战 击杀:** {value}", + "deaths_label": "**死亡:** {value}", + "kd_label": "**K/D:** {value}", + "win_rate_label": "**胜率:** {value}", + "playtime_label": "**游玩时间:** {value}", + "fetch_failed": "无法获取中队数据。" + }, + "analytics": { + "no_data_title": "没有数据", + "no_matches_desc": "未找到比赛。", + "no_comp_desc": "未找到阵容数据。", + "no_consistency_desc": "玩家数据不足(至少需要 50 场比赛)。", + "no_time_desc": "该项目的详细说明。", + "unknown_view": "未知视图。", + "map_title": "地图胜率:{squadron}", + "comp_title": "队伍阵容:{squadron}", + "consistency_title": "玩家稳定性:{squadron}", + "consistency_desc": "按 K/D 排序", + "time_title": "分时段表现:{squadron}", + "eu_timeslot": "\n**EU 时段**", + "na_timeslot": "\n**NA 时段**", + "off_peak": "\n**非高峰时段**", + "matchups_title": "📜 {squadron} — 对战历史", + "matchups_won_field": "🏆 胜场最多的对手", + "matchups_lost_field": "💀 负场最多的对手", + "no_matchups_desc": "该项目的详细说明。" + }, + "recent": { + "title": "近期比赛:{squadron}", + "no_matches_desc": "该项目的详细说明。" + }, + "h2h": { + "two_required_title": "需要两个中队", + "two_required_desc": "请至少提供一个中队,或先使用 `/set-squadron` 后再提供对手。", + "provide_a_desc": "请提供 `squadron_a`,或先使用 `/set-squadron`。", + "provide_b_desc": "请提供 `squadron_b`,或先使用 `/set-squadron`。", + "squadron_not_found_title": "未找到中队", + "same_squadron_title": "同一个中队", + "same_squadron_desc": "不能和自己进行交手记录查询。", + "record_desc": "**战绩:** {a_wins}胜 - {b_wins}负({total} 场)", + "no_matches_desc": "**{a}** 和 **{b}** 之间没有已记录比赛。" + }, + "track": { + "squadron_not_found": "未找到中队。", + "fetch_failed": "无法获取中队信息。" + }, + "unlock": { + "title": "SRE Bot 高级版", + "desc": "该项目的详细说明。", + "already_subscribed_title": "SRE Bot 高级版", + "already_subscribed_desc": "该项目的详细说明。", + "manage_discord_field": "管理订阅", + "manage_discord_value": "Your 订阅 is through **Discord**.\nTo cancel, go to **User 设置 → Subscriptions** in Discord.", + "manage_website_field": "管理订阅", + "manage_website_value": "Your 订阅 is through the **网站**.\n管理it at [whop.com/计费](https://whop.com/计费).", + "coming_soon_field": "即将推出", + "coming_soon_value": "高级版 订阅s are not yet available. Check back soon!", + "current_tier": "你当前使用 **{tier}** 套餐。", + "upgrade_to": "升级到 {tier}", + "upgrade_to_value": "升级到 **{tier}** 可获得更高中队上限和更多功能。" + }, + "dev": { + "restricted_dev_team": "此命令仅限开发团队使用。", + "restricted_bot_owner": "❌ 此命令仅限机器人所有者使用。", + "invalid_server_id": "❌ Invalid 服务器 ID. Must be a 17-19 digit Discord 服务器 ID.", + "expiry_too_soon": "❌ 到期时间戳必须至少是一个月以后。\n> 当前:\n> 最早:\n> 你提供的是:", + "entitlement_write_failed": "❌ 写入权益失败:{error}", + "entitlement_created_title": "✅ 已创建手动权益", + "entitlement_created_desc": "**服务器:** {guild_name} (`{server_id}`)\n**到期:** ()\n**创建:** ", + "query_failed": "查询失败:{error}", + "health_title": "机器人健康仪表盘", + "health_uptime": "运行时间", + "health_guilds": "服务器", + "health_games_processed": "已处理比赛", + "health_tasks": "任务", + "health_websocket": "WebSocket", + "health_never": "从未", + "health_errors": "({count} 个错误)", + "health_last_msg": "上一条消息 {ago}(共 {count} 条)", + "health_avg_ttl": "平均 TTL (最近 30 局)", + "entitlements_title": "有效权益(共 {count} 个)", + "entitlements_no_entries": "没有权益。", + "entitlements_empty_title": "有效权益", + "entitlements_empty_desc": "未找到有效权益。", + "entitlements_tag_discord": "标题", + "entitlements_tag_whop": "标题", + "entitlements_tag_manual": "手动", + "query_prefix": "查询:{name}" + }, + "stacks": { + "stack_title": "{leader} 的车队", + "stack_named_title": "{name}", + "no_members": "暂无成员。", + "members_field": "成员 ({count}/{max})", + "queue_field": "队列({count}/{max})", + "manage_title": "管理车队", + "no_pending_requests": "没有待处理申请。", + "disbanded_title": "车队 [已解散]", + "disbanded_desc": "此车队已被队长解散。", + "expired_title": "车队 [已过期]", + "expired_desc": "此车队已过期。", + "join_modal_title": "申请加入车队", + "join_vehicle_label": "你要使用什么载具?", + "join_vehicle_placeholder": "e.g. F-16C, WZ305...", + "ping_modal_title": "提醒消息", + "ping_message_label": "自定义消息(可选)", + "ping_message_placeholder": "请输入或选择…", + "rename_modal_title": "重命名车队", + "rename_label": "车队名称", + "rename_placeholder": "请输入或选择…", + "select_new_leader": "选择new 队长…", + "select_applicants": "选择applicants…", + "no_pending_applications": "没有pending applications.", + "select_to_remove": "选择people to remove…", + "no_members_or_applicants": "没有成员s or applicants.", + "select_to_ping": "选择people to ping individually…", + "stack_not_found": "❌ 找不到车队。", + "no_longer_exists": "❌ 此车队已不存在。", + "member_not_exists": "❌ 该成员已不存在。", + "already_has_stack": "❌ 该玩家已有活跃车队。", + "already_member": "❌ 你已经是此车队成员。", + "already_applied": "❌ 你已经有此车队的待处理申请。", + "queue_full": "❌ 队列已满({max}/{max})。请稍后再试。", + "application_sent": "✅ 申请已发送!车队队长会进行审核。", + "stack_disbanded": "✅ 车队已解散。", + "cancelled": "已取消。", + "select_member_transfer": "❌ 请选择要转让所有权的成员。", + "ownership_transferred": "✅ 所有权已转让给 {nick}。你已离开车队。", + "select_applicant_first": "❌ 请先至少选择一名申请者。", + "stack_full": "❌ 车队已满({max}/{max} 名成员)。", + "select_person_first": "❌ 请先至少选择一个人。", + "no_one_to_ping": "❌ 没有one to ping.", + "ping_footer": "{leader} 为 {stack} 发起提醒。", + "pinged": "✅ 已提醒!", + "select_from_dropdown": "❌ 请先从下拉菜单中至少选择一个人。", + "stack_renamed": "✅ 车队已重命名为 **{name}**。", + "only_member_use_disband": "❌ 你是唯一成员。请使用 **解散车队** 结束它。", + "select_transfer_prompt": "选择a 成员 to transfer ownership to before leaving:", + "left_stack": "✅ 你已离开车队。", + "application_withdrawn": "✅ 你的申请已撤回。", + "not_member_or_applicant": "❌ 你不是此车队的成员或申请者。", + "leader_only_manage": "❌ 只有车队队长可以管理此车队。", + "leader_only_disband": "❌ 只有车队队长可以解散此车队。", + "confirm_disband": "确定要解散此车队吗?此操作无法撤销。", + "already_active_stack": "⚠️ 你已经有一个活跃车队。如果原始嵌入消息丢失(例如机器人重启后),可以强制解散并重新创建。", + "force_created": "✅ 之前的车队已解散。新车队已创建。", + "no_active_stack": "❌ 你没有活跃车队。使用 `/stack-create` 创建一个。", + "could_not_parse_channel": "⚠️ 无法解析已存储的频道 ID。" + }, + "commands": { + "common": { + "season": "要生成卡片的赛季", + "theme": "卡片配色主题", + "squadron_short": "战队简称", + "player_username": "玩家用户名", + "choice_dark": "深色", + "choice_light": "浅色" + }, + "comp": { + "description": "查找某队最近已知阵容", + "squadron_short": "敌方队伍简称" + }, + "quick_log": { + "description": "在此频道为该战队设置提醒", + "squadron_name": "要监控的战队简称", + "type": "选择 Logs、Points、排行榜、周BR 或 全部", + "choice_logs": "日志", + "choice_points": "分数", + "choice_leaderboard": "排行榜", + "choice_both": "两者(日志 + 分数)", + "choice_weekly_br": "周BR" + }, + "sq_info": { + "description": "获取战队信息" + }, + "sq_info_graph": { + "description": "按当前赛季的活跃度与胜率显示战队阵容构成图表" + }, + "sq_card": { + "description": "为战队生成赛季总结卡", + "squadron": "战队简称" + }, + "sq_stats": { + "description": "显示战队分数随时间变化" + }, + "loss_calculator": { + "description": "计算玩家离队后的战队分数损失", + "player1": "离队玩家", + "player_optional": "离队玩家(可选)" + }, + "website": { + "description": "获取 SRE Bot 网站链接" + }, + "card": { + "description": "为玩家生成赛季总结卡" + }, + "player_stats": { + "description": "查看玩家的详细载具统计", + "username": "用于查询统计的 WT 用户名", + "uid": "用于查询统计的 WT UID" + }, + "view_player_games": { + "description": "查看玩家最近 20 场比赛" + }, + "view_match": { + "description": "按 ID 或玩家查看比赛记分板", + "match_id": "比赛的十六进制会话 ID", + "player_name": "用于浏览近期比赛的玩家名" + }, + "compare": { + "description": "比较玩家的 SQB 汇总统计", + "player1": "第一个玩家用户名", + "player2": "第二个玩家用户名", + "player_optional": "其他玩家用户名(可选)" + }, + "leaderboard": { + "description": "获取 SRE Bot 全球排行榜" + }, + "set_squadron": { + "description": "设置此服务器的战队标签", + "abbreviated_name": "要设置的战队简称" + }, + "setup": { + "description": "为此服务器设置机器人" + }, + "meta_management": { + "description": "管理此服务器的 meta 数据访问" + }, + "meta": { + "description": "按载具名称搜索战队 meta 名单", + "vehicle": "要搜索的载具名称" + }, + "top": { + "description": "查看排名前 20 的战队及详细统计" + }, + "language": { + "description": "更改机器人语言。" + }, + "translate_message": { + "name": "翻译消息" + }, + "sq_track": { + "description": "追踪战队并与上次检查对比", + "squadron_short_name": "要追踪的战队简称" + }, + "analytics": { + "description": "查看战队的高级 SQB 分析", + "view": "要显示的分析视图", + "choice_maps": "地图胜率", + "choice_comps": "队伍阵容", + "choice_consistency": "玩家稳定性", + "choice_time": "时段表现", + "choice_matchups": "交手历史" + }, + "recent": { + "description": "显示战队近期比赛", + "length": "要显示的比赛数量" + }, + "vs": { + "description": "两个战队的交手记录", + "squadron_a": "第一个战队", + "squadron_b": "第二个战队" + }, + "autolog_management": { + "description": "管理自动日志通知并诊断权限", + "diagnose_perms": "诊断此频道的自动日志权限" + }, + "diagnose_perms": { + "description": "诊断此频道的自动日志权限" + }, + "unlock": { + "description": "为此服务器解锁 Premium 功能" + }, + "credits": { + "description": "查看本项目制作团队" + }, + "schedule": { + "description": "查看当前赛季 BR 日程" + }, + "news": { + "description": "查看最新 SRE Bot 新闻和公告" + }, + "help": { + "description": "查看指南、服务条款和支持链接" + }, + "donate": { + "description": "支持 SRE Bot 开发" + }, + "stack_create": { + "description": "创建玩家组队", + "vehicle": "你要用什么载具开局?" + }, + "stack_manage": { + "description": "将你的活动组队重新发布到此频道" + }, + "bot_status": { + "description": "查看机器人状态:最近接收的对局与平均 TTL" + } + }, + "permission": { + "blacklisted_title": "❌ 已加入黑名单", + "blacklisted_desc": "你已被禁止使用此命令。", + "reason_line": "**原因:** {reason}", + "access_denied_title": "⛔ 访问被拒绝", + "no_permission_desc": "你没有权限使用此命令。", + "unexpected_error_title": "❗ 出错了,请上报...." + }, + "weekly_br": { + "title_wildcard": "周BR报告 — {br} BR", + "title_squadron": "周BR报告 — [{tag}] {long} • {br} BR", + "window_label": "时段:{start} → {end}", + "wildcard_desc_first": "按ELO排序前 {count} 中队 • 排名 {low}–{high}", + "wildcard_desc_second": "按ELO排序前 {count} 中队 • 排名 {low}–{high}", + "squadron_stats_line": "- {games} 场 • K/D {kdr} • 胜率 {wr}%", + "top_players_inline_header": "🥇 顶尖玩家:", + "player_line_short": " {rank}. {nick} ⭐ {score} ({games}场)", + "top_players_header": "**按ELO排序前 {count} 名玩家:**", + "player_line_full": "{rank}. **{nick}** ⭐ {score} • {games} 场 • K/D {kdr}", + "squadron_header_line": "中队 ELO:{score} • {games} 场 • 胜率 {wr}% • K/D {kdr}", + "squadron_header_no_aggregate": "中队 ELO:本周团队活动不足,无法评分。", + "no_data": "本次BR轮换中 [{tag}] 没有比赛记录。" + } +} diff --git a/BOT/lux_apis.py b/BOT/lux_apis.py new file mode 100644 index 0000000..e252588 --- /dev/null +++ b/BOT/lux_apis.py @@ -0,0 +1,438 @@ +""" +lux_apis.py + +Async client for the Spectra gaming API. Provides a WebSocket listener for real-time +replay streaming with auto-reconnect, HTTP functions for fetching squadron leaderboards, +and data transformation utilities to convert API responses into the local format. +""" + +# Standard Library Imports +import asyncio +import json +import logging +import os +from typing import Any, Awaitable, Callable, Dict, List, Optional + +# Third-Party Library Imports +import aiohttp +import pygob +import zstandard as zstd +from dotenv import load_dotenv +from websockets.asyncio.client import connect as wsconnect + +# Local Module Imports +try: + from .data_parser import LangTableReader + from .utils import REPLAYS_DIR +except ImportError: + LangTableReader = None # Running directly, not as module + REPLAYS_DIR = None + + +# Load environment variables +load_dotenv() + +logger = logging.getLogger(__name__) + +# Global replay queue for WebSocket messages +_replay_queue: asyncio.Queue = asyncio.Queue() + +# Constants from environment +WS_URL = os.getenv("SPECTRA_WS_SQB_URL", "") +API_KEY = os.getenv("SPECTRA_API_KEY", "") +SPECTRA_API_URL = os.getenv("SPECTRA_API_URL", "") +WS_GOB_URL = os.getenv("SPECTRA_WS_GOB_URL", "") +LEADERBOARD_PATH = "/v1/game/leaderboard" +REPLAY_SORT_PATH = "/v1/replays/sort" + +# Initialize translation reader for vehicle names +translate = LangTableReader("English") if LangTableReader else None + + +def _gob_to_dict(obj: object) -> Any: + """Recursively convert pygob namedtuples to plain dicts for JSON serialization.""" + fields = getattr(obj, '_fields', None) + if isinstance(obj, tuple) and fields is not None: + return {f: _gob_to_dict(getattr(obj, f)) for f in fields} + elif isinstance(obj, list): + return [_gob_to_dict(i) for i in obj] + elif isinstance(obj, dict): + return { + (k.decode('utf-8', errors='replace') if isinstance(k, bytes) else k): _gob_to_dict(v) + for k, v in obj.items() + } + elif isinstance(obj, bytes): + return obj.decode('utf-8', errors='replace') + return obj + + +def normalize_ws_message(data: Any) -> Optional[List[Dict[str, Any]]]: + """ + Normalize WebSocket message to list of replay dicts. + Handles: full replay data, array of replays, or wrapped containers. + + Returns: + List of replay dicts, or None if format is unknown + """ + # Case 1: Already a list of full replay objects + if isinstance(data, list) and data and isinstance(data[0], dict): + return data + + # Case 2: Single replay object (WS sends '_id', not 'id') + if isinstance(data, dict) and ('_id' in data or 'id' in data) and ('teams' in data or 'players' in data): + return [data] + + # Case 3: Wrapped in container (like fetch_replays response) + if isinstance(data, dict) and 'completed' in data: + return data['completed'] + + logger.warning(f"Unknown WS message format: {type(data)}") + return None + + +def get_replay_queue() -> asyncio.Queue: + """Get the global replay queue for external access.""" + return _replay_queue + + +async def ws_replay_listener(callback: Callable[[List[Dict[str, Any]]], Awaitable[None]]) -> None: + """ + Maintain persistent WebSocket connection to Spectra replay endpoint. + Queues incoming replays for sequential processing by the callback. + Auto-reconnects on disconnect with exponential backoff. + + Args: + callback: Async function to call with normalized replay data + """ + headers = {'Authorization': API_KEY} + reconnect_delay = 1 + + # Start queue processor as background task + processor_task = asyncio.create_task(_process_replay_queue(callback)) + + async def _connect_and_listen(url: str, label: str): + logger.info(f"WS attempting connect → {url}") + async with wsconnect(url, additional_headers=headers) as ws: + logger.info(f"WebSocket connected to {label}") + async for message in ws: + try: + data = json.loads(message) + replays = normalize_ws_message(data) + if replays: + await _replay_queue.put(replays) + except json.JSONDecodeError: + logger.warning(f"Invalid JSON from WS: {message[:100]}") + except Exception as e: + logger.error(f"Error processing WS message: {e}") + + try: + while True: + primary_url = WS_URL + primary_label = "Spectra" + try: + await _connect_and_listen(primary_url, primary_label) + except Exception as e: + logger.error(f"WebSocket error ({primary_label}): {e}") + + # Reconnect with exponential backoff + logger.info(f"Reconnecting in {reconnect_delay}s...") + await asyncio.sleep(reconnect_delay) + reconnect_delay = min(reconnect_delay * 2, 30) # Cap at 30s + finally: + processor_task.cancel() + + +async def _process_replay_queue(callback: Callable[[List[Dict[str, Any]]], Awaitable[None]]) -> None: + """ + Process queued replays in batches. + Waits for at least one message, then drains queue and processes all at once. + """ + while True: + try: + # Wait for at least one message + replays = await _replay_queue.get() + batch = list(replays) + _replay_queue.task_done() + + # Drain any others that accumulated while processing + while not _replay_queue.empty(): + try: + more = _replay_queue.get_nowait() + batch.extend(more) + _replay_queue.task_done() + except asyncio.QueueEmpty: + break + + if batch: + try: + await callback(batch) + except Exception as e: + logger.error(f"Error in replay callback: {e}") + + except asyncio.CancelledError: + logger.info("Replay queue processor cancelled") + break + except Exception as e: + logger.error(f"Error in queue processor: {e}") + + +async def fetch_leaderboard( + count: int = 200, + start: int = 0, + short: bool = False +) -> Optional[List[Dict[str, Any]]]: + """ + Fetch squadron leaderboard data from Spectra API. + + Args: + count: Number of clans to fetch (default 200) + start: Start position in leaderboard (default 0) + short: If True, exclude player data from response (default False) + + Returns: + JSON response with leaderboard data, or None on failure + """ + url = f"{SPECTRA_API_URL}{LEADERBOARD_PATH}" + # API_KEY may or may not include "Bearer " prefix - handle both cases + auth_value = API_KEY if API_KEY.startswith("Bearer ") else f"Bearer {API_KEY}" + headers = { + "accept": "application/json", + "count": str(count), + "start": str(start), + "short": str(short).lower(), + "Authorization": auth_value + } + + try: + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=headers) as response: + if response.status == 200: + data = await response.json() + return data + else: + logger.error(f"Leaderboard API error: {response.status} - {await response.text()}") + except Exception as e: + logger.error(f"Failed to fetch leaderboard: {e}") + + return None + + +async def fetch_leaderboard_bulk( + total_count: int = 1000, + batch_size: int = 200, + short: bool = False +) -> List[Dict[str, Any]]: + """ + Fetch multiple batches of leaderboard data. + + Args: + total_count: Total number of clans to fetch (default 1000) + batch_size: Clans per request (default 200) + short: If True, exclude player data (default False) + + Returns: + Combined list of all clan data from all batches + """ + all_clans = [] + num_batches = (total_count + batch_size - 1) // batch_size + + for i in range(num_batches): + start = i * batch_size + count = min(batch_size, total_count - start) + + data = await fetch_leaderboard(count=count, start=start, short=short) + + if data and isinstance(data, list): + all_clans.extend(data) + elif data: + logger.warning(f"Unexpected response format for batch {i+1}") + + return all_clans + + +async def fetch_replay_by_id( + replay_id: str, + sort_field: str = "sqb" +) -> Optional[Dict[str, Any]]: + """ + Fetch a single replay by ID from Spectra API. + + Args: + replay_id: The replay ID to fetch + sort_field: Game type sort field (default "sqb") + + Returns: + Raw API response dict, or None on failure + """ + url = f"{SPECTRA_API_URL}{REPLAY_SORT_PATH}" + auth_value = API_KEY if API_KEY.startswith("Bearer ") else f"Bearer {API_KEY}" + headers = { + "accept": "application/json", + "Authorization": auth_value, + "sortField": sort_field, + "id": replay_id, + } + + try: + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=headers) as response: + if response.status == 200: + return await response.json() + else: + logger.error(f"fetch_replay_by_id error: {response.status} - {await response.text()}") + except Exception as e: + logger.error(f"Failed to fetch replay {replay_id}: {e}") + + return None + + +async def test_leaderboard(): + """Test function to fetch leaderboard data and print results.""" + print(f"Fetching leaderboard from {SPECTRA_API_URL}...") + print(f"API Key configured: {'Yes' if API_KEY else 'No'}") + if API_KEY: + # Show first/last few chars for debugging without exposing full key + masked = f"{API_KEY[:15]}...{API_KEY[-8:]}" if len(API_KEY) > 25 else "[short key]" + print(f"API Key format: {masked}") + print() + + # Fetch 100 clans with player data + print("=== Fetching 100 clans in batches of 25 ===") + all_data = await fetch_leaderboard_bulk(total_count=100, batch_size=25, short=False) + print(f"Total clans fetched: {len(all_data)}") + + # Write to JSON file + output_file = "leaderboard_test_output.json" + with open(output_file, "w") as f: + json.dump(all_data, f, indent=2, default=str) + print(f"Data written to {output_file}") + + +async def test_fetch_replay_by_id(): + """Test function to fetch a single replay by ID and print results.""" + test_id = input("Enter replay ID to fetch: ").strip() + if not test_id: + print("No ID provided, aborting.") + return + + print(f"Fetching replay {test_id} from {SPECTRA_API_URL}...") + data = await fetch_replay_by_id(test_id) + if data: + output_file = f"replay_{test_id}_output.json" + with open(output_file, "w") as f: + json.dump(data, f, indent=2, default=str) + print(f"Data written to {output_file}") + else: + print("No data returned.") + + +async def ws_gob_listener(callback: Callable[[bytes, bytes], Awaitable[None]]) -> None: + """ + Maintain persistent WebSocket connection to the Spectra SQB .gob endpoint. + Server pushes raw zstd-compressed .gob binary after each SQB replay is parsed. + Client does not send messages. + + Args: + callback: Async function called with (compressed_bytes, decompressed_bytes) + """ + auth_value = API_KEY if API_KEY.startswith("Bearer ") else f"Bearer {API_KEY}" + headers = {"Authorization": auth_value} + decompressor = zstd.ZstdDecompressor() + reconnect_delay = 1 + + async def _connect_gob(url: str, label: str): + logger.info(f"GOB WS attempting connect → {url}") + async with wsconnect(url, additional_headers=headers) as ws: + logger.info(f"WebSocket connected to {label}") + reconnect_delay_ref = 1 # noqa: F841 — reset handled by caller + async for message in ws: + try: + raw = bytes(message) if isinstance(message, (bytes, bytearray, memoryview)) else message.encode() + data = decompressor.decompress(raw) + await callback(raw, data) + except zstd.ZstdError as e: + logger.error(f"zstd decompression failed: {e}") + except Exception as e: + logger.error(f"Error processing GOB message: {e}") + + while True: + primary_url = WS_GOB_URL + primary_label = "Spectra GOB endpoint" + + try: + await _connect_gob(primary_url, primary_label) + except Exception as e: + logger.error(f"GOB WebSocket error ({primary_label}): {e}") + + logger.info(f"GOB WS reconnecting in {reconnect_delay}s...") + await asyncio.sleep(reconnect_delay) + reconnect_delay = min(reconnect_delay * 2, 30) + + +async def test_gob_ws(): + """ + Connect to the SQB GOB WebSocket and dump received messages to files. + Each decompressed .gob blob is written to STORAGE/REPLAYS/.gob for inspection. + """ + from pathlib import Path + if REPLAYS_DIR is None: + raise RuntimeError("REPLAYS_DIR is not configured") + replays_dir = REPLAYS_DIR + replays_dir.mkdir(parents=True, exist_ok=True) + + auth_value = API_KEY if API_KEY.startswith("Bearer ") else f"Bearer {API_KEY}" + print(f"Connecting to {WS_GOB_URL}") + print(f"API Key configured: {'Yes' if API_KEY else 'No'}") + print(f"Saving to {replays_dir}") + print("Waiting for messages (Ctrl+C to stop)...\n") + + decompressor = zstd.ZstdDecompressor() + count = 0 + + async with wsconnect(WS_GOB_URL, additional_headers={"Authorization": auth_value}) as ws: + print("Connected.") + async for message in ws: + raw = bytes(message) if isinstance(message, (bytes, bytearray, memoryview)) else message.encode() + print(f"[{count}] Received {len(raw)} bytes (compressed)") + data = b"" + try: + data = decompressor.decompress(raw) + print(f"[{count}] Decompressed to {len(data)} bytes") + replay = pygob.load(data) + d = _gob_to_dict(replay) + session_id = d.get("SessionID", count) + out = replays_dir / f"{session_id}.json" + out.write_text(json.dumps(d, indent=2, default=str), encoding="utf-8") + print(f"[{count}] Decoded and written to {out}\n") + except zstd.ZstdError as e: + print(f"[{count}] zstd decompression failed: {e}") + out = replays_dir / f"gob_{count}_raw.bin" + out.write_bytes(raw) + print(f"[{count}] Raw bytes written to {out}\n") + except Exception as e: + print(f"[{count}] gob decode failed: {e}") + if data: + out = replays_dir / f"gob_{count}.gob" + out.write_bytes(data) + print(f"[{count}] Raw gob written to {out}\n") + count += 1 + + +if __name__ == "__main__": + # Setup for direct execution + import sys + from pathlib import Path + sys.path.insert(0, str(Path(__file__).parent.parent)) + + # Re-load env vars since module import was skipped + from dotenv import load_dotenv + load_dotenv() + API_KEY = os.getenv("SPECTRA_API_KEY", "") + + logging.basicConfig(level=logging.INFO) + + mode = sys.argv[1] if len(sys.argv) > 1 else "replay" + if mode == "replay": + asyncio.run(test_fetch_replay_by_id()) + elif mode == "gob": + asyncio.run(test_gob_ws()) diff --git a/BOT/meta_manager.py b/BOT/meta_manager.py new file mode 100644 index 0000000..bec6abb --- /dev/null +++ b/BOT/meta_manager.py @@ -0,0 +1,1180 @@ +""" +Meta_Manager.py + +Manages the Meta.db database which stores global player vehicle statistics. +Fetches vehicle data for all players from sq_battles.db and stores it in a structured format. +""" + +# Standard Library Imports +import asyncio +import hashlib +import json +import logging +import time +from typing import Any, Dict, List, Tuple, Optional + +# Third-Party Library Imports +import aiosqlite + +# Local Module Imports +from .data_parser import LangTableReader, normalize_name +from .game_api import obtain_clan_new_points, obtain_player_data_api +from .utils import STORAGE_DIR, SQUADRONS_DB_PATH, compress_json, decompress_json + + +STORAGE_DIR.mkdir(parents=True, exist_ok=True) + +# Database paths +SQ_BATTLES_DB_PATH = STORAGE_DIR / "sq_battles.db" +META_DB_PATH = STORAGE_DIR / "Meta.db" + +# Language translator for vehicle names +translate = LangTableReader("English") + + +async def init_meta_db(): + """Initialize Meta.db with the Players_Global, Guilds, and Guild_Metas tables. + + Also runs schema migrations (e.g. adding the ``password_hint`` column + to Guilds if missing). Idempotent — safe to call on every startup. + """ + async with aiosqlite.connect(META_DB_PATH) as db: + # Create Players_Global table - ONE ROW PER PLAYER with vehicles as JSON + await db.execute(""" + CREATE TABLE IF NOT EXISTS Players_Global ( + userID TEXT PRIMARY KEY, + nick TEXT NOT NULL, + clanTag TEXT, + clanName TEXT, + clanID TEXT, + lastDay INTEGER, + vehicles TEXT NOT NULL, + updated_unix INTEGER NOT NULL + ) + """) + + # Create index for efficient queries + await db.execute(""" + CREATE INDEX IF NOT EXISTS idx_players_global_updated + ON Players_Global(updated_unix) + """) + + # Create Guilds table for server-specific meta data access settings + await db.execute(""" + CREATE TABLE IF NOT EXISTS Guilds ( + guild_id TEXT PRIMARY KEY, + squadron_clanID TEXT NOT NULL, + squadron_name TEXT NOT NULL, + access_password TEXT NOT NULL, + require_password BOOLEAN DEFAULT 0, + lock_squadron_data BOOLEAN DEFAULT 0, + allow_public_meta BOOLEAN DEFAULT 0, + created_unix INTEGER NOT NULL, + updated_unix INTEGER NOT NULL + ) + """) + + # Create Guild_Metas table to store which players are in each guild's meta + await db.execute(""" + CREATE TABLE IF NOT EXISTS Guild_Metas ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT NOT NULL, + userID TEXT NOT NULL, + nick TEXT NOT NULL, + clanTag TEXT, + clanName TEXT, + clanID TEXT, + added_unix INTEGER NOT NULL, + + UNIQUE(guild_id, userID), + FOREIGN KEY(guild_id) REFERENCES Guilds(guild_id) ON DELETE CASCADE + ) + """) + + # Create index for efficient queries + await db.execute(""" + CREATE INDEX IF NOT EXISTS idx_guild_metas_guild + ON Guild_Metas(guild_id) + """) + + await db.execute(""" + CREATE INDEX IF NOT EXISTS idx_guild_metas_userid + ON Guild_Metas(userID) + """) + + # Migration: add password_hint column if it doesn't exist + cursor = await db.execute("PRAGMA table_info(Guilds)") + columns = [row[1] for row in await cursor.fetchall()] + if "password_hint" not in columns: + await db.execute("ALTER TABLE Guilds ADD COLUMN password_hint TEXT DEFAULT NULL") + + await db.commit() + logging.info("✅ Meta.db initialized with Players_Global, Guilds, and Guild_Metas tables") + + +def generate_password_from_clanid(clan_id: str) -> str: + """ + Generate a secure password from clanID. + + Args: + clan_id: The squadron's clan ID + + Returns: + Generated password string + """ + # Create a hash of the clan_id and take first 12 characters + # Add some salt for uniqueness + salt = "SREBOT_META_ACCESS" + hash_input = f"{clan_id}_{salt}".encode('utf-8') + hash_digest = hashlib.sha256(hash_input).hexdigest() + # Take first 12 chars and format nicely + password = f"{hash_digest[:4]}-{hash_digest[4:8]}-{hash_digest[8:12]}" + result = password.upper() + logging.info(f"[PASSWORD GEN] clanID: '{clan_id}' -> password generated") + return result + + +async def get_guild_settings(guild_id: str) -> Optional[dict]: + """ + Get guild settings from Meta.db. + + Args: + guild_id: Discord guild ID + + Returns: + Dict with guild settings or None if not found + """ + async with aiosqlite.connect(META_DB_PATH) as db: + cursor = await db.execute(""" + SELECT guild_id, squadron_clanID, squadron_name, access_password, + require_password, lock_squadron_data, allow_public_meta, created_unix, updated_unix, + password_hint + FROM Guilds + WHERE guild_id = ? + """, (guild_id,)) + row = await cursor.fetchone() + + if not row: + return None + + return { + "guild_id": row[0], + "squadron_clanID": row[1], + "squadron_name": row[2], + "access_password": row[3], + "require_password": bool(row[4]), + "lock_squadron_data": bool(row[5]), + "allow_public_meta": bool(row[6]), + "created_unix": row[7], + "updated_unix": row[8], + "password_hint": row[9] or "" + } + + +async def get_squadron_owner(squadron_clanID: str) -> Optional[dict]: + """ + Check if this squadron is already claimed by any server. + + Args: + squadron_clanID: Squadron clan ID + + Returns: + Dict with owner guild settings or None if squadron not claimed + """ + async with aiosqlite.connect(META_DB_PATH) as db: + cursor = await db.execute(""" + SELECT guild_id, squadron_clanID, squadron_name, access_password, + require_password, lock_squadron_data, allow_public_meta, created_unix, updated_unix, + password_hint + FROM Guilds + WHERE squadron_clanID = ? + ORDER BY created_unix ASC + LIMIT 1 + """, (squadron_clanID,)) + row = await cursor.fetchone() + + if not row: + return None + + return { + "guild_id": row[0], + "squadron_clanID": row[1], + "squadron_name": row[2], + "access_password": row[3], + "require_password": bool(row[4]), + "lock_squadron_data": bool(row[5]), + "allow_public_meta": bool(row[6]), + "created_unix": row[7], + "updated_unix": row[8], + "password_hint": row[9] or "" + } + + +async def create_or_update_guild(guild_id: str, squadron_clanID: str, squadron_name: str, + require_password: bool = False, lock_squadron_data: bool = False) -> str: + """ + Create or update guild settings in Meta.db. + + Args: + guild_id: Discord guild ID + squadron_clanID: Squadron clan ID + squadron_name: Squadron name + require_password: Whether to require password for meta access + lock_squadron_data: Whether to lock squadron data to this server + + Returns: + Access password for the guild + """ + # Generate password from clanID + password = generate_password_from_clanid(squadron_clanID) + current_time = int(time.time()) + + async with aiosqlite.connect(META_DB_PATH) as db: + # Check if guild exists + existing = await get_guild_settings(guild_id) + + if existing: + # Update existing (including new password for new squadron) + logging.info(f"[CREATE_OR_UPDATE] UPDATING guild {guild_id}: squadron_clanID {existing['squadron_clanID']} -> {squadron_clanID}") + await db.execute(""" + UPDATE Guilds + SET squadron_clanID = ?, + squadron_name = ?, + access_password = ?, + require_password = ?, + lock_squadron_data = ?, + updated_unix = ? + WHERE guild_id = ? + """, (squadron_clanID, squadron_name, password, int(require_password), + int(lock_squadron_data), current_time, guild_id)) + else: + # Create new + logging.info(f"[CREATE_OR_UPDATE] CREATING guild {guild_id}: squadron_clanID {squadron_clanID}") + await db.execute(""" + INSERT INTO Guilds + (guild_id, squadron_clanID, squadron_name, access_password, + require_password, lock_squadron_data, created_unix, updated_unix) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, (guild_id, squadron_clanID, squadron_name, password, + int(require_password), int(lock_squadron_data), current_time, current_time)) + + await db.commit() + + return password + + +async def validate_squadron_password(squadron_clanID: str, provided_password: str) -> bool: + """ + Validate if provided password matches the squadron's password. + + Args: + squadron_clanID: Squadron clan ID + provided_password: Password to validate + + Returns: + True if password matches, False otherwise + """ + owner = await get_squadron_owner(squadron_clanID) + if not owner: + logging.warning(f"[PASSWORD VALIDATE] No owner found for squadron {squadron_clanID}") + return False + + stored_password = owner["access_password"].upper() + provided_upper = provided_password.upper() + + logging.info(f"[PASSWORD VALIDATE] squadron_clanID: {squadron_clanID}, match: {stored_password == provided_upper}") + + return stored_password == provided_upper + + +async def update_squadron_password(squadron_clanID: str, new_password: str, + password_hint: Optional[str] = None) -> bool: + """ + Update the squadron's access password and optionally the password hint. + + Args: + squadron_clanID: Squadron clan ID + new_password: The new password to set + password_hint: Optional hint to store (None = no change, "" = clear hint) + + Returns: + True if successful, False otherwise + """ + async with aiosqlite.connect(META_DB_PATH) as db: + updates = ["access_password = ?", "updated_unix = ?"] + values = [new_password, int(time.time())] + + if password_hint is not None: + updates.append("password_hint = ?") + values.append(password_hint if password_hint else None) + + values.append(squadron_clanID) + + await db.execute(f""" + UPDATE Guilds + SET {', '.join(updates)} + WHERE squadron_clanID = ? + """, tuple(values)) + await db.commit() + + logging.info(f"[PASSWORD UPDATE] Password updated for squadron {squadron_clanID}") + return True + + +async def transfer_squadron_to_guild(squadron_clanID: str, new_guild_id: str, squadron_name: str) -> bool: + """ + Transfer squadron ownership to a new guild (when they move servers). + + Args: + squadron_clanID: Squadron clan ID + new_guild_id: New guild ID to transfer to + squadron_name: Squadron name + + Returns: + True if successful, False otherwise + """ + async with aiosqlite.connect(META_DB_PATH) as db: + # Update the guild_id for this squadron + await db.execute(""" + UPDATE Guilds + SET guild_id = ?, + squadron_name = ?, + updated_unix = ? + WHERE squadron_clanID = ? + """, (new_guild_id, squadron_name, int(time.time()), squadron_clanID)) + await db.commit() + + return True + + +async def update_guild_settings(guild_id: str, require_password: Optional[bool] = None, + lock_squadron_data: Optional[bool] = None, + allow_public_meta: Optional[bool] = None) -> bool: + """ + Update specific guild settings. + + Args: + guild_id: Discord guild ID + require_password: Whether to require password (None = no change) + lock_squadron_data: Whether to lock data (None = no change) + allow_public_meta: Whether to allow non-admins to use /meta (None = no change) + + Returns: + True if successful, False otherwise + """ + async with aiosqlite.connect(META_DB_PATH) as db: + updates = [] + values = [] + + if require_password is not None: + updates.append("require_password = ?") + values.append(int(require_password)) + + if lock_squadron_data is not None: + updates.append("lock_squadron_data = ?") + values.append(int(lock_squadron_data)) + + if allow_public_meta is not None: + updates.append("allow_public_meta = ?") + values.append(int(allow_public_meta)) + + if not updates: + return False + + updates.append("updated_unix = ?") + values.append(int(time.time())) + values.append(guild_id) + + await db.execute(f""" + UPDATE Guilds + SET {', '.join(updates)} + WHERE guild_id = ? + """, tuple(values)) + + await db.commit() + + return True + + +async def add_player_to_guild_meta(guild_id: str, user_id: str, squadron_clanID: str) -> tuple[bool, str]: + """ + Add a player to a guild's meta roster. + + Args: + guild_id: Discord guild ID + user_id: Player UID + squadron_clanID: Squadron clan ID to validate against + + Returns: + Tuple of (success: bool, message: str) + """ + # Get player data from Players_Global + async with aiosqlite.connect(META_DB_PATH) as db: + cursor = await db.execute(""" + SELECT DISTINCT userID, nick, clanTag, clanName, clanID + FROM Players_Global + WHERE userID = ? + LIMIT 1 + """, (user_id,)) + row = await cursor.fetchone() + + if not row: + return False, f"Player `{user_id}` not found in Players_Global database." + + player_uid, nick, clan_tag, clan_name, player_clan_id = row + + # Validate that player belongs to the squadron + if player_clan_id != squadron_clanID: + return False, f"Player `{nick}` ({user_id}) does not belong to this squadron." + + # Add to Guild_Metas + try: + await db.execute(""" + INSERT INTO Guild_Metas (guild_id, userID, nick, clanTag, clanName, clanID, added_unix) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, (guild_id, player_uid, nick, clan_tag, clan_name, player_clan_id, int(time.time()))) + await db.commit() + return True, f"Successfully added **{nick}** to guild meta." + except Exception as e: + error_msg = str(e) + if "UNIQUE constraint" in error_msg: + return False, f"Player **{nick}** is already in your guild meta." + return False, f"Failed to add player: {error_msg}" + + +async def bulk_add_squadron_players_to_guild_meta( + guild_id: str, + squadron_clanID: str, + squadron_name: str, + clan_tag: str, + members: Dict[str, Dict[str, Any]], +) -> Tuple[int, int, int]: + """ + Sync all squadron players to a guild's meta roster. + Adds new members and removes players who are no longer in the squadron. + + Args: + guild_id: Discord guild ID + squadron_clanID: Squadron clan ID + squadron_name: Squadron long name + clan_tag: Squadron tag name + members: Dict from obtain_clan_new_points(): ``{uid: {"nick": str, ...}}``. + Only the ``"nick"`` key is accessed per member. + + Returns: + Tuple of (added_count, removed_count, skipped_count) + """ + now = int(time.time()) + added = 0 + removed = 0 + skipped = 0 + current_uids = set(members.keys()) + + async with aiosqlite.connect(META_DB_PATH) as db: + # Insert new members + for uid, info in members.items(): + nick = info.get("nick", "Unknown") + cursor = await db.execute(""" + INSERT OR IGNORE INTO Guild_Metas + (guild_id, userID, nick, clanTag, clanName, clanID, added_unix) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, (guild_id, uid, nick, clan_tag, squadron_name, squadron_clanID, now)) + if cursor.rowcount > 0: + added += 1 + else: + skipped += 1 + + # Remove members who are no longer in the squadron + cursor = await db.execute( + "SELECT userID FROM Guild_Metas WHERE guild_id = ?", (guild_id,) + ) + existing_uids = {row[0] for row in await cursor.fetchall()} + departed_uids = existing_uids - current_uids + for uid in departed_uids: + await db.execute( + "DELETE FROM Guild_Metas WHERE guild_id = ? AND userID = ?", + (guild_id, uid) + ) + removed += 1 + + await db.commit() + + return added, removed, skipped + + +async def refresh_guild_player_vehicles(guild_id: str, max_concurrent: int = 5): + """ + Re-fetch vehicle data from the Game API for all players in a guild's meta roster. + Skips players updated within 2 days. + + Args: + guild_id: Discord guild ID + max_concurrent: Maximum concurrent API requests + + Returns: + None. Players_Global rows are updated in-place. + """ + async with aiosqlite.connect(META_DB_PATH) as db: + cursor = await db.execute( + "SELECT userID, nick FROM Guild_Metas WHERE guild_id = ?", (guild_id,) + ) + players = [(row[0], row[1]) for row in await cursor.fetchall()] + + if not players: + return + + semaphore = asyncio.Semaphore(max_concurrent) + + async with aiosqlite.connect(META_DB_PATH) as meta_db: + for i in range(0, len(players), 20): + batch = players[i:i + 20] + await process_player_batch(batch, meta_db, skip_existing=True, semaphore=semaphore) + await meta_db.commit() + + logging.info(f"[META] Refreshed vehicle data for {len(players)} players in guild {guild_id}") + + +async def sync_all_guild_metas() -> Dict[str, Any]: + """ + Daily sync: for every guild in the Guilds table, fetch the current squadron + roster from the Game API and reconcile Guild_Metas (add new, remove departed). + + Returns: + Summary dict with total added/removed counts and per-guild results. + """ + summary = {"guilds_processed": 0, "total_added": 0, "total_removed": 0, "errors": []} + + async with aiosqlite.connect(META_DB_PATH) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT guild_id, squadron_clanID, squadron_name FROM Guilds" + ) + guilds = await cursor.fetchall() + + for guild in guilds: + guild_id = guild["guild_id"] + clan_id = guild["squadron_clanID"] + squadron_name = guild["squadron_name"] + + try: + members, _total_points = await obtain_clan_new_points(squadron_name) + except Exception as e: + logging.warning(f"[META-SYNC] Failed to fetch members for guild {guild_id} ({squadron_name}): {e}") + summary["errors"].append(guild_id) + continue + + if not members: + logging.warning(f"[META-SYNC] No members returned for guild {guild_id} ({squadron_name}), skipping.") + continue + + # Resolve clan_tag from squadrons.db + clan_tag = "" + try: + async with aiosqlite.connect(SQUADRONS_DB_PATH) as sq_db: + sq_cursor = await sq_db.execute( + "SELECT tag_name FROM squadrons_data WHERE clan_id = ? LIMIT 1", + (clan_id,) + ) + row = await sq_cursor.fetchone() + if row: + clan_tag = row[0] or "" + except Exception: + pass + + try: + added, removed, _skipped = await bulk_add_squadron_players_to_guild_meta( + guild_id, clan_id, squadron_name, clan_tag, members + ) + summary["guilds_processed"] += 1 + summary["total_added"] += added + summary["total_removed"] += removed + if added or removed: + logging.info(f"[META-SYNC] Guild {guild_id} ({squadron_name}): +{added} added, -{removed} removed") + except Exception as e: + logging.warning(f"[META-SYNC] Failed to sync guild {guild_id}: {e}") + summary["errors"].append(guild_id) + + logging.info( + f"[META-SYNC] Done. {summary['guilds_processed']} guilds, " + f"+{summary['total_added']} added, -{summary['total_removed']} removed, " + f"{len(summary['errors'])} errors." + ) + return summary + + +async def remove_player_from_guild_meta(guild_id: str, user_id: str) -> tuple[bool, str]: + """ + Remove a player from a guild's meta roster. + + Args: + guild_id: Discord guild ID + user_id: Player UID + + Returns: + Tuple of (success: bool, message: str) + """ + async with aiosqlite.connect(META_DB_PATH) as db: + # Get player nick first + cursor = await db.execute(""" + SELECT nick FROM Guild_Metas + WHERE guild_id = ? AND userID = ? + """, (guild_id, user_id)) + row = await cursor.fetchone() + + if not row: + return False, f"Player `{user_id}` not found in your guild meta." + + nick = row[0] + + # Remove player + await db.execute(""" + DELETE FROM Guild_Metas + WHERE guild_id = ? AND userID = ? + """, (guild_id, user_id)) + await db.commit() + + return True, f"Successfully removed **{nick}** from guild meta." + + +async def get_guild_meta_players(guild_id: str) -> List[dict]: + """ + Get all players in a guild's meta roster. + + Args: + guild_id: Discord guild ID + + Returns: + List of dicts, each with keys: ``userID``, ``nick``, ``clanTag``, + ``clanName``, ``clanID``, ``added_unix``. + """ + async with aiosqlite.connect(META_DB_PATH) as db: + cursor = await db.execute(""" + SELECT userID, nick, clanTag, clanName, clanID, added_unix + FROM Guild_Metas + WHERE guild_id = ? + ORDER BY nick ASC + """, (guild_id,)) + rows = await cursor.fetchall() + + return [ + { + "userID": row[0], + "nick": row[1], + "clanTag": row[2], + "clanName": row[3], + "clanID": row[4], + "added_unix": row[5] + } + for row in rows + ] + + +def _fuzzy_match(search_terms: List[str], target: str) -> bool: + """ + Check if all search terms appear in the target string. + This allows searches like "leopard 2a7" to match "Leopard 2A7V". + + Args: + search_terms: List of lowercase search terms + target: Target string to match against + + Returns: + True if all terms found in target + """ + target_lower = target.lower() + return all(term in target_lower for term in search_terms) + + +def _score_match(search_terms: List[str], target: str) -> int: + """ + Score how well search terms match a target. + Higher score = better match. + + Args: + search_terms: List of lowercase search terms + target: Target string to match against + + Returns: + Match score (0 = no match) + """ + target_lower = target.lower() + score = 0 + + for term in search_terms: + if term in target_lower: + score += 10 + # Bonus for exact word match + if term in target_lower.split(): + score += 5 + # Bonus for start of word match + if target_lower.startswith(term) or f" {term}" in target_lower or f"-{term}" in target_lower: + score += 3 + + return score + + +async def get_guild_meta_vehicles(guild_id: str) -> List[dict]: + """ + Get all unique vehicles from players in a guild's meta roster. + Used for autocomplete suggestions. + + Deduplicates by human-readable name to avoid showing the same display name + multiple times (e.g., different nation variants like germ_leopard_2a4 and + sw_leopard_2a4 both display as "Leopard 2A4"). + + Args: + guild_id: Discord guild ID + + Returns: + List of dicts with vehicle_name (internal) and vehicle_human (readable) + """ + # Use dict keyed by human name to dedupe display duplicates + # Store first internal name encountered for each human name + vehicles_by_human = {} + + async with aiosqlite.connect(META_DB_PATH) as db: + # Get all players in this guild's meta + cursor = await db.execute(""" + SELECT userID FROM Guild_Metas WHERE guild_id = ? + """, (guild_id,)) + guild_players = await cursor.fetchall() + + for (user_id,) in guild_players: + cursor = await db.execute(""" + SELECT vehicles FROM Players_Global WHERE userID = ? + """, (user_id,)) + row = await cursor.fetchone() + + if not row or not row[0]: + continue + + try: + vehicles_array = decompress_json(row[0]) + except (json.JSONDecodeError, TypeError, OSError): + continue + + for vehicle in vehicles_array: + internal = vehicle.get("vehicle_name", "") + human = vehicle.get("vehicle_human", "") or internal + # Dedupe by human name - keep first internal name encountered + if human and human not in vehicles_by_human: + vehicles_by_human[human] = internal + + return [{"vehicle_name": v, "vehicle_human": k} for k, v in vehicles_by_human.items()] + + +async def search_guild_meta_by_vehicle(guild_id: str, vehicle_name: str, exact_only: bool = False) -> List[dict]: + """ + Search for players in guild meta who have a specific vehicle. + Searches within the vehicles JSON array for each player. + + Supports fuzzy matching on human-readable names: + - "KVT" matches "M1 KVT" + - "leopard 2a7" matches "Leopard 2A7V" + - "f16" matches "F-16C" + + Args: + guild_id: Discord guild ID. + vehicle_name: Vehicle name (partial/fuzzy match supported). + exact_only: If True, only return exact internal-name matches. + + Returns: + List of dicts with player info and vehicle stats. + """ + async with aiosqlite.connect(META_DB_PATH) as db: + # Get all players in this guild's meta + cursor = await db.execute(""" + SELECT userID, nick, clanTag, clanName + FROM Guild_Metas + WHERE guild_id = ? + """, (guild_id,)) + guild_players = await cursor.fetchall() + + results = [] + + # Prepare search terms (split by spaces, remove empty) + search_input = vehicle_name.strip().lower() + search_terms = [t for t in search_input.split() if t] + + # For each player, get their vehicles JSON from Players_Global + # First pass: try exact internal name match (from autocomplete) + # Second pass: fall back to fuzzy match on human-readable name only + exact_results = [] + fuzzy_results = [] + + for user_id, nick, clan_tag, clan_name in guild_players: + cursor = await db.execute(""" + SELECT vehicles + FROM Players_Global + WHERE userID = ? + """, (user_id,)) + row = await cursor.fetchone() + + if not row or not row[0]: + continue + + # Parse vehicles array (may be gzip-compressed BLOB or legacy TEXT) + try: + vehicles_array = decompress_json(row[0]) + except (json.JSONDecodeError, TypeError, OSError): + continue + + # Search through vehicles array for matching vehicles + for vehicle in vehicles_array: + vehicle_name_internal = vehicle.get("vehicle_name", "") + vehicle_human = vehicle.get("vehicle_human", "") or "" + + # Exact internal name match (highest priority) + if vehicle_name_internal.lower() == search_input: + match_score = 100 + exact_results.append((user_id, nick, clan_tag, clan_name, vehicle, match_score)) + continue + + # Fuzzy matching on human-readable name only (not internal name) + # This prevents "so_4050_vautour_2n" from matching "so_4050_vautour_2n_late" + human_lower = vehicle_human.lower() + + match_score = 0 + if search_terms: + if _fuzzy_match(search_terms, human_lower): + match_score = _score_match(search_terms, human_lower) + + if search_input in human_lower: + match_score = max(match_score, 15) + + if match_score > 0: + fuzzy_results.append((user_id, nick, clan_tag, clan_name, vehicle, match_score)) + + # Use exact results if found, otherwise fall back to fuzzy (unless exact_only) + if exact_results: + matched = exact_results + elif exact_only: + matched = [] + else: + matched = fuzzy_results + + for user_id, nick, clan_tag, clan_name, vehicle, match_score in matched: + stats = vehicle.get("stats", {}) + + results.append({ + "userID": user_id, + "nick": nick, + "clanTag": clan_tag, + "clanName": clan_name, + "intname": vehicle.get("vehicle_name", ""), + "vehicle_human": vehicle.get("vehicle_human", "") or "", + "mode": vehicle.get("mode", ""), + "flyouts": vehicle.get("flyouts", 0), + "was_in_session": vehicle.get("was_in_session", 0), + "deaths": stats.get("deaths", 0), + "ground_kills": stats.get("ground_kills", 0), + "air_kills": stats.get("air_kills", 0), + "_match_score": match_score + }) + + # Sort by match score (best matches first), then by player name + results.sort(key=lambda x: (-x.get("_match_score", 0), x.get("nick", "").lower())) + + # Remove the internal match score before returning + for r in results: + r.pop("_match_score", None) + + return results + + +async def get_all_player_uids() -> List[Tuple[str, str]]: + """ + Get all unique player UIDs and nicknames from sq_battles.db. + + Returns: + List of tuples (UID, nick) + """ + if not SQ_BATTLES_DB_PATH.exists(): + logging.error(f"❌ {SQ_BATTLES_DB_PATH} does not exist!") + return [] + + async with aiosqlite.connect(SQ_BATTLES_DB_PATH) as db: + cursor = await db.execute(""" + SELECT DISTINCT UID, nick + FROM player_games_hist + WHERE UID IS NOT NULL AND UID != '' + ORDER BY UID + """) + rows = await cursor.fetchall() + result = [(str(row[0]), str(row[1])) for row in rows] + logging.info(f"📊 Found {len(result)} unique players in sq_battles.db") + return result + + +def translate_vehicle_name(vehicle_cdk: str) -> Optional[str]: + """ + Translate vehicle CDK (code name) to human-readable name. + + Args: + vehicle_cdk: Internal vehicle code name + + Returns: + Human-readable vehicle name or None if not found + """ + try: + raw = translate.get_translate(vehicle_cdk) + if raw: + return normalize_name(raw) + return None + except Exception: + return None + + +async def fetch_player_vehicles(uid: str, max_retries: int = 3) -> Optional[dict]: + """ + Fetch player vehicle data from Game API with retry logic. + + Args: + uid: Player UID + max_retries: Maximum number of retry attempts for 503 errors + + Returns: + Cleaned player data dict or None on failure + """ + last_error = None + + for attempt in range(max_retries): + try: + data = await obtain_player_data_api(uid, raw=False) + + # Check if token expired (empty dict returned) + if not data or not isinstance(data, dict): + return None + + return data + except Exception as e: + last_error = e + error_msg = str(e) + # Check if it's a rate limit error (503) + if "503" in error_msg or "RETRY" in error_msg or "Network request failed" in error_msg: + if attempt < max_retries - 1: + wait_time = (attempt + 1) * 2 # Exponential backoff: 2s, 4s, 6s + # Silently retry - don't spam logs + await asyncio.sleep(wait_time) + continue + else: + # Only log after all retries exhausted + logging.info(f"⚠️ Rate limited UID {uid} after {max_retries} attempts") + return None + else: + # Non-rate-limit error - log it + logging.error(f"❌ Failed to fetch UID {uid}: {error_msg}") + return None + + return None + + +async def insert_player_vehicles(db, player_data: dict, updated_unix: int): + """ + Insert or update player vehicle data in Meta.db. + Stores ONE ROW PER PLAYER with all vehicles as JSON array. + + Args: + db: Database connection + player_data: Cleaned player data from Game API + updated_unix: Unix timestamp of when this data was fetched + + Returns: + None. The row is upserted into Players_Global. + """ + user_id = player_data.get("userID", "") + nick = player_data.get("nick", "") + clan_tag = player_data.get("clanTag", "") + clan_name = player_data.get("clanName", "") + clan_id = player_data.get("clanID", "") + last_day = player_data.get("lastDay", 0) + vehicles_raw = player_data.get("vehicles", []) + + if not user_id: + logging.warning("⚠️ Skipping player with no userID") + return + + # Build vehicles array with translated names + vehicles_array = [] + for vehicle in vehicles_raw: + vehicle_name = vehicle.get("name", "") + mode = vehicle.get("mode", "") + flyouts = vehicle.get("flyouts", 0) + was_in_session = vehicle.get("was_in_session", 0) + + if not vehicle_name or not mode: + continue + + # Translate vehicle name to human-readable + vehicle_human = translate_vehicle_name(vehicle_name) + + # Extract stats + stats_dict = {k: v for k, v in vehicle.items() + if k not in ["name", "mode", "flyouts", "was_in_session"]} + + vehicles_array.append({ + "vehicle_name": vehicle_name, + "vehicle_human": vehicle_human, + "mode": mode, + "flyouts": flyouts, + "was_in_session": was_in_session, + "stats": stats_dict + }) + + # Compress vehicles array for BLOB storage + vehicles_json = compress_json(vehicles_array) + + # Insert or update - ONE ROW PER PLAYER + await db.execute(""" + INSERT INTO Players_Global + (userID, nick, clanTag, clanName, clanID, lastDay, vehicles, updated_unix) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(userID) DO UPDATE SET + nick = excluded.nick, + clanTag = excluded.clanTag, + clanName = excluded.clanName, + clanID = excluded.clanID, + lastDay = excluded.lastDay, + vehicles = excluded.vehicles, + updated_unix = excluded.updated_unix + """, (user_id, nick, clan_tag, clan_name, clan_id, last_day, vehicles_json, updated_unix)) + + +async def process_player_batch(players_batch: List[Tuple[str, str]], meta_db, skip_existing: bool, semaphore): + """ + Process a batch of players concurrently. + + Args: + players_batch: List of (uid, nick) tuples + meta_db: Database connection + skip_existing: If True, skip players with recent data + semaphore: Asyncio semaphore for rate limiting + + Returns: + Dict with keys ``"success"``, ``"errors"``, and ``"skipped"`` + containing integer counts for each outcome. + """ + results = {"success": 0, "errors": 0, "skipped": 0} + + async def process_single_player(uid: str, nick: str): + async with semaphore: + try: + # Check if player was recently updated (within 2 days) + if skip_existing: + cursor = await meta_db.execute(""" + SELECT updated_unix FROM Players_Global WHERE userID = ? + """, (uid,)) + row = await cursor.fetchone() + if row and row[0]: + days_old = (time.time() - row[0]) / 86400 + if days_old < 2: + return "skipped" + + # Fetch player data + player_data = await fetch_player_vehicles(uid) + + if player_data: + # Insert vehicle data + updated_unix = int(time.time()) + await insert_player_vehicles(meta_db, player_data, updated_unix) + return "success" + else: + return "error" + + except Exception as e: + logging.error(f"❌ Error processing UID {uid}: {e}") + return "error" + + # Process all players in batch concurrently + tasks = [process_single_player(uid, nick) for uid, nick in players_batch] + batch_results = await asyncio.gather(*tasks, return_exceptions=True) + + # Count results + for result in batch_results: + if isinstance(result, Exception): + results["errors"] += 1 + elif result == "success": + results["success"] += 1 + elif result == "skipped": + results["skipped"] += 1 + elif result == "error": + results["errors"] += 1 + + return results + + +async def process_all_players(limit: Optional[int] = None, skip_existing: bool = True, batch_size: int = 20, max_concurrent: int = 5): + """ + Process all players and fetch their vehicle data. + + Args: + limit: Optional limit on number of players to process + skip_existing: If True, skip players already in Meta.db with recent data (within 2 days) + batch_size: Number of players to process in each batch before committing + max_concurrent: Maximum number of concurrent API requests (default 5 to avoid rate limits) + + Returns: + None. Results are committed to Meta.db and logged. + """ + # Initialize Meta.db + await init_meta_db() + + # Get all player UIDs + players = await get_all_player_uids() + + if limit: + players = players[:limit] + logging.info(f"📌 Processing limited to {limit} players") + + total = len(players) + processed = 0 + success = 0 + errors = 0 + skipped = 0 + + # Create semaphore for rate limiting + semaphore = asyncio.Semaphore(max_concurrent) + + async with aiosqlite.connect(META_DB_PATH) as meta_db: + # Process in batches + for i in range(0, total, batch_size): + batch = players[i:i + batch_size] + batch_results = await process_player_batch(batch, meta_db, skip_existing, semaphore) + + # Update counters + success += batch_results["success"] + errors += batch_results["errors"] + skipped += batch_results["skipped"] + processed += len(batch) + + # Commit after each batch + await meta_db.commit() + + + # Small delay between batches to avoid overwhelming the API + await asyncio.sleep(1) + + # Final commit + await meta_db.commit() + + + +async def main(): + """CLI entry point for meta_manager. + + Fetches and stores vehicle data for all players in sq_battles.db, + skipping recently-updated entries. + """ + # Process all players with concurrent batch processing + # max_concurrent=5 means up to 5 API requests running at once (reduced to avoid 503 errors) + # batch_size=50 means commit to DB every 50 players + await process_all_players( + limit=None, # No limit - process all players + skip_existing=True, # Skip players updated within 2 days + batch_size=50, # Commit every 50 players + max_concurrent=5 # 5 concurrent API requests (conservative to avoid rate limits) + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/BOT/receiver_bridge.py b/BOT/receiver_bridge.py new file mode 100644 index 0000000..0a102c1 --- /dev/null +++ b/BOT/receiver_bridge.py @@ -0,0 +1,232 @@ +""" +receiver_bridge.py + +Bridge helpers for external SREBOT transfer. + +This module provides two pieces: +1. A formal SREBOT API client that external consumers can use to query the + SREBOT HTTP API. +2. A persistent outbox for replay and GOB payloads so the external bridge + service can fan them out over websocket. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import quote +from typing import Any, Optional + +import aiofiles +import aiohttp + +logger = logging.getLogger(__name__) + + +def _env(name: str, default: str = "") -> str: + value = os.getenv(name, default) + return value.strip() + + +_storage_root_raw = _env("SREBOT_STORAGE_VOL_PATH") +if not _storage_root_raw: + raise RuntimeError("SREBOT_STORAGE_VOL_PATH must be set") +_STORAGE_ROOT = Path(_storage_root_raw) +SREBOT_API_BASE_URL = _env("SREBOT_API_BASE_URL", _env("SREBOT_HTTP_URL", "http://127.0.0.1:6000")).rstrip("/") +SREBOT_API_BEARER_TOKEN = _env("SREBOT_API_BEARER_TOKEN") +EXTERNAL_OUTBOX_PATH = Path(_env("SREBOT_EXTERNAL_OUTBOX_PATH", str(_STORAGE_ROOT / "external_bridge_outbox.jsonl"))) +EXTERNAL_OUTBOX_PATH.parent.mkdir(parents=True, exist_ok=True) + + +@dataclass(slots=True) +class SREBOTApiClient: + """Typed HTTP client for the SREBOT read-only API.""" + + base_url: str = SREBOT_API_BASE_URL + bearer_token: str = SREBOT_API_BEARER_TOKEN + timeout_seconds: float = 30.0 + + def _headers(self) -> dict[str, str]: + headers = {"Accept": "application/json"} + if self.bearer_token: + headers["Authorization"] = f"Bearer {self.bearer_token}" + return headers + + async def _request(self, path: str, params: Optional[dict[str, Any]] = None) -> Any: + url = f"{self.base_url}/{path.lstrip('/')}" + timeout = aiohttp.ClientTimeout(total=self.timeout_seconds) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(url, headers=self._headers(), params=params) as response: + response.raise_for_status() + return await response.json() + + async def get_info(self) -> Any: + return await self._request("/api/info") + + async def get_player(self, uid: str, **params: Any) -> Any: + return await self._request(f"/api/player/{quote(str(uid), safe='')}", params=params or None) + + async def get_player_games(self, uid: str, **params: Any) -> Any: + return await self._request(f"/api/player/{quote(str(uid), safe='')}/games", params=params or None) + + async def get_player_history(self, uid: str) -> Any: + return await self._request(f"/api/player/{quote(str(uid), safe='')}/history") + + async def search_players(self, nickname: str) -> Any: + return await self._request(f"/api/search/{quote(str(nickname), safe='')}") + + async def get_live(self, **params: Any) -> Any: + return await self._request("/api/live", params=params or None) + + async def get_match(self, session_id: str) -> Any: + return await self._request(f"/api/match/{quote(str(session_id), safe='')}") + + async def get_match_replay(self, session_id: str) -> Any: + return await self._request(f"/api/match/{quote(str(session_id), safe='')}/replay") + + async def search_games(self, **params: Any) -> Any: + return await self._request("/api/games/search", params=params or None) + + async def get_maps(self) -> Any: + return await self._request("/api/maps") + + async def get_squadron(self, squadron_name: str, **params: Any) -> Any: + return await self._request(f"/api/squadrons/{quote(str(squadron_name), safe='')}", params=params or None) + + async def get_leaderboard_players(self, **params: Any) -> Any: + return await self._request("/api/leaderboard/players", params=params or None) + + async def get_leaderboard_squadrons(self, **params: Any) -> Any: + return await self._request("/api/leaderboard/squadrons", params=params or None) + + async def get_leaderboard_vehicles(self, **params: Any) -> Any: + return await self._request("/api/leaderboard/vehicles", params=params or None) + + async def get_leaderboard_stats(self) -> Any: + return await self._request("/api/leaderboard/stats") + + +_default_api_client = SREBOTApiClient() + + +async def fetch_api_info() -> Any: + return await _default_api_client.get_info() + + +async def fetch_player(uid: str, **params: Any) -> Any: + return await _default_api_client.get_player(uid, **params) + + +async def fetch_player_games(uid: str, **params: Any) -> Any: + return await _default_api_client.get_player_games(uid, **params) + + +async def fetch_player_history(uid: str) -> Any: + return await _default_api_client.get_player_history(uid) + + +async def search_players(nickname: str) -> Any: + return await _default_api_client.search_players(nickname) + + +async def fetch_live(**params: Any) -> Any: + return await _default_api_client.get_live(**params) + + +async def fetch_match(session_id: str) -> Any: + return await _default_api_client.get_match(session_id) + + +async def fetch_match_replay(session_id: str) -> Any: + return await _default_api_client.get_match_replay(session_id) + + +async def search_games(**params: Any) -> Any: + return await _default_api_client.search_games(**params) + + +async def fetch_maps() -> Any: + return await _default_api_client.get_maps() + + +async def fetch_squadron(squadron_name: str, **params: Any) -> Any: + return await _default_api_client.get_squadron(squadron_name, **params) + + +async def fetch_leaderboard_players(**params: Any) -> Any: + return await _default_api_client.get_leaderboard_players(**params) + + +async def fetch_leaderboard_squadrons(**params: Any) -> Any: + return await _default_api_client.get_leaderboard_squadrons(**params) + + +async def fetch_leaderboard_vehicles(**params: Any) -> Any: + return await _default_api_client.get_leaderboard_vehicles(**params) + + +async def fetch_leaderboard_stats() -> Any: + return await _default_api_client.get_leaderboard_stats() + + +_EXTERNAL_OUTBOX_LOCK: asyncio.Lock | None = None + + +def _get_external_outbox_lock() -> asyncio.Lock: + global _EXTERNAL_OUTBOX_LOCK + if _EXTERNAL_OUTBOX_LOCK is None: + _EXTERNAL_OUTBOX_LOCK = asyncio.Lock() + return _EXTERNAL_OUTBOX_LOCK + + +async def _append_external_envelope(envelope: dict[str, Any]) -> None: + line = json.dumps(envelope, ensure_ascii=False, separators=(",", ":")) + async with _get_external_outbox_lock(): + async with aiofiles.open(EXTERNAL_OUTBOX_PATH, "a", encoding="utf-8") as handle: + await handle.write(line + "\n") + logger.info( + "Bridge envelope queued", + extra={ + "event_type": envelope.get("type"), + "outbox_path": str(EXTERNAL_OUTBOX_PATH), + }, + ) + + +async def publish_replay_batch(replays: list[dict[str, Any]]) -> None: + """Queue a replay batch for websocket delivery by the external bridge.""" + if not replays: + return + envelope = { + "type": "spectra.replay_batch", + "version": 1, + "source": "srebot", + "payload": {"replays": replays}, + } + await _append_external_envelope(envelope) + + +async def publish_gob_payload(payload: dict[str, Any]) -> None: + """Queue a GOB payload for websocket delivery by the external bridge.""" + envelope = { + "type": "spectra.gob", + "version": 1, + "source": "srebot", + "payload": payload, + } + await _append_external_envelope(envelope) + + +async def publish_event(event_type: str, payload: dict[str, Any]) -> None: + """Generic queue helper for future bridge events.""" + envelope = { + "type": event_type, + "version": 1, + "source": "srebot", + "payload": payload, + } + await _append_external_envelope(envelope) diff --git a/BOT/render_recap.py b/BOT/render_recap.py new file mode 100644 index 0000000..b4515e7 --- /dev/null +++ b/BOT/render_recap.py @@ -0,0 +1,1805 @@ +""" +render_recap.py + +CLI tool that renders a season recap PNG for either a squadron or a single +player. Invoked by web/server.js on cache miss. + +Usage (squadron mode — default): + python render_recap.py \\ + --mode squadron \\ + --clan-id 123456 \\ + --season 2026-II \\ + --season-start 1772348400 \\ + --season-end 1777852799 \\ + --week-boundaries 1772348400,1773039600,... \\ + --out /path/to/output.png + +Usage (player mode): + python render_recap.py \\ + --mode player \\ + --uid 987654321 \\ + --season 2026-II \\ + --season-start 1772348400 \\ + --season-end 1777852799 \\ + --week-boundaries 1772348400,1773039600,... \\ + --out /path/to/output.png + +Exits 0 on success, non-zero with stderr diagnostic on failure. +""" + +import argparse +import gzip +import json +import re +import logging +import os +import sqlite3 +import sys +from collections import defaultdict +from dataclasses import dataclass +from datetime import date as _date, datetime, timezone +from pathlib import Path +from typing import List, Literal, Optional, Tuple + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +from matplotlib.gridspec import GridSpec +from matplotlib.offsetbox import AnchoredOffsetbox, HPacker, TextArea + +plt.rcParams["font.family"] = "sans-serif" +plt.rcParams["font.sans-serif"] = [ + "DejaVu Sans", "Noto Sans", "Noto Sans Symbols2", + "Arial", "Liberation Sans", "sans-serif", +] + + +def _sanitize_render_text(s: str) -> str: + """Strip Unicode code points that render as tofu boxes (tag chars, + variation selectors).""" + if not s: + return s + return "".join( + c for c in s + if not ( + 0xE0000 <= ord(c) <= 0xE007F # language tag characters + or 0xFE00 <= ord(c) <= 0xFE0F # VS1-VS16 + or 0xE0100 <= ord(c) <= 0xE01EF # VS17-VS256 + ) + ) + + +_repo_root = Path(__file__).resolve().parent.parent +if str(_repo_root) not in sys.path: + sys.path.insert(0, str(_repo_root)) + +from BOT.data_parser import apply_vehicle_name_filters # noqa: E402 + +LOCALES_DIR = Path(__file__).resolve().parent.parent / "web" / "locales" +DEFAULT_LANG = "en" + + +def load_translations(lang: str) -> dict[str, str]: + """Load seasonCard.* strings for the given language, falling back to English for missing keys.""" + def _read(lang_code: str) -> dict: + path = LOCALES_DIR / f"{lang_code}.json" + if not path.exists(): + return {} + try: + return json.loads(path.read_text(encoding="utf-8")).get("seasonCard", {}) + except Exception: + return {} + + en = _read(DEFAULT_LANG) + translated = _read(lang) if lang != DEFAULT_LANG else {} + merged = dict(en) + merged.update(translated) + return merged + +RATING_COLOR = "#14b8a6" +WR_COLOR = "#f59e0b" +KD_COLOR = "#a78bfa" +BATTLES_COLOR = "#60a5fa" + +# Ordered so the first slots avoid clashing with the rolling-curve colors +# (WR #f59e0b orange, K/D #a78bfa violet, Battles #60a5fa blue). +_SQUADRON_PALETTE: List[str] = [ + "#14b8a6", # teal (same as RATING_COLOR — single-squadron default) + "#dc2626", # red + "#059669", # green + "#ec4899", # pink + "#84cc16", # lime + "#06b6d4", # cyan + "#db2777", # magenta + "#65a30d", # olive +] +_NO_SQUADRON_COLOR = "#64748b" + +THEMES: dict[str, dict[str, str]] = { + "light": { + "bg": "#fafafa", + "text": "#0f172a", + "muted": "#64748b", + "header_sub": "#475569", + "grid": "#94a3b8", + "divider": "#cbd5e1", + "footer": "#94a3b8", + }, + "dark": { + "bg": "#121418", + "text": "#e5e7eb", + "muted": "#9ca3af", + "header_sub": "#d1d5db", + "grid": "#2a2f36", + "divider": "#262a30", + "footer": "#6b7280", + }, +} + + +def theme_palette(name: str) -> dict[str, str]: + return THEMES.get(name, THEMES["light"]) + + +def smooth_series(series: List[Tuple[int, int]], window: int = 12 + ) -> List[Tuple[int, float]]: + """Centered moving average over (ts, value) pairs. Preserves length.""" + n = len(series) + if n == 0: + return [] + if n < window * 2: + return [(ts, float(v)) for ts, v in series] + half = window // 2 + out: List[Tuple[int, float]] = [] + for i in range(n): + lo = max(0, i - half) + hi = min(n, i + half + 1) + avg = sum(v for _, v in series[lo:hi]) / (hi - lo) + out.append((series[i][0], avg)) + return out + + +def _downsample_daily(series: list) -> list: + """Reduce to one point per UTC day, keeping each day's last (ts, value). + Caller can still apply smoothing; this just removes intra-day clustering + that otherwise renders as a vertical bar on a multi-month chart.""" + if not series: + return [] + out: list = [] + prev_day = None + last_pt = None + for ts, v in series: + day = datetime.fromtimestamp(ts, tz=timezone.utc).date() + if prev_day is not None and day != prev_day and last_pt is not None: + out.append(last_pt) + last_pt = (ts, v) + prev_day = day + if last_pt is not None: + out.append(last_pt) + return out + + +def _split_on_gaps(series: list, gap_seconds: int) -> list: + """Split a time-sorted series into sublists wherever consecutive timestamps + are more than gap_seconds apart. Prevents matplotlib from interpolating a + straight line across an inactive period.""" + if not series: + return [] + segments: list = [[]] + prev_ts = None + for item in series: + ts = item[0] + if prev_ts is not None and ts - prev_ts > gap_seconds and segments[-1]: + segments.append([]) + segments[-1].append(item) + prev_ts = ts + return [s for s in segments if s] + + +def _clip_to_ranges(series: list, timeline: list) -> list: + """Return a list of sublists — one per timeline range — containing only + the points whose ts falls inside that range's [first_ts, last_ts]. + Used to make rolling WR/KD/Battles curves respect squadron shading + boundaries instead of sprawling across unshaded gaps.""" + if not series or not timeline: + return [] + out: list = [] + for rng in timeline: + seg = [p for p in series if rng.first_ts <= p[0] <= rng.last_ts] + if seg: + out.append(seg) + return out + + +def _smooth_rolling_curve(series: list, window: int = 8) -> list: + """Centered moving average for already-computed rolling curves. + Accepts (ts, int) or (ts, float) pairs; returns (ts, float).""" + n = len(series) + if n == 0: + return [] + if n < window * 2: + return [(t, float(v)) for t, v in series] + half = window // 2 + out: list = [] + for i in range(n): + lo = max(0, i - half) + hi = min(n, i + half + 1) + avg = sum(v for _, v in series[lo:hi]) / (hi - lo) + out.append((series[i][0], float(avg))) + return out + + +_storage_env = os.environ.get("SREBOT_STORAGE_VOL_PATH", "").strip() +if not _storage_env: + raise RuntimeError("SREBOT_STORAGE_VOL_PATH must be set") +_STORAGE = Path(_storage_env) +SQUADRONS_DB = _STORAGE / "squadrons.db" +SQ_BATTLES_DB = _STORAGE / "sq_battles.db" +SEASONS_TXT = (Path(__file__).resolve().parent.parent + / "web" / "constants" / "seasons") + +_BR_LINE_RE = re.compile(r".*?max BR\s+(\d+(?:\.\d+)?)", + re.IGNORECASE) + + +def _load_br_schedule() -> List[dict]: + """Parse web/constants/seasons into a list of + [{'max_br': float, 'start': unix, 'end': unix}, ...]. + Contains every season's BR tiers. Callers filter by season window.""" + try: + text = SEASONS_TXT.read_text(encoding="utf-8") + except OSError: + return [] + entries: List[dict] = [] + for line in text.splitlines(): + m = _BR_LINE_RE.search(line) + if not m: + continue + entries.append({ + "start": int(m.group(1)), + "max_br": float(m.group(2)), + "end": 0, + }) + # End = next entry's start (minus one sec); last entry gets +7d default. + for i, e in enumerate(entries): + nxt = entries[i + 1]["start"] if i + 1 < len(entries) else 0 + e["end"] = (nxt - 1) if nxt > e["start"] else e["start"] + 7 * 86400 + return entries + + +@dataclass +class Args: + mode: Literal["squadron", "player"] + clan_id: Optional[int] + uid: Optional[str] + season: str + season_start: int + season_end: int + week_boundaries: List[int] + out: Path + theme: str + lang: str + + +def parse_args(argv: Optional[List[str]] = None) -> Args: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--mode", choices=["squadron", "player"], default="squadron") + p.add_argument("--clan-id", type=int, default=None) + p.add_argument("--uid", type=str, default=None) + p.add_argument("--season", required=True) + p.add_argument("--season-start", type=int, required=True) + p.add_argument("--season-end", type=int, required=True) + p.add_argument("--week-boundaries", default="") + p.add_argument("--out", type=Path, required=True) + p.add_argument("--theme", choices=list(THEMES.keys()), default="light") + p.add_argument("--lang", default=DEFAULT_LANG) + ns = p.parse_args(argv) + if ns.mode == "squadron" and ns.clan_id is None: + p.error("--mode squadron requires --clan-id") + if ns.mode == "player": + if not ns.uid: + p.error("--mode player requires --uid") + if not ns.uid.isdigit(): + p.error("--uid must be numeric") + boundaries = [int(x) for x in ns.week_boundaries.split(",") if x.strip()] + return Args( + mode=ns.mode, + clan_id=ns.clan_id, + uid=ns.uid, + season=ns.season, + season_start=ns.season_start, + season_end=ns.season_end, + week_boundaries=boundaries, + out=ns.out, + theme=ns.theme, + lang=ns.lang, + ) + + +def _open_ro(path: Path) -> sqlite3.Connection: + uri = f"file:{path}?mode=ro" + return sqlite3.connect(uri, uri=True, timeout=10.0) + + +@dataclass +class SquadronIdent: + clan_id: int + short_name: str + long_name: str + + +def resolve_squadron(conn_sq: sqlite3.Connection, clan_id: int) -> Optional[SquadronIdent]: + cur = conn_sq.execute( + "SELECT short_name, long_name FROM squadrons_data WHERE clan_id = ?", + (clan_id,), + ) + row = cur.fetchone() + if not row: + return None + return SquadronIdent(clan_id=clan_id, short_name=row[0] or "", long_name=row[1] or "") + + +@dataclass +class RatingDerived: + series: List[Tuple[int, int]] # (unix_time, total_score) ordered + final: Optional[int] + peak: Optional[int] + peak_ts: Optional[int] + first: Optional[int] + change: Optional[int] + + +def gather_squadron_rating(conn_sq: sqlite3.Connection, clan_id: int, + start: int, end: int) -> RatingDerived: + cur = conn_sq.execute( + "SELECT unix_time, total_score FROM squadrons_points " + "WHERE clan_id = ? AND unix_time BETWEEN ? AND ? " + "ORDER BY unix_time", + (clan_id, start, end), + ) + series: List[Tuple[int, int]] = [(row[0], row[1]) for row in cur.fetchall()] + if not series: + return RatingDerived(series=[], final=None, peak=None, peak_ts=None, first=None, change=None) + first = series[0][1] + final = series[-1][1] + peak_ts, peak = max(series, key=lambda t: t[1]) + return RatingDerived(series=series, final=final, peak=peak, peak_ts=peak_ts, + first=first, change=final - first) + + +@dataclass +class MatchRow: + session_id: str + endtime_unix: int + winning_sq: str + losing_sq: str + + +@dataclass +class MatchDerived: + stream: List[MatchRow] + total: int + wins: int + losses: int + wr_pct: Optional[float] + longest_win_streak: int + top_opponent: Optional[Tuple[str, int, int]] # (short, matches_vs, wins_vs) + + +def gather_squadron_match_stream(conn_b: sqlite3.Connection, short: str, + start: int, end: int) -> List[MatchRow]: + cur = conn_b.execute( + "SELECT session_id, endtime_unix, winning_sq, losing_sq " + "FROM match_summary " + "WHERE (winning_sq = ? OR losing_sq = ?) " + " AND endtime_unix BETWEEN ? AND ? " + "ORDER BY endtime_unix", + (short, short, start, end), + ) + return [MatchRow(session_id=r[0], endtime_unix=r[1], winning_sq=r[2] or "", + losing_sq=r[3] or "") for r in cur.fetchall()] + + +def gather_squadron_per_match_stream(conn_b: sqlite3.Connection, short: str, + start: int, end: int + ) -> "List[PlayerGameRow]": + """One synthetic PlayerGameRow per squadron match (kills/deaths summed + across all squadron members in that match, win/loss from match_summary). + Shaped as PlayerGameRow so the shared rolling helpers work unchanged.""" + cur = conn_b.execute( + "SELECT pgh.session_id, MAX(pgh.endtime_unix) AS ts, " + " ms.winning_sq, " + " COALESCE(SUM(pgh.ground_kills), 0) AS g_kills, " + " COALESCE(SUM(pgh.air_kills), 0) AS a_kills, " + " COALESCE(SUM(pgh.deaths), 0) AS deaths " + "FROM player_games_hist pgh " + "JOIN match_summary ms ON ms.session_id = pgh.session_id " + "WHERE pgh.squadron_name = ? AND pgh.endtime_unix BETWEEN ? AND ? " + "GROUP BY pgh.session_id " + "ORDER BY ts", + (short, start, end), + ) + rows: List[PlayerGameRow] = [] + for sid, ts, win_sq, g, a, d in cur.fetchall(): + rows.append(PlayerGameRow( + session_id=sid, endtime_unix=ts, squadron_name=short, + vehicle="", + ground_kills=g or 0, air_kills=a or 0, + assists=0, captures=0, deaths=d or 0, + victor_bool="WIN" if (win_sq or "") == short else "LOSS", + )) + return rows + + +def compute_squadron_match_derived(stream: List[MatchRow], short: str) -> MatchDerived: + total = len(stream) + wins = sum(1 for m in stream if m.winning_sq == short) + losses = total - wins + wr_pct = (wins / total * 100.0) if total else None + + # Longest win streak + longest = cur_streak = 0 + for m in stream: + if m.winning_sq == short: + cur_streak += 1 + if cur_streak > longest: + longest = cur_streak + else: + cur_streak = 0 + + # Top opponent (by match count), with wins-against count + opp_total: dict[str, int] = defaultdict(int) + opp_wins: dict[str, int] = defaultdict(int) + for m in stream: + other = m.losing_sq if m.winning_sq == short else m.winning_sq + if not other: + continue + opp_total[other] += 1 + if m.winning_sq == short: + opp_wins[other] += 1 + if opp_total: + top_name = max(opp_total, key=lambda k: opp_total[k]) + top_opponent: Optional[Tuple[str, int, int]] = (top_name, opp_total[top_name], opp_wins[top_name]) + else: + top_opponent = None + + return MatchDerived(stream=stream, total=total, wins=wins, losses=losses, + wr_pct=wr_pct, longest_win_streak=longest, top_opponent=top_opponent) + + +@dataclass +class TimelineRange: + squadron_name: str # "" = no squadron + first_ts: int + last_ts: int + match_count: int + + +def gather_player_squadron_timeline(conn_b: sqlite3.Connection, uid: str, + start: int, end: int) -> List[TimelineRange]: + """Walk player games in time order; group consecutive same-squadron rows + into ranges. Empty string squadron_name is a valid range ("no squadron").""" + cur = conn_b.execute( + "SELECT squadron_name, endtime_unix FROM player_games_hist " + "WHERE UID = ? AND endtime_unix BETWEEN ? AND ? " + "ORDER BY endtime_unix", + (uid, start, end), + ) + rows = cur.fetchall() + if not rows: + return [] + ranges: List[TimelineRange] = [] + cur_name = rows[0][0] or "" + cur_first = cur_last = rows[0][1] + cur_count = 1 + for name, ts in rows[1:]: + name = name or "" + if name == cur_name: + cur_last = ts + cur_count += 1 + else: + ranges.append(TimelineRange(cur_name, cur_first, cur_last, cur_count)) + cur_name, cur_first, cur_last, cur_count = name, ts, ts, 1 + ranges.append(TimelineRange(cur_name, cur_first, cur_last, cur_count)) + + # Drop ranges shorter than one day (brief squadron stints aren't worth + # surfacing in the chain, legend, or shading), then merge any adjacent + # same-squadron ranges that got left side-by-side after the drop. + ranges = [r for r in ranges if (r.last_ts - r.first_ts) >= 86400] + merged: List[TimelineRange] = [] + for r in ranges: + if merged and merged[-1].squadron_name == r.squadron_name: + prev = merged[-1] + merged[-1] = TimelineRange( + squadron_name=prev.squadron_name, + first_ts=prev.first_ts, + last_ts=r.last_ts, + match_count=prev.match_count + r.match_count, + ) + else: + merged.append(r) + ranges = merged + + if not ranges: + return [] + + # The final range covers "still in this squadron" — extend its last_ts + # through the season window so rating snapshots after the player's final + # match (decay, continued membership) still appear on the chart. + last = ranges[-1] + if last.last_ts < end: + ranges[-1] = TimelineRange( + squadron_name=last.squadron_name, + first_ts=last.first_ts, + last_ts=end, + match_count=last.match_count, + ) + return ranges + + +@dataclass +class PlayerGameRow: + session_id: str + endtime_unix: int + squadron_name: str + vehicle: str + ground_kills: int + air_kills: int + assists: int + captures: int + deaths: int + victor_bool: str # "WIN" | "LOSS" (case varies in source data) + + +def gather_player_game_stream(conn_b: sqlite3.Connection, uid: str, + start: int, end: int) -> List[PlayerGameRow]: + cur = conn_b.execute( + "SELECT session_id, endtime_unix, squadron_name, vehicle, " + " ground_kills, air_kills, assists, captures, deaths, victor_bool " + "FROM player_games_hist " + "WHERE UID = ? AND endtime_unix BETWEEN ? AND ? " + "ORDER BY endtime_unix", + (uid, start, end), + ) + return [ + PlayerGameRow( + session_id=r[0], endtime_unix=r[1], squadron_name=r[2] or "", + vehicle=apply_vehicle_name_filters(r[3] or ""), + ground_kills=r[4] or 0, air_kills=r[5] or 0, + assists=r[6] or 0, captures=r[7] or 0, deaths=r[8] or 0, + victor_bool=r[9] or "", + ) + for r in cur.fetchall() + ] + + +@dataclass +class RatingSegment: + squadron_name: str # "" if the range had no squadron + points: List[Tuple[int, int]] # (unix_time, player_points); empty if unresolvable + + +def _decompress_clan_pts(blob) -> Optional[dict]: + """clan_pts is a gzipped JSON of [members_dict, total_score]. Return + members_dict ({uid_str: {"points": N, ...}, ...}) or None on failure.""" + try: + if isinstance(blob, (bytes, memoryview)): + data = json.loads(gzip.decompress(bytes(blob))) + else: + data = json.loads(blob) + except (OSError, ValueError, TypeError): + return None + if isinstance(data, list) and data and isinstance(data[0], dict): + return data[0] + if isinstance(data, dict): + return data + return None + + +def _resolve_squadron_long_name(conn_sq: sqlite3.Connection, + short_name: str) -> Optional[str]: + if not short_name: + return None + cur = conn_sq.execute( + "SELECT long_name FROM squadrons_data WHERE short_name = ? LIMIT 1", + (short_name,), + ) + row = cur.fetchone() + return row[0] if row and row[0] else None + + +def gather_player_rating_trail(conn_sq: sqlite3.Connection, + timeline: List[TimelineRange], + uid: str) -> List[RatingSegment]: + """For each timeline range, read the player's personal SQB points (from + each squadrons_points snapshot's gzipped member roster) during that range. + Returns per-range (unix_time, player_points) lists.""" + segments: List[RatingSegment] = [] + long_cache: dict[str, Optional[str]] = {} + for rng in timeline: + if not rng.squadron_name: + segments.append(RatingSegment("", [])) + continue + if rng.squadron_name not in long_cache: + long_cache[rng.squadron_name] = _resolve_squadron_long_name( + conn_sq, rng.squadron_name + ) + long_name = long_cache[rng.squadron_name] + if not long_name: + segments.append(RatingSegment(rng.squadron_name, [])) + continue + cur = conn_sq.execute( + "SELECT unix_time, clan_pts FROM squadrons_points " + "WHERE long_name = ? AND unix_time BETWEEN ? AND ? " + "ORDER BY unix_time", + (long_name, rng.first_ts, rng.last_ts), + ) + points: List[Tuple[int, int]] = [] + for ts, blob in cur.fetchall(): + members = _decompress_clan_pts(blob) + if not members: + continue + entry = members.get(uid) or members.get(str(uid)) + if not isinstance(entry, dict): + continue + pts = entry.get("points") + if isinstance(pts, (int, float)): + points.append((ts, int(pts))) + segments.append(RatingSegment(rng.squadron_name, points)) + return segments + + +def gather_player_most_common_opponent(conn_b: sqlite3.Connection, + game_stream: List[PlayerGameRow] + ) -> Optional[Tuple[str, int]]: + """For each session the player was in, look up winning/losing squadron in + match_summary; opponent is whichever side isn't the player's squadron in + that game. Returns (opp_short_name, matches_vs) or None.""" + if not game_stream: + return None + ids = list({g.session_id for g in game_stream}) + placeholders = ",".join("?" * len(ids)) + cur = conn_b.execute( + f"SELECT session_id, winning_sq, losing_sq " + f"FROM match_summary WHERE session_id IN ({placeholders})", + ids, + ) + by_session: dict[str, Tuple[str, str]] = { + r[0]: (r[1] or "", r[2] or "") for r in cur.fetchall() + } + opp_counts: dict[str, int] = defaultdict(int) + for g in game_stream: + pair = by_session.get(g.session_id) + if not pair: + continue + w, l = pair + own = g.squadron_name + opp = l if w == own else (w if l == own else "") + if opp: + opp_counts[opp] += 1 + if not opp_counts: + return None + name = max(opp_counts, key=lambda k: opp_counts[k]) + return name, opp_counts[name] + + +def gather_player_frequent_teammate(conn_b: sqlite3.Connection, uid: str, + start: int, end: int + ) -> Optional[Tuple[str, int]]: + """Self-join on session_id — counts UIDs (not the player) that shared the + most sessions with the player while on the same squadron in that session.""" + cur = conn_b.execute( + "SELECT p.UID, COUNT(DISTINCT p.session_id) AS shared " + "FROM player_games_hist p " + "JOIN player_games_hist me " + " ON me.session_id = p.session_id " + " AND me.squadron_name = p.squadron_name " + "WHERE me.UID = ? " + " AND p.UID != ? " + " AND me.endtime_unix BETWEEN ? AND ? " + "GROUP BY p.UID " + "ORDER BY shared DESC " + "LIMIT 1", + (uid, uid, start, end), + ) + row = cur.fetchone() + if not row or not row[1]: + return None + teammate_uid, shared = row[0], int(row[1]) + # MIN(nick) would pick byte-sorted smallest — e.g. a 'coop/…' disconnect + # row beats the real nick. Pick the most frequent non-coop nick instead, + # tiebreaking by recency so renames surface the latest identity. + nick_cur = conn_b.execute( + "SELECT nick FROM player_games_hist " + "WHERE UID = ? AND nick NOT LIKE 'coop/%' " + "GROUP BY nick " + "ORDER BY COUNT(*) DESC, MAX(endtime_unix) DESC " + "LIMIT 1", + (teammate_uid,), + ) + nick_row = nick_cur.fetchone() + return (nick_row[0] if nick_row and nick_row[0] else "unknown"), shared + + +@dataclass +class BestMatchRow: + session_id: str + endtime_unix: int + vehicle: str + ground_kills: int + air_kills: int + assists: int + captures: int + deaths: int + + +@dataclass +class PlayerDerived: + total: int + wins: int + losses: int + wr_pct: Optional[float] + ground_kills: int + air_kills: int + total_kills: int + total_deaths: int + assists: int + captures: int + kd: Optional[float] + longest_win_streak: int + top_vehicle: Optional[Tuple[str, int]] + best_match: Optional[BestMatchRow] + peak_rating: Optional[Tuple[int, str, int]] # (rating, squadron_name, unix_time) + + +def compute_player_derived(stream: List[PlayerGameRow], + timeline: List[TimelineRange], + trail: List[RatingSegment]) -> PlayerDerived: + del timeline # reserved for future squadron-weighted metrics; currently unused + total = len(stream) + wins = sum(1 for g in stream if g.victor_bool.upper() == "WIN") + losses = total - wins + wr = (wins / total * 100.0) if total else None + + gk = sum(g.ground_kills for g in stream) + ak = sum(g.air_kills for g in stream) + dk = sum(g.deaths for g in stream) + asts = sum(g.assists for g in stream) + caps = sum(g.captures for g in stream) + kd = ((gk + ak) / dk) if dk else None + + longest = cur = 0 + for g in stream: + if g.victor_bool.upper() == "WIN": + cur += 1 + longest = max(longest, cur) + else: + cur = 0 + + veh_counts: dict[str, int] = defaultdict(int) + for g in stream: + if g.vehicle: + veh_counts[g.vehicle] += 1 + if veh_counts: + top_name = max(veh_counts, key=lambda k: veh_counts[k]) + top_vehicle: Optional[Tuple[str, int]] = (top_name, veh_counts[top_name]) + else: + top_vehicle = None + + best: Optional[BestMatchRow] = None + best_score = -1 + for g in stream: + score = g.ground_kills + g.air_kills + g.assists + g.captures + if score > best_score: + best_score = score + best = BestMatchRow( + session_id=g.session_id, endtime_unix=g.endtime_unix, + vehicle=g.vehicle, ground_kills=g.ground_kills, + air_kills=g.air_kills, assists=g.assists, captures=g.captures, + deaths=g.deaths, + ) + elif score == best_score and best is not None and g.deaths < best.deaths: + best = BestMatchRow( + session_id=g.session_id, endtime_unix=g.endtime_unix, + vehicle=g.vehicle, ground_kills=g.ground_kills, + air_kills=g.air_kills, assists=g.assists, captures=g.captures, + deaths=g.deaths, + ) + + peak: Optional[Tuple[int, str, int]] = None + for seg in trail: + for ts, rating in seg.points: + if peak is None or rating > peak[0]: + peak = (rating, seg.squadron_name, ts) + + return PlayerDerived( + total=total, wins=wins, losses=losses, wr_pct=wr, + ground_kills=gk, air_kills=ak, total_kills=gk + ak, + total_deaths=dk, assists=asts, captures=caps, kd=kd, + longest_win_streak=longest, top_vehicle=top_vehicle, + best_match=best, peak_rating=peak, + ) + + +@dataclass +class PlayerAggregates: + ground_kills: int + air_kills: int + deaths: int + assists: int + captures: int + total_kills: int + kd: Optional[float] + top_vehicle: Optional[Tuple[str, int]] # (vehicle_raw_name, games) + mvp: Optional[Tuple[str, int, int, int]] # (nick, kills, assists, captures) + most_active: Optional[Tuple[str, int]] # (nick, matches_played) + + +def gather_squadron_player_aggregates(conn_b: sqlite3.Connection, squadron_name: str, + start: int, end: int) -> PlayerAggregates: + # Totals + cur = conn_b.execute( + "SELECT COALESCE(SUM(ground_kills), 0), COALESCE(SUM(air_kills), 0), " + " COALESCE(SUM(deaths), 0), COALESCE(SUM(assists), 0), " + " COALESCE(SUM(captures), 0) " + "FROM player_games_hist " + "WHERE squadron_name = ? AND endtime_unix BETWEEN ? AND ?", + (squadron_name, start, end), + ) + g, a, d, asst, cap = cur.fetchone() + + # Top vehicle + cur = conn_b.execute( + "SELECT vehicle, COUNT(*) AS c FROM player_games_hist " + "WHERE squadron_name = ? AND endtime_unix BETWEEN ? AND ? " + " AND vehicle IS NOT NULL AND vehicle != '' " + "GROUP BY vehicle ORDER BY c DESC LIMIT 1", + (squadron_name, start, end), + ) + row = cur.fetchone() + top_vehicle: Optional[Tuple[str, int]] = ( + (apply_vehicle_name_filters(row[0]), row[1]) if row else None + ) + + # MVP — ranked by K + A + C (no weighting); report the breakdown + cur = conn_b.execute( + "SELECT MIN(nick) AS nick, " + " SUM(ground_kills + air_kills) AS kills, " + " SUM(assists) AS assists, " + " SUM(captures) AS captures " + "FROM player_games_hist " + "WHERE squadron_name = ? AND endtime_unix BETWEEN ? AND ? " + "GROUP BY UID " + "ORDER BY SUM(ground_kills + air_kills + assists + captures) DESC " + "LIMIT 1", + (squadron_name, start, end), + ) + row = cur.fetchone() + mvp: Optional[Tuple[str, int, int, int]] = ( + (row[0], row[1] or 0, row[2] or 0, row[3] or 0) + if row and (row[1] or row[2] or row[3]) else None + ) + + # Most active — matches played per UID + cur = conn_b.execute( + "SELECT MIN(nick) AS nick, COUNT(DISTINCT session_id) AS matches " + "FROM player_games_hist " + "WHERE squadron_name = ? AND endtime_unix BETWEEN ? AND ? " + "GROUP BY UID ORDER BY matches DESC LIMIT 1", + (squadron_name, start, end), + ) + row = cur.fetchone() + most_active: Optional[Tuple[str, int]] = (row[0], row[1]) if row and row[1] else None + + total_kills = g + a + kd = (total_kills / d) if d else None + return PlayerAggregates(ground_kills=g, air_kills=a, deaths=d, assists=asst, + captures=cap, total_kills=total_kills, kd=kd, + top_vehicle=top_vehicle, mvp=mvp, most_active=most_active) + + +def compute_squadron_wr_curve(stream: List[MatchRow], short: str, season_start: int, + season_end: int) -> Tuple[List[Tuple[int, float]], int]: + """ + Returns (points, window_size). Points: list of (unix_time, wr_pct) anchored + at each match's endtime, computed as rolling WR over the previous `window_size` + matches (inclusive of current). x-axis domain callers want = [season_start, season_end]. + """ + total = len(stream) + if total == 0: + return [], 0 + window = max(20, total // 20) + if total < window: + # Cumulative WR: point per match + running_wins = 0 + points: List[Tuple[int, float]] = [] + for i, m in enumerate(stream): + if m.winning_sq == short: + running_wins += 1 + wr = running_wins / (i + 1) * 100.0 + points.append((m.endtime_unix, wr)) + return points, window + # Full rolling + wins_flags = [1 if m.winning_sq == short else 0 for m in stream] + points = [] + window_sum = sum(wins_flags[:window]) + # First plottable index = window - 1 + points.append((stream[window - 1].endtime_unix, window_sum / window * 100.0)) + for i in range(window, total): + window_sum += wins_flags[i] - wins_flags[i - window] + points.append((stream[i].endtime_unix, window_sum / window * 100.0)) + return points, window + + +def compute_rolling_wr(stream: List[PlayerGameRow], + window_seconds: int = 7 * 86400 + ) -> List[Tuple[int, float]]: + """Trailing time-window win rate, one point per match. Stale games drop + out of the window naturally during play gaps so the curve doesn't mix + stats across weeks.""" + total = len(stream) + if total == 0: + return [] + wins = [1 if g.victor_bool.upper() == "WIN" else 0 for g in stream] + out: List[Tuple[int, float]] = [] + left = 0 + wins_in_window = 0 + for right in range(total): + t_r = stream[right].endtime_unix + wins_in_window += wins[right] + while stream[left].endtime_unix < t_r - window_seconds: + wins_in_window -= wins[left] + left += 1 + n = right - left + 1 + out.append((t_r, wins_in_window / n * 100.0)) + return out + + +def compute_rolling_kd(stream: List[PlayerGameRow], + window_seconds: int = 7 * 86400 + ) -> List[Tuple[int, float]]: + """Trailing time-window K/D, one point per match. deaths=0 is bounded + via max(deaths, 1) to keep values finite during kill-heavy early games.""" + total = len(stream) + if total == 0: + return [] + kills = [g.ground_kills + g.air_kills for g in stream] + deaths = [g.deaths for g in stream] + out: List[Tuple[int, float]] = [] + left = 0 + k_sum = d_sum = 0 + for right in range(total): + t_r = stream[right].endtime_unix + k_sum += kills[right] + d_sum += deaths[right] + while stream[left].endtime_unix < t_r - window_seconds: + k_sum -= kills[left] + d_sum -= deaths[left] + left += 1 + out.append((t_r, k_sum / max(d_sum, 1))) + return out + + +def compute_rolling_battles(stream: List[PlayerGameRow], + window_seconds: int = 7 * 86400 + ) -> List[Tuple[int, int]]: + """Time-based rolling match count: at each game timestamp t, count of games + with endtime_unix in [t - window, t].""" + total = len(stream) + if total == 0: + return [] + out: List[Tuple[int, int]] = [] + left = 0 + for right in range(total): + t_r = stream[right].endtime_unix + while stream[left].endtime_unix <= t_r - window_seconds: + left += 1 + out.append((t_r, right - left + 1)) + return out + + +# SQB timeslots (UTC) — source of truth is BOT/utils.py SQB_SLOTS_POSTED; duplicated +# here because BOT/utils.py pulls in Discord bot dependencies, too heavy for this CLI. +_SQB_TIMESLOTS_UTC: List[Tuple[str, int, int]] = [ + ("NA", 1 * 3600, 7 * 3600), # 01:00 – 07:00 + ("EU", 14 * 3600, 22 * 3600), # 14:00 – 22:00 +] + + +def _classify_timeslot(ts: int) -> Optional[Tuple[str, _date]]: + dt = datetime.fromtimestamp(ts, tz=timezone.utc) + sod = int(dt.replace(hour=0, minute=0, second=0, microsecond=0).timestamp()) + offset = ts - sod + for region, lo, hi in _SQB_TIMESLOTS_UTC: + if lo <= offset <= hi: + return region, dt.date() + return None + + +def compute_longest_timeslot_session(stream: List[PlayerGameRow] + ) -> Optional[Tuple[str, _date, int]]: + """Bucket games into (date_UTC, region) slots based on endtime_unix; return + (region, date, match_count) for the bucket with the most games. Matches + outside any posted SQB timeslot are excluded.""" + buckets: dict[Tuple[str, _date], int] = defaultdict(int) + for g in stream: + slot = _classify_timeslot(g.endtime_unix) + if slot is None: + continue + buckets[slot] += 1 + if not buckets: + return None + (region, d), n = max(buckets.items(), key=lambda kv: kv[1]) + return region, d, n + + +def compute_most_active_day_utc(stream: List[PlayerGameRow] + ) -> Optional[Tuple[_date, int]]: + """Return the UTC date with the most matches (ignores timeslot boundaries).""" + by_day: dict[_date, int] = defaultdict(int) + for g in stream: + d = datetime.fromtimestamp(g.endtime_unix, tz=timezone.utc).date() + by_day[d] += 1 + if not by_day: + return None + d, n = max(by_day.items(), key=lambda kv: kv[1]) + return d, n + + +def _fmt_int(n: Optional[int]) -> str: + return f"{n:,}" if n is not None else "—" + + +def _fmt_float(n: Optional[float], digits: int = 2) -> str: + return f"{n:.{digits}f}" if n is not None else "—" + + +def _fmt_date(ts: Optional[int]) -> str: + if ts is None: + return "—" + return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d") + + +def render_squadron_card(out_path: Path, ident: SquadronIdent, season: str, + rating: RatingDerived, match: MatchDerived, + players: PlayerAggregates, + rolling_wr: List[Tuple[int, float]], + rolling_kd: List[Tuple[int, float]], + rolling_battles: List[Tuple[int, int]], + season_start: int, season_end: int, + week_boundaries: List[int], + theme: str = "light", + lang: str = DEFAULT_LANG) -> None: + pal = theme_palette(theme) + tr = load_translations(lang) + def t(key: str) -> str: + return tr.get(key, key) + + fig = plt.figure(figsize=(16, 10), dpi=120, facecolor=pal["bg"]) + gs = GridSpec(3, 1, height_ratios=[0.55, 2.4, 1.35], hspace=0.35, + left=0.13, right=0.87, top=0.93, bottom=0.06, figure=fig) + + # ── Header / hero row ──────────────────────────────────────────── + ax_head = fig.add_subplot(gs[0]) + ax_head.set_axis_off() + ax_head.text(0.0, 0.75, f"{ident.short_name} · {ident.long_name}", + fontsize=22, fontweight="bold", color=pal["text"], + transform=ax_head.transAxes) + ax_head.text(1.0, 0.75, f"{season} {t('imgRecapSuffix')}", + fontsize=18, fontweight="bold", color=pal["header_sub"], + ha="right", transform=ax_head.transAxes) + hero = [ + (t("imgHeroFinalRating"), _fmt_int(rating.final)), + (t("imgHeroMatches"), _fmt_int(match.total)), + (t("imgHeroWinRate"), (f"{match.wr_pct:.1f}%" if match.wr_pct is not None else "—")), + (t("imgHeroKD"), _fmt_float(players.kd)), + ] + for i, (label, value) in enumerate(hero): + x = 0.04 + i * 0.24 + ax_head.text(x, 0.15, value, fontsize=26, fontweight="bold", + color=pal["text"], va="center", + transform=ax_head.transAxes) + ax_head.text(x, -0.10, label, fontsize=11, color=pal["muted"], + va="center", transform=ax_head.transAxes) + + for y in (0.55, -0.32): + ax_head.plot([0.01, 0.99], [y, y], color=pal["divider"], + alpha=0.85, linewidth=1.0, + transform=ax_head.transAxes, clip_on=False) + + # ── Main graph (rating + WR dual axis) ─────────────────────────── + ax = fig.add_subplot(gs[1]) + ax.set_facecolor(pal["bg"]) + ax.set_xlim(season_start, season_end) + + for b in week_boundaries: + if season_start <= b <= season_end: + ax.axvline(b, color=pal["grid"], alpha=0.3, linewidth=1) + + if rating.series: + smoothed = smooth_series(rating.series, window=12) + xs = [ts for ts, _ in smoothed] + ys = [v for _, v in smoothed] + ax.plot(xs, ys, color=RATING_COLOR, linewidth=2.2, zorder=3) + ax.set_ylabel(t("imgAxisRating"), color=RATING_COLOR, fontsize=12) + ax.tick_params(axis="y", colors=RATING_COLOR) + ax.spines["left"].set_color(RATING_COLOR) + ax.spines["top"].set_color(pal["grid"]) + ax.spines["bottom"].set_color(pal["grid"]) + ax.tick_params(axis="x", colors=pal["muted"]) + + def _prep(series: list) -> list: + return [_smooth_rolling_curve(_downsample_daily(part), window=4) + for part in _split_on_gaps(series, 7 * 86400)] + + ax_wr = ax.twinx() + for s in _prep(rolling_wr): + ax_wr.plot([p[0] for p in s], [p[1] for p in s], + color=WR_COLOR, linewidth=1.3, alpha=0.8, zorder=2) + ax_wr.set_ylim(0, 100) + ax_wr.set_ylabel(t("imgAxisWinRate"), color=WR_COLOR, fontsize=12) + ax_wr.tick_params(axis="y", colors=WR_COLOR) + ax_wr.spines["right"].set_color(WR_COLOR) + ax_wr.spines["left"].set_visible(False) + + ax_kd = ax.twinx() + ax_kd.spines["right"].set_position(("axes", 1.08)) + for s in _prep(rolling_kd): + ax_kd.plot([p[0] for p in s], [p[1] for p in s], + color=KD_COLOR, linewidth=1.1, alpha=0.7, zorder=2) + ax_kd.set_ylabel(t("imgAxisKD"), color=KD_COLOR, fontsize=12, labelpad=10) + ax_kd.tick_params(axis="y", colors=KD_COLOR) + ax_kd.spines["right"].set_color(KD_COLOR) + ax_kd.spines["left"].set_visible(False) + + ax_b = ax.twinx() + ax_b.spines["left"].set_position(("axes", -0.08)) + ax_b.spines["left"].set_visible(True) + ax_b.spines["right"].set_visible(False) + ax_b.yaxis.set_label_position("left") + ax_b.yaxis.tick_left() + for s in _prep(rolling_battles): + ax_b.plot([p[0] for p in s], [p[1] for p in s], + color=BATTLES_COLOR, linewidth=1.0, alpha=0.6, zorder=2) + ax_b.set_ylabel(t("imgAxisBattles"), color=BATTLES_COLOR, fontsize=12, labelpad=10) + ax_b.tick_params(axis="y", colors=BATTLES_COLOR) + ax_b.spines["left"].set_color(BATTLES_COLOR) + + boundary_inside = [b for b in week_boundaries if season_start <= b <= season_end] + if boundary_inside: + ax.set_xticks(boundary_inside) + ax.set_xticklabels( + [datetime.fromtimestamp(b, tz=timezone.utc).strftime("%m-%d") + for b in boundary_inside], + rotation=0, fontsize=9 + ) + ax.grid(axis="y", linestyle=":", color=pal["grid"], alpha=0.4) + + # BR schedule ticks above the chart — centered over each BR segment, + # with a faint dashed divider at each segment boundary so tiers read + # as distinct bands. + for entry in _load_br_schedule(): + s = int(entry["start"]) + e = int(entry["end"]) + if e < season_start or s > season_end: + continue + s_c = max(s, season_start) + e_c = min(e, season_end) + mid = (s_c + e_c) / 2 + if season_start < s < season_end: + ax.axvline(s, color=pal["muted"], alpha=0.45, linewidth=0.7, + linestyle=(0, (3, 3)), zorder=1) + ax.text(mid, 1.02, f"{float(entry['max_br']):.1f}", + color=pal["header_sub"], fontsize=10, fontweight="bold", + ha="center", va="bottom", + transform=ax.get_xaxis_transform(), clip_on=False) + + # ── Supporting stats grid ──────────────────────────────────────── + ax_stats = fig.add_subplot(gs[2]) + ax_stats.set_axis_off() + + def _fmt_opponent(op: Optional[Tuple[str, int, int]]) -> str: + if not op: + return "—" + name, total, wins = op + losses = total - wins + return (f"{name} ({total} {t('imgUnitMatches')}, " + f"{wins} {t('imgUnitWins')} / {losses} {t('imgUnitLosses')})") + + def _fmt_mvp(m: Optional[Tuple[str, int, int, int]]) -> str: + if not m: + return "—" + name, kills, assists, captures = m + return (f"{name} — {_fmt_int(kills)} {t('imgUnitKills')}, " + f"{_fmt_int(assists)} {t('imgUnitAssists')}, " + f"{_fmt_int(captures)} {t('imgUnitCaptures')}") + + # Short rows render in 2 columns; wide rows span full width. + short_rows: List[Tuple[str, str]] = [ + (t("imgStatPeakRating"), f"{_fmt_int(rating.peak)} ({_fmt_date(rating.peak_ts)})"), + (t("imgStatRatingChange"), f"{'+' if (rating.change or 0) >= 0 else ''}{_fmt_int(rating.change)}"), + (t("imgStatTotalKills"), f"{_fmt_int(players.total_kills)} ({_fmt_int(players.ground_kills)} {t('imgGroundShort')} / {_fmt_int(players.air_kills)} {t('imgAirShort')})"), + (t("imgStatTotalDeaths"), _fmt_int(players.deaths)), + (t("imgStatAssistsCaptures"), f"{_fmt_int(players.assists)} / {_fmt_int(players.captures)}"), + (t("imgStatMostPlayedVehicle"), f"{players.top_vehicle[0]} ({players.top_vehicle[1]} {t('imgUnitGames')})" if players.top_vehicle else "—"), + (t("imgStatMostActive"), f"{players.most_active[0]} ({_fmt_int(players.most_active[1])} {t('imgUnitMatches')})" if players.most_active else "—"), + (t("imgStatLongestWinStreak"), _fmt_int(match.longest_win_streak)), + ] + wide_rows: List[Tuple[str, str]] = [ + (t("imgStatMVP"), _fmt_mvp(players.mvp)), + (t("imgStatMostCommonOpponent"), _fmt_opponent(match.top_opponent)), + ] + + cols = 2 + short_per_col = (len(short_rows) + cols - 1) // cols # 4 + total_bands = short_per_col + len(wide_rows) # 6 + grid_top = 0.98 + grid_bottom = 0.05 + band_h = (grid_top - grid_bottom) / total_bands + + # Short rows — 2-column layout in the top `short_per_col` bands + for i, (label, value) in enumerate(short_rows): + col, row_in_col = divmod(i, short_per_col) + x = 0.02 + col * 0.52 + y = grid_top - band_h * (row_in_col + 0.5) + ax_stats.text(x, y, f"{label}:", fontsize=12, color=pal["muted"], + va="center", transform=ax_stats.transAxes) + ax_stats.text(x + 0.20, y, value, fontsize=12, color=pal["text"], + va="center", transform=ax_stats.transAxes) + + # Wide rows — full-width, below the short rows + for i, (label, value) in enumerate(wide_rows): + y = grid_top - band_h * (short_per_col + i + 0.5) + ax_stats.text(0.02, y, f"{label}:", fontsize=12, color=pal["muted"], + va="center", transform=ax_stats.transAxes) + ax_stats.text(0.22, y, value, fontsize=12, color=pal["text"], + va="center", transform=ax_stats.transAxes) + + # Horizontal dividers at band boundaries + for k in range(1, total_bands): + y = grid_top - band_h * k + if k < short_per_col: + # still in the 2-column zone — split divider around the vertical line + ax_stats.plot([0.01, 0.49], [y, y], color=pal["divider"], + alpha=0.85, linewidth=1.0, + transform=ax_stats.transAxes, clip_on=False) + ax_stats.plot([0.51, 0.99], [y, y], color=pal["divider"], + alpha=0.85, linewidth=1.0, + transform=ax_stats.transAxes, clip_on=False) + else: + # wide zone — single divider spans full width + ax_stats.plot([0.01, 0.99], [y, y], color=pal["divider"], + alpha=0.85, linewidth=1.0, + transform=ax_stats.transAxes, clip_on=False) + + # Vertical divider between columns — only across the short-row zone + ax_stats.plot([0.50, 0.50], + [grid_top - band_h * short_per_col, grid_top], + color=pal["divider"], alpha=0.85, linewidth=1.0, + transform=ax_stats.transAxes, clip_on=False) + + fig.text(0.5, 0.015, + f"SREBOT · {t('imgFooterGenerated')} {datetime.now(timezone.utc).strftime('%Y-%m-%d')}", + ha="center", fontsize=8, color=pal["footer"]) + + tmp = out_path.with_suffix(out_path.suffix + ".tmp") + tmp.parent.mkdir(parents=True, exist_ok=True) + try: + fig.savefig(tmp, dpi=120, facecolor=pal["bg"], bbox_inches=None, format="png") + os.replace(tmp, out_path) + finally: + plt.close(fig) + + +def render_squadron_placeholder(out_path: Path, short: str, season: str, + reason: Optional[str] = None, + theme: str = "light", + lang: str = DEFAULT_LANG) -> None: + pal = theme_palette(theme) + tr = load_translations(lang) + if reason is None: + reason = tr.get("imgPlaceholderNoData", "No data for {short} in {season}").format( + short=short, season=season + ) + fig = plt.figure(figsize=(16, 9), dpi=120, facecolor=pal["bg"]) + ax = fig.add_subplot(1, 1, 1) + ax.set_axis_off() + ax.text(0.5, 0.62, f"{short} · {season}", + fontsize=28, fontweight="bold", color=pal["text"], ha="center", + transform=ax.transAxes) + ax.text(0.5, 0.48, reason, + fontsize=16, color=pal["muted"], ha="center", + transform=ax.transAxes) + ax.text(0.5, 0.05, "SREBOT", + fontsize=10, color=pal["footer"], ha="center", + transform=ax.transAxes) + tmp = out_path.with_suffix(out_path.suffix + ".tmp") + tmp.parent.mkdir(parents=True, exist_ok=True) + try: + fig.savefig(tmp, dpi=120, facecolor=pal["bg"], format="png") + os.replace(tmp, out_path) + finally: + plt.close(fig) + + +def _assign_squadron_colors(timeline: List[TimelineRange]) -> dict[str, str]: + """Deterministic per-recap palette assignment in timeline order.""" + colors: dict[str, str] = {} + slot = 0 + for rng in timeline: + name = rng.squadron_name + if name == "": + colors.setdefault("", _NO_SQUADRON_COLOR) + continue + if name not in colors: + colors[name] = _SQUADRON_PALETTE[slot % len(_SQUADRON_PALETTE)] + slot += 1 + colors.setdefault("", _NO_SQUADRON_COLOR) + return colors + + +def render_player_card(out_path: Path, nick: str, uid: str, season: str, + timeline: List[TimelineRange], + trail: List[RatingSegment], + derived: PlayerDerived, + rolling_wr: List[Tuple[int, float]], + rolling_kd: List[Tuple[int, float]], + rolling_battles: List[Tuple[int, int]], + most_common_opp: Optional[Tuple[str, int]], + frequent_teammate: Optional[Tuple[str, int]], + longest_session: Optional[Tuple[str, _date, int]], + most_active_day: Optional[Tuple[_date, int]], + season_start: int, season_end: int, + week_boundaries: List[int], + theme: str = "light", + lang: str = DEFAULT_LANG) -> None: + pal = theme_palette(theme) + tr = load_translations(lang) + def t(key: str) -> str: + return tr.get(key, key) + + colors = _assign_squadron_colors(timeline) + unique_sq_keys = {rng.squadron_name for rng in timeline} + show_squadron_viz = len(unique_sq_keys) > 1 + + nick = _sanitize_render_text(nick) + + fig = plt.figure(figsize=(16, 10), dpi=120, facecolor=pal["bg"]) + gs = GridSpec(3, 1, height_ratios=[0.55, 2.4, 1.6], hspace=0.35, + left=0.13, right=0.87, top=0.93, bottom=0.06, figure=fig) + + # ── Header / hero ─────────────────────────────────────────────── + ax_head = fig.add_subplot(gs[0]) + ax_head.set_axis_off() + ax_head.text(0.0, 0.75, f"{nick} · {t('imgUIDLabel')} {uid}", + fontsize=22, fontweight="bold", color=pal["text"], + transform=ax_head.transAxes) + ax_head.text(1.0, 0.75, f"{season} {t('imgRecapSuffix')}", + fontsize=18, fontweight="bold", color=pal["header_sub"], + ha="right", transform=ax_head.transAxes) + hero = [ + (t("imgHeroBattles"), _fmt_int(derived.total)), + (t("imgHeroWinRate"), (f"{derived.wr_pct:.1f}%" if derived.wr_pct is not None else "—")), + (t("imgHeroKD"), _fmt_float(derived.kd)), + (t("imgHeroTotalKills"), _fmt_int(derived.total_kills)), + ] + for i, (label, value) in enumerate(hero): + x = 0.04 + i * 0.24 + ax_head.text(x, 0.15, value, fontsize=26, fontweight="bold", + color=pal["text"], va="center", + transform=ax_head.transAxes) + ax_head.text(x, -0.10, label, fontsize=11, color=pal["muted"], + va="center", transform=ax_head.transAxes) + + for y in (0.55, -0.32): + ax_head.plot([0.01, 0.99], [y, y], color=pal["divider"], + alpha=0.85, linewidth=1.0, + transform=ax_head.transAxes, clip_on=False) + + # ── Main chart with 4 axes ────────────────────────────────────── + ax = fig.add_subplot(gs[1]) + ax.set_facecolor(pal["bg"]) + ax.set_xlim(season_start, season_end) + + if show_squadron_viz: + for rng in timeline: + c = colors.get(rng.squadron_name, _NO_SQUADRON_COLOR) + alpha = 0.12 if rng.squadron_name else 0.05 + ax.axvspan(rng.first_ts, rng.last_ts, color=c, alpha=alpha, zorder=0) + + for b in week_boundaries: + if season_start <= b <= season_end: + ax.axvline(b, color=pal["grid"], alpha=0.3, linewidth=1, zorder=1) + + for seg in trail: + if not seg.points: + continue + smoothed = smooth_series(seg.points, window=12) + xs = [p[0] for p in smoothed] + ys = [p[1] for p in smoothed] + # Rating line is always teal — squadron shading + the Squadrons + # Represented chain carry the per-squadron color key. + ax.plot(xs, ys, color=RATING_COLOR, linewidth=2.2, zorder=3) + ax.set_ylabel(t("imgAxisRating"), color=RATING_COLOR, fontsize=12) + ax.tick_params(axis="y", colors=RATING_COLOR) + ax.spines["left"].set_color(RATING_COLOR) + + def _prep(series: list) -> list: + # Clip to squadron timeline ranges so rolling curves match the + # shading, then daily-downsample + smooth inside each segment. + return [_smooth_rolling_curve(_downsample_daily(part), window=4) + for part in _clip_to_ranges(series, timeline)] + + ax_wr = ax.twinx() + for s in _prep(rolling_wr): + ax_wr.plot([p[0] for p in s], [p[1] for p in s], + color=WR_COLOR, linewidth=1.3, alpha=0.8, zorder=2) + ax_wr.set_ylim(0, 100) + ax_wr.set_ylabel(t("imgAxisWinRate"), color=WR_COLOR, fontsize=12) + ax_wr.tick_params(axis="y", colors=WR_COLOR) + ax_wr.spines["right"].set_color(WR_COLOR) + ax_wr.spines["left"].set_visible(False) # don't paint over ax's teal left spine + + ax_kd = ax.twinx() + ax_kd.spines["right"].set_position(("axes", 1.08)) + for s in _prep(rolling_kd): + ax_kd.plot([p[0] for p in s], [p[1] for p in s], + color=KD_COLOR, linewidth=1.1, alpha=0.7, zorder=2) + ax_kd.set_ylabel(t("imgAxisKD"), color=KD_COLOR, fontsize=12, labelpad=10) + ax_kd.tick_params(axis="y", colors=KD_COLOR) + ax_kd.spines["right"].set_color(KD_COLOR) + ax_kd.spines["left"].set_visible(False) + + ax_b = ax.twinx() + ax_b.spines["left"].set_position(("axes", -0.08)) + ax_b.spines["left"].set_visible(True) + ax_b.spines["right"].set_visible(False) + ax_b.yaxis.set_label_position("left") + ax_b.yaxis.tick_left() + for s in _prep(rolling_battles): + ax_b.plot([p[0] for p in s], [p[1] for p in s], + color=BATTLES_COLOR, linewidth=1.0, alpha=0.6, zorder=2) + ax_b.set_ylabel(t("imgAxisBattles"), color=BATTLES_COLOR, fontsize=12, labelpad=10) + ax_b.tick_params(axis="y", colors=BATTLES_COLOR) + ax_b.spines["left"].set_color(BATTLES_COLOR) + + boundary_inside = [b for b in week_boundaries if season_start <= b <= season_end] + if boundary_inside: + ax.set_xticks(boundary_inside) + ax.set_xticklabels( + [datetime.fromtimestamp(b, tz=timezone.utc).strftime("%m-%d") + for b in boundary_inside], + rotation=0, fontsize=9 + ) + ax.tick_params(axis="x", colors=pal["muted"]) + ax.grid(axis="y", linestyle=":", color=pal["grid"], alpha=0.4) + + # BR schedule ticks above the chart — centered over each BR segment, + # with a faint dashed divider at each segment boundary so tiers read + # as distinct bands. + for entry in _load_br_schedule(): + s = int(entry["start"]) + e = int(entry["end"]) + if e < season_start or s > season_end: + continue + s_c = max(s, season_start) + e_c = min(e, season_end) + mid = (s_c + e_c) / 2 + if season_start < s < season_end: + ax.axvline(s, color=pal["muted"], alpha=0.45, linewidth=0.7, + linestyle=(0, (3, 3)), zorder=1) + ax.text(mid, 1.02, f"{float(entry['max_br']):.1f}", + color=pal["header_sub"], fontsize=10, fontweight="bold", + ha="center", va="bottom", + transform=ax.get_xaxis_transform(), clip_on=False) + + # ── Stats grid (bottom) ───────────────────────────────────────── + ax_stats = fig.add_subplot(gs[2]) + ax_stats.set_axis_off() + + kdac_value = ( + f"{_fmt_int(derived.total_kills)} " + f"({_fmt_int(derived.ground_kills)} {t('imgGroundShort')} / " + f"{_fmt_int(derived.air_kills)} {t('imgAirShort')}) / " + f"{_fmt_int(derived.total_deaths)} / " + f"{_fmt_int(derived.assists)} / " + f"{_fmt_int(derived.captures)}" + ) + + short_rows: List[Tuple[str, str]] = [ + (t("imgStatKDAC"), kdac_value), + (t("imgStatMostPlayedVehicle"), + f"{derived.top_vehicle[0]} ({derived.top_vehicle[1]} {t('imgUnitGames')})" + if derived.top_vehicle else "—"), + (t("imgStatLongestWinStreak"), _fmt_int(derived.longest_win_streak)), + (t("imgStatLongestSession"), + f"{longest_session[0]} · {longest_session[1].isoformat()} — " + f"{longest_session[2]} {t('imgUnitMatches')}" + if longest_session else "—"), + (t("imgStatMostActiveDay"), + f"{most_active_day[0].isoformat()} — {most_active_day[1]} {t('imgUnitMatches')}" + if most_active_day else "—"), + (t("imgStatMostCommonOpponent"), + f"{most_common_opp[0]} ({most_common_opp[1]} {t('imgUnitMatches')})" + if most_common_opp else "—"), + ] + + peak_str = (f"{_fmt_int(derived.peak_rating[0])} · {derived.peak_rating[1]} · " + f"{_fmt_date(derived.peak_rating[2])}") if derived.peak_rating else "—" + if derived.best_match: + bm = derived.best_match + best_match_str = t("imgBestMatchLine").format( + vehicle=bm.vehicle, + gk=bm.ground_kills, + ak=bm.air_kills, + assists=bm.assists, + cap=bm.captures, + deaths=bm.deaths, + date=_fmt_date(bm.endtime_unix), + ) + else: + best_match_str = "—" + + chain_parts: List[str] = [] + for rng in timeline: + dot = "●" if rng.squadron_name else "○" + name = rng.squadron_name if rng.squadron_name else t("imgUnitNoSquadron") + chain_parts.append(f"{dot} {name} ({rng.match_count})") + chain_str = " → ".join(chain_parts) if chain_parts else "—" + + teammate_str = (f"{frequent_teammate[0]} — {frequent_teammate[1]} " + f"{t('imgUnitTogether')}") if frequent_teammate else "—" + + wide_rows: List[Tuple[str, str]] = [ + (t("imgStatPeakSquadronRating"), peak_str), + (t("imgStatBestMatch"), best_match_str), + (t("imgStatSquadronsRepresented"), chain_str), + (t("imgStatFrequentTeammate"), teammate_str), + ] + + cols = 2 + short_per_col = (len(short_rows) + cols - 1) // cols + total_bands = short_per_col + len(wide_rows) + grid_top = 0.98 + band_h = (grid_top - 0.05) / total_bands + + for i, (label, value) in enumerate(short_rows): + col, row_in_col = divmod(i, short_per_col) + x = 0.02 + col * 0.52 + y = grid_top - band_h * (row_in_col + 0.5) + ax_stats.text(x, y, f"{label}:", fontsize=12, color=pal["muted"], + va="center", transform=ax_stats.transAxes) + ax_stats.text(x + 0.20, y, value, fontsize=12, color=pal["text"], + va="center", transform=ax_stats.transAxes) + + squadrons_label = t("imgStatSquadronsRepresented") + for i, (label, value) in enumerate(wide_rows): + y = grid_top - band_h * (short_per_col + i + 0.5) + ax_stats.text(0.02, y, f"{label}:", fontsize=12, color=pal["muted"], + va="center", transform=ax_stats.transAxes) + if label == squadrons_label: + children: List = [] + for j, rng in enumerate(timeline): + if j > 0: + children.append(TextArea( + " → ", + textprops=dict(color=pal["muted"], fontsize=12), + )) + seg_color = colors.get(rng.squadron_name, _NO_SQUADRON_COLOR) + name = rng.squadron_name if rng.squadron_name else t("imgUnitNoSquadron") + dot_char = "●" if rng.squadron_name else "○" + children.append(TextArea( + f"{dot_char} {name} ({rng.match_count})", + textprops=dict(color=seg_color, fontsize=12), + )) + if not children: + children.append(TextArea( + "—", textprops=dict(color=pal["text"], fontsize=12))) + packed = HPacker(children=children, align="center", pad=0, sep=0) + ax_stats.add_artist(AnchoredOffsetbox( + loc="center left", child=packed, pad=0, frameon=False, + bbox_to_anchor=(0.22, y), bbox_transform=ax_stats.transAxes, + )) + else: + ax_stats.text(0.22, y, value, fontsize=12, color=pal["text"], + va="center", transform=ax_stats.transAxes) + + for k in range(1, total_bands): + y = grid_top - band_h * k + if k < short_per_col: + ax_stats.plot([0.01, 0.49], [y, y], color=pal["divider"], + alpha=0.85, linewidth=1.0, + transform=ax_stats.transAxes, clip_on=False) + ax_stats.plot([0.51, 0.99], [y, y], color=pal["divider"], + alpha=0.85, linewidth=1.0, + transform=ax_stats.transAxes, clip_on=False) + else: + ax_stats.plot([0.01, 0.99], [y, y], color=pal["divider"], + alpha=0.85, linewidth=1.0, + transform=ax_stats.transAxes, clip_on=False) + ax_stats.plot([0.50, 0.50], + [grid_top - band_h * short_per_col, grid_top], + color=pal["divider"], alpha=0.85, linewidth=1.0, + transform=ax_stats.transAxes, clip_on=False) + + fig.text(0.5, 0.015, + f"SREBOT · {t('imgFooterGenerated')} {datetime.now(timezone.utc).strftime('%Y-%m-%d')}", + ha="center", fontsize=8, color=pal["footer"]) + + tmp = out_path.with_suffix(out_path.suffix + ".tmp") + tmp.parent.mkdir(parents=True, exist_ok=True) + try: + fig.savefig(tmp, dpi=120, facecolor=pal["bg"], bbox_inches=None, format="png") + os.replace(tmp, out_path) + finally: + plt.close(fig) + + +def render_player_placeholder(out_path: Path, nick: str, season: str, + reason: Optional[str] = None, + theme: str = "light", + lang: str = DEFAULT_LANG) -> None: + pal = theme_palette(theme) + tr = load_translations(lang) + if reason is None: + reason = tr.get("imgPlaceholderNoDataPlayer", + "No data for {nick} in {season}").format(nick=nick, season=season) + fig = plt.figure(figsize=(16, 9), dpi=120, facecolor=pal["bg"]) + ax = fig.add_subplot(1, 1, 1) + ax.set_axis_off() + ax.text(0.5, 0.62, f"{nick} · {season}", + fontsize=28, fontweight="bold", color=pal["text"], ha="center", + transform=ax.transAxes) + ax.text(0.5, 0.48, reason, + fontsize=16, color=pal["muted"], ha="center", + transform=ax.transAxes) + ax.text(0.5, 0.05, "SREBOT", + fontsize=10, color=pal["footer"], ha="center", + transform=ax.transAxes) + tmp = out_path.with_suffix(out_path.suffix + ".tmp") + tmp.parent.mkdir(parents=True, exist_ok=True) + try: + fig.savefig(tmp, dpi=120, facecolor=pal["bg"], format="png") + os.replace(tmp, out_path) + finally: + plt.close(fig) + + +def run_squadron(args: Args) -> int: + assert args.clan_id is not None + with _open_ro(SQUADRONS_DB) as conn_sq: + ident = resolve_squadron(conn_sq, args.clan_id) + if ident is None: + logging.error(f"unknown clan_id={args.clan_id}") + return 2 + logging.info(f"resolved {ident.short_name} / {ident.long_name}") + rating = gather_squadron_rating(conn_sq, args.clan_id, args.season_start, args.season_end) + logging.info(f"rating: series={len(rating.series)} final={rating.final} peak={rating.peak} change={rating.change}") + + with _open_ro(SQ_BATTLES_DB) as conn_b: + stream = gather_squadron_match_stream(conn_b, ident.short_name, args.season_start, args.season_end) + players = gather_squadron_player_aggregates(conn_b, ident.short_name, args.season_start, args.season_end) + squad_stream = gather_squadron_per_match_stream( + conn_b, ident.short_name, args.season_start, args.season_end + ) + match = compute_squadron_match_derived(stream, ident.short_name) + logging.info(f"matches: total={match.total} wins={match.wins} wr={match.wr_pct} streak={match.longest_win_streak} top_opp={match.top_opponent}") + rolling_wr = compute_rolling_wr(squad_stream) + rolling_kd = compute_rolling_kd(squad_stream) + rolling_battles = compute_rolling_battles(squad_stream) + logging.info(f"rolling: wr_pts={len(rolling_wr)} kd_pts={len(rolling_kd)} battles_pts={len(rolling_battles)}") + + if rating.final is None and match.total == 0: + logging.info("no data for squadron in season; writing placeholder") + render_squadron_placeholder(args.out, ident.short_name, args.season, theme=args.theme, lang=args.lang) + return 0 + + logging.info(f"players: kills={players.total_kills} deaths={players.deaths} kd={players.kd} top_veh={players.top_vehicle} active={players.most_active}") + if players.mvp: + _n, _k, _a, _c = players.mvp + logging.info(f"mvp: {_n} K={_k} A={_a} C={_c}") + + render_squadron_card( + args.out, ident, args.season, + rating, match, players, + rolling_wr, rolling_kd, rolling_battles, + args.season_start, args.season_end, + args.week_boundaries, + theme=args.theme, + lang=args.lang, + ) + logging.info(f"wrote {args.out}") + return 0 + + +def _resolve_latest_player_nick(conn_b: sqlite3.Connection, uid: str) -> str: + cur = conn_b.execute( + "SELECT nick FROM player_games_hist " + "WHERE UID = ? AND nick NOT LIKE 'coop/%' " + "ORDER BY session_id DESC LIMIT 1", + (uid,), + ) + row = cur.fetchone() + return row[0] if row else uid + + +def run_player(args: Args) -> int: + assert args.uid is not None + with _open_ro(SQ_BATTLES_DB) as conn_b, _open_ro(SQUADRONS_DB) as conn_sq: + nick = _resolve_latest_player_nick(conn_b, args.uid) + timeline = gather_player_squadron_timeline( + conn_b, args.uid, args.season_start, args.season_end + ) + stream = gather_player_game_stream( + conn_b, args.uid, args.season_start, args.season_end + ) + trail = gather_player_rating_trail(conn_sq, timeline, args.uid) + opp = gather_player_most_common_opponent(conn_b, stream) + teammate = gather_player_frequent_teammate( + conn_b, args.uid, args.season_start, args.season_end + ) + + # Membership check: the final timeline range was extended to season_end + # so we could scan past the player's last game. Clamp it back to the last + # snapshot where the player actually appeared in the squadron's roster + # (the last rating-trail point), so shading doesn't extend past when they + # left the squadron. + if timeline: + final_idx = len(timeline) - 1 + final_seg = trail[final_idx] if final_idx < len(trail) else None + observed_ts = final_seg.points[-1][0] if final_seg and final_seg.points else None + last_rng = timeline[final_idx] + clamp_to = observed_ts if observed_ts is not None else min( + last_rng.last_ts, args.season_end + ) + if clamp_to < last_rng.last_ts: + timeline[final_idx] = TimelineRange( + squadron_name=last_rng.squadron_name, + first_ts=last_rng.first_ts, + last_ts=clamp_to, + match_count=last_rng.match_count, + ) + + if not stream and all(not seg.points for seg in trail): + logging.info("no data for player in season; writing placeholder") + render_player_placeholder(args.out, nick, args.season, + theme=args.theme, lang=args.lang) + return 0 + + derived = compute_player_derived(stream, timeline, trail) + rolling_wr = compute_rolling_wr(stream) + rolling_kd = compute_rolling_kd(stream) + rolling_battles = compute_rolling_battles(stream) + longest = compute_longest_timeslot_session(stream) + active_day = compute_most_active_day_utc(stream) + + logging.info( + f"player nick={nick} games={len(stream)} timeline_ranges={len(timeline)} " + f"rating_segments={sum(1 for s in trail if s.points)} " + f"wr={derived.wr_pct} kd={derived.kd} streak={derived.longest_win_streak}" + ) + + render_player_card( + args.out, nick, args.uid, args.season, + timeline, trail, derived, + rolling_wr, rolling_kd, rolling_battles, + opp, teammate, longest, active_day, + args.season_start, args.season_end, + args.week_boundaries, + theme=args.theme, lang=args.lang, + ) + logging.info(f"wrote {args.out}") + return 0 + + +def main(argv: Optional[List[str]] = None) -> int: + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s", stream=sys.stderr) + args = parse_args(argv) + logging.info(f"render start mode={args.mode} season={args.season}") + if args.mode == "squadron": + return run_squadron(args) + return run_player(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/BOT/scoreboard.py b/BOT/scoreboard.py new file mode 100644 index 0000000..3b16417 --- /dev/null +++ b/BOT/scoreboard.py @@ -0,0 +1,1272 @@ +""" +scoreboard.py + +Generates scoreboard images for autologging match results. +Renders a styled PNG with blurred map backgrounds, player stats, vehicle icons, +team compositions, win/loss records, and squadron point diffs using PIL. +""" + +# Standard Library Imports +import asyncio +import cProfile +import io +import logging +import os +import pstats +import re +import sys +from datetime import datetime, timezone +from functools import lru_cache +from pathlib import Path + +# Third-Party Library Imports +import numpy as np +from PIL import Image, ImageDraw, ImageFilter, ImageFont +from PIL.Image import Resampling + +# Make SHARED (sibling of SREBOT under BOTS/) importable +_SHARED_DIR = Path(__file__).resolve().parents[2] / "SHARED" +if str(_SHARED_DIR) not in sys.path: + sys.path.insert(0, str(_SHARED_DIR)) + +# Local Module Imports +# Toggle for data_parser dependency +# Set to False to avoid importing data_parser and show all vehicles as "?" in team composition +USE_DATA_PARSER = True + +if USE_DATA_PARSER: + from data_parser import count_unit_types, apply_vehicle_name_filters +else: + def count_unit_types(internal_name_list): + """Fallback: return all vehicles as unknown type.""" + player_count = len([v for v in internal_name_list if v != "MEOW"]) + return {"?": player_count} if player_count > 0 else {} + def apply_vehicle_name_filters(name): + return name + +BASE_DIR = Path(__file__).resolve().parent +MAPS_DIR = _SHARED_DIR / "MAPS" +ICON_BASE_DIR = _SHARED_DIR / "ICONS" +TEXT_FONT_PATH = _SHARED_DIR / "FONTS" / "arial_unicode_ms.otf" + + +def _normalize_squad_key(value: str | None) -> str: + """Casefold and trim identifiers so scoreboard + prefs align.""" + if not value: + return "" + return re.sub(r"\s+", " ", value).strip().casefold() + +FONTS = {} + +def load_fonts(base_width): + """ + Load and cache all fonts for scoreboard rendering. + Re-uses cache if already loaded. + """ + global FONTS + + if FONTS: + return FONTS + + # main text fonts (measurable) + FONTS = { + "title": ImageFont.truetype(TEXT_FONT_PATH, int(base_width * 0.04)), + "team": ImageFont.truetype(TEXT_FONT_PATH, int(base_width * 0.03)), + "body": ImageFont.truetype(TEXT_FONT_PATH, int(base_width * 0.0175)), + "stat": ImageFont.truetype(TEXT_FONT_PATH, int(base_width * 0.022)), + "comp": ImageFont.truetype(TEXT_FONT_PATH, int(base_width * 0.018)), + "winloss": ImageFont.truetype(TEXT_FONT_PATH, int(base_width * 0.023)), + "info": ImageFont.truetype(TEXT_FONT_PATH, int(base_width * 0.016)), + "small": ImageFont.truetype(TEXT_FONT_PATH, int(base_width * 0.014)), + } + + return FONTS + + +# ────────────────────────────────────────────────────────────────────────────────────────────── +# Icon Caching Functions +# ────────────────────────────────────────────────────────────────────────────────────────────── + +@lru_cache(maxsize=200) +def _load_icon_cached(icon_path_str: str, size_tuple: tuple, resample_mode: int): + """ + Internal cached function. Loads icon from disk and resizes. + Uses only hashable types for lru_cache compatibility. + LRU cache with maxsize=200 automatically evicts least-used icons. + """ + icon_path = Path(icon_path_str) + + # Load and convert to RGBA + img = Image.open(icon_path).convert("RGBA") + + # Resize if size specified + if size_tuple: + img = img.resize(size_tuple, resample_mode) + + return img + + +def load_cached_icon(icon_path, size=None, resample_filter=Image.Resampling.LANCZOS): + """ + Load and cache icon images for scoreboard rendering. + LRU cache with maxsize=200 automatically evicts least-used icons. + + Args: + icon_path: Path object or string to icon file + size: Optional (width, height) tuple for resizing + resample_filter: Resampling filter for resize (default: LANCZOS) + + Returns: + PIL Image object in RGBA mode + """ + # Convert to hashable types for lru_cache + path_str = str(icon_path) + size_tuple = tuple(size) if size else None + resample_int = int(resample_filter) + + return _load_icon_cached(path_str, size_tuple, resample_int) + + +@lru_cache(maxsize=20) +def _load_map_background_cached(map_path_str: str, blur_radius: int): + """ + Internal cached function. Loads map background and applies blur. + LRU cache with maxsize=20 stores up to 20 blurred map backgrounds. + """ + map_path = Path(map_path_str) + + # Load, convert, and blur + background = Image.open(map_path).convert("RGBA") + background = background.filter(ImageFilter.GaussianBlur(radius=blur_radius)) + + return background + + +def load_cached_map_background(map_path, blur_radius=2): + """ + Load and cache map background images with blur applied. + LRU cache with maxsize=20 prevents excessive memory usage. + + Args: + map_path: Path to map background image + blur_radius: Gaussian blur radius to apply + + Returns: + PIL Image object in RGBA mode with blur applied + """ + return _load_map_background_cached(str(map_path), blur_radius) + + +def get_pts_color(value: int) -> tuple[int,int,int,int]: + """ + Color gradient based on points: + 1500 = green, 1600 = yellow, 1750 = orange, + 1850 = red-orange, 1900+ = red. + Below 1500 = green. + """ + + if value is None: + return (180, 180, 180, 255) # grey + + # Green for anything below 1500 + if value < 1500: + value = 1500 + + # Phase 1: 1500-1600 green → yellow + if value < 1600: + progress = (value - 1500) / 100.0 + r = int(255 * progress) # 0 → 255 + g = 255 + b = 0 + + # Phase 2: 1600-1750 yellow → orange + elif value < 1750: + progress = (value - 1600) / 150.0 + r = 255 + g = int(255 - (115 * progress)) # 255 → 140 + b = 0 + + # Phase 3: 1750-1850 orange → red-orange + elif value < 1850: + progress = (value - 1750) / 100.0 + r = 255 + g = int(140 - (80 * progress)) # 140 → 60 + b = 0 + + # Phase 4: 1850+ red-orange → red + else: + if value > 1900: + value = 1900 + progress = (value - 1850) / 50.0 + r = 255 + g = int(60 - (60 * progress)) # 60 → 0 + b = 0 + + return (r, g, b, 255) + + +def get_gradient_color(win_rate): + """ + Calculate color gradient from red to yellow to lime green based on win percentage. + 0% = Red (255, 0, 0), 50% = Yellow (255, 255, 0), 100% = Lime Green (0, 255, 0) + Transitions smoothly in 1% intervals + """ + win_rate = max(0, min(100, win_rate)) + + if win_rate <= 50: + red = 255 + green = int(255 * (win_rate / 50)) + blue = 0 + else: + red = int(255 * (1 - (win_rate - 50) / 50)) + green = 255 + blue = 0 + + return (red, green, blue, 255) + + +def make_vignette(width, height, base_alpha=140, max_alpha=175, power=4): + """Build a radial alpha mask for a vignette overlay. + + Args: + width: Image width in pixels. + height: Image height in pixels. + base_alpha: Minimum alpha at the center (0-255). + max_alpha: Maximum alpha at the edges (0-255). + power: Exponent controlling vignette falloff curve. + + Returns: + A PIL Image in "L" mode containing the alpha mask. + """ + base_alpha = max(0, min(255, base_alpha)) + max_alpha = max(0, min(255, max_alpha)) + if max_alpha < base_alpha: + max_alpha = base_alpha + + y, x = np.ogrid[:height, :width] + cx, cy = width / 2.0, height / 2.0 + dx = (x - cx) / cx + dy = (y - cy) / cy + d = np.sqrt(dx*dx + dy*dy) + d = np.clip(d, 0, 1) + + alpha = base_alpha + (max_alpha - base_alpha) * (d ** power) + alpha = np.clip(alpha, 0, 255).astype(np.uint8) + + return Image.fromarray(alpha, mode="L") + + +# ────────────────────────────────────────────────────────────────────────────────────────────── +# 1) Synchronous helper that does all the heavy PIL/math work and saves to disk. +# (Run this on a worker thread via asyncio.to_thread.) +# ────────────────────────────────────────────────────────────────────────────────────────────── +def _create_scoreboard_sync(match_details, + winning_team, + team1_details, + team2_details, + map_file, + output_path, + bar_color="", + diffs=None, WL=None, is_draw=False): + + """CPU-bound routine that renders the full scoreboard image and saves it. + + Loads the map background, builds a vignette gradient, draws all text/icons + for both teams, resizes/compresses, and writes the final PNG. + + Args: + match_details: Dict with match metadata (utc_timestamp, session_id). + winning_team: Squadron short name of the winner. + team1_details: Dict with "squadron" and "players" list for team 1. + team2_details: Dict with "squadron" and "players" list for team 2. + map_file: Map display name (e.g. "Abandoned Factory"). + output_path: Filesystem path to write the output PNG. + bar_color: Color hint for the header bar ("win", "loss", or ""). + diffs: Squadron point diffs dict, keyed by squadron name. + WL: Win/loss record dict, keyed by squadron name. + is_draw: Whether the match ended in a draw. + """ + + # ── A) Figure out paths & background + map_file_clean = re.sub(r"^\s*\[[^]]+\]\s*", "", map_file) + map_name = map_file_clean + map_file_clean = map_file_clean.replace(" ", "_") + + target = f"{map_file_clean}.jpg" + + # look for any file in MAPS whose name matches target, ignoring case + try: + candidates = {f.name for f in MAPS_DIR.iterdir() if f.is_file()} + except FileNotFoundError: + map_image_path = str(MAPS_DIR / target) + else: + match = next((fn for fn in candidates if fn.lower() == target.lower()), None) + if match: + map_image_path = str(MAPS_DIR / match) + else: + map_image_path = str(MAPS_DIR / target) + + # Load base background (with caching) + blur_power = 2 + try: + background = load_cached_map_background(map_image_path, blur_radius=blur_power) + except Exception as e: + logging.error(f"[Scoreboard] Failed to open map image {map_image_path}: {e}") + raise + + bg_width, bg_height = background.size + margin = 0 + + # ── B) Build vignette overlay (NumPy vectorized) + alpha_band = make_vignette(bg_width, bg_height, base_alpha=140, max_alpha=175, power=4) + + # Build a black overlay with that alpha gradient + overlay = Image.new("RGBA", (bg_width, bg_height), (0, 0, 0, 0)) + black_rgb = Image.new("RGBA", (bg_width, bg_height), (0, 0, 0, 255)) + overlay = Image.composite(black_rgb, overlay, alpha_band) + + draw = ImageDraw.Draw(overlay) + + BODY_FONT_SIZE = int(bg_width * 0.0175) + STAT_FONT_SIZE = int(bg_width * 0.022) + + # ── C) Load fonts + fonts = load_fonts(bg_width) + font_title = fonts["title"] + font_team = fonts["team"] + font_body = fonts["body"] + stat_font = fonts["stat"] + comp_font = fonts["comp"] + winloss_font = fonts["winloss"] + info_font = fonts["info"] + small_font = fonts["small"] + + + resample_filter = Image.Resampling.LANCZOS + + normalized_diffs = {} + if isinstance(diffs, dict) and diffs: + for key, value in diffs.items(): + norm_key = _normalize_squad_key(key) + if norm_key and norm_key not in normalized_diffs: + normalized_diffs[norm_key] = (key, value) + + + # ── D) Draw match_details (timestamp + session ID) in top-right + padding = 15 + ts_epoch = int(match_details["utc_timestamp"]) + dt_utc = datetime.fromtimestamp(ts_epoch, tz=timezone.utc) + ts_text = dt_utc.strftime("%H:%M:%S - %Y-%m-%d UTC") + sid_text = f"{match_details['session_id']}" + + ts_bbox = draw.textbbox((0,0), ts_text, font=info_font) + x_ts = bg_width - margin - (ts_bbox[2] - ts_bbox[0]) - padding - 10 + y_ts = margin + padding + 10 + draw.text((x_ts, y_ts), ts_text, font=info_font, fill=(200,200,200,255)) + + sid_bbox = draw.textbbox((0,0), sid_text, font=info_font) + x_id = bg_width - margin - (sid_bbox[2] - sid_bbox[0]) - padding - 10 + y_id = y_ts + (ts_bbox[3] - ts_bbox[1]) + 20 + draw.text((x_id, y_id), sid_text, font=info_font, fill=(200,200,200,255)) + + received_raw = match_details.get("received_unix") + if received_raw is not None: + try: + delay = int(received_raw) - ts_epoch + except (TypeError, ValueError): + delay = None + if delay is not None and delay >= 60: + d_hr, rem = divmod(delay, 3600) + d_min, d_sec = divmod(rem, 60) + delay_text = f"TTL: {d_hr:02d}:{d_min:02d}:{d_sec:02d}" + delay_color = (255, 60, 60, 255) if delay > 300 else (200, 200, 200, 255) + delay_bbox = draw.textbbox((0, 0), delay_text, font=info_font) + x_delay = bg_width - margin - (delay_bbox[2] - delay_bbox[0]) - padding - 10 + y_delay = y_id + (sid_bbox[3] - sid_bbox[1]) + 20 + draw.text((x_delay, y_delay), delay_text, font=info_font, fill=delay_color) + + # ── E) Draw top titles (map name + winner) + title_text = map_name + win_text = "DRAW" if is_draw else f"Winner - {winning_team}" + y = 50 + + # Centered map-name + title_bbox = draw.textbbox((0,0), title_text, font=font_title) + title_width = title_bbox[2] - title_bbox[0] + x_center = (bg_width - title_width) // 2 + draw.text((x_center, y), title_text, font=font_title, fill=(255,255,255,255)) + title_height = title_bbox[3] - title_bbox[1] + y += title_height + 40 + + win_loss_data = WL or {} + + # Squadrons for each team + team1_squadron = team1_details.get("squadron", "Unknown") + team2_squadron = team2_details.get("squadron", "Unknown") + + # Pull stats directly from the WL dict instead of hitting storage + stats1 = win_loss_data.get(team1_squadron, {"wins": 0, "losses": 0}) + stats2 = win_loss_data.get(team2_squadron, {"wins": 0, "losses": 0}) + + # Unpack for drawing + team1_wins, team1_losses = stats1["wins"], stats1["losses"] + team2_wins, team2_losses = stats2["wins"], stats2["losses"] + + # Store winloss data as raw values: (win_str, loss_str, percent_str, fill_color) + team1_total = team1_wins + team1_losses + if team1_total > 0: + team1_win_rate = (team1_wins / team1_total) * 100 + team1_winloss_data = (str(team1_wins), str(team1_losses), f"{team1_win_rate:.0f}%", get_gradient_color(team1_win_rate)) + else: + team1_winloss_data = None + + team2_total = team2_wins + team2_losses + if team2_total > 0: + team2_win_rate = (team2_wins / team2_total) * 100 + team2_winloss_data = (str(team2_wins), str(team2_losses), f"{team2_win_rate:.0f}%", get_gradient_color(team2_win_rate)) + else: + team2_winloss_data = None + + # Centered winner text (not drawn — winner/draw is conveyed by team name colors) + win_bbox = draw.textbbox((0,0), win_text, font=font_title) + win_height = win_bbox[3] - win_bbox[1] + y_start = y + win_height + 60 # Back to normal spacing since winloss moved to icon level + + # ── F) Compute layout for two team columns + x_start = margin + 45 + gap_between = 0 + col_width = (bg_width - (x_start * 2) - gap_between) // 2 + + def draw_team(idx, team_data, start_x, start_y, section_width, buffer): + """Draw one team's block onto the scoreboard canvas. + + Renders squadron header, player rows (nick, vehicle icon, stats), + team composition, and W/L record. Layout is mirrored for team 2. + + Args: + idx: Team index (1 or 2). Team 2 is drawn right-aligned. + team_data: Dict with "squadron", "players", and optional metadata. + start_x: Left x-coordinate of the team column. + start_y: Top y-coordinate to begin drawing. + section_width: Available pixel width for this team's column. + buffer: Shared list collecting the running y-position for layout. + """ + flipped = (idx == 2) + + Username_fill = (250, 227, 200, 255) + Living_vehicle_fill = (255, 255, 255, 255) + Dead_vehicle_fill = (200, 200, 200, 255) + Positive_Points_fill = (60, 255, 60, 255) + Negative_Points_fill = (255, 60, 60, 255) + Unknown_Points_fill = (200, 200, 200, 255) + + + # --- 1) Squadron header & points --- + squadron_short = team_data.get("squadron", "Unknown") + squadron_long = team_data.get("squadron_long", "Unknown") + matched_diff_source = None + squad_diffs = None + if normalized_diffs: + for candidate in (squadron_long, squadron_short): + norm_candidate = _normalize_squad_key(candidate) + match = normalized_diffs.get(norm_candidate) + if match: + matched_diff_source, squad_diffs = match + break + + diff_keys = list(diffs.keys()) if isinstance(diffs, dict) else None + + + points_diff = {} + current_points = {} + sq_string = "" + sq_points_fill = (200, 200, 200, 255) # neutral grey + + if squad_diffs: + points_diff = squad_diffs.get("points_diff", {}) + current_points = squad_diffs.get("current_points", {}) + diff_total = squad_diffs.get("diff_total", 0) + + sq_points = int(diff_total) + if sq_points > 0: + sq_string, sq_points_fill = f"+{sq_points}", Positive_Points_fill + elif sq_points < 0: + sq_string, sq_points_fill = str(sq_points), Negative_Points_fill + else: + sq_string, sq_points_fill = "0", Unknown_Points_fill + + + squad_bbox = draw.textbbox((0,0), squadron_short, font=font_team) + squad_width = squad_bbox[2] - squad_bbox[0] + squad_height = squad_bbox[3] - squad_bbox[1] + header_y = start_y - 10 + text = squadron_short + + # Yellow color for draws (255, 255, 0, 255) + Draw_fill = (255, 255, 0, 255) + + if is_draw: + fill = Draw_fill + else: + fill = Positive_Points_fill if squadron_short == winning_team else Negative_Points_fill + + gap = 30 + if not flipped: + name_x = start_x + pts_x = start_x + + if diffs: + pts_x = name_x + squad_width + gap + else: + name_x = start_x + section_width - squad_width + pts_x = start_x + + if diffs: + sq_bbox = draw.textbbox((0,0), sq_string, font=font_team) + sq_width = sq_bbox[2] - sq_bbox[0] + pts_x = name_x - gap - sq_width + + draw.text((name_x, header_y), text, font=font_team, fill=fill) + + if diffs: + draw.text((pts_x, header_y), sq_string, font=font_team, fill=sq_points_fill) + + # --- 2) Comp notation below header --- + notation_list = count_unit_types([p["vehicle"] for p in team_data.get("players", [])]) + comp_order = [ + ("F", "Fighters"), + ("B", "Bombers"), + ("H", "Helicopters"), + ("L", "Light"), + ("T", "Tanks"), + ("AA", "AA"), + ("?", "?") + ] + + comp_y = header_y + squad_height + 40 + + if not flipped: + # Build left→right + comp_x = start_x + 5 + first = True + for code, _ in comp_order: + cnt = notation_list.get(code, 0) + if cnt > 0: + txt = f"{cnt}{code}" + if not first: + sep = "/ " + sep_w = draw.textbbox((0, 0), sep, font=comp_font)[2] + draw.text((comp_x, comp_y), sep, font=comp_font, fill=(255, 255, 255, 255)) + comp_x += sep_w + 5 + draw.text((comp_x, comp_y), txt, font=comp_font, fill=(255, 255, 255, 255)) + txt_w = draw.textbbox((0, 0), txt, font=comp_font)[2] + comp_x += txt_w + 15 + first = False + else: + # Build a single string, then right‐align + codes_drawn = [] + for code, _ in comp_order: + cnt = notation_list.get(code, 0) + if cnt > 0: + codes_drawn.append(f"{cnt}{code}") + if codes_drawn: + full_comp_str = " / ".join(codes_drawn) + full_w = draw.textbbox((0, 0), full_comp_str, font=comp_font)[2] + comp_x = start_x + section_width - 5 - full_w + draw.text((comp_x, comp_y), full_comp_str, font=comp_font, fill=(255, 255, 255, 255)) + # else: nothing to draw + + # --- 3) Column headers with icons --- + if not flipped: + columns = ["", "Air", "Ground", "Assists", "Deaths", "Caps"] + else: + columns = ["", "Caps", "Deaths", "Assists", "Ground", "Air"] + + num_stat_cols = len(columns) - 1 + stat_area_width= int(section_width * 0.32) + stat_start = start_x + section_width - stat_area_width + buffer + + col_positions = [start_x] + [ + stat_start + int(i * stat_area_width / num_stat_cols) + for i in range(num_stat_cols) + ] + if flipped: + col_positions = [ + start_x + (section_width - (x - start_x)) for x in col_positions + ] + + + ICON_SIZE = int(STAT_FONT_SIZE * 1.1) + base_icon_map = { + "Air": ICON_BASE_DIR / "fighter_icon.png", + "Ground": ICON_BASE_DIR / "tank_icon.png", + "Assists": ICON_BASE_DIR / "assists_icon.png", + "Deaths": ICON_BASE_DIR / "deaths_icon.png", + "Caps": ICON_BASE_DIR / "cap_icon.png" + } + + if not flipped: + cols_to_draw = columns + pos_to_draw = col_positions + else: + cols_to_draw = list(reversed(columns)) + pos_to_draw = list(reversed(col_positions)) + + for i, name in enumerate(cols_to_draw): + icon_file = base_icon_map.get(name) + if not icon_file: + continue + header_x = pos_to_draw[i] + try: + icon_img = load_cached_icon(icon_file, (ICON_SIZE, ICON_SIZE), resample_filter) + header_icon_y = header_y + 90 + overlay.paste(icon_img, (header_x - 15, header_icon_y), icon_img) + except Exception: + pass + + row_height = ICON_SIZE + 30 + y_offset = header_y + row_height + 60 + + # --- 4) Player rows + players_sorted = sorted( + team_data.get("players", []), + key=lambda p: int(p.get("score", 0)), reverse=True + ) + + for player in players_sorted: + uid = str(player.get("uid")) + pts_str = "" + pts_fill = (200, 200, 200, 255) # neutral + pts = None + c_pts = None + + if squad_diffs: # only if this team was actually tracked + pts = points_diff.get(uid) + c_pts = current_points.get(uid) + + if pts is None: + pts_str = "???" # tracked, but this player missing in snapshot + pts_fill = Unknown_Points_fill + elif pts > 0: + pts_str = f"+{pts}" + pts_fill = Positive_Points_fill + elif pts < 0: + pts_str = str(pts) + pts_fill = Negative_Points_fill + else: + pts_str = "0" + pts_fill = Unknown_Points_fill + else: + # not tracked at all by this guild → leave blank + pts_str = "" + + + + + # --- Prepare icon image --- + if player.get("vehicle", "") == "DISCONNECTED": + + ICON_PATH = ICON_BASE_DIR / "disconnected.png" + else: + ICON_PATH = ICON_BASE_DIR / "VEHICLES" / f"{player.get('vehicle','').lower()}.png" + + icon_display_size = int(BODY_FONT_SIZE * 3.0) + size_tuple = (icon_display_size, icon_display_size) + try: + vicon = load_cached_icon(ICON_PATH, size_tuple, resample_filter) + except Exception: + # If vehicle icon fails to load (invalid vehicle), use not_found icon as fallback + try: + vicon = load_cached_icon( + ICON_BASE_DIR / "not_found.png", + size_tuple, + resample_filter + ) + except Exception: + vicon = None + + # --- Name & (c_pts) formatting (no inline font mixing) --- + + # Prefer fake_nick if present, otherwise fall back to nick + name_raw = (player.get("fake_nick") or player.get("nick") or "").strip() + + # Strip platform suffixes + name_raw = name_raw.replace("@live", "").replace("@psn", "") + + vehicle_new = player.get("vehicle_new", "DISCONNECTED") + + # Handle None, empty string, or missing value + if not vehicle_new: + vehicle_new = "DISCONNECTED" + else: + vehicle_new = apply_vehicle_name_filters(vehicle_new) + + + show_pts = bool(diffs) # only show when diffs data is present + + # compute name/vehicle metrics using only the base name (no c_pts) + name_bbox = draw.textbbox((0,0), name_raw, font=font_body) + name_w = name_bbox[2] - name_bbox[0] + name_h = name_bbox[3] - name_bbox[1] + + vehicle_bbox = draw.textbbox((0,0), vehicle_new, font=font_body) + vehicle_w = vehicle_bbox[2] - vehicle_bbox[0] + vehicle_h = vehicle_bbox[3] - vehicle_bbox[1] + + identity_w = max(name_w, vehicle_w) + identity_h = name_h + 5 + vehicle_h + + row_height = max(identity_h, int(BODY_FONT_SIZE * 2.70)) + text_y = y_offset + (row_height - identity_h) // 2 + icon_y = y_offset + (row_height - int(BODY_FONT_SIZE * 2.35)) // 2 + player_name_y_offset = 12 + vehicle_name_y_offset = 4 + + # === Draw icon + name + vehicle === + if not flipped: + icon_x = start_x + if vicon: + overlay.paste(vicon, (icon_x, icon_y + 3), vicon) + + text_x = icon_x + icon_display_size + 15 + + # draw name + draw.text((text_x, text_y-player_name_y_offset), name_raw, font=font_body, fill=Username_fill) + + if show_pts and c_pts is not None: + pts_text = f"({c_pts})" + pts_bbox = draw.textbbox((0,0), pts_text, font=small_font) + pts_w = pts_bbox[2] - pts_bbox[0] + pts_h = pts_bbox[3] - pts_bbox[1] + pts_x = text_x + name_w + 8 + pts_y = (text_y + (name_h - pts_h) // 2) - player_name_y_offset + draw.text((pts_x, pts_y), pts_text, font=small_font, fill=get_pts_color(c_pts)) + + # vehicle (left side) + vehicle_y = (text_y + name_h + 10) - vehicle_name_y_offset + player_dead = (int(player.get("deaths", 0)) > 0) + draw.text( + (text_x, vehicle_y), + vehicle_new, + font=font_body, + fill=Dead_vehicle_fill if player_dead or vehicle_new == "DISCONNECTED" + else Living_vehicle_fill + ) + + else: + # flipped: icon on the right, name right-aligned against it + icon_x = start_x + section_width - icon_display_size - 5 + if vicon: + overlay.paste(vicon, (icon_x, icon_y + 3), vicon) + + text_x = icon_x - name_w - 15 + + # draw name + draw.text((text_x, text_y-player_name_y_offset), name_raw, font=font_body, fill=Username_fill) + + # draw (c_pts) in smaller font to the LEFT of the name + if show_pts and c_pts is not None: + pts_text = f"({c_pts})" + pts_bbox = draw.textbbox((0,0), pts_text, font=small_font) + pts_w = pts_bbox[2] - pts_bbox[0] + pts_h = pts_bbox[3] - pts_bbox[1] + pts_x = text_x - 8 - pts_w + pts_y = (text_y + (name_h - pts_h) // 2) - player_name_y_offset + draw.text((pts_x, pts_y), pts_text, font=small_font, fill=get_pts_color(c_pts)) + + # vehicle (right side) + vehicle_y = (text_y + name_h + 10) - vehicle_name_y_offset + vehicle_x = icon_x - vehicle_w - 15 + player_dead = (int(player.get("deaths", 0)) > 0) + draw.text( + (vehicle_x, vehicle_y), + vehicle_new, + font=font_body, + fill=Dead_vehicle_fill if player_dead or vehicle_new == "DISCONNECTED" + else Living_vehicle_fill + ) + + + if diffs: + # 1) measure your text + stat_bbox = draw.textbbox((0, 0), pts_str, font=stat_font) + stat_w = stat_bbox[2] - stat_bbox[0] + stat_h = stat_bbox[3] - stat_bbox[1] + + pts_string_offset = 0 # positive is down + + # 2) vertical centering + pts_y = (y_offset + (row_height - stat_h) // 2) + pts_string_offset + + # 3) compute your right‐edge anchor + if not flipped: + anchor_x = col_positions[1] - 35 + pts_x_draw = anchor_x + + # 4b) draw + draw.text((pts_x_draw, pts_y), pts_str, font=stat_font, fill=pts_fill, anchor="ra") + + else: + # flipped side stays as before + pts_x_draw = col_positions[1] + 65 + + # 4b) draw + draw.text((pts_x_draw, pts_y), pts_str, font=stat_font, fill=pts_fill) + + # Draw the five stat columns + stats = [ + player.get("air_kills", 0), + player.get("ground_kills", 0), + player.get("assists", 0), + player.get("deaths", 0), + player.get("captures", 0) + ] + + base_labels = ["Air", "Ground", "Assists", "Deaths", "Caps"] + if not flipped: + labels = base_labels + positions = col_positions[1:] + else: + labels = base_labels + positions = list(reversed(col_positions[1:])) + + for val, x_pos, label in zip(stats, positions, labels): + try: + num = int(val) + except Exception: + num = 0 + + if label in ("Air","Ground") and num > 0: + fill_color = (60, 255, 60, 255) + elif label == "Deaths" and num > 0: + fill_color = (255, 20, 20, 255) + elif label == "Caps" and num > 0: + fill_color = (255, 255, 0, 255) + elif label == "Assists" and num > 0: + fill_color = (230, 150, 90, 255) + else: + fill_color = (255, 255, 255, 255) + + num_str = str(num) + num_bbox= draw.textbbox((0,0), num_str, font=stat_font) + num_h = num_bbox[3] - num_bbox[1] + stat_y = y_offset + (row_height - num_h) // 2 + + draw.text((x_pos, stat_y), num_str, font=stat_font, fill=fill_color) + + y_offset += row_height + 15 + + # ── G) Draw separator line and both teams side-by-side + dx = 0 + dy_top = -50 + dy_bottom = 50 + + if bar_color == "win": + bar_color_fill = (60, 255, 60, 255) + elif bar_color == "loss": + bar_color_fill = (255, 60, 60, 255) + elif bar_color == "draw": + bar_color_fill = (255, 255, 0, 255) + else: + bar_color_fill = (255, 255, 255, 255) + + sep_x = x_start + col_width + gap_between // 2 + dx + sep_y1 = y_start + dy_top + sep_y2 = bg_height - margin - dy_bottom + + draw.line([(sep_x, sep_y1), (sep_x, sep_y2)], + fill=bar_color_fill, + width=5) + + draw_team( + idx=1, + team_data=team1_details, + start_x=x_start + 10, + start_y=y_start - 130, + section_width=col_width, + buffer=-10 + ) + draw_team( + idx=2, + team_data=team2_details, + start_x=x_start + col_width + gap_between, + start_y=y_start - 130, + section_width=col_width, + buffer=33 + ) + + # Draw winloss data in center area between title and icons + icon_level_y = y_start - 130 - 5 # Slightly down from previous position + center_x = bg_width // 2 + + def _draw_winloss(draw, x_start, y, win_num, loss_num, percent_part, pct_fill): + """Draw colored W-L-% text starting at x_start. Returns nothing.""" + cx = x_start + + # Win number + W (green) + draw.text((cx, y), win_num, font=winloss_font, fill=(0, 255, 0, 255)) + cx += draw.textbbox((0,0), win_num, font=winloss_font)[2] + draw.text((cx, y), "W", font=winloss_font, fill=(0, 255, 0, 255)) + cx += draw.textbbox((0,0), "W", font=winloss_font)[2] + + # Dash (grey) + draw.text((cx, y), " - ", font=winloss_font, fill=(200, 200, 200, 255)) + cx += draw.textbbox((0,0), " - ", font=winloss_font)[2] + + # Loss number + L (red) + draw.text((cx, y), loss_num, font=winloss_font, fill=(255, 60, 60, 255)) + cx += draw.textbbox((0,0), loss_num, font=winloss_font)[2] + draw.text((cx, y), "L", font=winloss_font, fill=(255, 60, 60, 255)) + cx += draw.textbbox((0,0), "L", font=winloss_font)[2] + + # Dash (grey) + draw.text((cx, y), " - ", font=winloss_font, fill=(200, 200, 200, 255)) + cx += draw.textbbox((0,0), " - ", font=winloss_font)[2] + + # Percentage (color depends on win rate) + draw.text((cx, y), percent_part, font=winloss_font, fill=pct_fill) + + if team1_winloss_data: + win_num, loss_num, percent_part, wl_fill = team1_winloss_data + total_text = f"{win_num}W - {loss_num}L - {percent_part}" + total_bbox = draw.textbbox((0,0), total_text, font=winloss_font) + total_width = total_bbox[2] - total_bbox[0] + _draw_winloss(draw, center_x - total_width - 30, icon_level_y, win_num, loss_num, percent_part, wl_fill) + + if team2_winloss_data: + win_num, loss_num, percent_part, wl_fill = team2_winloss_data + _draw_winloss(draw, center_x + 30, icon_level_y, win_num, loss_num, percent_part, wl_fill) + + # ── H) Composite overlay onto background, downsample, and save + final_img = Image.alpha_composite(background, overlay) + + # Lower is more compression, think of it like what percentage of the W / H to keep + compression_level: float = 0.42 + w, h = final_img.size + new_size = (int(w * compression_level), int(h * compression_level)) + + resized = final_img.resize(new_size, resample=Resampling.LANCZOS) + + # Convert to RGB to remove unused alpha channel (~25% size reduction) + resized = resized.convert("RGB") + + try: + resized.save(output_path, format="PNG", compress_level=1, optimize=False) + + except Exception as e: + logging.error(f"[Scoreboard] ✗ Failed to save to {output_path}: {e}") + raise + + +# ────────────────────────────────────────────────────────────────────────────────────────────── +# 2) Async wrapper that simply offloads the above helper to a thread +# ────────────────────────────────────────────────────────────────────────────────────────────── +async def create_scoreboard(match_details, + winning_team, + team1_details, + team2_details, + map_file, + output_path, + bar_color="", + diffs=None, + WL=None, + is_draw=False): + """Async entry point that offloads scoreboard rendering to a worker thread. + + Args: + match_details: Dict with match metadata (utc_timestamp, session_id). + winning_team: Squadron short name of the winner. + team1_details: Dict with "squadron" and "players" list for team 1. + team2_details: Dict with "squadron" and "players" list for team 2. + map_file: Map display name (e.g. "Abandoned Factory"). + output_path: Filesystem path to write the output PNG. + bar_color: Color hint for the header bar ("win", "loss", or ""). + diffs: Squadron point diffs dict, keyed by squadron name. + WL: Win/loss record dict, keyed by squadron name. + is_draw: Whether the match ended in a draw. + + Raises: + Exception: Re-raised from _create_scoreboard_sync on render failure. + """ + # Ensure the parent folder is present + base_dir = os.path.dirname(output_path) + os.makedirs(base_dir, exist_ok=True) + + try: + await asyncio.to_thread(_create_scoreboard_sync, + match_details, + winning_team, + team1_details, + team2_details, + map_file, + output_path, + bar_color, + diffs, + WL, + is_draw + ) + + except Exception as e: + logging.error(f"[Scoreboard] create_scoreboard_sync failed: {e}") + raise + + + +# Example usage: +async def test(): + """Generate a test scoreboard image with hardcoded sample match data.""" + team1 = { + "squadron": + "AVR", + "players": [{ + "uid": 140943953, + "nick": "\u0410VR", + "index": 5, + "vehicle": "ussr_pantsyr_s1", + "vehicle_new": "Meow", + "air_kills": 1, + "ground_kills": 0, + "assists": 0, + "deaths": 0, + "captures": 0, + "score": 320 + }, { + "uid": 145639262, + "nick": "bullpuppy\u30c5", + "index": 6, + "vehicle": "DISCONNECTED", + "vehicle_new": "DISCONNECTED", + "air_kills": 0, + "ground_kills": 1, + "assists": 1, + "deaths": 1, + "captures": 0, + "score": 184 + }, { + "uid": 154923412, + "nick": "\u041e\u0448\u0438\u0431\u043a\u0430\u30c5", + "index": 7, + "vehicle": "germ_leopard_2a7v", + "vehicle_new": "Leopard 2A7V", + "air_kills": 0, + "ground_kills": 2, + "assists": 2, + "deaths": 1, + "captures": 0, + "score": 600 + }, { + "uid": 17424877, + "nick": "\u0396\u039b\u039f", + "index": 8, + "vehicle": "ef_2000_block_10", + "vehicle_new": "EF-2000", + "air_kills": 0, + "ground_kills": 0, + "assists": 0, + "deaths": 0, + "captures": 0, + "score": 0 + }, { + "uid": 25721576, + "nick": "1N1ck", + "index": 9, + "vehicle": "rafale_c_f3", + "vehicle_new": "Rafale C F3", + "air_kills": 1, + "ground_kills": 0, + "assists": 0, + "deaths": 1, + "captures": 0, + "score": 355 + }, { + "uid": 63482105, + "nick": "link_54", + "index": 11, + "vehicle": "rafale_c_f3", + "vehicle_new": "Rafale C F3", + "air_kills": 1, + "ground_kills": 0, + "assists": 0, + "deaths": 0, + "captures": 0, + "score": 284 + }, { + "uid": 66489055, + "nick": "BlackSkr1pt", + "index": 12, + "vehicle": "us_m1a2_abrams_v2", + "vehicle_new": "M1A2 Sep", + "air_kills": 0, + "ground_kills": 1, + "assists": 0, + "deaths": 0, + "captures": 1, + "score": 620 + }, { + "uid": 66490901, + "nick": + "\u0412\u0435\u0440\u0442\u043e\u0448\u043b\u044e\u0445\u0430", + "index": 13, + "vehicle": "spitfire_lf_mk9e_weisman", + "vehicle_new": "Weizman's Spitfire LF Mk.IXe", + "air_kills": 0, + "ground_kills": 0, + "assists": 1, + "deaths": 1, + "captures": 0, + "score": 269 + }] + } + team2 = { + "squadron": + "VCoM", + "players": [{ + "uid": 116628911, + "nick": "FrostChicken", + "index": 0, + "vehicle": "sw_leopard_2a6nl", + "vehicle_new": "Leopard 2A6NL", + "air_kills": 0, + "ground_kills": 0, + "assists": 0, + "deaths": 1, + "captures": 0, + "score": 80 + }, { + "uid": 124076506, + "nick": "AstroZeus6571@live", + "index": 1, + "vehicle": "saab_jas39c", + "vehicle_new": "JAS39C", + "air_kills": 1, + "ground_kills": 0, + "assists": 2, + "deaths": 1, + "captures": 0, + "score": 336 + }, { + "uid": 125095780, + "nick": "HitNRunTitan@live", + "index": 2, + "vehicle": "f_15e", + "vehicle_new": "F-15E", + "air_kills": 0, + "ground_kills": 0, + "assists": 0, + "deaths": 1, + "captures": 0, + "score": 59 + }, { + "uid": 134108969, + "nick": "MoneylessMonkey", + "index": 3, + "vehicle": "us_m1a1_hc_abrams", + "vehicle_new": "M1A1 HC", + "air_kills": 0, + "ground_kills": 0, + "assists": 0, + "deaths": 1, + "captures": 0, + "score": 80 + }, { + "uid": 136002299, + "nick": "DaddyDabbin3112@live", + "index": 4, + "vehicle": "f_15e", + "vehicle_new": "F-15E", + "air_kills": 0, + "ground_kills": 0, + "assists": 0, + "deaths": 1, + "captures": 0, + "score": 60 + }, { + "uid": 32616799, + "nick": "ViRuSSoNy", + "index": 10, + "vehicle": "it_otomatic", + "vehicle_new": "Otomatic", + "air_kills": 0, + "ground_kills": 0, + "assists": 0, + "deaths": 1, + "captures": 0, + "score": 69 + }, { + "uid": 7429595, + "nick": "farfadet12", + "index": 14, + "vehicle": "ef_2000_fgr4", + "vehicle_new": "FG-2000", + "air_kills": 0, + "ground_kills": 0, + "assists": 0, + "deaths": 1, + "captures": 0, + "score": 0 + }, { + "uid": 91889275, + "nick": "noel_nootje@psn", + "index": 15, + "vehicle": "il_merkava_mk_4m", + "vehicle_new": "Tan-SAM Kai (TEL)", + "air_kills": 0, + "ground_kills": 1, + "assists": 0, + "deaths": 1, + "captures": 0, + "score": 366 + }] + } + BASE_DIR = Path(__file__).resolve().parent + output_dir = BASE_DIR / "TEST_IN_ME" + os.makedirs(output_dir, exist_ok=True) + output_path = os.path.join(output_dir, "output.png") + + # Call the async create_scoreboard, which offloads work correctly + await create_scoreboard( + match_details={"utc_timestamp": "1746424038", "session_id": "4acb2a60017e0f6", "received_unix": 1746424428}, + winning_team="AVR", + team1_details=team1, + team2_details=team2, + map_file="Abandoned FaCtory", + output_path=output_path, + bar_color="win", + diffs=[], + WL={}, + is_draw=False + ) + + img_dim = Image.open(output_path).size + + file_size_bytes = os.path.getsize(output_path) + + size = file_size_bytes / 1024 + unit = "KB" + if size > 1024: + size /= 1024 + unit = "MB" + img_size = f"{size:.2f} {unit}" + logging.info(f"Test complete. Check the generated image at {output_path} : Image Dimensions: {img_dim} : Image Size: {img_size}") + + +def profile_async(coro): + """Run an async coroutine under cProfile and print the top 20 slowest calls. + + Args: + coro: An awaitable coroutine object to profile (passed to asyncio.run). + """ + pr = cProfile.Profile() + pr.enable() + try: + asyncio.run(coro) + finally: + pr.disable() + s = io.StringIO() + ps = pstats.Stats(pr, stream=s).sort_stats("cumulative") + ps.print_stats(20) # show top 20 slowest + print(f"\n[Profiler results]\n{s.getvalue()}") + +if __name__ == "__main__": + profile_async(test()) # run the test function with profiling + #asyncio.run(test()) # run the test function normally without profiling diff --git a/BOT/srebot_external.py b/BOT/srebot_external.py new file mode 100644 index 0000000..05b76a8 --- /dev/null +++ b/BOT/srebot_external.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python3 +"""External SREBOT bridge service. + +This PM2-managed process does two things: +1. Proxies read-only SREBOT queries on the external port. +2. Broadcasts SREBOT replay/GOB envelopes over websocket to any connected + client. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import aiohttp +from aiohttp import web +from dotenv import load_dotenv + + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +load_dotenv() + +from BOT.receiver_bridge import EXTERNAL_OUTBOX_PATH # noqa: E402 + +logging.basicConfig( + level=logging.INFO, + format="[%(asctime)s] [%(levelname)s] [srebot-external] %(message)s", +) +logger = logging.getLogger("srebot-external") + + +def _env(name: str, default: str = "") -> str: + return os.getenv(name, default).strip() + + +@dataclass(slots=True) +class ExternalSettings: + host: str = _env("SREBOT_EXTERNAL_HOST", "0.0.0.0") + port: int = int(_env("SREBOT_EXTERNAL_PORT", "18081")) + bearer_token: str = _env("SREBOT_EXTERNAL_BEARER_TOKEN", _env("SREBOT_API_BEARER_TOKEN")) + upstream_url: str = _env("SREBOT_EXTERNAL_UPSTREAM_URL", "http://127.0.0.1:6000").rstrip("/") + upstream_bearer_token: str = _env("SREBOT_EXTERNAL_UPSTREAM_BEARER_TOKEN", _env("SREBOT_API_BEARER_TOKEN")) + outbox_path: Path = Path(_env("SREBOT_EXTERNAL_OUTBOX_PATH", str(EXTERNAL_OUTBOX_PATH))) + offset_path: Path = Path(_env("SREBOT_EXTERNAL_OFFSET_PATH", str(Path(str(EXTERNAL_OUTBOX_PATH)).with_suffix(".offset")))) + poll_interval_seconds: float = float(_env("SREBOT_EXTERNAL_POLL_INTERVAL", "0.5")) + reconnect_delay_seconds: float = float(_env("SREBOT_EXTERNAL_RECONNECT_DELAY", "1.0")) + + +SETTINGS = ExternalSettings() +SETTINGS.outbox_path.parent.mkdir(parents=True, exist_ok=True) +SETTINGS.offset_path.parent.mkdir(parents=True, exist_ok=True) + + +HOP_BY_HOP_HEADERS = { + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", +} + +CONNECTED_WEBSOCKETS: set[web.WebSocketResponse] = set() +CONNECTED_LOCK = asyncio.Lock() + + +def _auth_ok(request: web.Request) -> bool: + if not SETTINGS.bearer_token: + return True + return request.headers.get("Authorization", "") == f"Bearer {SETTINGS.bearer_token}" + + +@web.middleware +async def auth_middleware(request: web.Request, handler): + if request.path in {"/health", "/"} or request.path.startswith("/ws/"): + return await handler(request) + + if not _auth_ok(request): + logger.warning("Unauthorized request", extra={"path": request.rel_url.path_qs}) + return web.json_response({"error": "Unauthorized"}, status=401) + + return await handler(request) + + +def _upstream_headers() -> dict[str, str]: + headers = {"Accept": "application/json"} + if SETTINGS.upstream_bearer_token: + headers["Authorization"] = f"Bearer {SETTINGS.upstream_bearer_token}" + return headers + + +def _read_offset() -> int: + try: + return int(SETTINGS.offset_path.read_text(encoding="utf-8").strip()) + except Exception: + return 0 + + +def _write_offset(offset: int) -> None: + SETTINGS.offset_path.write_text(str(offset), encoding="utf-8") + + +async def health(_: web.Request) -> web.Response: + return web.json_response( + { + "status": "ok", + "service": "srebot-external", + "http": SETTINGS.upstream_url, + "websocket": "/ws/srebot", + } + ) + + +async def proxy_api(request: web.Request) -> web.StreamResponse: + target = f"{SETTINGS.upstream_url}{request.rel_url.path_qs}" + request_start = time.monotonic() + logger.info( + "AXBot query in", + extra={ + "method": request.method, + "path": request.rel_url.path_qs, + }, + ) + + body = await request.read() if request.can_read_body else b"" + async with request.app["http_session"].request( + request.method, + target, + headers=_upstream_headers(), + data=body if body else None, + ) as upstream: + payload = await upstream.read() + duration_ms = round((time.monotonic() - request_start) * 1000, 1) + logger.info( + "AXBot query out", + extra={ + "method": request.method, + "path": request.rel_url.path_qs, + "status": upstream.status, + "bytes": len(payload), + "duration_ms": duration_ms, + }, + ) + + headers = { + key: value + for key, value in upstream.headers.items() + if key.lower() not in HOP_BY_HOP_HEADERS + and key.lower() not in {"content-length", "content-encoding"} + } + return web.Response(body=payload, status=upstream.status, headers=headers) + + +async def root(_: web.Request) -> web.Response: + return web.json_response( + { + "service": "srebot-external", + "message": "Use /api/* for queries and /ws/srebot for replay/gob events.", + } + ) + + +async def websocket_handler(request: web.Request) -> web.WebSocketResponse: + if not _auth_ok(request): + logger.warning("Unauthorized websocket", extra={"path": request.rel_url.path_qs}) + ws = web.WebSocketResponse() + await ws.prepare(request) + await ws.close(code=1008, message=b"Unauthorized") + return ws + + ws = web.WebSocketResponse(heartbeat=20) + await ws.prepare(request) + + async with CONNECTED_LOCK: + CONNECTED_WEBSOCKETS.add(ws) + + logger.info("Websocket connected", extra={"clients": len(CONNECTED_WEBSOCKETS)}) + + try: + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + logger.info("Websocket recv", extra={"bytes": len(msg.data)}) + elif msg.type == aiohttp.WSMsgType.ERROR: + logger.warning("Websocket error", extra={"error": str(ws.exception())}) + finally: + async with CONNECTED_LOCK: + CONNECTED_WEBSOCKETS.discard(ws) + logger.info("Websocket disconnected", extra={"clients": len(CONNECTED_WEBSOCKETS)}) + + return ws + + +async def _broadcast(envelope: dict[str, Any]) -> None: + serialized = json.dumps(envelope, ensure_ascii=False, separators=(",", ":")) + async with CONNECTED_LOCK: + targets = list(CONNECTED_WEBSOCKETS) + + if not targets: + logger.info( + "No websocket clients connected", + extra={"event_type": envelope.get("type")}, + ) + return + + dead: list[web.WebSocketResponse] = [] + for ws in targets: + try: + await ws.send_str(serialized) + except Exception as exc: + logger.warning( + "Failed to send websocket envelope", + extra={"event_type": envelope.get("type"), "error": str(exc)}, + ) + dead.append(ws) + + if dead: + async with CONNECTED_LOCK: + for ws in dead: + CONNECTED_WEBSOCKETS.discard(ws) + + logger.info( + "Websocket broadcast", + extra={ + "event_type": envelope.get("type"), + "clients": len(targets) - len(dead), + "payload_keys": list((envelope.get("payload") or {}).keys())[:8], + }, + ) + + +# Truncate the outbox once we've consumed past this many bytes. The file is +# append-only and previously grew unbounded — we observed it at 1.9 GB on disk +# with all data already relayed and offset matching size. Truncating when +# fully caught up keeps disk usage flat without cooperating with the writer. +# Race: a writer in the BOT process may append between the size check and +# the truncate call. Those envelopes would be lost, but envelopes here are +# best-effort match-replay events; rare loss during a 100 MB-scale rotation +# is acceptable. +_OUTBOX_TRUNCATE_THRESHOLD_BYTES = int(_env("SREBOT_EXTERNAL_TRUNCATE_BYTES", str(100 * 1024 * 1024))) + + +def _maybe_truncate_outbox(position: int) -> int: + try: + current_size = SETTINGS.outbox_path.stat().st_size + if ( + position >= _OUTBOX_TRUNCATE_THRESHOLD_BYTES + and position == current_size + ): + with SETTINGS.outbox_path.open("r+b") as handle: + handle.truncate(0) + _write_offset(0) + logger.info( + "Outbox caught up; truncated", + extra={"reclaimed_bytes": position}, + ) + return 0 + except FileNotFoundError: + pass + except Exception as exc: + logger.warning("Outbox truncate failed", extra={"error": str(exc)}) + return position + + +async def relay_outbox_loop(app: web.Application) -> None: + reconnect_delay = SETTINGS.reconnect_delay_seconds + position = _read_offset() + + while True: + try: + if not SETTINGS.outbox_path.exists(): + await asyncio.sleep(1.0) + continue + + current_size = SETTINGS.outbox_path.stat().st_size + if position > current_size: + logger.info( + "Outbox truncated; resetting offset", + extra={"old_offset": position, "current_size": current_size}, + ) + position = 0 + _write_offset(position) + + with SETTINGS.outbox_path.open("r", encoding="utf-8") as handle: + handle.seek(position) + line = handle.readline() + if not line: + position = _maybe_truncate_outbox(position) + await asyncio.sleep(SETTINGS.poll_interval_seconds) + continue + + try: + envelope = json.loads(line) + except json.JSONDecodeError: + position = handle.tell() + _write_offset(position) + logger.warning("Skipping malformed outbox line", extra={"offset": position}) + continue + + position = handle.tell() + _write_offset(position) + await _broadcast(envelope) + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning( + "Bridge loop error", + extra={"error": str(exc), "retry_in_seconds": reconnect_delay}, + ) + await asyncio.sleep(reconnect_delay) + reconnect_delay = min(reconnect_delay * 2, 30.0) + + +async def create_http_session(app: web.Application): + app["http_session"] = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) + try: + yield + finally: + await app["http_session"].close() + + +async def start_relay_task(app: web.Application): + task = asyncio.create_task(relay_outbox_loop(app)) + app["relay_task"] = task + try: + yield + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + +def create_app() -> web.Application: + app = web.Application(middlewares=[auth_middleware]) + app.router.add_get("/", root) + app.router.add_get("/health", health) + app.router.add_get("/ws/srebot", websocket_handler) + app.router.add_route("*", "/api/{tail:.*}", proxy_api) + app.cleanup_ctx.append(create_http_session) + app.cleanup_ctx.append(start_relay_task) + return app + + +def main() -> None: + web.run_app(create_app(), host=SETTINGS.host, port=SETTINGS.port) + + +if __name__ == "__main__": + main() diff --git a/BOT/stack_manager.py b/BOT/stack_manager.py new file mode 100644 index 0000000..6cccaac --- /dev/null +++ b/BOT/stack_manager.py @@ -0,0 +1,1244 @@ +""" +stack_manager.py + +Stack management feature: leaders create a persistent embed, players apply to join, +leader accepts/declines applicants. +""" + +# Standard Library Imports +import json +import time +from pathlib import Path + +# Third-Party Library Imports +import discord +from discord import app_commands +from discord.ext import commands + +# Local Module Imports +from .utils import ( + DEFAULT_FOOTER_CAT, + STACKS_DIR, + load_json, + write_json, + is_blacklisted, + permission_fail, + esc, + collect_command_stats, + command_locale, +) +from .utils import t, guild_lang + +MAX_MEMBERS = 8 +MAX_REQUESTERS = 20 + + +# ============================================================================ +# HELPERS +# ============================================================================ + +def get_stack_path(guild_id: str, leader_discord_id: str) -> Path: + """Return the filesystem Path for a stack's JSON file. + + Args: + guild_id: Discord guild ID string. + leader_discord_id: Discord user ID string of the stack leader. + + Returns: + Path object pointing to the stack JSON file. + """ + return STACKS_DIR / f"{guild_id}-{leader_discord_id}.json" + + +async def load_stack(guild_id: str, leader_discord_id: str) -> dict | None: + """Load a stack's data from its JSON file asynchronously. + + Args: + guild_id: Discord guild ID string. + leader_discord_id: Discord user ID string of the stack leader. + + Returns: + The parsed stack dict, or None if the file does not exist or is empty. + """ + path = get_stack_path(guild_id, leader_discord_id) + if not path.exists(): + return None + data = await load_json(path, None) + return data if data else None + + +def load_stack_sync(guild_id: str, leader_discord_id: str) -> dict | None: + """Sync version for use in __init__ methods that can't be async.""" + path = get_stack_path(guild_id, leader_discord_id) + if not path.exists(): + return None + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return data if data else None + except Exception: + return None + + +async def save_stack(data: dict) -> bool: + """Persist a stack's data dict to its JSON file. + + Args: + data: Stack data dict containing at least guild_id and leader_discord_id. + + Returns: + True on success, False on failure. + """ + path = get_stack_path(data["guild_id"], data["leader_discord_id"]) + STACKS_DIR.mkdir(parents=True, exist_ok=True) + return await write_json(path, data) + + +def build_stack_embed(data: dict, lang: str = "en") -> discord.Embed: + """Build the public-facing Discord embed for a stack. + + Shows the stack name, member list with vehicles, leader star indicator, + and any pending applicants in the queue. + + Args: + data: Stack data dict with members, requesters, leader info, and name. + lang: Language code for i18n. + + Returns: + A discord.Embed ready to send or edit into a message. + """ + leader_nick = esc(data["leader_nick"]) + members = data.get("members", []) + requesters = data.get("requesters", []) + + embed = discord.Embed( + title=data.get("name") or t(lang, "stacks.stack_title", leader=data['leader_nick']), + color=discord.Color.blurple(), + ) + + if members: + lines = [] + for m in members: + nick = esc(m['nick']) + vehicle = esc(m['vehicle']) + if m["discord_id"] == data["leader_discord_id"]: + lines.append(f"{nick} — {vehicle} (⭐)") + else: + lines.append(f"{nick} — {vehicle}") + members_text = "\n".join(lines) + else: + members_text = t(lang, "stacks.no_members") + + embed.add_field( + name=t(lang, "stacks.members_field", count=len(members), max=MAX_MEMBERS), + value=members_text, + inline=False, + ) + + if requesters: + req_lines = [ + f"{esc(r['nick'])} — {esc(r['vehicle'])}" + for r in requesters + ] + embed.add_field( + name=t(lang, "stacks.queue_field", count=len(requesters), max=MAX_REQUESTERS), + value="\n".join(req_lines), + inline=False, + ) + + embed.set_footer(text=DEFAULT_FOOTER_CAT) + return embed + + +def _build_manage_embed(data: dict, lang: str = "en") -> discord.Embed: + """Build the ephemeral management embed showing members and queue. + + Args: + data: Stack data dict with members and requesters lists. + lang: Language code for i18n. + + Returns: + A discord.Embed for the leader's management panel. + """ + requesters = data.get("requesters", []) + members = data.get("members", []) + embed = discord.Embed(title=t(lang, "stacks.manage_title"), color=discord.Color.blurple()) + embed.add_field( + name=t(lang, "stacks.members_field", count=len(members), max=MAX_MEMBERS), + value="\n".join( + f"{esc(m['nick'])} — {esc(m['vehicle'])}" + for m in members + ) or t(lang, "common.none_option"), + inline=False, + ) + embed.add_field( + name=t(lang, "stacks.queue_field", count=len(requesters), max=MAX_REQUESTERS), + value="\n".join( + f"{esc(r['nick'])} — {esc(r['vehicle'])}" + for r in requesters + ) or t(lang, "stacks.no_pending_requests"), + inline=False, + ) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + return embed + + +async def _do_transfer( + data: dict, + new_leader_id: str, + public_stack_view: "StackView", + lang: str = "en", +) -> str | None: + """ + Transfer stack ownership to new_leader_id. + Renames the JSON file, updates public_stack_view in place. + Returns an error string on failure, or None on success. + """ + guild_id = data["guild_id"] + old_leader_id = data["leader_discord_id"] + + new_leader = next((m for m in data["members"] if m["discord_id"] == new_leader_id), None) + if new_leader is None: + return t(lang, "stacks.member_not_exists") + + new_path = get_stack_path(guild_id, new_leader_id) + if new_path.exists(): + return t(lang, "stacks.already_has_stack") + + old_path = get_stack_path(guild_id, old_leader_id) + + data["leader_discord_id"] = new_leader["discord_id"] + data["leader_nick"] = new_leader["nick"] + data["leader_vehicle"] = new_leader["vehicle"] + data["members"] = [m for m in data["members"] if m["discord_id"] != old_leader_id] + + await save_stack(data) # writes to new path + old_path.unlink(missing_ok=True) + + public_stack_view.leader_discord_id = new_leader_id + return None + + +async def refresh_stack_message(view: "StackView") -> None: + """Re-render and edit the public stack message with current data. + + Args: + view: The StackView whose associated message should be refreshed. + """ + if view.message is None: + return + data = await load_stack(view.guild_id, view.leader_discord_id) + if data is None: + return + embed = build_stack_embed(data, lang=view.lang) + await view.message.edit(embed=embed, view=view) + + +# ============================================================================ +# MODAL +# ============================================================================ + +class JoinStackModal(discord.ui.Modal, title="Apply to Join Stack"): + """Modal presented to users requesting to join a stack. + + Collects the vehicle the player intends to use and adds them + to the stack's requester queue upon submission. + """ + + vehicle_input = discord.ui.TextInput( + label="What will you play?", + placeholder="e.g. F-16C, WZ305...", + max_length=100, + required=True, + ) + + def __init__(self, guild_id: str, leader_discord_id: str, stack_view: "StackView", lang: str = "en"): + super().__init__() + self.guild_id = guild_id + self.leader_discord_id = leader_discord_id + self.stack_view = stack_view + self.lang = lang + self.title = t(lang, "stacks.join_modal_title") + self.vehicle_input.label = t(lang, "stacks.join_vehicle_label") + self.vehicle_input.placeholder = t(lang, "stacks.join_vehicle_placeholder") + + async def on_submit(self, interaction: discord.Interaction) -> None: + """Validate the application and append the user to the stack's request queue.""" + data = await load_stack(self.guild_id, self.leader_discord_id) + if data is None: + await interaction.response.send_message( + t(self.lang, "stacks.no_longer_exists"), ephemeral=True + ) + return + + user_id = str(interaction.user.id) + nick = interaction.user.display_name + vehicle = self.vehicle_input.value.strip() + + if any(m["discord_id"] == user_id for m in data["members"]): + await interaction.response.send_message( + t(self.lang, "stacks.already_member"), ephemeral=True + ) + return + + if any(r["discord_id"] == user_id for r in data["requesters"]): + await interaction.response.send_message( + t(self.lang, "stacks.already_applied"), ephemeral=True + ) + return + + if len(data["requesters"]) >= MAX_REQUESTERS: + await interaction.response.send_message( + t(self.lang, "stacks.queue_full", max=MAX_REQUESTERS), + ephemeral=True, + ) + return + + data["requesters"].append({"discord_id": user_id, "nick": nick, "vehicle": vehicle}) + await save_stack(data) + + await refresh_stack_message(self.stack_view) + + await interaction.response.send_message( + t(self.lang, "stacks.application_sent"), ephemeral=True + ) + + +# ============================================================================ +# DISBAND CONFIRMATION VIEW +# ============================================================================ + +class DisbandConfirmView(discord.ui.View): + """Ephemeral confirmation view with Yes/Cancel buttons for disbanding a stack.""" + + def __init__(self, stack_view: "StackView", lang: str = "en"): + super().__init__(timeout=30) + self.stack_view = stack_view + self.lang = lang + self.confirm.label = t(lang, "buttons.yes_disband") + self.cancel.label = t(lang, "buttons.cancel") + + @discord.ui.button(label="Yes, Disband", style=discord.ButtonStyle.red) + async def confirm(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: + await interaction.response.edit_message( + content=t(self.lang, "stacks.stack_disbanded"), embed=None, view=None + ) + path = get_stack_path(self.stack_view.guild_id, self.stack_view.leader_discord_id) + path.unlink(missing_ok=True) + + embed = discord.Embed( + title=t(self.lang, "stacks.disbanded_title"), + description=t(self.lang, "stacks.disbanded_desc"), + color=discord.Color.dark_red(), + ) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + if self.stack_view.message: + await self.stack_view.message.edit(embed=embed, view=None) + self.stack_view.stop() + + @discord.ui.button(label="Cancel", style=discord.ButtonStyle.grey) + async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: + await interaction.response.edit_message(content=t(self.lang, "stacks.cancelled"), embed=None, view=None) + + +# ============================================================================ +# TRANSFER LEAVE VIEW (ephemeral, leader-only) +# ============================================================================ + +class TransferLeaveView(discord.ui.View): + """Shown when the leader clicks Leave / Withdraw and other members exist.""" + + def __init__(self, guild_id: str, leader_discord_id: str, public_stack_view: "StackView", lang: str = "en"): + super().__init__(timeout=60) + self.guild_id = guild_id + self.leader_discord_id = leader_discord_id + self.public_stack_view = public_stack_view + self.lang = lang + self.selected_uid: str | None = None + + data = load_stack_sync(guild_id, leader_discord_id) + non_leader = [ + m for m in (data.get("members", []) if data else []) + if m["discord_id"] != leader_discord_id + ] + options = [ + discord.SelectOption( + label=f"{m['nick']} — {m['vehicle']}"[:100], + value=m["discord_id"], + ) + for m in non_leader + ] + select = discord.ui.Select(placeholder=t(lang, "stacks.select_new_leader"), options=options) + select.callback = self._on_select + self.add_item(select) + + transfer_btn = discord.ui.Button(label=t(lang, "buttons.transfer_leave"), style=discord.ButtonStyle.blurple) + transfer_btn.callback = self._on_transfer + self.add_item(transfer_btn) + + async def _on_select(self, interaction: discord.Interaction) -> None: + if interaction.data: + self.selected_uid = interaction.data["values"][0] # type: ignore[typeddict-item] + await interaction.response.defer() + + async def _on_transfer(self, interaction: discord.Interaction) -> None: + if not self.selected_uid: + await interaction.response.send_message( + t(self.lang, "stacks.select_member_transfer"), ephemeral=True + ) + return + + data = await load_stack(self.guild_id, self.leader_discord_id) + if data is None: + await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True) + return + + new_nick = next( + (m["nick"] for m in data["members"] if m["discord_id"] == self.selected_uid), "?" + ) + error = await _do_transfer(data, self.selected_uid, self.public_stack_view, lang=self.lang) + if error: + await interaction.response.send_message(error, ephemeral=True) + return + + await refresh_stack_message(self.public_stack_view) + await interaction.response.edit_message( + content=t(self.lang, "stacks.ownership_transferred", nick=esc(new_nick)), + view=None, + ) + + +# ============================================================================ +# MANAGE SUB-VIEWS (ephemeral, leader-only) +# ============================================================================ + +class AcceptMembersView(discord.ui.View): + """Sub-view: accept or decline applicants from the queue.""" + + def __init__(self, guild_id: str, leader_discord_id: str, public_stack_view: "StackView", lang: str = "en"): + super().__init__(timeout=300) + self.guild_id = guild_id + self.leader_discord_id = leader_discord_id + self.public_stack_view = public_stack_view + self.lang = lang + self.selected_requester_uids: list[str] = [] + + data = load_stack_sync(guild_id, leader_discord_id) + requesters = data.get("requesters", []) if data else [] + + if requesters: + req_options = [ + discord.SelectOption( + label=f"{r['nick']} — {r['vehicle']}"[:100], + value=r["discord_id"], + ) + for r in requesters + ] + req_select = discord.ui.Select( + placeholder=t(lang, "stacks.select_applicants"), + options=req_options, + min_values=1, + max_values=len(req_options), + ) + req_select.callback = self._on_requester_select + self.add_item(req_select) + + accept_btn = discord.ui.Button(label=t(lang, "buttons.accept_selected"), style=discord.ButtonStyle.green) + accept_btn.callback = self._on_accept + self.add_item(accept_btn) + + accept_all_btn = discord.ui.Button(label=t(lang, "buttons.accept_all"), style=discord.ButtonStyle.green) + accept_all_btn.callback = self._on_accept_all + self.add_item(accept_all_btn) + + decline_btn = discord.ui.Button(label=t(lang, "buttons.decline_selected"), style=discord.ButtonStyle.red) + decline_btn.callback = self._on_decline + self.add_item(decline_btn) + else: + disabled_req = discord.ui.Select( + placeholder=t(lang, "stacks.no_pending_applications"), + options=[discord.SelectOption(label="—", value="none")], + disabled=True, + ) + self.add_item(disabled_req) + + back_btn = discord.ui.Button(label=t(lang, "buttons.back"), style=discord.ButtonStyle.grey) + back_btn.callback = self._on_back + self.add_item(back_btn) + + async def _on_requester_select(self, interaction: discord.Interaction) -> None: + if interaction.data: + self.selected_requester_uids = interaction.data["values"] # type: ignore[typeddict-item] + await interaction.response.defer() + + async def _on_accept(self, interaction: discord.Interaction) -> None: + if not self.selected_requester_uids: + await interaction.response.send_message( + t(self.lang, "stacks.select_applicant_first"), ephemeral=True + ) + return + + data = await load_stack(self.guild_id, self.leader_discord_id) + if data is None: + await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True) + return + + available_slots = MAX_MEMBERS - len(data["members"]) + to_accept_ids = self.selected_requester_uids[:available_slots] + + if not to_accept_ids: + await interaction.response.send_message( + t(self.lang, "stacks.stack_full", max=MAX_MEMBERS), ephemeral=True + ) + return + + accepted = [r for r in data["requesters"] if r["discord_id"] in to_accept_ids] + data["members"].extend(accepted) + accepted_ids = {r["discord_id"] for r in accepted} + data["requesters"] = [r for r in data["requesters"] if r["discord_id"] not in accepted_ids] + await save_stack(data) + self.selected_requester_uids = [] + + await refresh_stack_message(self.public_stack_view) + + new_view = AcceptMembersView(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang) + await interaction.response.edit_message(embed=_build_manage_embed(data, lang=self.lang), view=new_view) + + async def _on_accept_all(self, interaction: discord.Interaction) -> None: + data = await load_stack(self.guild_id, self.leader_discord_id) + if data is None: + await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True) + return + + available_slots = MAX_MEMBERS - len(data["members"]) + if available_slots <= 0: + await interaction.response.send_message( + t(self.lang, "stacks.stack_full", max=MAX_MEMBERS), ephemeral=True + ) + return + + to_accept = data["requesters"][:available_slots] + accepted_ids = {r["discord_id"] for r in to_accept} + data["members"].extend(to_accept) + data["requesters"] = [r for r in data["requesters"] if r["discord_id"] not in accepted_ids] + await save_stack(data) + self.selected_requester_uids = [] + + await refresh_stack_message(self.public_stack_view) + + new_view = AcceptMembersView(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang) + await interaction.response.edit_message(embed=_build_manage_embed(data, lang=self.lang), view=new_view) + + async def _on_decline(self, interaction: discord.Interaction) -> None: + if not self.selected_requester_uids: + await interaction.response.send_message( + t(self.lang, "stacks.select_applicant_first"), ephemeral=True + ) + return + + data = await load_stack(self.guild_id, self.leader_discord_id) + if data is None: + await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True) + return + + declined_ids = set(self.selected_requester_uids) + data["requesters"] = [r for r in data["requesters"] if r["discord_id"] not in declined_ids] + await save_stack(data) + self.selected_requester_uids = [] + + await refresh_stack_message(self.public_stack_view) + + new_view = AcceptMembersView(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang) + await interaction.response.edit_message(embed=_build_manage_embed(data, lang=self.lang), view=new_view) + + async def _on_back(self, interaction: discord.Interaction) -> None: + data = await load_stack(self.guild_id, self.leader_discord_id) + new_view = ManageStackView(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang) + await interaction.response.edit_message( + content=None, embed=_build_manage_embed(data, lang=self.lang) if data else None, view=new_view + ) + + +class RemoveMembersView(discord.ui.View): + """Sub-view: remove members and/or requesters by various bulk or selective actions.""" + + def __init__(self, guild_id: str, leader_discord_id: str, public_stack_view: "StackView", lang: str = "en"): + super().__init__(timeout=300) + self.guild_id = guild_id + self.leader_discord_id = leader_discord_id + self.public_stack_view = public_stack_view + self.lang = lang + # values are prefixed: "m:{id}" for members, "r:{id}" for requesters + self.selected_values: list[str] = [] + + data = load_stack_sync(guild_id, leader_discord_id) + non_leader_members = [ + m for m in (data.get("members", []) if data else []) + if m["discord_id"] != leader_discord_id + ] + requesters = data.get("requesters", []) if data else [] + + all_options = [ + discord.SelectOption( + label=f"[Active] {m['nick']} — {m['vehicle']}"[:100], + value=f"m:{m['discord_id']}", + ) + for m in non_leader_members + ] + [ + discord.SelectOption( + label=f"[Queue] {r['nick']} — {r['vehicle']}"[:100], + value=f"r:{r['discord_id']}", + ) + for r in requesters + ] + + if all_options: + select = discord.ui.Select( + placeholder=t(lang, "stacks.select_to_remove"), + options=all_options, + min_values=1, + max_values=len(all_options), + ) + select.callback = self._on_select + self.add_item(select) + else: + self.add_item(discord.ui.Select( + placeholder=t(lang, "stacks.no_members_or_applicants"), + options=[discord.SelectOption(label="—", value="none")], + disabled=True, + )) + + remove_all_btn = discord.ui.Button(label=t(lang, "buttons.remove_all"), style=discord.ButtonStyle.red) + remove_all_btn.callback = self._on_remove_all + self.add_item(remove_all_btn) + + remove_active_btn = discord.ui.Button(label=t(lang, "buttons.remove_active"), style=discord.ButtonStyle.red) + remove_active_btn.callback = self._on_remove_active + self.add_item(remove_active_btn) + + remove_queued_btn = discord.ui.Button(label=t(lang, "buttons.remove_queued"), style=discord.ButtonStyle.red) + remove_queued_btn.callback = self._on_remove_queued + self.add_item(remove_queued_btn) + + remove_selected_btn = discord.ui.Button(label=t(lang, "buttons.remove_selected"), style=discord.ButtonStyle.red) + remove_selected_btn.callback = self._on_remove_selected + self.add_item(remove_selected_btn) + + back_btn = discord.ui.Button(label=t(lang, "buttons.back"), style=discord.ButtonStyle.grey) + back_btn.callback = self._on_back + self.add_item(back_btn) + + async def _on_select(self, interaction: discord.Interaction) -> None: + if interaction.data: + self.selected_values = interaction.data["values"] # type: ignore[typeddict-item] + await interaction.response.defer() + + async def _apply_removal( + self, + interaction: discord.Interaction, + remove_member_ids: set[str], + remove_requester_ids: set[str], + ) -> None: + data = await load_stack(self.guild_id, self.leader_discord_id) + if data is None: + await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True) + return + + remove_member_ids -= {self.leader_discord_id} + data["members"] = [m for m in data["members"] if m["discord_id"] not in remove_member_ids] + data["requesters"] = [r for r in data["requesters"] if r["discord_id"] not in remove_requester_ids] + await save_stack(data) + self.selected_values = [] + + await refresh_stack_message(self.public_stack_view) + + new_view = RemoveMembersView(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang) + await interaction.response.edit_message(embed=_build_manage_embed(data, lang=self.lang), view=new_view) + + async def _on_remove_all(self, interaction: discord.Interaction) -> None: + data = await load_stack(self.guild_id, self.leader_discord_id) + if data is None: + await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True) + return + member_ids = {m["discord_id"] for m in data.get("members", [])} + requester_ids = {r["discord_id"] for r in data.get("requesters", [])} + await self._apply_removal(interaction, member_ids, requester_ids) + + async def _on_remove_active(self, interaction: discord.Interaction) -> None: + data = await load_stack(self.guild_id, self.leader_discord_id) + if data is None: + await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True) + return + member_ids = {m["discord_id"] for m in data.get("members", [])} + await self._apply_removal(interaction, member_ids, set()) + + async def _on_remove_queued(self, interaction: discord.Interaction) -> None: + data = await load_stack(self.guild_id, self.leader_discord_id) + if data is None: + await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True) + return + requester_ids = {r["discord_id"] for r in data.get("requesters", [])} + await self._apply_removal(interaction, set(), requester_ids) + + async def _on_remove_selected(self, interaction: discord.Interaction) -> None: + if not self.selected_values: + await interaction.response.send_message( + t(self.lang, "stacks.select_person_first"), ephemeral=True + ) + return + member_ids = {v[2:] for v in self.selected_values if v.startswith("m:")} + requester_ids = {v[2:] for v in self.selected_values if v.startswith("r:")} + await self._apply_removal(interaction, member_ids, requester_ids) + + async def _on_back(self, interaction: discord.Interaction) -> None: + data = await load_stack(self.guild_id, self.leader_discord_id) + new_view = ManageStackView(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang) + await interaction.response.edit_message( + content=None, embed=_build_manage_embed(data, lang=self.lang) if data else None, view=new_view + ) + + +class PingMessageModal(discord.ui.Modal, title="Ping Message"): + """Modal for composing an optional custom ping message.""" + + message = discord.ui.TextInput( + label="Custom message (optional)", + placeholder='e.g. Come now! Stack starting!', + required=False, + max_length=200, + ) + + def __init__( + self, + guild_id: str, + leader_discord_id: str, + ping_type: str, + selected_uids: list[str] | None = None, + lang: str = "en", + ): + super().__init__() + self.guild_id = guild_id + self.leader_discord_id = leader_discord_id + self.ping_type = ping_type # "all" | "active" | "queued" | "select" + self.selected_uids = selected_uids or [] + self.lang = lang + self.title = t(lang, "stacks.ping_modal_title") + self.message.label = t(lang, "stacks.ping_message_label") + self.message.placeholder = t(lang, "stacks.ping_message_placeholder") + + async def on_submit(self, interaction: discord.Interaction) -> None: + data = await load_stack(self.guild_id, self.leader_discord_id) + if data is None: + await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True) + return + + if self.ping_type == "all": + targets = ( + [m["discord_id"] for m in data.get("members", []) if m["discord_id"] != self.leader_discord_id] + + [r["discord_id"] for r in data.get("requesters", [])] + ) + elif self.ping_type == "active": + targets = [m["discord_id"] for m in data.get("members", [])] + elif self.ping_type == "queued": + targets = [r["discord_id"] for r in data.get("requesters", [])] + else: + targets = self.selected_uids + + if not targets: + await interaction.response.send_message(t(self.lang, "stacks.no_one_to_ping"), ephemeral=True) + return + + mentions = " ".join(f"<@{uid}>" for uid in targets) + custom = self.message.value.strip() + if custom: + content = f"{mentions} {custom}" + else: + stack_name = data.get("name") or "the stack" + content = f"{mentions}\n-# {t(self.lang, 'stacks.ping_footer', leader=esc(data['leader_nick']), stack=stack_name)}" + + await interaction.response.send_message(t(self.lang, "stacks.pinged"), ephemeral=True) + await interaction.channel.send(content) # type: ignore[union-attr] + + +class PingMembersView(discord.ui.View): + """Sub-view: ping all, active members, queued applicants, or a custom selection.""" + + def __init__(self, guild_id: str, leader_discord_id: str, public_stack_view: "StackView", lang: str = "en"): + super().__init__(timeout=300) + self.guild_id = guild_id + self.leader_discord_id = leader_discord_id + self.public_stack_view = public_stack_view + self.lang = lang + self.selected_uids: list[str] = [] + + data = load_stack_sync(guild_id, leader_discord_id) + members = data.get("members", []) if data else [] + requesters = data.get("requesters", []) if data else [] + + all_options = [ + discord.SelectOption( + label=f"[Active] {m['nick']} — {m['vehicle']}"[:100], + value=m["discord_id"], + ) + for m in members + ] + [ + discord.SelectOption( + label=f"[Queue] {r['nick']} — {r['vehicle']}"[:100], + value=r["discord_id"], + ) + for r in requesters + ] + + if all_options: + select = discord.ui.Select( + placeholder=t(lang, "stacks.select_to_ping"), + options=all_options, + min_values=1, + max_values=len(all_options), + ) + select.callback = self._on_select + self.add_item(select) + else: + self.add_item(discord.ui.Select( + placeholder=t(lang, "stacks.no_members_or_applicants"), + options=[discord.SelectOption(label="—", value="none")], + disabled=True, + )) + + ping_all_btn = discord.ui.Button(label=t(lang, "buttons.ping_all"), style=discord.ButtonStyle.green) + ping_all_btn.callback = self._on_ping_all + self.add_item(ping_all_btn) + + ping_active_btn = discord.ui.Button(label=t(lang, "buttons.ping_active"), style=discord.ButtonStyle.blurple) + ping_active_btn.callback = self._on_ping_active + self.add_item(ping_active_btn) + + ping_queued_btn = discord.ui.Button(label=t(lang, "buttons.ping_queued"), style=discord.ButtonStyle.blurple) + ping_queued_btn.callback = self._on_ping_queued + self.add_item(ping_queued_btn) + + ping_select_btn = discord.ui.Button(label=t(lang, "buttons.ping_selected"), style=discord.ButtonStyle.blurple) + ping_select_btn.callback = self._on_ping_selected + self.add_item(ping_select_btn) + + back_btn = discord.ui.Button(label=t(lang, "buttons.back"), style=discord.ButtonStyle.grey) + back_btn.callback = self._on_back + self.add_item(back_btn) + + async def _on_select(self, interaction: discord.Interaction) -> None: + if interaction.data: + self.selected_uids = interaction.data["values"] # type: ignore[typeddict-item] + await interaction.response.defer() + + async def _on_ping_all(self, interaction: discord.Interaction) -> None: + await interaction.response.send_modal( + PingMessageModal(self.guild_id, self.leader_discord_id, "all", lang=self.lang) + ) + + async def _on_ping_active(self, interaction: discord.Interaction) -> None: + await interaction.response.send_modal( + PingMessageModal(self.guild_id, self.leader_discord_id, "active", lang=self.lang) + ) + + async def _on_ping_queued(self, interaction: discord.Interaction) -> None: + await interaction.response.send_modal( + PingMessageModal(self.guild_id, self.leader_discord_id, "queued", lang=self.lang) + ) + + async def _on_ping_selected(self, interaction: discord.Interaction) -> None: + if not self.selected_uids: + await interaction.response.send_message( + t(self.lang, "stacks.select_from_dropdown"), ephemeral=True + ) + return + await interaction.response.send_modal( + PingMessageModal(self.guild_id, self.leader_discord_id, "select", self.selected_uids, lang=self.lang) + ) + + async def _on_back(self, interaction: discord.Interaction) -> None: + data = await load_stack(self.guild_id, self.leader_discord_id) + new_view = ManageStackView(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang) + await interaction.response.edit_message( + content=None, embed=_build_manage_embed(data, lang=self.lang) if data else None, view=new_view + ) + + +class RenameStackModal(discord.ui.Modal, title="Rename Stack"): + """Modal for the stack leader to rename their stack.""" + + name = discord.ui.TextInput( + label="Stack name", + placeholder="e.g. Night Owls, Alpha Squad...", + max_length=50, + required=True, + ) + + def __init__(self, guild_id: str, leader_discord_id: str, public_stack_view: "StackView", lang: str = "en"): + super().__init__() + self.guild_id = guild_id + self.leader_discord_id = leader_discord_id + self.public_stack_view = public_stack_view + self.lang = lang + self.title = t(lang, "stacks.rename_modal_title") + self.name.label = t(lang, "stacks.rename_label") + self.name.placeholder = t(lang, "stacks.rename_placeholder") + + async def on_submit(self, interaction: discord.Interaction) -> None: + data = await load_stack(self.guild_id, self.leader_discord_id) + if data is None: + await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True) + return + + data["name"] = self.name.value.strip() + await save_stack(data) + + await refresh_stack_message(self.public_stack_view) + await interaction.response.send_message( + t(self.lang, "stacks.stack_renamed", name=data['name']), ephemeral=True + ) + + +# ============================================================================ +# MANAGE VIEW (ephemeral, leader-only) +# ============================================================================ + +class ManageStackView(discord.ui.View): + """Entry view with 4 navigation buttons for stack management. + + Buttons: Accept Members, Remove Members, Ping Members, Rename Stack. + """ + + def __init__(self, guild_id: str, leader_discord_id: str, public_stack_view: "StackView", lang: str = "en"): + super().__init__(timeout=300) + self.guild_id = guild_id + self.leader_discord_id = leader_discord_id + self.public_stack_view = public_stack_view + self.lang = lang + self.accept_btn.label = t(lang, "buttons.accept_members") + self.remove_btn.label = t(lang, "buttons.remove_members") + self.ping_btn.label = t(lang, "buttons.ping_members") + self.rename_btn.label = t(lang, "buttons.rename_stack") + + @discord.ui.button(label="Accept Members", style=discord.ButtonStyle.green) + async def accept_btn(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: + data = await load_stack(self.guild_id, self.leader_discord_id) + if data is None: + await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True) + return + new_view = AcceptMembersView(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang) + await interaction.response.edit_message(embed=_build_manage_embed(data, lang=self.lang), view=new_view) + + @discord.ui.button(label="Remove Members", style=discord.ButtonStyle.red) + async def remove_btn(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: + data = await load_stack(self.guild_id, self.leader_discord_id) + if data is None: + await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True) + return + new_view = RemoveMembersView(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang) + await interaction.response.edit_message(embed=_build_manage_embed(data, lang=self.lang), view=new_view) + + @discord.ui.button(label="Ping Members", style=discord.ButtonStyle.blurple) + async def ping_btn(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: + data = await load_stack(self.guild_id, self.leader_discord_id) + if data is None: + await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True) + return + new_view = PingMembersView(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang) + await interaction.response.edit_message(embed=_build_manage_embed(data, lang=self.lang), view=new_view) + + @discord.ui.button(label="Rename Stack", style=discord.ButtonStyle.grey) + async def rename_btn(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: + await interaction.response.send_modal( + RenameStackModal(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang) + ) + + +# ============================================================================ +# PUBLIC STACK VIEW +# ============================================================================ + +class StackView(discord.ui.View): + """Public persistent view attached to the stack embed message. + + Provides Join, Leave, Manage, and Disband buttons. Times out after + 8 hours, at which point the stack is automatically cleaned up. + """ + + def __init__(self, guild_id: str, leader_discord_id: str, lang: str = "en"): + super().__init__(timeout=28800) # 8 hours + self.guild_id = guild_id + self.leader_discord_id = leader_discord_id + self.lang = lang + self.message: discord.Message | None = None + self.join_btn.label = t(lang, "buttons.request_to_join") + self.leave_btn.label = t(lang, "buttons.leave_withdraw") + self.manage_btn.label = t(lang, "buttons.manage_stack") + self.disband_btn.label = t(lang, "buttons.disband_stack") + + @discord.ui.button(label="Request to Join", style=discord.ButtonStyle.green) + async def join_btn(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: + """Open the JoinStackModal for the user to submit a join application.""" + data = await load_stack(self.guild_id, self.leader_discord_id) + if data is None: + await interaction.response.send_message( + t(self.lang, "stacks.no_longer_exists"), ephemeral=True + ) + return + + await interaction.response.send_modal( + JoinStackModal(self.guild_id, self.leader_discord_id, self, lang=self.lang) + ) + + @discord.ui.button(label="Leave / Withdraw", style=discord.ButtonStyle.grey) + async def leave_btn(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: + """Remove the user from members or requesters, or prompt ownership transfer if leader.""" + user_id = str(interaction.user.id) + + if user_id == self.leader_discord_id: + data = await load_stack(self.guild_id, self.leader_discord_id) + non_leader = [ + m for m in (data.get("members", []) if data else []) + if m["discord_id"] != self.leader_discord_id + ] + if not non_leader: + await interaction.response.send_message( + t(self.lang, "stacks.only_member_use_disband"), + ephemeral=True, + ) + return + await interaction.response.send_message( + t(self.lang, "stacks.select_transfer_prompt"), + view=TransferLeaveView(self.guild_id, self.leader_discord_id, self, lang=self.lang), + ephemeral=True, + ) + return + + data = await load_stack(self.guild_id, self.leader_discord_id) + if data is None: + await interaction.response.send_message( + t(self.lang, "stacks.no_longer_exists"), ephemeral=True + ) + return + + if any(m["discord_id"] == user_id for m in data["members"]): + data["members"] = [m for m in data["members"] if m["discord_id"] != user_id] + await save_stack(data) + await refresh_stack_message(self) + await interaction.response.send_message(t(self.lang, "stacks.left_stack"), ephemeral=True) + return + + if any(r["discord_id"] == user_id for r in data["requesters"]): + data["requesters"] = [r for r in data["requesters"] if r["discord_id"] != user_id] + await save_stack(data) + await refresh_stack_message(self) + await interaction.response.send_message( + t(self.lang, "stacks.application_withdrawn"), ephemeral=True + ) + return + + await interaction.response.send_message( + t(self.lang, "stacks.not_member_or_applicant"), ephemeral=True + ) + + @discord.ui.button(label="Manage Stack ⚙️", style=discord.ButtonStyle.blurple) + async def manage_btn(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: + """Open the ephemeral management panel (leader-only).""" + if interaction.user.id != int(self.leader_discord_id): + await interaction.response.send_message( + t(self.lang, "stacks.leader_only_manage"), ephemeral=True + ) + return + + data = await load_stack(self.guild_id, self.leader_discord_id) + if data is None: + await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True) + return + + manage_view = ManageStackView(self.guild_id, self.leader_discord_id, self, lang=self.lang) + await interaction.response.send_message( + embed=_build_manage_embed(data, lang=self.lang), view=manage_view, ephemeral=True + ) + + @discord.ui.button(label="Disband Stack", style=discord.ButtonStyle.red) + async def disband_btn(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: + """Show a disband confirmation prompt (leader-only).""" + if interaction.user.id != int(self.leader_discord_id): + await interaction.response.send_message( + t(self.lang, "stacks.leader_only_disband"), ephemeral=True + ) + return + + await interaction.response.send_message( + t(self.lang, "stacks.confirm_disband"), + view=DisbandConfirmView(self, lang=self.lang), + ephemeral=True, + ) + + async def on_timeout(self) -> None: + path = get_stack_path(self.guild_id, self.leader_discord_id) + try: + path.unlink(missing_ok=True) + except Exception: + pass + + if self.message is None: + return + try: + embed = discord.Embed( + title=t(self.lang, "stacks.expired_title"), + description=t(self.lang, "stacks.expired_desc"), + color=discord.Color.dark_grey(), + ) + embed.set_footer(text=DEFAULT_FOOTER_CAT) + await self.message.edit(embed=embed, view=None) + except Exception: + pass + + +# ============================================================================ +# FORCE CREATE VIEW (ephemeral, used when duplicate stack exists) +# ============================================================================ + +class ForceCreateView(discord.ui.View): + """Shown when the user already has a stack JSON — lets them nuke it and start fresh.""" + + def __init__(self, guild_id: str, leader_id: str, nick: str, vehicle: str, channel_id: str, lang: str = "en"): + super().__init__(timeout=30) + self.guild_id = guild_id + self.leader_id = leader_id + self.nick = nick + self.vehicle = vehicle + self.channel_id = channel_id + self.lang = lang + self.confirm.label = t(lang, "buttons.force_disband_create") + self.cancel.label = t(lang, "buttons.cancel") + + @discord.ui.button(label="Force Disband & Create New", style=discord.ButtonStyle.red) + async def confirm(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: + get_stack_path(self.guild_id, self.leader_id).unlink(missing_ok=True) + + data = { + "guild_id": self.guild_id, + "leader_discord_id": self.leader_id, + "leader_nick": self.nick, + "leader_vehicle": self.vehicle, + "channel_id": self.channel_id, + "message_id": None, + "name": None, + "created_at": int(time.time()), + "members": [ + {"discord_id": self.leader_id, "nick": self.nick, "vehicle": self.vehicle} + ], + "requesters": [], + } + STACKS_DIR.mkdir(parents=True, exist_ok=True) + await save_stack(data) + + view = StackView(self.guild_id, self.leader_id, lang=self.lang) + embed = build_stack_embed(data, lang=self.lang) + msg = await interaction.channel.send(embed=embed, view=view) # type: ignore[union-attr] + + view.message = msg + data["message_id"] = str(msg.id) + await save_stack(data) + + await interaction.response.edit_message( + content=t(self.lang, "stacks.force_created"), view=None + ) + + @discord.ui.button(label="Cancel", style=discord.ButtonStyle.grey) + async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: + await interaction.response.edit_message(content=t(self.lang, "stacks.cancelled"), view=None) + + +# ============================================================================ +# COMMAND REGISTRATION +# ============================================================================ + +def register_commands(bot: commands.Bot) -> None: + """Register the /stack-create and /stack-manage slash commands on the bot. + + Args: + bot: The Discord bot instance to register commands on. + """ + + @is_blacklisted() + @bot.tree.command(name="stack-create", description=command_locale("Create a new player stack", "commands.stack_create.description")) + @app_commands.describe(vehicle=command_locale("What vehicle will you start with?", "commands.stack_create.vehicle")) + async def stack_create(interaction: discord.Interaction, vehicle: str) -> None: + await collect_command_stats(interaction) + await interaction.response.defer(ephemeral=False) + + guild_id = str(interaction.guild_id) + lang = await guild_lang(interaction.guild_id) if interaction.guild_id else "en" + leader_id = str(interaction.user.id) + nick = interaction.user.display_name + + stack_path = get_stack_path(guild_id, leader_id) + if stack_path.exists(): + await interaction.followup.send( + t(lang, "stacks.already_active_stack"), + view=ForceCreateView(guild_id, leader_id, nick, vehicle.strip(), str(interaction.channel_id), lang=lang), + ephemeral=True, + ) + return + + data = { + "guild_id": guild_id, + "leader_discord_id": leader_id, + "leader_nick": nick, + "leader_vehicle": vehicle.strip(), + "channel_id": str(interaction.channel_id), + "message_id": None, + "name": None, + "created_at": int(time.time()), + "members": [ + {"discord_id": leader_id, "nick": nick, "vehicle": vehicle.strip()} + ], + "requesters": [], + } + + STACKS_DIR.mkdir(parents=True, exist_ok=True) + await save_stack(data) + + view = StackView(guild_id, leader_id, lang=lang) + embed = build_stack_embed(data, lang=lang) + msg = await interaction.followup.send(embed=embed, view=view, wait=True) + + view.message = msg + data["message_id"] = str(msg.id) + await save_stack(data) + + @stack_create.error + async def stack_create_error(interaction: discord.Interaction, error: Exception) -> None: + await permission_fail(interaction, error) + + @is_blacklisted() + @bot.tree.command(name="stack-manage", description=command_locale("Re-post your active stack embed to this channel", "commands.stack_manage.description")) + async def stack_manage(interaction: discord.Interaction) -> None: + await collect_command_stats(interaction) + await interaction.response.defer(ephemeral=False) + + guild_id = str(interaction.guild_id) + lang = await guild_lang(interaction.guild_id) if interaction.guild_id else "en" + leader_id = str(interaction.user.id) + + data = await load_stack(guild_id, leader_id) + if data is None: + await interaction.followup.send( + t(lang, "stacks.no_active_stack"), + ephemeral=True, + ) + return + + view = StackView(guild_id, leader_id, lang=lang) + embed = build_stack_embed(data, lang=lang) + msg = await interaction.followup.send(embed=embed, view=view, wait=True) + + view.message = msg + data["message_id"] = str(msg.id) + await save_stack(data) + + @stack_manage.error + async def stack_manage_error(interaction: discord.Interaction, error: Exception) -> None: + await permission_fail(interaction, error) diff --git a/BOT/task_executors.py b/BOT/task_executors.py new file mode 100644 index 0000000..4680225 --- /dev/null +++ b/BOT/task_executors.py @@ -0,0 +1,2179 @@ +""" +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_SERVER_IDS, + 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/: + - After 12 hours: deletes regenerable files (PNGs, MP4s) + - After 48 hours: deletes entire directory (GOB + JSON included) + + Age is determined from the mtime of replay.gob / 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.gob", "replay_data.json"} + + 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 + + gob_path = entry_path / "replay.gob" + json_path = entry_path / "replay_data.json" + if gob_path.exists(): + entry_mtime = gob_path.stat().st_mtime + elif 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 Chg Now KDR KPS\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} {kps_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"] != "": + 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]]] = {} + + for guild in bot.guilds: + guild_id = guild.id + + if guild_id in BLACKLISTED_SERVER_IDS: + 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") + + 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 + raw_chan = squad_prefs.get("Leave") or squad_prefs.get("Points") + if not raw_chan: + continue + # Respect the Points tier cap — identical gate used by execute_points_alarm_task. + if tier_enforcement_active() and pref_key not in allowed_points: + continue + try: + channel_id = int(raw_chan.strip("<#>")) + except Exception: + 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 + squadron_channels.setdefault(target_name, []).append((guild_id, channel_id)) + + 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: + 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"", + end=f"", + ) + + # The "highest ELO 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"", + end=f"", + ) + + 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 `.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 ELO 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 ELO 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 + + # Dedupe: drop per-squadron entries that target the same channel as wildcard. + if wildcard_channel_id is not None: + squadron_channels = [ + (cid, pref_key, info) + for cid, pref_key, info in squadron_channels + if cid != wildcard_channel_id + ] + + # 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) diff --git a/BOT/tasks.py b/BOT/tasks.py new file mode 100644 index 0000000..093f7b9 --- /dev/null +++ b/BOT/tasks.py @@ -0,0 +1,543 @@ +""" +tasks.py + +Scheduler definitions using discord.ext.tasks loops. +Thin wrappers that invoke the corresponding execute_* functions from task_executors.py +on configured intervals. +""" + +# Standard Library Imports +import asyncio +import json +import logging +import shutil +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, Optional + +# Third-Party Library Imports +from discord.ext import tasks + +# Local Module Imports +from . import lux_apis +from .autologging import handle_ws_replays, handle_gob_message +from .health import record_task_run, write_heartbeat +from .meta_manager import process_all_players, sync_all_guild_metas +from .task_executors import ( + execute_ldb_alarm_task, + execute_points_alarm_task, + execute_leave_alarm_task, + execute_squadron_stats_tracker, + execute_update_squadrons_db_task, + execute_sync_squadron_members_bulk, + execute_sync_squadron_members_points_loop, + execute_cleanup_stale_squadrons, + execute_weekly_br_report_task, + init_squadrons_points_table, + cleanup_replays, +) +from .utils import ( + get_bot, + STACKS_DIR, + refresh_entitled_guilds, + SQB_STATS_TRACKER_WINDOWS, + SQB_BOUNDARY_TIMES, +) + + +async def _record(task_name: str, success: bool, error: str = ""): + """Record task execution for health dashboard.""" + await record_task_run(task_name, success, error) + + +# ============================================================================ +# LEADERBOARD ALARM TASK +# ============================================================================ + +@tasks.loop(seconds=60) +async def ldb_alarm_task(): + """Checks time and triggers leaderboard alarm at 22:35 and 7:35 UTC.""" + now_utc = datetime.now(timezone.utc).time() + if (now_utc.hour, now_utc.minute) in [(22, 35), (7, 35)]: + try: + await execute_ldb_alarm_task() + await _record("ldb_alarm", True) + except Exception as e: + await _record("ldb_alarm", False, str(e)) + raise + + +@ldb_alarm_task.before_loop +async def before_ldb_alarm_task(): + await get_bot().wait_until_ready() + + +# ============================================================================ +# SQUADRON STATS TRACKER TASKS +# ============================================================================ + +@tasks.loop(hours=1) +async def squadron_stats_tracker_task(): + """Track squadron points every hour during SQB timeslot hours.""" + now_utc = datetime.now(timezone.utc).time() + if any(start <= now_utc <= end for _, start, end in SQB_STATS_TRACKER_WINDOWS): + try: + await execute_squadron_stats_tracker() + await _record("squadron_stats_tracker", True) + except Exception as e: + await _record("squadron_stats_tracker", False, str(e)) + raise + + +@tasks.loop(seconds=60) +async def squadron_stats_boundary_task(): + """Track squadron points at timeslot boundaries (see SQB_BOUNDARY_TIMES in utils).""" + now_utc = datetime.now(timezone.utc) + if now_utc.time().replace(second=0, microsecond=0) in SQB_BOUNDARY_TIMES: + try: + await execute_squadron_stats_tracker() + await _record("squadron_stats_boundary", True) + except Exception as e: + await _record("squadron_stats_boundary", False, str(e)) + raise + + +@squadron_stats_tracker_task.before_loop +async def before_squadron_stats_tracker_task(): + await get_bot().wait_until_ready() + await init_squadrons_points_table() + + +@squadron_stats_boundary_task.before_loop +async def before_squadron_stats_boundary_task(): + await get_bot().wait_until_ready() + + +# ============================================================================ +# ENTITLEMENT CACHE REFRESH (hourly fallback) +# ============================================================================ + +@tasks.loop(hours=1) +async def entitlement_cache_task(): + """Refresh the entitlement cache hourly so new subscriptions are picked up + even when no autolog batches or points alarms are running.""" + try: + await refresh_entitled_guilds(force=True) + await _record("entitlement_cache", True) + except Exception as e: + await _record("entitlement_cache", False, str(e)) + raise + + +@entitlement_cache_task.before_loop +async def before_entitlement_cache(): + await get_bot().wait_until_ready() + + +# ============================================================================ +# POINTS ALARM TASK +# ============================================================================ + +@tasks.loop(seconds=60) +async def points_alarm_task(): + """Checks time and triggers points alarm at 22:25 and 7:25 UTC.""" + now_utc = datetime.now(timezone.utc).time() + region = None + if now_utc.hour == 22 and now_utc.minute == 25: + region = "EU" + elif now_utc.hour == 7 and now_utc.minute == 25: + region = "NA" + if region: + try: + await execute_points_alarm_task(region) + await _record("points_alarm", True) + except Exception as e: + await _record("points_alarm", False, str(e)) + raise + + +@points_alarm_task.before_loop +async def before_points_alarm_task(): + await get_bot().wait_until_ready() + + +# ============================================================================ +# REPLAY CLEANUP TASK +# ============================================================================ + +@tasks.loop(hours=4) +async def replay_cleanup_task(): + """Cleans up replay directories every 4 hours.""" + try: + await cleanup_replays() + await _record("replay_cleanup", True) + except Exception as e: + await _record("replay_cleanup", False, str(e)) + raise + + +@replay_cleanup_task.before_loop +async def before_replay_cleanup_task(): + await get_bot().wait_until_ready() + + +# ============================================================================ +# LEAVE ALARM TASK +# ============================================================================ + +@tasks.loop(minutes=30) +async def leave_alarm_task(): + """Runs leave alarm every 30 minutes.""" + try: + await execute_leave_alarm_task() + await _record("leave_alarm", True) + except Exception as e: + await _record("leave_alarm", False, str(e)) + raise + + +@leave_alarm_task.before_loop +async def before_leave_alarm_task(): + await get_bot().wait_until_ready() + + +# ============================================================================ +# UPDATE SQUADRONS DB TASK +# ============================================================================ + +_startup_db_done = False + + +@tasks.loop(hours=1) +async def update_squadrons_db_task(): + """ + Every 1 hour: + 1. Refresh the main squadrons.db via Game API. + 2. Sync squadron member UIDs for top 1000 squadrons (bulk API). + 3. Update the Bot's status + """ + global _startup_db_done + if not _startup_db_done: + _startup_db_done = True + return # Skip — already ran in _startup_heavy_init + try: + await execute_update_squadrons_db_task(count=1000) + await execute_sync_squadron_members_bulk(count=1000) + await execute_cleanup_stale_squadrons() + await _record("update_squadrons_db", True) + except Exception as e: + await _record("update_squadrons_db", False, str(e)) + raise + + + +@update_squadrons_db_task.before_loop +async def before_update_squadrons_db_task(): + await get_bot().wait_until_ready() + + +# ============================================================================ +# UPDATE META DATA TASK +# ============================================================================ + +@tasks.loop(hours=12) +async def update_meta_data_task(): + """ + Every 12 hours: + Update all player vehicle data in Meta.db from the game API. + Skips players that were updated within 2 days. + """ + try: + await process_all_players( + limit=None, + skip_existing=True, + batch_size=50, + max_concurrent=5 + ) + await _record("update_meta_data", True) + except Exception as e: + await _record("update_meta_data", False, str(e)) + logging.error(f"[META-DB] Error during meta data update: {e}") + + +@update_meta_data_task.before_loop +async def before_update_meta_data_task(): + await get_bot().wait_until_ready() + + +# ============================================================================ +# WEBSOCKET AUTOLOG TASK +# ============================================================================ + +@tasks.loop(count=1) +async def ws_autolog_task(): + """ + Single-run task that maintains persistent WebSocket connection. + Replaces the polling-based auto_logging_task. + """ + await lux_apis.ws_replay_listener(handle_ws_replays) + + +@ws_autolog_task.before_loop +async def before_ws_autolog(): + await get_bot().wait_until_ready() + + +@ws_autolog_task.after_loop +async def after_ws_autolog(): + if ws_autolog_task.failed(): + logging.error("[WS] ws_autolog_task died, restarting in 10s...") + await asyncio.sleep(10) + ws_autolog_task.start() + + +# ============================================================================ +# WEBSOCKET GOB LISTENER TASK +# ============================================================================ + +@tasks.loop(count=1) +async def ws_gob_task(): + """ + Single-run task that maintains persistent WebSocket connection to the GOB endpoint. + Saves incoming compressed GOB replays to disk for on-demand video generation. + """ + await lux_apis.ws_gob_listener(handle_gob_message) + + +@ws_gob_task.before_loop +async def before_ws_gob(): + await get_bot().wait_until_ready() + + +@ws_gob_task.after_loop +async def after_ws_gob(): + if ws_gob_task.failed(): + logging.error("[GOB] ws_gob_task died, restarting in 10s...") + await asyncio.sleep(10) + ws_gob_task.start() + + +# ============================================================================ +# SQUADRON POINTS CONTINUOUS UPDATER +# ============================================================================ + +@tasks.loop(count=1) +async def squadron_points_loop_task(): + """ + Continuously update squadron member points forever. + Very slow (3s per clan), runs indefinitely in the background. + """ + await execute_sync_squadron_members_points_loop(count=1000, delay=3.0) + + +@squadron_points_loop_task.before_loop +async def before_squadron_points_loop(): + await get_bot().wait_until_ready() + + +@squadron_points_loop_task.after_loop +async def after_squadron_points_loop(): + if squadron_points_loop_task.failed(): + logging.error("[SQ-POINTS] squadron_points_loop_task died, restarting in 10s...") + await asyncio.sleep(10) + squadron_points_loop_task.start() + + +# ============================================================================ +# STACKS CLEANUP TASK +# ============================================================================ + +# Fires at 07:30 and 22:30 UTC — 20 minutes after each SQB timeslot ends. +_STACKS_CLEANUP_TIMES = [(7, 30), (22, 30)] + + +@tasks.loop(seconds=60) +async def stacks_cleanup_task(): + """Wipe all active stacks at the end of each SQB timeslot.""" + now_utc = datetime.now(timezone.utc) + if (now_utc.hour, now_utc.minute) in _STACKS_CLEANUP_TIMES: + try: + if STACKS_DIR.exists(): + shutil.rmtree(STACKS_DIR) + STACKS_DIR.mkdir(parents=True, exist_ok=True) + logging.info("[STACKS] Wiped STACKS_DIR at timeslot end.") + await _record("stacks_cleanup", True) + except Exception as e: + await _record("stacks_cleanup", False, str(e)) + logging.error(f"[STACKS] Failed to wipe STACKS_DIR: {e}") + + +@stacks_cleanup_task.before_loop +async def before_stacks_cleanup_task(): + await get_bot().wait_until_ready() + + +# ============================================================================ +# HEALTH HEARTBEAT TASK +# ============================================================================ + +@tasks.loop(seconds=30) +async def health_heartbeat_task(): + """Write bot health snapshot every 30 seconds.""" + await write_heartbeat() + + +@health_heartbeat_task.before_loop +async def before_health_heartbeat_task(): + await get_bot().wait_until_ready() + + +# ============================================================================ +# GUILD META MEMBER SYNC TASK +# ============================================================================ + +@tasks.loop(hours=24) +async def sync_guild_metas_task(): + """ + Every 24 hours: for each configured guild, fetch the current squadron roster + from the Game API and reconcile Guild_Metas — adding new members and removing + players who have left the squadron. + """ + try: + await sync_all_guild_metas() + await _record("sync_guild_metas", True) + except Exception as e: + await _record("sync_guild_metas", False, str(e)) + logging.error(f"[META-SYNC] Error during daily guild meta sync: {e}") + + +@sync_guild_metas_task.before_loop +async def before_sync_guild_metas_task(): + await get_bot().wait_until_ready() + + +# ============================================================================ +# WEEKLY BR REPORT TASK +# ============================================================================ + +# Fires ~10 min after each BR window ends (10 * 60 = 600s). +_WEEKLY_BR_FIRE_OFFSET_S = 600 +_WEEKLY_BR_FIRE_TOLERANCE_S = 60 # tolerance window for the 60s loop tick +_SCHEDULE_PATH = Path(__file__).parent / "SCHEDULE.json" + + +def _just_ended_br_window(now_ts: int) -> Optional[Dict[str, Any]]: + """Return the SCHEDULE.json entry whose end is `_WEEKLY_BR_FIRE_OFFSET_S` + seconds in the past (within `_WEEKLY_BR_FIRE_TOLERANCE_S`), or None. + """ + try: + with open(_SCHEDULE_PATH, "r", encoding="utf-8") as fp: + schedule = json.load(fp) + except Exception as e: + logging.error("[WBR] Failed to read SCHEDULE.json: %s", e) + return None + + target = now_ts - _WEEKLY_BR_FIRE_OFFSET_S + for entry in schedule: + try: + end_ts = int(entry["end"]) + except (KeyError, TypeError, ValueError): + continue + if target - _WEEKLY_BR_FIRE_TOLERANCE_S <= end_ts <= target: + return entry + return None + + +@tasks.loop(seconds=60) +async def weekly_br_report_task(): + """Fire the Weekly BR Report ~10 min after each BR window ends.""" + now_ts = int(datetime.now(timezone.utc).timestamp()) + window = _just_ended_br_window(now_ts) + if window is None: + return + try: + await execute_weekly_br_report_task(window) + await _record("weekly_br_report", True) + except Exception as e: + await _record("weekly_br_report", False, str(e)) + raise + + +@weekly_br_report_task.before_loop +async def before_weekly_br_report_task(): + await get_bot().wait_until_ready() + + +# ============================================================================ +# TASK STARTER FUNCTION +# ============================================================================ + +async def _startup_heavy_init(): + """Run heavy DB/API tasks at startup, then start recurring tasks.""" + try: + await execute_update_squadrons_db_task(count=1000) + except Exception as e: + logging.error(f"[STARTUP] squadrons.db update failed: {e}") + + try: + await execute_sync_squadron_members_bulk(count=1000) + except Exception as e: + logging.error(f"[STARTUP] squadron members sync failed: {e}") + + try: + await execute_cleanup_stale_squadrons() + except Exception as e: + logging.error(f"[STARTUP] stale squadron cleanup failed: {e}") + + await asyncio.sleep(5) + + # Recurring tasks (started after heavy init completes) + update_squadrons_db_task.start() + squadron_stats_tracker_task.start() + leave_alarm_task.start() + + # Continuous background tasks + squadron_points_loop_task.start() + update_meta_data_task.start() + sync_guild_metas_task.start() + + +async def start_all_tasks(): + """ + Start all background tasks. Heavy DB init runs in a background task + so on_ready returns quickly. + + Order: + 1. Lightweight time-check tasks (just check clock, no heavy work) + 2. WebSocket listeners (persistent connections) + 3. Heavy DB/API tasks (background — does not block on_ready) + """ + # Phase 1: Lightweight time-check tasks + entitlement_cache_task.start() + ldb_alarm_task.start() + squadron_stats_boundary_task.start() + points_alarm_task.start() + stacks_cleanup_task.start() + replay_cleanup_task.start() + health_heartbeat_task.start() + weekly_br_report_task.start() + # Phase 2: WebSocket listeners + ws_autolog_task.start() + ws_gob_task.start() + # Phase 3: Heavy DB tasks (background — doesn't block on_ready) + asyncio.create_task(_startup_heavy_init()) + + +def stop_all_tasks(): + """Stop all background tasks. Call this on shutdown.""" + entitlement_cache_task.cancel() + ldb_alarm_task.cancel() + squadron_stats_tracker_task.cancel() + squadron_stats_boundary_task.cancel() + points_alarm_task.cancel() + stacks_cleanup_task.cancel() + replay_cleanup_task.cancel() + leave_alarm_task.cancel() + update_squadrons_db_task.cancel() + update_meta_data_task.cancel() + ws_autolog_task.cancel() + ws_gob_task.cancel() + squadron_points_loop_task.cancel() + sync_guild_metas_task.cancel() + health_heartbeat_task.cancel() + weekly_br_report_task.cancel() diff --git a/BOT/tests/smoke_player_recap.py b/BOT/tests/smoke_player_recap.py new file mode 100644 index 0000000..c3476a8 --- /dev/null +++ b/BOT/tests/smoke_player_recap.py @@ -0,0 +1,81 @@ +""" +Smoke test for BOT/render_recap.py in --mode player. + +Usage: + source .venv/bin/activate && python BOT/tests/smoke_player_recap.py +""" + +import sqlite3 +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +_storage_env = os.environ.get("SREBOT_STORAGE_VOL_PATH", "").strip() +if not _storage_env: + raise RuntimeError("SREBOT_STORAGE_VOL_PATH must be set") +STORAGE = Path(_storage_env) +SEASON_START = "1772348400" +SEASON_END = "1777852799" +SEASON = "2026-II" + + +def pick_uid() -> str: + conn = sqlite3.connect(f"file:{STORAGE/'sq_battles.db'}?mode=ro", uri=True) + cur = conn.execute( + "SELECT UID FROM player_games_hist " + "WHERE endtime_unix BETWEEN ? AND ? " + "GROUP BY UID ORDER BY COUNT(*) DESC LIMIT 1", + (int(SEASON_START), int(SEASON_END)), + ) + row = cur.fetchone() + conn.close() + return row[0] if row else "" + + +def run_smoke(uid: str, theme: str = "dark", lang: str = "en") -> int: + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / f"player-{theme}-{lang}.png" + cmd = [ + sys.executable, + "BOT/render_recap.py", + "--mode", "player", + "--uid", uid, + "--season", SEASON, + "--season-start", SEASON_START, + "--season-end", SEASON_END, + "--theme", theme, + "--lang", lang, + "--out", str(out), + ] + print("Running:", " ".join(cmd)) + result = subprocess.run(cmd, capture_output=True, text=True) + print("stdout:", result.stdout) + print("stderr:", result.stderr) + if result.returncode != 0: + print(f"FAIL: exit={result.returncode}") + return result.returncode + size = out.stat().st_size if out.exists() else 0 + if size < 10_000: + print(f"FAIL: suspicious size {size} bytes at {out}") + return 1 + print(f"OK: wrote {size} bytes ({theme}/{lang})") + return 0 + + +def main() -> int: + uid = pick_uid() + if not uid: + print("FAIL: no UID with season activity available for smoke test") + return 1 + print(f"Using UID {uid}") + code = 0 + for theme in ("light", "dark"): + for lang in ("en", "ru"): + code |= run_smoke(uid, theme=theme, lang=lang) + return code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/BOT/tests/smoke_recap.py b/BOT/tests/smoke_recap.py new file mode 100644 index 0000000..be61d32 --- /dev/null +++ b/BOT/tests/smoke_recap.py @@ -0,0 +1,50 @@ +""" +Smoke test for BOT/render_recap.py. + +Runs the renderer end-to-end against the live HC storage volume databases +and verifies a PNG lands at the --out path. + +Usage: + source .venv/bin/activate && python BOT/tests/smoke_recap.py +""" + +import subprocess +import sys +import tempfile +from pathlib import Path + + +def run_smoke(clan_id: int = 123456, season: str = "2026-II") -> int: + """Run the renderer for a given clan/season; return exit code.""" + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "card.png" + cmd = [ + sys.executable, + "BOT/render_recap.py", + "--mode", "squadron", + "--clan-id", str(clan_id), + "--season", season, + "--season-start", "1772348400", + "--season-end", "1777852799", + "--out", str(out), + ] + print("Running:", " ".join(cmd)) + result = subprocess.run(cmd, capture_output=True, text=True) + print("stdout:", result.stdout) + print("stderr:", result.stderr) + if result.returncode != 0: + print(f"FAIL: exit={result.returncode}") + return result.returncode + if not out.exists(): + print(f"FAIL: {out} does not exist") + return 1 + if out.stat().st_size == 0: + print(f"FAIL: {out} is empty") + return 1 + print(f"OK: wrote {out.stat().st_size} bytes") + return 0 + + +if __name__ == "__main__": + code = run_smoke() + sys.exit(code) diff --git a/BOT/utils.py b/BOT/utils.py new file mode 100644 index 0000000..781d34a --- /dev/null +++ b/BOT/utils.py @@ -0,0 +1,2016 @@ +""" +utils.py + +Shared utilities, constants, and permission helpers used across the bot. +""" + +# Standard Library Imports +import asyncio +import base64 +import gzip +import json +import logging +import os +import re +import sys +import time +import unicodedata +from datetime import datetime, time as dt_time, timedelta, timezone +from pathlib import Path +from typing import Any, Dict, List, Literal, Optional, TypedDict + +# Third-Party Library Imports +import aiofiles +import aiosqlite +import discord +import zstandard +from discord import app_commands +from discord.ext import commands +from discord.utils import escape_markdown, escape_mentions +from dotenv import load_dotenv +from wcwidth import wcswidth + +# Make SHARED (sibling of SREBOT under BOTS/) importable +_SHARED_DIR = Path(__file__).resolve().parents[2] / "SHARED" +if str(_SHARED_DIR) not in sys.path: + sys.path.insert(0, str(_SHARED_DIR)) + +# Local Module Imports +from data_parser import ( + LangTableReader, + UnitTags, + apply_vehicle_name_filters, + normalize_name, +) + +load_dotenv() + + +def require_storage_dir() -> Path: + """Return the configured storage root or fail fast if misconfigured.""" + raw = os.environ.get("SREBOT_STORAGE_VOL_PATH", "").strip() + if not raw: + raise RuntimeError("SREBOT_STORAGE_VOL_PATH must be set") + return Path(raw) + + +def esc(text: str) -> str: + """Escape both markdown and mentions for safe embed/message display.""" + return escape_mentions(escape_markdown(str(text))) + +# ============================================================================ +# CONSTANTS +# ============================================================================ + +# Base storage paths +STORAGE_DIR = require_storage_dir() +ICONS_DIR = _SHARED_DIR / "ICONS" + +# Cache and Auth directories +CACHE_DIR = STORAGE_DIR / "CACHE" +AUTH_DIR = STORAGE_DIR / "AUTH" +STACKS_DIR = STORAGE_DIR / "STACKS" +REPLAYS_DIR = STORAGE_DIR / "REPLAYS" +STORAGE_DIR.mkdir(parents=True, exist_ok=True) + +# Databases +SQ_BATTLES_DB_PATH = STORAGE_DIR / "sq_battles.db" +SQUADRONS_DB_PATH = STORAGE_DIR / "squadrons.db" +WL_DB_PATH = STORAGE_DIR / "wl.db" +POINTS_DB_PATH = STORAGE_DIR / "points.db" +ENTITLEMENTS_DB_PATH = STORAGE_DIR / "entitlements.db" +COMMAND_DATA_DB_PATH = STORAGE_DIR / "COMMAND_DATA.db" + + +def replay_session_dir(session_id: str | int) -> Path: + """Return the canonical on-disk replay directory for a hex session ID.""" + session = str(session_id).strip().lower() + if session.startswith("0x"): + session = session[2:] + return REPLAYS_DIR / session + + +def replay_data_path(session_id: str | int) -> Path: + return replay_session_dir(session_id) / "replay_data.json" + +# Dev team Discord user IDs (bot owner + trusted devs) +DEV_DISCORD_IDS: set[int] = { + 1357793112277127290, + 396729572814749706, + 383992974649982976, + 621961120617594880, + 386224084129939456, +} + +# Default Strings +DEFAULT_FOOTER_CAT: str = "ᓚᘏᗢ" + +# Discord Token +TOKEN = os.environ.get('DISCORD_KEY') + +# ============================================================================ +# JSON COMPRESSION HELPERS +# ============================================================================ + +def compress_json(obj, **kwargs) -> bytes: + """Serialize obj to JSON and gzip-compress it for BLOB storage.""" + return gzip.compress(json.dumps(obj, **kwargs).encode("utf-8"), compresslevel=6) + + +def decompress_json(data): + """Parse a JSON column that may be gzip-compressed (bytes) or plain TEXT (str).""" + if isinstance(data, (bytes, memoryview)): + return json.loads(gzip.decompress(bytes(data))) + return json.loads(data) + + +# ============================================================================ +# BLACKLISTS +# ============================================================================ + +BLACKLISTED_SERVER_IDS = [ +] + +BLACKLISTED_SQUADRONS = [ + "FIR3", + "F4WRD", +] + +BLACKLISTED_USER_IDS = [ + 635917136619110411, #wolfhunter4374 + 498231384238850048, #liquidtaeja + 1166571862789193769, #astro + 872791644947234847, #nova + 591290628722393090, #rayan + 1129994258267512842, #squawk + 1417443909918916670, #maverickfighter9 + 1034483237197713539, #HK416A6 + 770662021045026857, #caprisun + 815535178122002463, #markboss +] + +# ── Premium / Entitlements ──────────────────────────────────────────────────── +PREMIUM_ACTIVATION_TS: int = 1775459700 # Unix timestamp when premium gating activates +COMP_FREE_UNTIL_TS: int = 1777620600 # Free /comp period ends + +# ── Tier enforcement (activation timestamp updated per policy) ─────────────── +TIER_ENFORCEMENT_TS: int = 1778107232 + +Tier = Literal["standard", "pro", "max"] + +TIER_NOTIF_CAPS: Dict[str, Dict[str, Optional[int]]] = { + "standard": {"Logs": 10, "Points": 10}, + "pro": {"Logs": 25, "Points": 25}, + "max": {"Logs": None, "Points": None}, +} + +TIER_ALLOWS_WILDCARDS: Dict[str, bool] = { + "standard": False, + "pro": True, + "max": True, +} + +TIER_ORDER: List[str] = ["standard", "pro", "max"] # ascending; used for "highest wins" +WILDCARD_KEYS: set[str] = {"*", "all", "everything"} + +# Env-var SKU IDs — populated once the Whop/Discord products exist. +# Standard SKU is kept as an int since discord.ui.Button(sku_id=...) needs an int. +DISCORD_SKU_ID_STANDARD: int = int(os.environ.get('DISCORD_SKU_ID_STANDARD', '1478970400158384220')) +DISCORD_SKU_ID_PRO: Optional[str] = os.environ.get("DISCORD_SKU_ID_PRO") +DISCORD_SKU_ID_MAX: Optional[str] = os.environ.get("DISCORD_SKU_ID_MAX") + + +def tier_cap(tier: Optional[str], notif_type: str) -> Optional[int]: + """Enabled-entry cap for a (tier, notif_type). None = unlimited; None tier = 0.""" + if tier is None: + return 0 + if notif_type in ("Leaderboard", "Global", "WeeklyBR"): + return None + return TIER_NOTIF_CAPS.get(tier, {}).get(notif_type, 0) + + +def tier_allows_wildcard(tier: Optional[str]) -> bool: + return bool(tier) and TIER_ALLOWS_WILDCARDS.get(tier or "", False) + + +def tier_enforcement_active(now: Optional[float] = None) -> bool: + return (now if now is not None else time.time()) >= TIER_ENFORCEMENT_TS + + +def sku_id_to_tier(sku_id: Optional[str]) -> Optional[str]: + """Map a Discord SKU ID to a tier, or None if unknown.""" + if not sku_id: + return None + s = str(sku_id) + if s == DISCORD_SKU_ID_MAX: + return "max" + if s == DISCORD_SKU_ID_PRO: + return "pro" + if s == str(DISCORD_SKU_ID_STANDARD): + return "standard" + return None + + +def _tier_rank(tier: Optional[str]) -> int: + try: + return TIER_ORDER.index(tier) if tier else -1 + except ValueError: + return -1 + + +def higher_tier(a: Optional[str], b: Optional[str]) -> Optional[str]: + """Return whichever of a/b has the higher rank in TIER_ORDER. None/unknown < all.""" + ra, rb = _tier_rank(a), _tier_rank(b) + if ra < 0 and rb < 0: + return None + return a if ra >= rb else b +# Free-tier /comp cap per timeslot. +COMP_LIMIT_PER_TIMESLOT: int = 15 + +# ── SQB schedule (UTC, DST-immune) ─────────────────────────────────────────── +# Edit SQB_SLOTS_POSTED and the margin constants when Gaijin changes the +# season schedule; every downstream time derives from these. +SQB_SLOTS_POSTED: List[tuple[str, dt_time, dt_time]] = [ + ("EU", dt_time(14, 0), dt_time(22, 0)), + ("NA", dt_time(1, 0), dt_time(7, 0)), +] +# Hourly squadron-points snapshot window (consumed by squadron_stats_tracker_task in tasks.py) +SQB_STATS_TRACKER_PRE_MIN: int = 5 # snapshot window opens this many minutes before posted start +SQB_STATS_TRACKER_POST_MIN: int = 10 # snapshot window closes this many minutes after posted end + +# Per-minute boundary snapshot ticks (consumed by squadron_stats_boundary_task in tasks.py) +SQB_BOUNDARY_PRE_MIN: int = 10 # boundary tick this many minutes before posted start +SQB_BOUNDARY_POST_MIN: int = 30 # boundary tick this many minutes after posted end + +# /comp rate-limit window (consumed by get_current_timeslot_start_ts, enforced in botscript.py) +SQB_COMP_LIMIT_PRE_MIN: int = 0 # /comp limit window opens this many minutes before posted start +SQB_COMP_LIMIT_POST_MIN: int = 20 # /comp limit window closes this many minutes after posted end + +def _shift_time(t: dt_time, minutes: int) -> dt_time: + total = (t.hour * 60 + t.minute + minutes) % (24 * 60) + return dt_time(total // 60, total % 60) + +SQB_STATS_TRACKER_WINDOWS: List[tuple[str, dt_time, dt_time]] = [ + (name, _shift_time(s, -SQB_STATS_TRACKER_PRE_MIN), _shift_time(e, SQB_STATS_TRACKER_POST_MIN)) + for name, s, e in SQB_SLOTS_POSTED +] + +SQB_BOUNDARY_TIMES: List[dt_time] = [ + edge + for _, posted_start, posted_end in SQB_SLOTS_POSTED + for edge in ( + _shift_time(posted_start, -SQB_BOUNDARY_PRE_MIN), + _shift_time(posted_end, SQB_BOUNDARY_POST_MIN), + ) +] + +# Cached guild_id → tier (highest rank among active sources); rebuilt with a TTL guard +_entitled_tiers: Dict[int, str] = {} +_entitled_guilds_ts: float = 0.0 +_ENTITLEMENT_CACHE_TTL: float = 60.0 # seconds + + +def _merge_tier(acc: Dict[int, str], guild_id: int, tier: Optional[str]) -> None: + """Upsert guild_id → tier into acc, keeping the higher-ranked tier.""" + t = tier or "standard" # defensive: unknown/NULL → standard + current = acc.get(guild_id) + if current is None: + acc[guild_id] = t + else: + acc[guild_id] = higher_tier(current, t) or current + + +async def refresh_entitled_guilds(*, force: bool = False) -> None: + """Rebuild the entitled-tiers cache from all three sources. + + Skips the refresh if the cache was populated less than + _ENTITLEMENT_CACHE_TTL seconds ago, unless *force* is True. + """ + global _entitled_tiers, _entitled_guilds_ts + + if not force and _entitled_tiers and (time.monotonic() - _entitled_guilds_ts) < _ENTITLEMENT_CACHE_TTL: + return + + result: Dict[int, str] = {} + + # 1. Whop (guild_entitlements) + try: + async with aiosqlite.connect(ENTITLEMENTS_DB_PATH) as db: + cur = await db.execute( + "SELECT guild_id, tier FROM guild_entitlements WHERE status='active'" + ) + rows = await cur.fetchall() + for (gid, tier) in rows: + try: + _merge_tier(result, int(gid), tier) + except (ValueError, TypeError): + pass + except Exception as e: + logging.error(f"[PREMIUM] Failed to load Whop entitlements: {e}") + + # 2. Manual entitlements + try: + async with aiosqlite.connect(ENTITLEMENTS_DB_PATH) as db: + cur = await db.execute( + "SELECT guild_id, tier FROM manual_entitlements WHERE expires_at > ?", + [int(time.time())], + ) + rows = await cur.fetchall() + for (gid, tier) in rows: + try: + _merge_tier(result, int(gid), tier) + except (ValueError, TypeError): + pass + except Exception as e: + logging.error(f"[PREMIUM] Failed to load manual entitlements: {e}") + + # 3. Discord native SKU entitlements — also sync to DB for web API + # Collapse to one row per guild, keeping the highest tier. A guild can hold + # multiple active Discord entitlements (e.g. during Pro→Max upgrade or if the + # owner bought two SKUs). The web-side `discord_entitlements` table has + # guild_id as PRIMARY KEY, so duplicates would collide — dedup first. + best_per_guild: Dict[str, tuple[str, str]] = {} # guild_id → (sku_id, tier) + discord_fetch_failed = False + try: + bot = get_bot() + async for ent in bot.entitlements(exclude_ended=True): + if not ent.guild_id: + continue + gid = str(ent.guild_id) + sku_id = str(getattr(ent, "sku_id", "") or "") or str(DISCORD_SKU_ID_STANDARD) + tier = sku_id_to_tier(sku_id) or "standard" + existing = best_per_guild.get(gid) + if existing is None or _tier_rank(tier) > _tier_rank(existing[1]): + best_per_guild[gid] = (sku_id, tier) + except Exception as e: + logging.error(f"[PREMIUM] Failed to load Discord entitlements: {e}") + discord_fetch_failed = True + + # Resilience guard: if the Discord API failed OR returned a suspiciously + # small set vs the last successful sync, fall back to the cached + # discord_entitlements table rather than wholesale-replacing it. This + # prevents Discord outages from silently demoting paying guilds. + try: + async with aiosqlite.connect(ENTITLEMENTS_DB_PATH) as db: + await db.execute( + "CREATE TABLE IF NOT EXISTS discord_entitlements " + "(guild_id TEXT PRIMARY KEY, sku_id TEXT, tier TEXT, " + "updated_at INTEGER DEFAULT (strftime('%s','now')))" + ) + cur = await db.execute("SELECT COUNT(*) FROM discord_entitlements") + row = await cur.fetchone() + prior_count = int(row[0]) if row else 0 + new_count = len(best_per_guild) + degraded = ( + discord_fetch_failed + or (prior_count > 0 and new_count == 0) + or (prior_count >= 4 and new_count < prior_count // 2) + ) + if degraded: + logging.warning( + f"[PREMIUM] Discord entitlements result looks degraded " + f"(api_failed={discord_fetch_failed}, new={new_count}, prior={prior_count}); " + f"keeping existing discord_entitlements table" + ) + cur2 = await db.execute("SELECT guild_id, tier FROM discord_entitlements") + async for (gid_str, tier_str) in cur2: + try: + _merge_tier(result, int(gid_str), tier_str) + except (ValueError, TypeError): + pass + else: + for gid_str, (_sku_id, tier_str) in best_per_guild.items(): + try: + _merge_tier(result, int(gid_str), tier_str) + except (ValueError, TypeError): + pass + await db.execute("DELETE FROM discord_entitlements") + for gid_str, (sku_id, tier_str) in best_per_guild.items(): + await db.execute( + "INSERT INTO discord_entitlements (guild_id, sku_id, tier) VALUES (?, ?, ?)", + [gid_str, sku_id, tier_str], + ) + await db.commit() + except Exception as e: + logging.error(f"[PREMIUM] Failed to sync discord_entitlements table: {e}") + + _entitled_tiers = result + _entitled_guilds_ts = time.monotonic() + logging.info(f"[PREMIUM] Refreshed entitlement cache: {len(result)} guilds entitled") + + +def invalidate_entitled_guilds_cache() -> None: + """Reset the TTL so the next refresh_entitled_guilds() call does a full reload.""" + global _entitled_guilds_ts + _entitled_guilds_ts = 0.0 + + +async def get_guild_tier(guild_id: int) -> Optional[str]: + """Return 'standard' | 'pro' | 'max' or None if not entitled. + + Uses the cache built by refresh_entitled_guilds(); falls back to a direct + DB + Discord-API lookup if the cache is empty. + """ + if _entitled_tiers: + return _entitled_tiers.get(guild_id) + + best: Optional[str] = None + + # Fallback: direct lookup + try: + async with aiosqlite.connect(ENTITLEMENTS_DB_PATH) as db: + cur = await db.execute( + "SELECT tier FROM guild_entitlements WHERE guild_id=? AND status='active'", + [str(guild_id)], + ) + row = await cur.fetchone() + if row: + best = higher_tier(best, row[0] or "standard") + cur2 = await db.execute( + "SELECT tier FROM manual_entitlements WHERE guild_id=? AND expires_at > ?", + [str(guild_id), int(time.time())], + ) + row2 = await cur2.fetchone() + if row2: + best = higher_tier(best, row2[0] or "standard") + except Exception as e: + logging.error(f"[PREMIUM] Failed to check entitlement for {guild_id}: {e}") + + try: + bot = get_bot() + async for ent in bot.entitlements(guild=discord.Object(id=guild_id), exclude_ended=True): + if ent.guild_id == guild_id: + sku_id = str(getattr(ent, "sku_id", "") or "") + best = higher_tier(best, sku_id_to_tier(sku_id) or "standard") + except Exception as e: + logging.error(f"[PREMIUM] Failed to check Discord entitlement for {guild_id}: {e}") + + return best + + +async def is_guild_entitled(guild_id: int) -> bool: + """Binary entitlement check — True if guild has any active tier.""" + return (await get_guild_tier(guild_id)) is not None +# ───────────────────────────────────────────────────────────────────────────── + +# ============================================================================ +# BOT INSTANCE HOLDER +# ============================================================================ + +_bot_instance: Optional[commands.Bot] = None + + +def set_bot(bot: commands.Bot) -> None: + """Register the bot instance globally.""" + global _bot_instance + _bot_instance = bot + + +def get_bot() -> commands.Bot: + """Retrieve the globally registered bot instance. Raises if not set.""" + if _bot_instance is None: + raise RuntimeError("Bot instance not set. Call set_bot() first.") + return _bot_instance + + +# ============================================================================ +# EXCEPTION CLASSES +# ============================================================================ + +class AdminCheckFailure(app_commands.CheckFailure): + """Raised when a user lacks administrator permissions for a command.""" + pass + + +class BlacklistCheckFailure(app_commands.CheckFailure): + """Raised when a blacklisted user or guild attempts to run a command.""" + pass + + +# ============================================================================ +# PERMISSION DECORATORS +# ============================================================================ + +def is_admin(): + """Return an app-command check that verifies administrator or bot-owner status. + + Raises: + AdminCheckFailure: If the user is not in a guild or lacks + administrator permissions. + """ + async def predicate(interaction: discord.Interaction): + if interaction.guild is None or not isinstance(interaction.user, discord.Member): + raise AdminCheckFailure("You must be in a guild to run this command.") + + if interaction.user.id == 809619070639013888: + return True # bot owner + + if not interaction.user.guild_permissions.administrator: + raise AdminCheckFailure("You must be an administrator to run this command.") + return True + return app_commands.check(predicate) + + +async def is_dev_team(interaction: discord.Interaction) -> bool: + """Check if the user is a dev team member or the bot owner.""" + if interaction.user.id in DEV_DISCORD_IDS: + return True + bot = get_bot() + if bot and await bot.is_owner(interaction.user): + return True + return False + + +def is_blacklisted(): + """Return an app-command check that rejects blacklisted users or guilds. + + Entries in BLACKLISTED_USER_IDS may be plain ints or + ``(user_id, reason)`` tuples. + + Raises: + BlacklistCheckFailure: If the guild or user is blacklisted, + optionally carrying the reason string. + """ + async def predicate(interaction: discord.Interaction): + guild = interaction.guild + if guild is not None and guild.id in BLACKLISTED_SERVER_IDS: + raise BlacklistCheckFailure(t("en", "common.access_denied_desc")) + + uid = interaction.user.id + for entry in BLACKLISTED_USER_IDS: + if isinstance(entry, tuple): + blocked_id, reason = entry + else: + blocked_id, reason = entry, None + if uid == blocked_id: + raise BlacklistCheckFailure(reason) + return True + return app_commands.check(predicate) + + +# ============================================================================ +# PERMISSION ERROR HANDLER +# ============================================================================ + +async def permission_fail(interaction: discord.Interaction, error): + """Handle permission-related errors with appropriate embeds.""" + lang = await guild_lang(interaction.guild_id) if interaction.guild_id else "en" + if isinstance(error, BlacklistCheckFailure): + reason = error.args[0] if error.args else None + desc = t(lang, "permission.blacklisted_desc") + if reason: + desc += "\n" + t(lang, "permission.reason_line", reason=reason) + embed = discord.Embed( + title=t(lang, "permission.blacklisted_title"), + description=desc, + color=discord.Color.red() + ) + elif isinstance(error, AdminCheckFailure): + embed = discord.Embed( + title=t(lang, "permission.access_denied_title"), + description=f"{error.args[0]}", + color=discord.Color.orange() + ) + elif isinstance(error, app_commands.CheckFailure): + embed = discord.Embed( + title=t(lang, "permission.access_denied_title"), + description=t(lang, "permission.no_permission_desc"), + color=discord.Color.orange() + ) + else: + # fallback for unexpected errors + embed = discord.Embed( + title=t(lang, "permission.unexpected_error_title"), + description=str(error), + color=discord.Color.dark_red() + ) + + try: + await interaction.response.send_message(embed=embed, ephemeral=True) + except discord.InteractionResponded: + await interaction.followup.send(embed=embed, ephemeral=True) + + +# ============================================================================ +# STRING UTILITIES +# ============================================================================ + +def norm(s: str) -> str: + """Normalize a string for comparison (lowercase, strip whitespace).""" + return (s or "").strip().lower() + + +def discord_len(s: str) -> int: + """ + Discord counts wide/fullwidth CJK characters as 2. + We mirror that so chunks never exceed the 1024 embed-field limit. + """ + total = 0 + for ch in s: + if unicodedata.east_asian_width(ch) in ("W", "F"): # wide/fullwidth + total += 2 + else: + total += 1 + return total + + +def pad_display_width(text: str, target_width: int) -> str: + """ + Pad string so its DISPLAY width matches target_width. + If it's longer, truncate safely. + """ + display_width = wcswidth(text) + if display_width < 0: + display_width = len(text) + + # If string fits, pad + if display_width < target_width: + return text + " " * (target_width - display_width) + + # If it's too long, truncate and pad to target width + out = "" + cur_width = 0 + for ch in text: + w = wcswidth(ch) + if w < 0: # fallback + w = 1 + if cur_width + w > target_width: + break + out += ch + cur_width += w + return out + " " * (target_width - cur_width) + + +def _row_to_dict(row, cursor) -> Dict[str, Any]: + """Convert a sqlite row to a dictionary.""" + if row is None: + return {} + return {desc[0]: row[i] for i, desc in enumerate(cursor.description)} + + +# ============================================================================ +# JSON UTILITIES +# ============================================================================ + +async def load_json(path: Path, default: Any = None) -> Any: + """Load JSON from a file asynchronously, returning default if file doesn't exist.""" + try: + async with aiofiles.open(path, "r", encoding="utf-8") as f: + return json.loads(await f.read()) + except FileNotFoundError: + return default if default is not None else {} + except Exception as e: + logging.error(f"Error loading JSON from {path}: {e}") + return default if default is not None else {} + + +async def write_json(path: Path, data: Any, indent: int = 4) -> bool: + """Write data to a JSON file atomically. Returns True on success.""" + try: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + async with aiofiles.open(tmp, "w", encoding="utf-8") as f: + await f.write(json.dumps(data, indent=indent, ensure_ascii=False)) + os.replace(tmp, path) + return True + except Exception as e: + logging.error(f"Error writing JSON to {path}: {e}") + return False + + +# ============================================================================ +# PREFERENCES +# ============================================================================ + +async def load_guild_preferences(guild_id: int) -> Dict[str, Any]: + """Load preferences for a guild from STORAGE/PREFERENCES/.""" + prefs_dir = STORAGE_DIR / "PREFERENCES" + pref_path = prefs_dir / f"{guild_id}-preferences.json" + return await load_json(pref_path, {}) + + +async def save_guild_preferences(guild_id: int, preferences: Dict[str, Any]) -> bool: + """Save preferences for a guild to STORAGE/PREFERENCES/.""" + prefs_dir = STORAGE_DIR / "PREFERENCES" + prefs_dir.mkdir(parents=True, exist_ok=True) + pref_path = prefs_dir / f"{guild_id}-preferences.json" + return await write_json(pref_path, preferences) + + +async def remove_guild_pref_notification( + guild_id: int, + pref_key: str, + notif_type: str, + *, + preferences: Optional[Dict[str, Any]] = None, +) -> bool: + """Remove one stored notification route; drop the whole entry if nothing usable remains.""" + prefs = preferences if preferences is not None else await load_guild_preferences(guild_id) + entry = prefs.get(pref_key) + if not isinstance(entry, dict) or notif_type not in entry: + return False + + entry.pop(notif_type, None) + + remaining_route_keys = [ + k for k in entry.keys() + if k not in {"Short", "Long"} + ] + if not remaining_route_keys: + prefs.pop(pref_key, None) + + return await save_guild_preferences(guild_id, prefs) + + +# ── Tier-aware preference helpers ──────────────────────────────────────────── + +def is_notif_enabled(entry: Any, notif_type: str) -> bool: + """True if entry[notif_type] resolves to a real (non-DISABLED) channel ID.""" + if not isinstance(entry, dict): + return False + raw = str(entry.get(notif_type, "")) + if not raw or "DISABLED" in raw.upper(): + return False + return bool(re.search(r"\d{17,19}", raw)) + + +def enabled_pref_keys_for(prefs: Dict[str, Any], notif_type: str) -> List[str]: + """Squadron keys (in JSON insertion order) whose entry has this notif enabled.""" + return [k for k, v in prefs.items() if is_notif_enabled(v, notif_type)] + + +def allowed_pref_keys_for(prefs: Dict[str, Any], tier: Optional[str], notif_type: str) -> set[str]: + """Enabled keys for this notif type that pass the tier cap (first-N slice). + + - Leaderboard / Global: always uncapped. + - Wildcards (*, all, everything): dropped entirely on tiers that don't allow them; + on tiers that do allow them, they do NOT count against the cap. + - Pre-activation: returns all enabled keys. + """ + keys = enabled_pref_keys_for(prefs, notif_type) + + if not tier_enforcement_active(): + return set(keys) + + wildcards = [k for k in keys if k.lower() in WILDCARD_KEYS] + non_wildcards = [k for k in keys if k.lower() not in WILDCARD_KEYS] + + if not tier_allows_wildcard(tier): + wildcards = [] + + cap = tier_cap(tier, notif_type) + if cap is None: + return set(non_wildcards) | set(wildcards) + return set(non_wildcards[:cap]) | set(wildcards) + + +def enabled_non_wildcard_keys_for(prefs: Dict[str, Any], notif_type: str) -> List[str]: + """Enabled keys excluding wildcards — used for cap accounting (wildcards don't count).""" + return [k for k in enabled_pref_keys_for(prefs, notif_type) if k.lower() not in WILDCARD_KEYS] + + +async def load_features(guild_id: int) -> Dict[str, Any]: + """ + Reads STORAGE_DIR / "FEATURES" / "{guild_id}-features.json" and returns its parsed content. + If the file doesn't exist, creates it with default values and returns those. + """ + features_dir = STORAGE_DIR / "FEATURES" + features_dir.mkdir(parents=True, exist_ok=True) + features_path = features_dir / f"{guild_id}-features.json" + + try: + async with aiofiles.open(features_path, "r", encoding="utf-8") as f: + return json.loads(await f.read()) + except FileNotFoundError: + features = {"Translate": "False", "Language": ""} + try: + async with aiofiles.open(features_path, "w", encoding="utf-8") as f: + await f.write(json.dumps(features, indent=4)) + except Exception as e: + logging.error(f"Error writing default features for guild {guild_id}: {e}") + return features + except Exception as e: + logging.error(f"Error loading features for guild {guild_id}: {e}") + return {"Translate": "False", "Language": ""} + + +async def save_features(guild_id: int, features: Dict[str, Any]) -> bool: + """ + Serializes `features` (a dict) to JSON and writes it to + STORAGE_DIR / "FEATURES" / "{guild_id}-features.json", creating directories if needed. + """ + features_dir = STORAGE_DIR / "FEATURES" + features_dir.mkdir(parents=True, exist_ok=True) + features_path = features_dir / f"{guild_id}-features.json" + + try: + async with aiofiles.open(features_path, "w", encoding="utf-8") as f: + await f.write(json.dumps(features, indent=4)) + return True + except Exception as e: + logging.error(f"Error saving features for guild {guild_id}: {e}") + return False + + +# ============================================================================ +# SQUADRON RESOLUTION +# ============================================================================ + +async def resolve_clan(short: Optional[str] = None, tag: Optional[str] = None, long: Optional[str] = None) -> Optional[Dict[str, Any]]: + """ + Resolve a clan by short name, tag, or long name. + Returns dict with 'short_name', 'tag_name', 'long_name', 'clan_id'. + """ + async with aiosqlite.connect(SQUADRONS_DB_PATH) as db: + if short: + cursor = await db.execute( + "SELECT short_name, tag_name, long_name, clan_id FROM squadrons_data WHERE LOWER(short_name) = ? LIMIT 1", + (short.lower(),) + ) + elif tag: + cursor = await db.execute( + "SELECT short_name, tag_name, long_name, clan_id FROM squadrons_data WHERE LOWER(tag_name) = ? LIMIT 1", + (tag.lower(),) + ) + elif long: + cursor = await db.execute( + "SELECT short_name, tag_name, long_name, clan_id FROM squadrons_data WHERE LOWER(long_name) = ? LIMIT 1", + (long.lower(),) + ) + else: + return None + + row = await cursor.fetchone() + if row: + return { + "short_name": row[0], + "tag_name": row[1], + "long_name": row[2], + "clan_id": int(row[3]) if row[3] is not None else None, + } + + # Return unresolved placeholder + return { + "short_name": short or tag or "", + "tag_name": tag or short or "", + "long_name": long or "", + "clan_id": None, + } + + +async def resolve_clan_id(long_name: str) -> Optional[int]: + """Look up the numeric clan_id for a squadron by its long name. + + Used by commands that hit website endpoints keyed by clan_id (recap + cards etc.). Returns None if the squadron isn't in squadrons_data. + """ + async with aiosqlite.connect(SQUADRONS_DB_PATH) as db: + cursor = await db.execute( + "SELECT clan_id FROM squadrons_data WHERE LOWER(long_name) = ? LIMIT 1", + (long_name.lower(),), + ) + row = await cursor.fetchone() + if row and row[0] is not None: + try: + return int(row[0]) + except (TypeError, ValueError): + return None + return None + + +async def resolve_pref_key( + key: str, entry: Optional[Dict[str, Any]] = None +) -> Optional[Dict[str, Any]]: + """Resolve a preferences-dict key to {clan_id, long_name, short_name, tag_name}. + + Preferences keys may be: + - A numeric clan_id (post-migration shape). + - A long_name (legacy / orphaned entry the migration couldn't resolve). + - A short_name (rare, but the migration tolerates it). + + Returns None for unresolvable keys (orphan squadron not in squadrons_data). + Wildcard keys ("Global", "everything", "all", "*") are NOT this function's + concern - the caller must handle them upstream. + """ + if not key: + return None + entry = entry or {} + key_str = str(key).strip() + key_lc = key_str.lower() + + async with aiosqlite.connect(SQUADRONS_DB_PATH) as db: + if key_str.isdigit(): + cur = await db.execute( + "SELECT clan_id, long_name, short_name, tag_name " + "FROM squadrons_data WHERE clan_id = ? LIMIT 1", + (int(key_str),), + ) + else: + cur = await db.execute( + "SELECT clan_id, long_name, short_name, tag_name FROM squadrons_data " + "WHERE LOWER(long_name) = ? OR LOWER(short_name) = ? LIMIT 1", + (key_lc, key_lc), + ) + row = await cur.fetchone() + if row: + return { + "clan_id": int(row[0]) if row[0] is not None else None, + "long_name": row[1], + "short_name": row[2], + "tag_name": row[3], + } + + # Fallback to display-only fields stashed by the migration on the entry itself. + long_fallback = entry.get("Long") if isinstance(entry, dict) else None + short_fallback = entry.get("Short") if isinstance(entry, dict) else None + if long_fallback or short_fallback: + return { + "clan_id": int(key_str) if key_str.isdigit() else None, + "long_name": long_fallback or key_str, + "short_name": short_fallback or key_str, + "tag_name": short_fallback or key_str, + } + return None + + +async def resolve_clans(shorts: Optional[List[str]] = None, tags: Optional[List[str]] = None) -> List[Dict[str, str]]: + """ + Resolve multiple clans by short names or tags. + Returns list of dicts with 'short_name', 'tag_name', 'long_name'. + """ + results = [] + shorts = shorts or [] + tags = tags or [] + + # Process shorts first + for short in shorts: + if short: + result = await resolve_clan(short=short) + if result: + results.append(result) + + # Then process tags (for any not already resolved) + resolved_shorts = {r["short_name"].lower() for r in results} + for tag in tags: + if tag and tag.lower() not in resolved_shorts: + result = await resolve_clan(tag=tag) + if result: + results.append(result) + + return results + + +async def get_guild_squadron( + guild_id: int | str | None, + user_input: str = "", +) -> Dict[str, str]: + """Resolve a squadron for a guild context. + + If user_input is provided, resolves it via resolve_clan(short=...). + Otherwise falls back to the guild's default from SQUADRONS.json. + + Returns dict with 'short_name', 'long_name', 'tag_name'. + Raises ValueError with user-friendly message if resolution fails. + """ + if user_input: + clan = await resolve_clan(short=user_input) + if not clan or clan["long_name"] == "": + raise ValueError(f"Squadron `{user_input}` not found.") + return clan + + # Fall back to guild default + squadrons_path = STORAGE_DIR / "SQUADRONS.json" + squadrons = await load_json(squadrons_path, {}) + guild_sq = squadrons.get(str(guild_id), {}) + if not guild_sq: + raise ValueError("No squadron set for this server. Use `/set-squadron` first.") + + short = guild_sq.get("SQ_ShortHand_Name", "") + long_name = guild_sq.get("SQ_LongHandName", "") + if not long_name: + raise ValueError("No squadron set for this server. Use `/set-squadron` first.") + + return {"short_name": short, "long_name": long_name, "tag_name": short} + + +# ============================================================================ +# TIME UTILITIES +# ============================================================================ + +def get_current_timeslot_start_ts() -> Optional[int]: + """Return today's /comp rate-limit window start (epoch) when the current UTC + time falls inside [posted_start - SQB_COMP_LIMIT_PRE_MIN, posted_end + SQB_COMP_LIMIT_POST_MIN] + for any posted SQB slot; else None. + + Outside the window the caller applies no limit. Inside it, the returned + timestamp scopes usage counting to the whole limit window so one quota + covers the in-slot window plus the post-close grace period. + """ + now_utc = datetime.now(timezone.utc) + now_t = now_utc.time() + for _name, posted_start, posted_end in SQB_SLOTS_POSTED: + limit_start = _shift_time(posted_start, -SQB_COMP_LIMIT_PRE_MIN) + limit_end = _shift_time(posted_end, SQB_COMP_LIMIT_POST_MIN) + if limit_start <= now_t <= limit_end: + window_open = now_utc.replace( + hour=limit_start.hour, + minute=limit_start.minute, + second=0, + microsecond=0, + ) + return int(window_open.timestamp()) + return None + + +def get_most_recent_posted_timeslot_window( + region: str, now: Optional[datetime] = None, end_grace_minutes: int = 0 +) -> Optional[tuple[int, int]]: + """Return the most recently completed posted SQB slot for a region. + + The returned window is based on `SQB_SLOTS_POSTED`, with an optional end + grace applied only to the slot end. Result is `(start_ts, end_ts)` in UTC + epoch seconds. If the current day's slot has not ended yet, fall back to + the previous day's occurrence for that region. + """ + now_utc = now.astimezone(timezone.utc) if now else datetime.now(timezone.utc) + target = str(region or "").upper() + + for slot_region, posted_start, posted_end in SQB_SLOTS_POSTED: + if slot_region.upper() != target: + continue + + start_dt = now_utc.replace( + hour=posted_start.hour, + minute=posted_start.minute, + second=0, + microsecond=0, + ) + end_dt = now_utc.replace( + hour=posted_end.hour, + minute=posted_end.minute, + second=0, + microsecond=0, + ) + + if end_dt <= start_dt: + end_dt += timedelta(days=1) + if end_grace_minutes: + end_dt += timedelta(minutes=end_grace_minutes) + if now_utc < end_dt: + start_dt -= timedelta(days=1) + end_dt -= timedelta(days=1) + + return int(start_dt.timestamp()), int(end_dt.timestamp()) + + return None + + +async def get_comp_usage_in_timeslot(guild_id: int, since_ts: int) -> int: + """Count /comp invocations for a guild since a given timestamp.""" + try: + async with aiosqlite.connect(COMMAND_DATA_DB_PATH, timeout=5.0) as db: + await db.execute("PRAGMA busy_timeout=5000;") + cur = await db.execute( + "SELECT COUNT(*) FROM command_usage " + "WHERE command_name='comp' AND guild_id=? AND timestamp >= ?", + (str(guild_id), since_ts), + ) + row = await cur.fetchone() + return row[0] if row else 0 + except Exception: + logging.debug("get_comp_usage_in_timeslot query failed", exc_info=True) + return 0 + + +def minutes_ago(unix_timestamp: int) -> str: + """Convert a unix timestamp to a human-readable 'X minutes ago' string.""" + if not unix_timestamp: + return "unknown" + + now = int(time.time()) + diff = now - unix_timestamp + + if diff < 60: + return f"{diff}s ago" + elif diff < 3600: + return f"{diff // 60}m ago" + elif diff < 86400: + return f"{diff // 3600}h ago" + else: + return f"{diff // 86400}d ago" + + +def parse_channel_id(channel_str: str) -> Optional[int]: + """Parse a channel ID from various formats like '<#123456>' or '123456'.""" + if not channel_str: + return None + + # Remove <# and > if present + cleaned = channel_str.strip() + if cleaned.startswith("<#") and cleaned.endswith(">"): + cleaned = cleaned[2:-1] + + # Remove DISABLED- prefix if present + if cleaned.startswith("DISABLED-"): + cleaned = cleaned[9:] + + try: + return int(cleaned) + except ValueError: + return None + + +# ============================================================================ +# REPLAY / LOCAL FORMAT +# ============================================================================ + +def transform_to_local_format(api_data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """ + Transform Spectra API response format to the local replay format used throughout the bot. + + Expects: {"completed": []} + For WebSocket messages shaped as {"data": {...}, "type": "..."}, wrap first: + transform_to_local_format({"completed": [msg["data"]]}) + """ + try: + if not api_data or "completed" not in api_data or not api_data["completed"]: + logging.error("Invalid API data structure") + return None + + replay = api_data["completed"][0] + + winner_winged = replay.get("winner") + loser_winged = replay.get("loser") + + winner_squadron = winner_winged[1:-1] if winner_winged else "" + loser_squadron = loser_winged[1:-1] if loser_winged else "" + + is_draw = replay.get("draw", False) + + # Build UID -> player info lookup from players dict + players_dict = replay.get("players", {}) + uid_lookup = {} # uid_str -> {name, tag_stripped} + + winner_players: List[Dict[str, Any]] = [] + loser_players: List[Dict[str, Any]] = [] + + for uid_str, pdata in players_dict.items(): + try: + tag = pdata.get("tag", "") + tag_stripped = tag[1:-1] if tag else "" + name = pdata.get("name", "") + + uid_lookup[uid_str] = {"name": name, "tag_stripped": tag_stripped} + + # Pick first used unit + vehicle = "DISCONNECTED" + for unit_entry in pdata.get("units", []): + if unit_entry.get("used"): + vehicle = unit_entry.get("unit", "DISCONNECTED") + break + + player_entry = { + "uid": int(uid_str), + "nick": name, + "fake_nick": None, + "index": int(uid_str), + "vehicle": vehicle, + "vehicle_new": "", + "air_kills": pdata.get("air_kills", 0), + "ground_kills": pdata.get("ground_kills", 0), + "assists": pdata.get("assists", 0), + "deaths": pdata.get("deaths", 0), + "captures": pdata.get("captures", 0), + "score": pdata.get("score", 0), + } + + # Assign to winner or loser by comparing tag + if tag == winner_winged: + winner_players.append(player_entry) + elif tag == loser_winged: + loser_players.append(player_entry) + except (ValueError, TypeError) as e: + logging.warning(f"Skipping bad player UID {uid_str}: {e}") + continue + + # Transform chat entries to formatted strings for downstream regex parsing + def _fmt_time(ms): + """Format milliseconds as MM:SS.""" + total_s = ms // 1000 + return f"{total_s // 60:02d}:{total_s % 60:02d}" + + chat_log = [] + for chat_entry in replay.get("chat", []): + uid = str(chat_entry.get("uid", "")) + scope = chat_entry.get("type", "ALL") + message = chat_entry.get("message", "") + time_ms = chat_entry.get("time", 0) + + info = uid_lookup.get(uid, {"name": "Unknown", "tag_stripped": "???"}) + chat_log.append( + f"[{_fmt_time(time_ms)}] [{scope}] [{info['tag_stripped']}] `{info['name']}`: {message}" + ) + + # Decompress events if they arrive as a base85+zstd compressed string + raw_events = replay.get("events", {}) + if isinstance(raw_events, str): + try: + compressed = base64.b85decode(raw_events) + raw_events = json.loads(zstandard.decompress(compressed).decode("utf-8")) + except Exception as e: + logging.error(f"Failed to decompress events: {e}") + raw_events = {} + + # Merge damage and kills events, sort by time (ignore awards) + merged_raw = [] + + for kill in raw_events.get("kills", []): + merged_raw.append({ + "kind": "kill", + "time": kill.get("time", 0), + "offender_uid": str(kill["offender_uid"]) if kill.get("offender_uid") is not None else None, + "offender_unit": kill.get("offender_unit"), + "offended_uid": str(kill["offended_uid"]) if kill.get("offended_uid") is not None else None, + "offended_unit": kill.get("offended_unit"), + "crashed": kill.get("crashed", False), + "weapon": kill.get("weapon", ""), + "afire": False, + }) + + for dmg in raw_events.get("damage", []): + merged_raw.append({ + "kind": "damage", + "time": dmg.get("time", 0), + "offender_uid": str(dmg["offender_uid"]) if dmg.get("offender_uid") is not None else None, + "offender_unit": dmg.get("offender_unit"), + "offended_uid": str(dmg["offended_uid"]) if dmg.get("offended_uid") is not None else None, + "offended_unit": dmg.get("offended_unit"), + "crashed": False, + "weapon": "", + "afire": dmg.get("afire", False), + }) + + merged_raw.sort(key=lambda e: e.get("time", 0)) + + # Pre-format battle log lines (like chat_log) for direct display + try: + _translate = LangTableReader("English") + except Exception: + _translate = None + + def _resolve_vehicle(unit_cdk): + """Translate internal vehicle CDK to human-readable name.""" + if not unit_cdk: + return "Unknown" + if _translate: + translated = _translate.get_translate(unit_cdk) + if translated: + return apply_vehicle_name_filters(translated) + return unit_cdk + + def _resolve_player(uid): + """Return (name, clan_tag) for a UID from the uid_lookup dict.""" + if uid is None: + return "Unknown", "" + info = uid_lookup.get(str(uid)) + if info: + return info["name"], info["tag_stripped"] + return f"Player#{uid}", "" + + def _team_prefix(sq): + """Return '+' for winner, '-' for loser, or ' ' for other squadrons.""" + if sq == winner_squadron: + return "+" + elif sq == loser_squadron: + return "-" + return " " + + battle_log = [] + for ev in merged_raw: + time_str = _fmt_time(ev["time"]) + kind = ev["kind"] + + if kind == "kill": + attacker_uid = ev["offender_uid"] + victim_name, victim_sq = _resolve_player(ev["offended_uid"]) + victim_vehicle = _resolve_vehicle(ev["offended_unit"]) + + if attacker_uid is None or ev["crashed"]: + prefix = _team_prefix(victim_sq) + sq_tag = f"[{victim_sq}]" + battle_log.append( + f"{prefix}[{time_str}] {sq_tag:<7} {victim_name} ({victim_vehicle}) crashed" + ) + else: + name, sq = _resolve_player(attacker_uid) + vehicle = _resolve_vehicle(ev["offender_unit"]) + prefix = _team_prefix(sq) + sq_tag = f"[{sq}]" + battle_log.append( + f"{prefix}[{time_str}] {sq_tag:<7} {name} ({vehicle}) destroyed {victim_name} ({victim_vehicle})" + ) + + elif kind == "damage": + attacker_uid = ev["offender_uid"] + if attacker_uid is None: + continue + name, sq = _resolve_player(attacker_uid) + vehicle = _resolve_vehicle(ev["offender_unit"]) + victim_name, _ = _resolve_player(ev["offended_uid"]) + victim_vehicle = _resolve_vehicle(ev["offended_unit"]) + afire = "(FIRE) " if ev["afire"] else "" + prefix = _team_prefix(sq) + sq_tag = f"[{sq}]" + battle_log.append( + f"{prefix}[{time_str}] {sq_tag:<7} {name} ({vehicle}) damaged {afire}{victim_name} ({victim_vehicle})" + ) + + raw_id = replay.get("_id") + start_ts = replay.get("start_ts") or 0 + end_ts = replay.get("end_ts") or 0 + + session_id_dec = str(raw_id) if raw_id is not None else "" + try: + session_id_hex = hex(int(raw_id)).replace("0x", "") if raw_id is not None else "" + except (ValueError, TypeError): + session_id_hex = "" + + return { + "winning_team_squadron": winner_squadron, + "losing_team_squadron": loser_squadron, + "squadrons": [loser_squadron, winner_squadron], + "squadrons_tagged": [f"{loser_winged}", f"{winner_winged}"], + "session_id_dec": session_id_dec, + "session_id_hex": session_id_hex, + "timestamp": end_ts, + "map": replay.get("mission_name", ""), + "mode": replay.get("mission_mode", ""), + "duration": end_ts - start_ts, + "draw": is_draw, + "teams": [ + { + "team_index": 0, + "clan_id": "", + "squadron": winner_squadron, + "squadron_tagged": f"{winner_winged}", + "squadron_long": "", + "players": winner_players, + }, + { + "team_index": 1, + "clan_id": "", + "squadron": loser_squadron, + "squadron_tagged": f"{loser_winged}", + "squadron_long": "", + "players": loser_players, + }, + ], + "chat_log": chat_log, + "battle_log": battle_log, + "type": replay.get("type", ""), + } + + except Exception as e: + logging.error(f"Failed to transform data: {e}") + return None + + +# ============================================================================ +# (VEHICLE CACHE) +# ============================================================================ + +# Global caches for vehicle data (populated on demand) +game_data_cache: Optional[List] = None +game_data_cache_all: Optional[List] = None + +def _augmented_tags(unit_tags: UnitTags, cdk: str) -> dict: + """Return the cached tags dict for *cdk*, with a few derived tags folded in. + + ``unittags.blk`` stores some classification info on the entry's top-level + ``type`` field rather than inside ``tags`` (notably ``type=helicopter`` — + the per-helicopter-class tags like ``type_attack_helicopter`` are present, + but not the generic ``type_helicopter``). The bot's ``data_parser._get_tags`` + derives those at lookup time; we mirror that here so the on-disk cache + consumed by the website classifies vehicles the same way. + """ + entry = unit_tags.raw.get(cdk, {}) or {} + tags = dict(entry.get("tags") or {}) + type_field = entry.get("type") + if type_field: + tags.setdefault(type_field, True) + if type_field == "helicopter": + tags.setdefault("type_helicopter", True) + return tags + + +async def init_game_cache(): + """Initialize the vehicle data cache (only vehicles with icons).""" + global game_data_cache + + unit_tags = UnitTags.get() + all_names = unit_tags.all_names + logging.info(f"[GAMES] TOTAL VEHICLES: {len(all_names)}") + + icons_dir = _SHARED_DIR / "ICONS" / "VEHICLES" + # Case-insensitive lookup: unittags.blk uses CDKs like "ussr_su_122P" while the + # icon file on disk is "ussr_su_122p.png". On case-sensitive filesystems an exact + # match silently drops these vehicles from the cache. + icons_on_disk = {p.name.lower(): p.name for p in icons_dir.iterdir() if p.suffix == ".png"} + present = [(cdk, icons_on_disk[f"{cdk}.png".lower()]) for cdk in all_names if f"{cdk}.png".lower() in icons_on_disk] + logging.info(f"[GAMES] ICON PAIRS FOUND: {len(present)}") + + translate = LangTableReader("English") + + cache = [] + for cdk, icon in present: + raw = translate.get_translate(cdk) + human = normalize_name(raw) if raw else cdk + misc_params = _augmented_tags(unit_tags, cdk) + cache.append([cdk, human, icon, misc_params]) + + out_path = CACHE_DIR / "vehicle_data_cache.json" + CACHE_DIR.mkdir(parents=True, exist_ok=True) + with open(out_path, "w", encoding="utf-8") as f: + json.dump(cache, f, ensure_ascii=False, indent=2) + + game_data_cache = cache + return game_data_cache + + +async def init_game_cache_all(): + """Initialize the complete vehicle data cache (all vehicles).""" + global game_data_cache_all + + unit_tags = UnitTags.get() + all_names = unit_tags.all_names + logging.info(f"[GAMES] TOTAL VEHICLES (ALL): {len(all_names)}") + + icons_dir = _SHARED_DIR / "ICONS" / "VEHICLES" + icons_on_disk = {p.name.lower(): p.name for p in icons_dir.iterdir() if p.suffix == ".png"} if icons_dir.is_dir() else {} + + translate = LangTableReader("English") + + cache = [] + for cdk in all_names: + raw = translate.get_translate(cdk) + if raw is None: + raw = cdk + + human = normalize_name(raw) + icon = icons_on_disk.get(f"{cdk}.png".lower(), f"{cdk}.png") + misc_params = _augmented_tags(unit_tags, cdk) + cache.append([cdk, human, icon, misc_params]) + + out_path = CACHE_DIR / "vehicle_data_cache_all.json" + CACHE_DIR.mkdir(parents=True, exist_ok=True) + with open(out_path, "w", encoding="utf-8") as f: + json.dump(cache, f, ensure_ascii=False, indent=2) + + game_data_cache_all = cache + return game_data_cache_all + + +# Languages exposed by the website (matches web/locales/*.json). Maps the site +# locale code to the column name used inside lang/units.csv. The columns are +# stored with literal angle brackets (e.g. ````) — without them +# ``update_language()`` silently fails its ``in header_info`` check and every +# reader falls back to column 0 (which happens to be English, masking the bug). +_WEB_LANG_TO_LANG_COLUMN = { + "en": "", + "ru": "", + "fr": "", + "it": "", + "uk": "", + "de": "", + "es": "", + "pl": "", + "cs": "", + "zh-CN": "", +} + + +async def init_vehicle_translation_cache(): + """Generate ``vehicle_translations.json`` with localized names per vehicle. + + Output schema (served by the Node API at ``/api/i18n/vehicles``): + + { "ussr_t_34_85": { "en": "T-34-85", "ru": "Т-34-85", ... }, ... } + + Uses the same lang.vromfs.bin source the Discord bot reads when it + translates scoreboards on /language switch. + """ + unit_tags = UnitTags.get() + all_names = unit_tags.all_names + + available_columns = list(LangTableReader.header_info) + logging.info(f"[I18N] Vehicle translations: {len(all_names)} vehicles, lang columns available: {available_columns}") + + translators: dict[str, LangTableReader] = {} + for site_code, lang_column in _WEB_LANG_TO_LANG_COLUMN.items(): + reader = LangTableReader(lang_column) + if not reader.update_language(lang_column): + logging.warning( + f"[I18N] Vehicle translations: lang column '{lang_column}' not found for site lang '{site_code}', " + f"available columns: {available_columns}" + ) + continue + translators[site_code] = reader + + if not translators: + logging.error( + "[I18N] Vehicle translations: no usable language columns matched. " + f"Wanted {list(_WEB_LANG_TO_LANG_COLUMN.values())}, got {available_columns}. " + "Output will be an empty {} until the column-name map is corrected." + ) + + out: dict[str, dict[str, str]] = {} + for cdk in all_names: + names: dict[str, str] = {} + for site_code, reader in translators.items(): + raw = reader.get_translate(cdk) + if raw is None: + continue + # Keep visible decoration glyphs (▄ ◢ ◊ etc.) so country / event + # indicators survive to the website. Discord-side renderers still + # call apply_vehicle_name_filters() with default strip_decorations=True. + cleaned = apply_vehicle_name_filters(raw, strip_decorations=False) + if cleaned: + names[site_code] = cleaned + if names: + out[cdk] = names + + out_path = CACHE_DIR / "vehicle_translations.json" + CACHE_DIR.mkdir(parents=True, exist_ok=True) + with open(out_path, "w", encoding="utf-8") as f: + json.dump(out, f, ensure_ascii=False) + + logging.info(f"[I18N] Wrote vehicle translations for {len(out)} vehicles in {len(translators)} languages → {out_path}") + return out + + +# ============================================================================ +# COMMAND STATS +# ============================================================================ + +_CMD_STATS_INIT_DONE = False + + +async def init_command_stats_db() -> None: + """Create the command_usage table and indexes if they don't exist.""" + global _CMD_STATS_INIT_DONE + if _CMD_STATS_INIT_DONE: + return + + async with aiosqlite.connect(COMMAND_DATA_DB_PATH, timeout=10.0) as db: + await db.execute("PRAGMA journal_mode=WAL;") + await db.execute("PRAGMA synchronous=NORMAL;") + await db.execute("PRAGMA busy_timeout=5000;") + await db.execute(""" + CREATE TABLE IF NOT EXISTS command_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + command_name TEXT NOT NULL, + user_id TEXT NOT NULL, + guild_id TEXT, + channel_id TEXT, + timestamp INTEGER NOT NULL + ) + """) + await db.execute("CREATE INDEX IF NOT EXISTS idx_cmd_usage_command ON command_usage(command_name)") + await db.execute("CREATE INDEX IF NOT EXISTS idx_cmd_usage_timestamp ON command_usage(timestamp)") + await db.execute("CREATE INDEX IF NOT EXISTS idx_cmd_usage_guild ON command_usage(guild_id)") + await db.commit() + + _CMD_STATS_INIT_DONE = True + logging.info("Initialized COMMAND_DATA.db") + + +async def collect_command_stats(interaction) -> None: + """Record a single command invocation. Never raises.""" + try: + cmd_name = interaction.command.name if interaction.command else "unknown" + async with aiosqlite.connect(COMMAND_DATA_DB_PATH, timeout=5.0) as db: + await db.execute("PRAGMA busy_timeout=5000;") + await db.execute( + "INSERT INTO command_usage (command_name, user_id, guild_id, channel_id, timestamp) " + "VALUES (?, ?, ?, ?, ?)", + ( + cmd_name, + str(interaction.user.id), + str(interaction.guild_id) if interaction.guild_id else None, + str(interaction.channel_id) if interaction.channel_id else None, + int(time.time()), + ), + ) + await db.commit() + except Exception: + logging.debug("command_stats insert failed", exc_info=True) + + +# --------------------------------------------------------------------------- +# i18n — locale lookups backed by BOT/locales/*.json +# --------------------------------------------------------------------------- + +_LOCALES_DIR = Path(__file__).parent / "locales" + +_LANG_MAP = { + "": "en", + "": "fr", + "": "it", + "": "de", + "": "es", + "": "ru", + "": "pl", + "": "cs", + "": "zh-CN", + "": "pt", + "": "uk", +} + +_locales: Dict[str, dict] = {} + + +def _load_locales() -> None: + _locales.clear() + for f in _LOCALES_DIR.glob("*.json"): + with open(f, "r", encoding="utf-8") as fh: + _locales[f.stem] = json.load(fh) + + +def t(lang: str, key: str, **kwargs) -> str: + """Look up a translation by dotted key, with English fallback.""" + code = _LANG_MAP.get(lang, lang) if lang.startswith("<") else lang + if code not in _locales: + code = "en" + + val: Any = _locales.get(code) + for part in key.split("."): + if isinstance(val, dict): + val = val.get(part) + else: + val = None + break + + if val is None and code != "en": + val = _locales.get("en") + for part in key.split("."): + if isinstance(val, dict): + val = val.get(part) + else: + val = None + break + + if val is None: + return key + + if kwargs: + try: + return str(val).format(**kwargs) + except (KeyError, IndexError): + return str(val) + return str(val) + + +_DISCORD_LOCALE_TO_LANG = { + "cs": "cs", + "de": "de", + "es-ES": "es", + "fr": "fr", + "it": "it", + "pl": "pl", + "pt-BR": "pt", + "ru": "ru", + "uk": "uk", + "zh-CN": "zh-CN", +} + + +def command_locale(default: str, key: str) -> app_commands.locale_str: + """Mark command metadata for Discord app-command localization.""" + return app_commands.locale_str(default, key=key) + + +class LocaleJsonTranslator(app_commands.Translator): + """Discord app-command translator backed by BOT/locales/*.json.""" + + async def translate( + self, + string: app_commands.locale_str, + locale: discord.Locale, + context: app_commands.TranslationContext, + ) -> Optional[str]: + key = string.extras.get("key") + if not key: + return None + lang = _DISCORD_LOCALE_TO_LANG.get(str(locale)) + if not lang: + return None + translated = t(lang, key) + if translated == key or translated == string.message: + return None + return translated[:100] + + +def lang_from_features(features: dict) -> str: + """Extract locale code from a guild's features dict.""" + stored = features.get("Language", "") + return _LANG_MAP.get(stored, "en") + + +async def guild_lang(guild_id: int) -> str: + """Get the locale code for a guild by reading its features file.""" + features = await load_features(guild_id) + return lang_from_features(features) + + +# ============================================================================ +# SEASON SCHEDULE (shared with web/utils/seasons.js) +# ============================================================================ + +SEASONS_FILE = Path(__file__).resolve().parent.parent / "web" / "constants" / "seasons" + +_SEASON_HEADER_RE = re.compile(r"^\s*(\d{4})-(I{1,3}|IV|VI{0,3}|IX|X)\s*$") +_WEEK_LINE_RE = re.compile( + r"^week\s+\d+\s+\(\d{2}\.\d{2}\s+[—-]\s+\d{2}\.\d{2}\)\s+" +) +_EOS_LINE_RE = re.compile( + r"^until\s+eos\s+\(\d{2}\.\d{2}\s+[—-]\s+(\d{2})\.(\d{2})\)\s+" +) + +SEASON_NAME_RE = re.compile(r"^\d{4}-(I{1,3}|IV|VI{0,3}|IX|X)$") + + +class SeasonRange(TypedDict): + start: int + end: int + status: str # "in_progress" or "completed" + + +def _end_of_day_utc(year: int, day: int, month: int) -> int: + return int(datetime(year, month, day, 23, 59, 59, tzinfo=timezone.utc).timestamp()) + + +def _parse_seasons_file(content: str) -> Dict[str, Dict[str, int]]: + """Parse into {name: {start, end}} without computed status.""" + out: Dict[str, Dict[str, int]] = {} + current: Optional[str] = None + first_ts: Optional[int] = None + eos_day: Optional[int] = None + eos_month: Optional[int] = None + + def commit() -> None: + nonlocal current, first_ts, eos_day, eos_month + if current and first_ts is not None and eos_day is not None and eos_month is not None: + year = int(current.split("-")[0]) + out[current] = { + "start": first_ts, + "end": _end_of_day_utc(year, eos_day, eos_month), + } + current = None + first_ts = None + eos_day = None + eos_month = None + + for line in content.split("\n"): + h = _SEASON_HEADER_RE.match(line) + if h: + commit() + current = f"{h.group(1)}-{h.group(2)}" + continue + if not current: + continue + w = _WEEK_LINE_RE.match(line) + if w and first_ts is None: + first_ts = int(w.group(1)) + continue + e = _EOS_LINE_RE.match(line) + if e: + eos_day = int(e.group(1)) + eos_month = int(e.group(2)) + commit() + return out + + +_seasons_cached_content: Optional[str] = None +_seasons_cached_parsed: Optional[Dict[str, Dict[str, int]]] = None + + +def _load_seasons() -> tuple[str, Dict[str, Dict[str, int]]]: + global _seasons_cached_content, _seasons_cached_parsed + if _seasons_cached_content is None or _seasons_cached_parsed is None: + _seasons_cached_content = SEASONS_FILE.read_text(encoding="utf-8") + _seasons_cached_parsed = _parse_seasons_file(_seasons_cached_content) + return _seasons_cached_content, _seasons_cached_parsed + + +def get_seasons() -> Dict[str, SeasonRange]: + """Return all seasons with computed status based on current time.""" + _, parsed = _load_seasons() + now = int(time.time()) + out: Dict[str, SeasonRange] = {} + for name, rng in parsed.items(): + out[name] = { + "start": rng["start"], + "end": rng["end"], + "status": "completed" if rng["end"] < now else "in_progress", + } + return out + + +def get_season_range(name: str) -> Optional[SeasonRange]: + return get_seasons().get(name) + + +def get_week_boundaries(name: str) -> List[int]: + """All `week N (…) ` timestamps for the given season, in order.""" + content, _ = _load_seasons() + out: List[int] = [] + in_season = False + week_ts = re.compile(r"") + for line in content.split("\n"): + h = _SEASON_HEADER_RE.match(line) + if h: + in_season = f"{h.group(1)}-{h.group(2)}" == name + continue + if not in_season: + continue + m = week_ts.search(line) + if m: + out.append(int(m.group(1))) + return out + + +# ============================================================================ +# SEASON RECAP CARDS (shared cache with web/server.js) +# ============================================================================ + +_REPO_ROOT = Path(__file__).resolve().parent.parent +SQUADRON_RECAP_CACHE_DIR = STORAGE_DIR / "RECAPS" / "squadrons" +PLAYER_RECAP_CACHE_DIR = STORAGE_DIR / "RECAPS" / "players" + +_RECAP_PYTHON_BIN = _REPO_ROOT / ".venv" / "bin" / "python" +_RECAP_SCRIPT = _REPO_ROOT / "BOT" / "render_recap.py" + +RECAP_TTL_SECONDS = 24 * 60 * 60 # in-progress season TTL (matches web) +RECAP_RENDER_TIMEOUT_SECONDS = 30 + +RECAP_THEMES = {"light", "dark"} +RECAP_DEFAULT_THEME = "dark" +# Languages the recap renderer supports (must match web/server.js RECAP_LANGS). +RECAP_LANGS = {"cs", "de", "en", "es", "fr", "it", "pl", "ru", "uk", "zh-CN"} +RECAP_DEFAULT_LANG = "en" + + +class RecapError(Exception): + """Raised when a recap card cannot be produced.""" + + +def normalize_recap_theme(theme: str) -> str: + return theme if theme in RECAP_THEMES else RECAP_DEFAULT_THEME + + +def normalize_recap_lang(lang: str) -> str: + return lang if lang in RECAP_LANGS else RECAP_DEFAULT_LANG + + +def _recap_cleanup_tmp(out_path: Path) -> None: + try: + (out_path.with_suffix(out_path.suffix + ".tmp")).unlink(missing_ok=True) + except Exception: + pass + + +async def _spawn_recap_render( + mode: Literal["squadron", "player"], + *, + clan_id: Optional[int], + uid: Optional[int], + season: str, + season_start: int, + season_end: int, + week_boundaries: List[int], + out_path: Path, + theme: str, + lang: str, +) -> None: + args: List[str] = [ + str(_RECAP_SCRIPT), + "--mode", mode, + "--season", season, + "--season-start", str(season_start), + "--season-end", str(season_end), + "--week-boundaries", ",".join(str(ts) for ts in week_boundaries), + "--theme", theme, + "--lang", lang, + "--out", str(out_path), + ] + if mode == "squadron": + if clan_id is None: + raise RecapError("clan_id required for squadron recap") + args += ["--clan-id", str(clan_id)] + else: + if uid is None: + raise RecapError("uid required for player recap") + args += ["--uid", str(uid)] + + proc = await asyncio.create_subprocess_exec( + str(_RECAP_PYTHON_BIN), + *args, + cwd=str(_REPO_ROOT), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + _stdout, stderr = await asyncio.wait_for( + proc.communicate(), timeout=RECAP_RENDER_TIMEOUT_SECONDS + ) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + _recap_cleanup_tmp(out_path) + raise RecapError(f"recap render timed out after {RECAP_RENDER_TIMEOUT_SECONDS}s") + + if proc.returncode != 0: + err = (stderr or b"").decode("utf-8", errors="replace")[:2000] + _recap_cleanup_tmp(out_path) + logging.error( + "(RECAP) render failed mode=%s season=%s theme=%s lang=%s rc=%s stderr=%s", + mode, season, theme, lang, proc.returncode, err, + ) + raise RecapError("recap render failed") + + +async def _get_recap( + mode: Literal["squadron", "player"], + *, + clan_id: Optional[int], + uid: Optional[int], + season: str, + theme: str, + lang: str, +) -> Path: + theme = normalize_recap_theme(theme) + lang = normalize_recap_lang(lang) + + season_range = get_season_range(season) + if not season_range: + raise RecapError(f"unknown season: {season}") + + if mode == "squadron": + assert clan_id is not None + cache_dir = SQUADRON_RECAP_CACHE_DIR / season + cache_path = cache_dir / f"{clan_id}-{theme}-{lang}.png" + else: + assert uid is not None + cache_dir = PLAYER_RECAP_CACHE_DIR / season + cache_path = cache_dir / f"{uid}-{theme}-{lang}.png" + + serve_from_cache = False + try: + stat = cache_path.stat() + if season_range["status"] == "completed": + serve_from_cache = True + elif (time.time() - stat.st_mtime) < RECAP_TTL_SECONDS: + serve_from_cache = True + except FileNotFoundError: + pass + + if not serve_from_cache: + cache_dir.mkdir(parents=True, exist_ok=True) + await _spawn_recap_render( + mode, + clan_id=clan_id, + uid=uid, + season=season, + season_start=season_range["start"], + season_end=season_range["end"], + week_boundaries=get_week_boundaries(season), + out_path=cache_path, + theme=theme, + lang=lang, + ) + + if not cache_path.exists(): + raise RecapError("recap file missing after render") + + return cache_path + + +async def get_squadron_recap( + clan_id: int, season: str, theme: str, lang: str +) -> Path: + return await _get_recap( + "squadron", clan_id=clan_id, uid=None, + season=season, theme=theme, lang=lang, + ) + + +async def get_player_recap( + uid: int, season: str, theme: str, lang: str +) -> Path: + return await _get_recap( + "player", clan_id=None, uid=uid, + season=season, theme=theme, lang=lang, + ) + + +_load_locales() diff --git a/BOT/weekly_br_elo.py b/BOT/weekly_br_elo.py new file mode 100644 index 0000000..3b965bc --- /dev/null +++ b/BOT/weekly_br_elo.py @@ -0,0 +1,450 @@ +""" +weekly_br_elo.py + +Window-scoped player & squadron ELO scoring. Python port of the +computePerformanceScore + ratingCtes pipeline from server.js, used by the +Weekly BR Report executor. + +The percentile distribution (benchmark) is built from the same in-window +dataset, so scores represent ranking against peers who also played that week. +""" + +# Standard Library Imports +import logging +import math +from typing import Any, Dict, List, Optional, Tuple + +# Third-Party Library Imports +import aiosqlite + +# Local Module Imports +from .utils import SQ_BATTLES_DB_PATH + + +# Score weights (mirror server.js:631 computePerformanceScore) +_W_KDR = 0.32 +_W_HEAVY_RATE = 0.23 +_W_KILLS_PER_GAME = 0.14 +_W_GAMES_LOG = 0.10 +_W_WIN_RATE = 0.06 +_W_ASSISTS_PER_GAME = 0.06 +_W_CAPTURES_PER_GAME = 0.04 +_W_DEATHS_PER_GAME = 0.05 # lower-is-better + +_METRIC_KEYS: Tuple[str, ...] = ( + "kdr", + "kills_per_game", + "heavy_rate", + "win_rate", + "assists_per_game", + "captures_per_game", + "deaths_per_game", + "games_log", +) + + +def _rating_ctes(entity_expr: str) -> str: + """Return the WITH-clause CTEs that produce per-session heavy scores. + + Direct port of server.js:647 ratingCtes(). The window predicate uses + `endtime_unix BETWEEN ? AND ?` -- params order: (start_ts, end_ts). + """ + return f""" + WITH player_sessions AS ( + SELECT + {entity_expr} AS entity_key, + p.UID, + p.session_id, + SUM(p.ground_kills + p.air_kills) AS kills, + SUM(p.assists) AS assists, + SUM(p.captures) AS captures, + SUM(p.deaths) AS deaths, + MAX(CASE WHEN UPPER(p.victor_bool) = 'WIN' THEN 1 ELSE 0 END) AS win + FROM player_games_hist p + WHERE p.UID IS NOT NULL + AND p.nick NOT LIKE 'coop/%' + AND p.endtime_unix >= ? + AND p.endtime_unix <= ? + GROUP BY entity_key, p.UID, p.session_id + ), + leader_stats AS ( + SELECT ps.session_id, MAX(ps.kills) AS max_kills + FROM player_sessions ps + GROUP BY ps.session_id + ), + leader_counts AS ( + SELECT ps.session_id, COUNT(*) AS top_count + FROM player_sessions ps + JOIN leader_stats ls ON ls.session_id = ps.session_id + AND ls.max_kills = ps.kills + GROUP BY ps.session_id + ), + second_stats AS ( + SELECT ps.session_id, MAX(ps.kills) AS second_kills + FROM player_sessions ps + JOIN leader_stats ls ON ls.session_id = ps.session_id + WHERE ps.kills < ls.max_kills + GROUP BY ps.session_id + ), + scored_sessions AS ( + SELECT + ps.*, + CASE + WHEN ps.kills >= 3 + AND ps.kills = ls.max_kills + AND lc.top_count = 1 + AND (ps.kills - COALESCE(ss.second_kills, 0)) >= 2 + THEN MIN(1.0, (ps.kills - 2) / 2.0) + WHEN ps.kills >= 3 + AND ps.kills = ls.max_kills + AND lc.top_count = 1 + THEN 0.6 * MIN(1.0, (ps.kills - 2) / 2.0) + ELSE 0 + END AS heavy_score + FROM player_sessions ps + JOIN leader_stats ls ON ls.session_id = ps.session_id + JOIN leader_counts lc ON lc.session_id = ps.session_id + LEFT JOIN second_stats ss ON ss.session_id = ps.session_id + ) + """ + + +async def _fetch_rows(sql: str, params: Tuple[Any, ...]) -> List[Dict[str, Any]]: + """Run a read-only query against sq_battles.db and return list-of-dicts.""" + 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;") + async with db.execute(sql, params) as cursor: + cols = [c[0] for c in (cursor.description or [])] + rows = await cursor.fetchall() + return [dict(zip(cols, r)) for r in rows] + + +async def _query_player_aggregates( + start_ts: int, end_ts: int +) -> List[Dict[str, Any]]: + sql = _rating_ctes("p.UID") + """ + SELECT + entity_key AS uid, + COUNT(*) AS games, + SUM(kills) AS total_kills, + SUM(assists) AS total_assists, + SUM(captures) AS total_captures, + SUM(deaths) AS total_deaths, + SUM(win) AS wins, + SUM(heavy_score) AS heavy_score + FROM scored_sessions + GROUP BY entity_key + """ + return await _fetch_rows(sql, (start_ts, end_ts)) + + +async def _query_squadron_aggregates( + start_ts: int, end_ts: int +) -> List[Dict[str, Any]]: + sql = _rating_ctes("p.squadron_name") + """, + squadron_sessions AS ( + SELECT + entity_key, + session_id, + SUM(kills) AS kills, + SUM(assists) AS assists, + SUM(captures) AS captures, + SUM(deaths) AS deaths, + MAX(win) AS win, + SUM(heavy_score) AS heavy_score + FROM scored_sessions + WHERE entity_key IS NOT NULL + AND entity_key != 'UNKNOWN' + AND entity_key != '' + GROUP BY entity_key, session_id + ) + SELECT + entity_key AS squadron_name, + COUNT(*) AS games, + SUM(kills) AS total_kills, + SUM(assists) AS total_assists, + SUM(captures) AS total_captures, + SUM(deaths) AS total_deaths, + SUM(win) AS wins, + SUM(heavy_score) AS heavy_score + FROM squadron_sessions + GROUP BY entity_key + """ + return await _fetch_rows(sql, (start_ts, end_ts)) + + +async def _query_uid_to_meta( + start_ts: int, end_ts: int +) -> Dict[str, Tuple[str, str]]: + """Map UID -> (most-recent nick, most-recent squadron_name) within window.""" + sql = """ + SELECT + p.UID AS uid, + p.nick AS nick, + p.squadron_name AS squadron_name, + MAX(p.endtime_unix) AS last_seen + FROM player_games_hist p + WHERE p.UID IS NOT NULL + AND p.nick NOT LIKE 'coop/%' + AND p.endtime_unix >= ? + AND p.endtime_unix <= ? + GROUP BY p.UID + """ + rows = await _fetch_rows(sql, (start_ts, end_ts)) + out: Dict[str, Tuple[str, str]] = {} + for r in rows: + uid = str(r.get("uid") or "") + if not uid: + continue + out[uid] = (str(r.get("nick") or ""), str(r.get("squadron_name") or "")) + return out + + +def _normalize_rating_stats(row: Dict[str, Any]) -> Dict[str, float]: + games = max(0, int(row.get("games") or 0)) + kills = max(0, int(row.get("total_kills") or 0)) + deaths = max(0, int(row.get("total_deaths") or 0)) + assists = max(0, int(row.get("total_assists") or 0)) + captures = max(0, int(row.get("total_captures") or 0)) + wins = max(0, int(row.get("wins") or 0)) + heavy = max(0.0, float(row.get("heavy_score") or 0)) + return { + "games": float(games), + "kdr": (kills / deaths) if deaths > 0 else float(kills), + "kills_per_game": (kills / games) if games > 0 else 0.0, + "heavy_rate": (heavy / games) if games > 0 else 0.0, + "win_rate": (wins / games * 100.0) if games > 0 else 0.0, + "assists_per_game": (assists / games) if games > 0 else 0.0, + "captures_per_game": (captures / games) if games > 0 else 0.0, + "deaths_per_game": (deaths / games) if games > 0 else 0.0, + "games_log": math.log1p(games), + } + + +def _build_benchmark(rows: List[Dict[str, Any]]) -> Dict[str, List[float]]: + """Build sorted percentile distributions for each scoring metric.""" + normalized = [_normalize_rating_stats(r) for r in rows] + return {m: sorted(float(n[m]) for n in normalized) for m in _METRIC_KEYS} + + +def _upper_bound(values: List[float], v: float) -> int: + lo, hi = 0, len(values) + while lo < hi: + mid = (lo + hi) // 2 + if values[mid] <= v: + lo = mid + 1 + else: + hi = mid + return lo + + +def _lower_bound(values: List[float], v: float) -> int: + lo, hi = 0, len(values) + while lo < hi: + mid = (lo + hi) // 2 + if values[mid] < v: + lo = mid + 1 + else: + hi = mid + return lo + + +def _metric_percentile( + value: float, values: List[float], lower_is_better: bool = False +) -> float: + if not values: + return 0.0 + if lower_is_better: + return (len(values) - _lower_bound(values, value)) / len(values) + return _upper_bound(values, value) / len(values) + + +def _compute_performance_score( + row: Dict[str, Any], benchmark: Dict[str, List[float]] +) -> float: + s = _normalize_rating_stats(row) + weighted = ( + _W_KDR * _metric_percentile(s["kdr"], benchmark["kdr"]) + + _W_HEAVY_RATE * _metric_percentile(s["heavy_rate"], benchmark["heavy_rate"]) + + _W_KILLS_PER_GAME + * _metric_percentile(s["kills_per_game"], benchmark["kills_per_game"]) + + _W_GAMES_LOG * _metric_percentile(s["games_log"], benchmark["games_log"]) + + _W_WIN_RATE * _metric_percentile(s["win_rate"], benchmark["win_rate"]) + + _W_ASSISTS_PER_GAME + * _metric_percentile(s["assists_per_game"], benchmark["assists_per_game"]) + + _W_CAPTURES_PER_GAME + * _metric_percentile(s["captures_per_game"], benchmark["captures_per_game"]) + + _W_DEATHS_PER_GAME + * _metric_percentile( + s["deaths_per_game"], benchmark["deaths_per_game"], lower_is_better=True + ) + ) + sample = max(0.0, min(1.0, math.sqrt(s["games"] / 10.0))) + return round(max(0.0, min(5.0, 5.0 * weighted * sample)), 2) + + +async def player_scores(start_ts: int, end_ts: int) -> Dict[str, Dict[str, Any]]: + """Return {uid: {nick, squadron_name, score, games, kdr, win_rate, kills_per_game}}.""" + rows = await _query_player_aggregates(start_ts, end_ts) + if not rows: + return {} + benchmark = _build_benchmark(rows) + meta = await _query_uid_to_meta(start_ts, end_ts) + out: Dict[str, Dict[str, Any]] = {} + for r in rows: + uid = str(r.get("uid") or "") + if not uid: + continue + norm = _normalize_rating_stats(r) + score = _compute_performance_score(r, benchmark) + nick, squadron = meta.get(uid, ("", "")) + out[uid] = { + "uid": uid, + "nick": nick, + "squadron_name": squadron, + "score": score, + "games": int(norm["games"]), + "kdr": norm["kdr"], + "win_rate": norm["win_rate"], + "kills_per_game": norm["kills_per_game"], + } + return out + + +async def squadron_scores(start_ts: int, end_ts: int) -> Dict[str, Dict[str, Any]]: + """Return {squadron_name: {score, games, kdr, win_rate, kills_per_game}}.""" + rows = await _query_squadron_aggregates(start_ts, end_ts) + if not rows: + return {} + benchmark = _build_benchmark(rows) + out: Dict[str, Dict[str, Any]] = {} + for r in rows: + sq = str(r.get("squadron_name") or "") + if not sq: + continue + norm = _normalize_rating_stats(r) + score = _compute_performance_score(r, benchmark) + out[sq] = { + "squadron_name": sq, + "score": score, + "games": int(norm["games"]), + "kdr": norm["kdr"], + "win_rate": norm["win_rate"], + "kills_per_game": norm["kills_per_game"], + } + return out + + +def top_n_squadrons_with_top_k_players_from_maps( + sq_map: Dict[str, Dict[str, Any]], + pl_map: Dict[str, Dict[str, Any]], + n: int = 20, + k: int = 5, +) -> List[Dict[str, Any]]: + """Sync helper: top N squadrons + top K players from already-computed maps. + + Lets a caller pay the cost of `squadron_scores`/`player_scores` once and + then synthesize multiple report payloads (wildcard + per-squadron) from + the same data without re-hitting the DB. + """ + ranked = sorted( + sq_map.values(), + key=lambda r: (-float(r["score"]), -int(r["games"]), str(r["squadron_name"])), + )[:n] + + by_squadron: Dict[str, List[Dict[str, Any]]] = {} + for p in pl_map.values(): + s = str(p.get("squadron_name") or "") + if not s: + continue + by_squadron.setdefault(s, []).append(p) + + payload: List[Dict[str, Any]] = [] + for r in ranked: + s = str(r["squadron_name"]) + roster = sorted( + by_squadron.get(s, []), + key=lambda p: (-float(p["score"]), -int(p["games"]), str(p.get("nick") or "")), + )[:k] + payload.append({**r, "top_players": roster}) + return payload + + +def squadron_report_for_variants_from_maps( + sq_map: Dict[str, Dict[str, Any]], + pl_map: Dict[str, Dict[str, Any]], + name_variants: List[str], + k: int = 15, +) -> Tuple[Optional[Dict[str, Any]], List[Dict[str, Any]]]: + """Sync helper: per-squadron payload from already-computed maps.""" + sq_row: Optional[Dict[str, Any]] = None + for v in name_variants: + if not v: + continue + if v in sq_map: + sq_row = sq_map[v] + break + + variant_set = {v for v in name_variants if v} + roster = [p for p in pl_map.values() if str(p.get("squadron_name") or "") in variant_set] + roster.sort( + key=lambda p: (-float(p["score"]), -int(p["games"]), str(p.get("nick") or "")) + ) + return sq_row, roster[:k] + + +async def top_n_squadrons_with_top_k_players( + start_ts: int, end_ts: int, n: int = 20, k: int = 5 +) -> List[Dict[str, Any]]: + """Wildcard-mode payload: top N squadrons by score, each with their top K players. + + Convenience wrapper that fetches the maps then calls the `_from_maps` helper. + Prefer the explicit two-step (compute maps once, call from-maps multiple + times) when building reports for many subscribers. + """ + sq_map = await squadron_scores(start_ts, end_ts) + pl_map = await player_scores(start_ts, end_ts) + return top_n_squadrons_with_top_k_players_from_maps(sq_map, pl_map, n=n, k=k) + + +async def squadron_report_for_variants( + start_ts: int, + end_ts: int, + name_variants: List[str], + k: int = 15, +) -> Tuple[Optional[Dict[str, Any]], List[Dict[str, Any]]]: + """Per-squadron mode payload: best-matching squadron row + its top K players. + + Tries name_variants in order against the squadron name appearing in + player_games_hist (long_name, tag_name, short_name). Returns (None, []) + if no variant matched and no players in window carry any of the variants. + """ + sq_map = await squadron_scores(start_ts, end_ts) + pl_map = await player_scores(start_ts, end_ts) + return squadron_report_for_variants_from_maps(sq_map, pl_map, name_variants, k=k) + + +def br_color_int(max_br: float) -> int: + """Map a BR value to an embed sidebar color (matches /schedule color bands).""" + if max_br >= 13.0: + return 0xE74C3C + if max_br >= 10.0: + return 0xE67E22 + if max_br >= 7.0: + return 0xF1C40F + if max_br >= 4.0: + return 0x2ECC71 + return 0x3498DB + + +__all__ = [ + "player_scores", + "squadron_scores", + "top_n_squadrons_with_top_k_players", + "top_n_squadrons_with_top_k_players_from_maps", + "squadron_report_for_variants", + "squadron_report_for_variants_from_maps", + "br_color_int", +] diff --git a/BOT/wl.py b/BOT/wl.py new file mode 100644 index 0000000..dfc3203 --- /dev/null +++ b/BOT/wl.py @@ -0,0 +1,490 @@ +""" +wl.py + +Win/Loss tracking database. Manages the wl.db SQLite database which stores +per-squadron win/loss/draw records and match history. + +Post clan_id migration: wl_standings is keyed by clan_id (stable) with the +squadron short tag denormalized for display and joins. Callers still pass +short squadron tags from the replay JSON; this module resolves them to +clan_id via squadrons.db internally and returns dicts keyed by the input +squadron text so the renderer doesn't need to change. +""" + +# Standard Library Imports +import asyncio +import hashlib +import json +import logging +import os +import sqlite3 +import time +from pathlib import Path +from typing import Dict, List, Optional, Tuple +from .utils import STORAGE_DIR +WL_DB = STORAGE_DIR / "wl.db" +SQUADRONS_DB = STORAGE_DIR / "squadrons.db" + +_DB_PATH: str = str(WL_DB) +_INITIALIZED = False + +# Bounded cache for squadron-name → clan_id lookups. Squadrons rarely change +# clan_id, so a 5-minute TTL is plenty and keeps the hot path off disk. +_CLAN_ID_CACHE: dict[str, Optional[int]] = {} +_CLAN_ID_CACHE_AT: float = 0.0 +_CLAN_ID_CACHE_TTL = 300.0 + + +# ============================================================================ +# DATABASE INITIALIZATION +# ============================================================================ + +def init(db_path: Optional[str] = None) -> None: + """Initialize the W/L database.""" + global _DB_PATH, _INITIALIZED + _DB_PATH = db_path or str(WL_DB) + _ensure_db() + _INITIALIZED = True + + +def wl_bootstrap(): + """Bootstrap the W/L database (alias for init).""" + init(str(WL_DB)) + + +def _conn() -> sqlite3.Connection: + """Create a database connection.""" + con = sqlite3.connect(_DB_PATH, timeout=10, isolation_level=None) + con.execute("PRAGMA journal_mode=WAL;") + con.execute("PRAGMA foreign_keys=ON;") + return con + + +def _ensure_db() -> None: + """Ensure database tables exist (clan_id-keyed schema).""" + with _conn() as con: + con.executescript(""" + CREATE TABLE IF NOT EXISTS wl_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + external_id TEXT UNIQUE, + session_id TEXT, + winner TEXT NOT NULL, + winner_clan_id INTEGER, + squads_json TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS wl_standings ( + clan_id INTEGER PRIMARY KEY, + squadron TEXT NOT NULL, + wins INTEGER NOT NULL DEFAULT 0, + losses INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_wl_standings_squadron + ON wl_standings(squadron); + """) + # Best-effort ALTER for installs that predate the winner_clan_id column. + cols = {r[1] for r in con.execute("PRAGMA table_info(wl_events)").fetchall()} + if "winner_clan_id" not in cols: + try: + con.execute("ALTER TABLE wl_events ADD COLUMN winner_clan_id INTEGER") + except sqlite3.OperationalError: + pass + + +# ============================================================================ +# INTERNAL HELPERS +# ============================================================================ + +def _normalize(s: Optional[str]) -> Optional[str]: + """Normalize squadron name for consistency.""" + if not s: + return None + s = s.strip() + return s if s and s.upper() != "UNKNOWN" else None + + +def _resolve_clan_id(name: str) -> Optional[int]: + """Resolve a squadron short_name / long_name / tag_name → clan_id. + + Returns None if the squadron can't be found in squadrons_data (orphan). + Cached with a TTL since squadrons_data is the source of truth. + """ + global _CLAN_ID_CACHE, _CLAN_ID_CACHE_AT + now = time.time() + if now - _CLAN_ID_CACHE_AT > _CLAN_ID_CACHE_TTL: + _CLAN_ID_CACHE = {} + _CLAN_ID_CACHE_AT = now + + key = (name or "").strip().lower() + if not key or key == "unknown": + return None + if key in _CLAN_ID_CACHE: + return _CLAN_ID_CACHE[key] + + cid: Optional[int] = None + try: + with sqlite3.connect(str(SQUADRONS_DB), timeout=5) as con: + row = con.execute( + "SELECT clan_id FROM squadrons_data " + "WHERE LOWER(short_name) = ? OR LOWER(long_name) = ? OR LOWER(tag_name) = ? " + "LIMIT 1", + (key, key, key), + ).fetchone() + if row and row[0] is not None: + cid = int(row[0]) + except Exception: + cid = None + + _CLAN_ID_CACHE[key] = cid + return cid + + +def _idempotency_key(session_id: Optional[str], winner: str, squads: List[str]) -> str: + """Generate a unique key to prevent duplicate entries.""" + base = f"{session_id or ''}|{winner}|{','.join(sorted(squads))}" + return hashlib.sha1(base.encode("utf-8")).hexdigest()[:20] + + +def _upsert_standing( + con: sqlite3.Connection, *, + clan_id: int, + squadron: str, + wins_delta: int, + losses_delta: int, +) -> None: + """Upsert a wl_standings row keyed by clan_id, refreshing the squadron text.""" + con.execute( + """ + INSERT INTO wl_standings (clan_id, squadron, wins, losses) + VALUES (?, ?, ?, ?) + ON CONFLICT(clan_id) DO UPDATE SET + squadron = excluded.squadron, + wins = wl_standings.wins + excluded.wins, + losses = wl_standings.losses + excluded.losses + """, + (int(clan_id), squadron, int(wins_delta), int(losses_delta)), + ) + + +def _tx_upsert_event_and_apply( + con: sqlite3.Connection, *, + external_id: str, + session_id: Optional[str], + winner: str, + squads: List[str], +) -> None: + """Insert event and update standings within a transaction. + + Squadrons that can't be resolved to a clan_id (orphan / not in + squadrons_data) are skipped for the standings upsert but still recorded + on the event so a later rebuild can replay them. + """ + winner_cid = _resolve_clan_id(winner) + con.execute( + "INSERT OR IGNORE INTO wl_events " + "(external_id, session_id, winner, winner_clan_id, squads_json) " + "VALUES (?, ?, ?, ?, ?)", + ( + external_id, + session_id, + winner, + winner_cid, + json.dumps(squads, ensure_ascii=False), + ), + ) + if con.execute("SELECT changes()").fetchone()[0] == 0: + return # duplicate, already applied + + if winner_cid is not None: + _upsert_standing(con, clan_id=winner_cid, squadron=winner, wins_delta=1, losses_delta=0) + else: + logging.warning("[W/L] winner '%s' has no clan_id; skipping standings update", winner) + + for los in (s for s in squads if s != winner): + los_cid = _resolve_clan_id(los) + if los_cid is None: + logging.warning("[W/L] loser '%s' has no clan_id; skipping standings update", los) + continue + _upsert_standing(con, clan_id=los_cid, squadron=los, wins_delta=0, losses_delta=1) + + +# ============================================================================ +# PUBLIC API - ASYNC +# ============================================================================ + +async def record_result( + winner: str, + squadrons: List[str], + session_id: Optional[str] = None +) -> Dict[str, Dict[str, int]]: + """ + Record a match result (async). + Returns updated standings for all squadrons involved (keyed by squadron text). + """ + if not _INITIALIZED: + _ensure_db() + + w = _normalize(winner) + sqs = [s for s in (_normalize(x) for x in squadrons) if s] + if w is None or len(sqs) < 2 or w not in sqs: + return {} + external_id = _idempotency_key(session_id, w, sqs) + + def _work() -> Dict[str, Dict[str, int]]: + with _conn() as con: + con.execute("BEGIN IMMEDIATE") + try: + _tx_upsert_event_and_apply( + con, external_id=external_id, + session_id=session_id, winner=w, squads=sqs + ) + con.execute("COMMIT") + except Exception: + con.execute("ROLLBACK") + raise + return _read_standings_for(con, sqs) + + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, _work) + + +async def record_draw( + squadrons: List[str], + session_id: Optional[str] = None +) -> Dict[str, Dict[str, int]]: + """ + Record a draw (async) — counts as a loss for all involved squadrons. + Returns updated standings (keyed by squadron text). + """ + if not _INITIALIZED: + _ensure_db() + + sqs = [s for s in (_normalize(x) for x in squadrons) if s] + if len(sqs) < 2: + return {} + external_id = _idempotency_key(session_id, "DRAW", sqs) + + def _work() -> Dict[str, Dict[str, int]]: + with _conn() as con: + con.execute("BEGIN IMMEDIATE") + try: + con.execute( + "INSERT OR IGNORE INTO wl_events " + "(external_id, session_id, winner, winner_clan_id, squads_json) " + "VALUES (?, ?, ?, ?, ?)", + (external_id, session_id, "DRAW", None, + json.dumps(sqs, ensure_ascii=False)), + ) + if con.execute("SELECT changes()").fetchone()[0] > 0: + for sq in sqs: + cid = _resolve_clan_id(sq) + if cid is None: + logging.warning( + "[W/L] draw participant '%s' has no clan_id; " + "skipping standings update", sq + ) + continue + _upsert_standing( + con, clan_id=cid, squadron=sq, + wins_delta=0, losses_delta=1, + ) + con.execute("COMMIT") + except Exception: + con.execute("ROLLBACK") + raise + return _read_standings_for(con, sqs) + + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, _work) + + +async def schedule_wl_update( + winner: str, + squadrons: List[str], + session_id: Optional[str] = None +) -> Dict[str, Dict[str, int]]: + """Alias for record_result (for backwards compatibility).""" + return await record_result(winner, list(squadrons), session_id) + + +# ============================================================================ +# PUBLIC API - SYNC +# ============================================================================ + +def record_result_sync( + winner: str, + squadrons: List[str], + session_id: Optional[str] = None +) -> Dict[str, Dict[str, int]]: + """ + Record a match result (sync). + Returns updated standings for all squadrons involved (keyed by squadron text). + """ + if not _INITIALIZED: + _ensure_db() + + w = _normalize(winner) + sqs = [s for s in (_normalize(x) for x in squadrons) if s] + if w is None or len(sqs) < 2 or w not in sqs: + return {} + external_id = _idempotency_key(session_id, w, sqs) + + with _conn() as con: + con.execute("BEGIN IMMEDIATE") + try: + _tx_upsert_event_and_apply( + con, external_id=external_id, + session_id=session_id, winner=w, squads=sqs + ) + con.execute("COMMIT") + except Exception: + con.execute("ROLLBACK") + raise + return _read_standings_for(con, sqs) + + +def _wl_update_sync( + winner: str, + squadrons: List[str], + session_id: Optional[str] = None +) -> Dict[str, Dict[str, int]]: + """Alias for record_result_sync (for backwards compatibility).""" + return record_result_sync(winner, list(squadrons), session_id) + + +# ============================================================================ +# PUBLIC API - QUERIES +# ============================================================================ + +def _read_standings_for( + con: sqlite3.Connection, squadrons: List[str] +) -> Dict[str, Dict[str, int]]: + """Return {squadron_text: {wins, losses}} for the given input squadrons. + + Looks up each squadron's clan_id then reads by clan_id so a squadron that + just renamed still finds its row. Falls back to a squadron-text match for + orphans. + """ + out: Dict[str, Dict[str, int]] = {sq: {"wins": 0, "losses": 0} for sq in squadrons} + if not squadrons: + return out + + cid_by_input: Dict[str, Optional[int]] = {sq: _resolve_clan_id(sq) for sq in squadrons} + cids = [c for c in cid_by_input.values() if c is not None] + + if cids: + rows = con.execute( + f"SELECT clan_id, wins, losses FROM wl_standings " + f"WHERE clan_id IN ({','.join(['?']*len(cids))})", + cids, + ).fetchall() + by_cid = {int(r[0]): (int(r[1]), int(r[2])) for r in rows} + for sq, cid in cid_by_input.items(): + if cid is not None and cid in by_cid: + w, l = by_cid[cid] + out[sq] = {"wins": w, "losses": l} + + # Fallback by squadron text for orphans (no clan_id resolved). + orphans = [sq for sq, cid in cid_by_input.items() if cid is None] + if orphans: + rows = con.execute( + f"SELECT squadron, wins, losses FROM wl_standings " + f"WHERE squadron IN ({','.join(['?']*len(orphans))})", + orphans, + ).fetchall() + for s, w, l in rows: + out[s] = {"wins": int(w), "losses": int(l)} + + return out + + +def get_standings(squadrons: List[str]) -> Dict[str, Dict[str, int]]: + """Get W/L standings for a list of squadrons (keyed by squadron text).""" + if not _INITIALIZED: + _ensure_db() + if not squadrons: + return {} + + with _conn() as con: + return _read_standings_for(con, list(squadrons)) + + +def get_wl_counts(squadrons: List[str]) -> Dict[str, Dict[str, int]]: + """Alias for get_standings (for backwards compatibility).""" + return get_standings(list(squadrons)) + + +def get_leaderboard(limit: int = 20) -> List[Tuple[str, int, int]]: + """Get the W/L leaderboard sorted by win difference.""" + if not _INITIALIZED: + _ensure_db() + + with _conn() as con: + return [ + (str(s), int(w), int(l)) + for s, w, l in con.execute(""" + SELECT squadron, wins, losses + FROM wl_standings + ORDER BY (wins - losses) DESC, wins DESC, squadron ASC + LIMIT ? + """, (limit,)).fetchall() + ] + + +def build_wl_leaderboard(limit: int = 20) -> List[Tuple[str, int, int]]: + """Alias for get_leaderboard (for backwards compatibility).""" + return get_leaderboard(int(limit)) + + +# ============================================================================ +# MAINTENANCE +# ============================================================================ + +def rebuild_from_events() -> None: + """Rebuild standings from events (for data recovery).""" + if not _INITIALIZED: + _ensure_db() + + with _conn() as con: + con.execute("BEGIN IMMEDIATE") + try: + con.execute("DELETE FROM wl_standings") + for ext_id, session_id, winner, squads_json in con.execute( + "SELECT external_id, session_id, winner, squads_json " + "FROM wl_events ORDER BY id ASC" + ): + squads = json.loads(squads_json) + if winner == "DRAW": + for sq in squads: + cid = _resolve_clan_id(sq) + if cid is None: + continue + _upsert_standing( + con, clan_id=cid, squadron=sq, + wins_delta=0, losses_delta=1, + ) + else: + _tx_upsert_event_and_apply( + con, external_id=ext_id, + session_id=session_id, winner=winner, + squads=squads, + ) + con.execute("COMMIT") + except Exception: + con.execute("ROLLBACK") + raise + + +def clean_WL() -> None: + """Clear all W/L data (events and standings).""" + con = sqlite3.connect(str(_DB_PATH), timeout=10, isolation_level=None) + try: + con.execute("BEGIN IMMEDIATE") + con.execute("DELETE FROM wl_events") + con.execute("DELETE FROM wl_standings") + con.execute("COMMIT") + except Exception: + con.execute("ROLLBACK") + raise + finally: + con.close() diff --git a/README.md b/README.md new file mode 100644 index 0000000..15cce84 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ + +### Installation + +1. **Clone the repository** + ```bash + git clone https://github.com/Sop-rs/SREBOT_MEOW.git + cd SREBOT_MEOW + ``` + +2. **Set up Python virtual environment** + ```bash + python3 -m venv .venv + source venv/bin/activate # On Windows: venv\Scripts\activate + ``` + +3. **Install dependencies** + ```bash + pip install -r requirements.txt + ``` + +4. **Configure environment variables** + ```bash + nano .env + ``` + + Edit the existing `.env` in the repo root and keep the storage path there: + ```env + SREBOT_DEPLOY_PATH=/absolute/path/to/SREBOT_MEOW + DISCORD_KEY=your_discord_bot_token_here + DEEPL_KEY=your_deepl_api_key_here # Optional + GITHUB_WEBHOOK_SECRET=your_webhook_secret # For auto-deployment + SREBOT_STORAGE_VOL_PATH=/absolute/path/to/storage + SREBOT_API_BEARER_TOKEN=your_internal_api_token # Optional, protects /api/* + SREBOT_EXTERNAL_PORT=18081 # External bridge port + SREBOT_EXTERNAL_BEARER_TOKEN=your_external_bridge_token # Optional, protects the bridge API and websocket + SREBOT_EXTERNAL_UPSTREAM_URL=http://127.0.0.1:6000 # Internal SREBOT API to proxy + ``` + +5. **Run the bot** + ```bash + python BotScript.py + ``` + +### AXBot bridge process + +`ecosystem.config.js` now includes a dedicated PM2 app named `srebot-axbot`. +It proxies read-only SREBOT queries and broadcasts replay/GOB envelopes over +websocket on the same external port. +Its outbox/state files live under the shared storage volume configured in +`.env` via `SREBOT_STORAGE_VOL_PATH`. + +Useful commands: + +```bash +pm2 start ecosystem.config.js --only srebot-api +pm2 start ecosystem.config.js --only srebot-axbot +pm2 logs srebot-axbot +``` + +Clients should point their query client at: + +```env +SREBOT_API_BASE_URL=http://:18081 +``` + +The bridge app logs both sides of the transfer: +- incoming client HTTP requests +- outgoing proxy responses +- websocket envelopes broadcast to connected clients +# test diff --git a/dateindex.js b/dateindex.js new file mode 100644 index 0000000..1ca0c2c --- /dev/null +++ b/dateindex.js @@ -0,0 +1,71 @@ +require('dotenv').config(); +const sqlite3 = require('sqlite3').verbose(); +const path = require('path'); +const STORAGE_ROOT = (process.env.SREBOT_STORAGE_VOL_PATH || '').trim(); +if (!STORAGE_ROOT) { + throw new Error('SREBOT_STORAGE_VOL_PATH must be set'); +} +const DB_PATH = path.join(STORAGE_ROOT, 'sq_battles.db'); + +console.log(`Opening database at: ${DB_PATH}`); + +const db = new sqlite3.Database(DB_PATH, sqlite3.OPEN_READWRITE, (err) => { + if (err) { + console.error('Failed to open database:', err.message); + process.exit(1); + } + console.log('Connected to database successfully'); + + const checkIndexQuery = ` + SELECT name FROM sqlite_master + WHERE type='index' AND name='idx_endtime_unix' + `; + + db.get(checkIndexQuery, [], (err, row) => { + if (err) { + console.error('Error checking for existing index:', err.message); + db.close(); + process.exit(1); + } + + if (row) { + console.log('Index idx_endtime_unix already exists'); + db.close(); + console.log('Nothing to do - exiting'); + process.exit(0); + } + + console.log('Creating index idx_endtime_unix on player_games_hist(endtime_unix)...'); + const createIndexQuery = `CREATE INDEX idx_endtime_unix ON player_games_hist(endtime_unix)`; + + db.run(createIndexQuery, [], (err) => { + if (err) { + console.error('Failed to create index:', err.message); + db.close(); + process.exit(1); + } + + console.log('Index created successfully!'); + + db.get(checkIndexQuery, [], (err, row) => { + if (err) { + console.error('Error verifying index:', err.message); + } else if (row) { + console.log('Index verified successfully'); + } else { + console.warn('Warning: Index not found after creation'); + } + + db.close((err) => { + if (err) { + console.error('Error closing database:', err.message); + process.exit(1); + } + console.log('Database connection closed'); + console.log('\nIndex creation complete!'); + process.exit(0); + }); + }); + }); + }); +}); diff --git a/ecosystem.config.js b/ecosystem.config.js new file mode 100644 index 0000000..a95c5b4 --- /dev/null +++ b/ecosystem.config.js @@ -0,0 +1,128 @@ +require('dotenv').config(); + +const DEPLOY_PATH = process.env.SREBOT_DEPLOY_PATH || __dirname; + +module.exports = { + apps: [ + // Discord Bot + { + name: 'srebot', + script: 'start_bot.py', + interpreter: `${DEPLOY_PATH}/.venv/bin/python`, + cwd: DEPLOY_PATH, + instances: 1, + autorestart: true, + watch: false, + max_memory_restart: '16000M', + env: { + NODE_ENV: 'production', + PYTHONUNBUFFERED: '1' + }, + log_file: './logs/bot_combined.log', + out_file: './logs/bot_out.log', + error_file: './logs/bot_error.log', + log_date_format: 'YYYY-MM-DD HH:mm:ss Z', + merge_logs: true, + kill_timeout: 5000, + restart_delay: 3000 + }, + + // API Server + { + name: 'srebot-api', + script: 'server.js', + interpreter: 'node', + node_args: '--max-old-space-size=6144', + cwd: DEPLOY_PATH, + instances: 1, + autorestart: true, + watch: false, + max_memory_restart: '4G', + env: { + NODE_ENV: 'production', + PORT: 6000 + }, + log_file: './logs/api_combined.log', + out_file: './logs/api_out.log', + error_file: './logs/api_error.log', + log_date_format: 'YYYY-MM-DD HH:mm:ss Z', + merge_logs: true, + kill_timeout: 5000, + restart_delay: 2000 + }, + + // External bridge for AXBot traffic: + // - Proxies read-only API queries to the internal SREBOT API + // - Streams bridge envelopes to AXBot over websocket + { + name: 'srebot-axbot', + script: 'BOT/srebot_external.py', + interpreter: `${DEPLOY_PATH}/.venv/bin/python`, + cwd: DEPLOY_PATH, + instances: 1, + autorestart: true, + watch: false, + max_memory_restart: '1G', + env: { + NODE_ENV: 'production', + PYTHONUNBUFFERED: '1', + SREBOT_EXTERNAL_HOST: '0.0.0.0', + SREBOT_EXTERNAL_PORT: 18081, + SREBOT_EXTERNAL_UPSTREAM_URL: 'http://127.0.0.1:6000' + }, + log_file: './logs/axbot_combined.log', + out_file: './logs/axbot_out.log', + error_file: './logs/axbot_error.log', + log_date_format: 'YYYY-MM-DD HH:mm:ss Z', + merge_logs: true, + kill_timeout: 5000, + restart_delay: 2000 + }, + + // GitHub Webhook Receiver (auto-deploy on push to main) + { + name: 'srebot-webhook', + script: 'github_webhook_updater.py', + interpreter: `${DEPLOY_PATH}/.venv/bin/python`, + cwd: DEPLOY_PATH, + instances: 1, + autorestart: true, + watch: false, + max_memory_restart: '500M', + env: { + NODE_ENV: 'production', + PYTHONUNBUFFERED: '1', + WEBHOOK_PORT: 9000 + }, + log_file: './logs/webhook_combined.log', + out_file: './logs/webhook_out.log', + error_file: './logs/webhook_error.log', + log_date_format: 'YYYY-MM-DD HH:mm:ss Z', + merge_logs: true, + kill_timeout: 3000, + restart_delay: 2000 + }, + + // Website (Next.js) + { + name: 'srebot-web', + script: 'server.js', + cwd: `${DEPLOY_PATH}/web`, + instances: 3, + exec_mode: 'cluster', + autorestart: true, + watch: false, + max_memory_restart: '500M', + env: { + NODE_ENV: 'production', + PORT: 3001 + }, + log_file: './logs/web_combined.log', + out_file: './logs/web_out.log', + error_file: './logs/web_error.log', + log_date_format: 'YYYY-MM-DD HH:mm:ss Z', + merge_logs: true, + kill_timeout: 5000 + } + ] +}; diff --git a/github_webhook_updater.py b/github_webhook_updater.py new file mode 100644 index 0000000..f12548f --- /dev/null +++ b/github_webhook_updater.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +""" +GitHub Webhook Handler for auto-deploying SREBOT on main branch pushes. +Listens for push events, pulls changes, and restarts pm2. +""" + +import hmac +import hashlib +import logging +import subprocess +import os +from flask import Flask, request, jsonify +from dotenv import load_dotenv + +load_dotenv() + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s [%(levelname)s] %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) +logger = logging.getLogger(__name__) + +app = Flask(__name__) + +WEBHOOK_SECRET = os.environ.get('GITHUB_WEBHOOK_SECRET', '') +REPO_PATH = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +WEBHOOK_FILENAME = 'SREBOT/github_webhook_updater.py' + + +def verify_signature(payload: bytes, signature: str) -> bool: + """Verify GitHub webhook signature.""" + if not WEBHOOK_SECRET: + logger.warning("No webhook secret configured") + return False + + if not signature: + logger.warning("No signature provided in request") + return False + + expected = 'sha256=' + hmac.new( + WEBHOOK_SECRET.encode(), + payload, + hashlib.sha256 + ).hexdigest() + + return hmac.compare_digest(expected, signature) + + +def pull_and_restart(changed_files: list[str]) -> tuple[bool, str]: + """Pull latest changes from main and restart pm2 processes.""" + try: + # Change to repo directory + os.chdir(REPO_PATH) + logger.info(f"Changed to repo directory: {REPO_PATH}") + + # Fetch and pull main branch + logger.info("Fetching from origin/main...") + subprocess.run( + ['git', 'fetch', 'origin', 'main'], + capture_output=True, text=True, timeout=30 + ) + + logger.info("Pulling from origin/main...") + pull_result = subprocess.run( + ['git', 'pull', 'origin', 'main'], + capture_output=True, text=True, timeout=60 + ) + + if pull_result.returncode != 0: + logger.error(f"Git pull failed: {pull_result.stderr}") + return False, f"Git pull failed: {pull_result.stderr}" + + # Check if there were actual changes + if 'Already up to date' in pull_result.stdout: + logger.info("Already up to date, no restart needed") + return True, "Already up to date, no restart needed" + + logger.info(f"Pull successful: {pull_result.stdout.strip()}") + + # Determine which processes to restart based on changed files + processes_to_restart = [] + + # Bot: restart if SREBOT/BOT/, SHARED/, SREBOT/start_bot.py, ecosystem.config.js, + # or SREBOT root .py files changed + bot_changed = any( + f.startswith('SREBOT/BOT/') + or f.startswith('SHARED/') + or f == 'SREBOT/start_bot.py' + or f == 'SREBOT/ecosystem.config.js' + or (f.startswith('SREBOT/') and f.endswith('.py') and f.count('/') == 1 and not f.endswith('/' + WEBHOOK_FILENAME)) + for f in changed_files + ) + if bot_changed: + processes_to_restart.append('srebot') + logger.info("Bot files changed, will restart srebot") + + # API server: restart if SREBOT/server.js changed + api_changed = any(f == 'SREBOT/server.js' for f in changed_files) + if api_changed: + processes_to_restart.append('srebot-api') + logger.info("API server.js changed, will restart srebot-api") + + # Web frontend: restart if SREBOT/web/ files changed + web_changed = any(f.startswith('SREBOT/web/') for f in changed_files) + if web_changed: + # Rebuild CSS if tailwind source or config changed + css_changed = any( + f in ('SREBOT/web/public/css/tailwind.css', 'SREBOT/web/tailwind.config.js') + for f in changed_files + ) + if css_changed: + logger.info("Tailwind source changed, rebuilding CSS...") + build_result = subprocess.run( + ['npm', 'run', 'build:css'], + capture_output=True, text=True, timeout=60, + cwd=os.path.join(REPO_PATH, 'SREBOT', 'web') + ) + if build_result.returncode != 0: + logger.error(f"CSS build failed: {build_result.stderr}") + return False, f"CSS build failed: {build_result.stderr}" + logger.info("CSS build successful") + + # Rebuild obfuscated JS if any JS source files changed + js_changed = any( + f.startswith('SREBOT/web/public/js/') and f.endswith('.js') and '/dist/' not in f + for f in changed_files + ) + if js_changed: + logger.info("JS source files changed, rebuilding obfuscated JS...") + build_result = subprocess.run( + ['node', 'build.js'], + capture_output=True, text=True, timeout=120, + cwd=os.path.join(REPO_PATH, 'SREBOT', 'web') + ) + if build_result.returncode != 0: + logger.warning(f"JS build failed (non-fatal): {build_result.stderr}") + logger.warning("Continuing deploy — unobfuscated JS will be served as fallback") + else: + logger.info("JS build successful") + + processes_to_restart.append('srebot-web') + logger.info("Web files changed, will restart srebot-web") + + # Webhook: restart if this file changed + webhook_changed = any(WEBHOOK_FILENAME in f for f in changed_files) + if webhook_changed: + processes_to_restart.append('srebot-webhook') + logger.info(f"Webhook file changed, will restart srebot-webhook") + + if not processes_to_restart: + logger.info(f"No relevant process files changed, skipping restart. Files: {changed_files}") + return True, "Pulled but no restart needed" + + # Flush pm2 logs before restarting to free disk space + logger.info("Flushing PM2 logs...") + subprocess.run( + ['pm2', 'flush'], + capture_output=True, text=True, timeout=30 + ) + + # Restart pm2 processes + restarted = [] + for process in processes_to_restart: + logger.info(f"Restarting PM2 process: {process}") + restart_result = subprocess.run( + ['pm2', 'restart', process], + capture_output=True, text=True, timeout=30 + ) + if restart_result.returncode != 0: + logger.error(f"PM2 restart failed for {process}: {restart_result.stderr}") + return False, f"PM2 restart failed for {process}: {restart_result.stderr}" + restarted.append(process) + + logger.info(f"PM2 restart successful: {restarted}") + return True, f"Pulled and restarted {restarted}" + + except subprocess.TimeoutExpired: + logger.error("Command timed out") + return False, "Command timed out" + except Exception as e: + logger.exception(f"Unexpected error during pull_and_restart: {e}") + return False, f"Error: {str(e)}" + + +@app.route('/webhook', methods=['POST']) +def webhook(): + """Handle GitHub webhook POST requests.""" + logger.info(f"Received webhook request from {request.remote_addr}") + + # Verify signature + signature = request.headers.get('X-Hub-Signature-256', '') + if not verify_signature(request.data, signature): + logger.warning(f"Invalid signature from {request.remote_addr}") + return jsonify({'error': 'Invalid signature'}), 403 + + # Parse event type + event = request.headers.get('X-GitHub-Event', '') + logger.info(f"GitHub event type: {event}") + + if event == 'ping': + logger.info("Received ping event, responding with pong") + return jsonify({'message': 'pong'}), 200 + + if event != 'push': + logger.info(f"Ignoring non-push event: {event}") + return jsonify({'message': f'Ignoring event: {event}'}), 200 + + # Parse JSON payload with error handling + try: + payload = request.get_json() + if payload is None: + logger.error("Failed to parse JSON payload (returned None)") + return jsonify({'error': 'Invalid JSON payload'}), 400 + except Exception as e: + logger.error(f"Failed to parse JSON payload: {e}") + return jsonify({'error': 'Invalid JSON payload'}), 400 + + # Check if it's the main branch + ref = payload.get('ref', '') + logger.info(f"Push to ref: {ref}") + + if ref != 'refs/heads/main': + logger.info(f"Ignoring push to non-main branch: {ref}") + return jsonify({'message': f'Ignoring branch: {ref}'}), 200 + + # Extract changed files from commits + commits = payload.get('commits', []) + pusher = payload.get('pusher', {}).get('name', 'unknown') + logger.info(f"Push from {pusher} with {len(commits)} commit(s)") + + changed_files = [] + for commit in commits: + changed_files.extend(commit.get('added', [])) + changed_files.extend(commit.get('modified', [])) + changed_files.extend(commit.get('removed', [])) + + logger.info(f"Changed files: {changed_files}") + + # Pull and restart + success, message = pull_and_restart(changed_files) + + if success: + logger.info(f"Webhook update successful: {message}") + return jsonify({'success': True, 'message': message}), 200 + else: + logger.error(f"Webhook update failed: {message}") + return jsonify({'success': False, 'error': message}), 500 + + +@app.route('/health', methods=['GET']) +def health(): + """Health check endpoint.""" + return jsonify({'status': 'ok'}), 200 + + +if __name__ == '__main__': + port = int(os.environ.get('WEBHOOK_PORT', 9000)) + logger.info(f"Starting webhook server on port {port}") + logger.info(f"Repo path: {REPO_PATH}") + app.run(host='0.0.0.0', port=port) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e375b07 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2342 @@ +{ + "name": "srebot-player-api", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "srebot-player-api", + "version": "1.0.0", + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "sqlite3": "^5.1.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "license": "MIT", + "optional": true + }, + "node_modules/@npmcli/fs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", + "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "@gar/promisify": "^1.0.1", + "semver": "^7.3.5" + } + }, + "node_modules/@npmcli/move-file": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", + "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "license": "MIT", + "optional": true, + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC", + "optional": true + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "license": "MIT", + "optional": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC", + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT", + "optional": true + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacache": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", + "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "@npmcli/fs": "^1.0.0", + "@npmcli/move-file": "^1.0.1", + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "glob": "^7.1.4", + "infer-owner": "^1.0.4", + "lru-cache": "^6.0.0", + "minipass": "^3.1.1", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.2", + "mkdirp": "^1.0.3", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^8.0.1", + "tar": "^6.0.2", + "unique-filename": "^1.1.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "optional": true, + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT", + "optional": true + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC", + "optional": true + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT", + "optional": true + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "license": "MIT", + "optional": true + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC", + "optional": true + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC", + "optional": true + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC", + "optional": true + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "license": "ISC", + "optional": true + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "optional": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "license": "MIT", + "optional": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC", + "optional": true + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-fetch-happen": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", + "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", + "license": "ISC", + "optional": true, + "dependencies": { + "agentkeepalive": "^4.1.3", + "cacache": "^15.2.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^6.0.0", + "minipass": "^3.1.3", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^1.3.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.2", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^6.0.0", + "ssri": "^8.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-fetch": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", + "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.1.0", + "minipass-sized": "^1.0.3", + "minizlib": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "optionalDependencies": { + "encoding": "^0.1.12" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.77.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.77.0.tgz", + "integrity": "sha512-DSmt0OEcLoK4i3NuscSbGjOf3bqiDEutejqENSplMSFA/gmB8mkED9G4pKWnPl7MDU4rSHebKPHeitpDfyH0cQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/node-gyp": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", + "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", + "license": "MIT", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^9.1.0", + "nopt": "^5.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "license": "ISC", + "optional": true + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "license": "MIT", + "optional": true, + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC", + "optional": true + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "optional": true + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", + "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/socks-proxy-agent/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socks-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/sqlite3": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", + "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1", + "tar": "^6.1.11" + }, + "optionalDependencies": { + "node-gyp": "8.x" + }, + "peerDependencies": { + "node-gyp": "8.x" + }, + "peerDependenciesMeta": { + "node-gyp": { + "optional": true + } + } + }, + "node_modules/ssri": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", + "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unique-filename": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", + "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "unique-slug": "^2.0.0" + } + }, + "node_modules/unique-slug": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", + "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", + "license": "ISC", + "optional": true, + "dependencies": { + "imurmurhash": "^0.1.4" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "optional": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2104162 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "srebot-player-api", + "version": "1.0.0", + "description": "Node.js API server for SREBOT player statistics", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node server.js" + }, + "dependencies": { + "dotenv": "^16.3.1", + "express": "^4.18.2", + "sqlite3": "^5.1.6", + "cors": "^2.8.5" + }, + "engines": { + "node": ">=14.0.0" + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4ae62bc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "WtFileUtils" +version = "0.1.5" +authors = [ + { name="LivingTheDagor", email="LivingTheDagor@gmail.com" }, +] +description = "A VROMFs and Binary BLK Reader" +readme = "README.md" +requires-python = ">=3.12" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] + +[project.urls] +Homepage = "https://github.com/LivingTheDagor/WtFileUtils" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..731c0c8 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,14 @@ +{ + "pythonVersion": "3.14", + "extraPaths": [ + ".venv/lib/python3.14/site-packages", + "../SHARED" + ], + "exclude": [ + ".venv", + "../SHARED/DAGOR_FILES", + "web/node_modules", + "node_modules", + "**/__pycache__" + ] +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0758beb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,22 @@ +discord.py>=2.4.0,<3.0.0 +requests>=2.32.3,<3.0.0 +beautifulsoup4>=4.12.3,<5.0.0 +lxml>=5.0.0 +zstandard +pygob @ git+https://github.com/mgeisler/pygob.git +lz4==4.3.3 +aiofiles +aiohttp +aiohttp-socks +websockets>=13.0 +aiosqlite +deepl +python-dotenv>=1.0.0 +flask>=2.0.0 +Pillow>=10.0.0 +WtFileUtils +dotenv +numpy +wcwidth +fontTools +matplotlib diff --git a/scripts/diag_entitlements.py b/scripts/diag_entitlements.py new file mode 100644 index 0000000..cd093b8 --- /dev/null +++ b/scripts/diag_entitlements.py @@ -0,0 +1,170 @@ +""" +Diagnostic: query Discord's entitlements API directly and report what it returns +right now. Compare against the local entitlements.db cache. + +Usage (on the server): + cd ~/GitHub/SREBOT_MEOW + source .venv/bin/activate + python scripts/diag_entitlements.py + +Targeted guild: 1379510072815779961 (bot owner's max-tier server). +""" +from __future__ import annotations + +import asyncio +import os +import sqlite3 +import sys +import time +from pathlib import Path + +import aiohttp +from dotenv import load_dotenv + +load_dotenv() + +APPLICATION_ID = "1254679514466877540" +TARGET_GUILD = "1379510072815779961" +TOKEN = os.environ.get("DISCORD_KEY", "") +_storage_env = os.environ.get("SREBOT_STORAGE_VOL_PATH", "").strip() +if not _storage_env: + raise RuntimeError("SREBOT_STORAGE_VOL_PATH must be set") +STORAGE = Path(_storage_env) +DB_PATH = STORAGE / "entitlements.db" + + +async def fetch_all_entitlements() -> tuple[list[dict], list[tuple[int, int, str, str]]]: + """ + Returns (entitlements, page_logs). + page_logs: list of (page_num, count, status, after_cursor) + """ + if not TOKEN: + print("ERROR: DISCORD_KEY env var not set", file=sys.stderr) + sys.exit(1) + + headers = {"Authorization": f"Bot {TOKEN}"} + url = f"https://discord.com/api/v10/applications/{APPLICATION_ID}/entitlements" + params: dict[str, str] = {"exclude_ended": "true", "limit": "100"} + + all_ents: list[dict] = [] + page_logs: list[tuple[int, int, str, str]] = [] + page = 0 + after: str | None = None + + timeout = aiohttp.ClientTimeout(total=60) + async with aiohttp.ClientSession(timeout=timeout) as session: + while True: + page += 1 + q = dict(params) + if after: + q["after"] = after + t0 = time.monotonic() + async with session.get(url, headers=headers, params=q) as resp: + elapsed = time.monotonic() - t0 + status = f"{resp.status} ({elapsed:.2f}s)" + if resp.status != 200: + body = await resp.text() + page_logs.append((page, 0, status, after or "")) + print(f"[!] page {page} failed: {status}\n {body[:300]}") + break + batch = await resp.json() + page_logs.append((page, len(batch), status, after or "")) + all_ents.extend(batch) + if len(batch) < 100: + break + # paginate forward by id + after = batch[-1]["id"] + # safety break + if page > 50: + print("[!] hit 50-page safety limit, stopping") + break + + return all_ents, page_logs + + +def read_db_state() -> dict[str, int]: + if not DB_PATH.exists(): + return {"db_missing": 1} + conn = sqlite3.connect(str(DB_PATH)) + try: + cur = conn.cursor() + out: dict[str, int] = {} + out["guild_entitlements_total"] = cur.execute("SELECT COUNT(*) FROM guild_entitlements").fetchone()[0] + out["guild_entitlements_active"] = cur.execute("SELECT COUNT(*) FROM guild_entitlements WHERE status='active'").fetchone()[0] + out["manual_entitlements_total"] = cur.execute("SELECT COUNT(*) FROM manual_entitlements").fetchone()[0] + out["discord_entitlements_total"] = cur.execute("SELECT COUNT(*) FROM discord_entitlements").fetchone()[0] + row = cur.execute("SELECT * FROM discord_entitlements WHERE guild_id=?", (TARGET_GUILD,)).fetchone() + out["target_in_discord_table"] = 1 if row else 0 + return out + finally: + conn.close() + + +async def main() -> None: + print("=" * 70) + print(f"Discord Entitlements Diagnostic — {time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())}") + print("=" * 70) + + print(f"\n[1] Local DB state ({DB_PATH}):") + for k, v in read_db_state().items(): + print(f" {k}: {v}") + + print("\n[2] Discord API: GET /applications/{app_id}/entitlements?exclude_ended=true") + ents, pages = await fetch_all_entitlements() + + print("\n Page log:") + for p, n, status, after in pages: + print(f" page {p:>2} count={n:>3} status={status:<18} after={after}") + + print(f"\n Total entitlements returned: {len(ents)}") + + # Group by guild + by_guild: dict[str, list[dict]] = {} + for e in ents: + gid = e.get("guild_id") + if gid: + by_guild.setdefault(str(gid), []).append(e) + + print(f" Unique guild_ids: {len(by_guild)}") + print(f" Entitlements with NULL guild_id: {sum(1 for e in ents if not e.get('guild_id'))}") + + # Status breakdown + deleted = sum(1 for e in ents if e.get("deleted")) + consumed = sum(1 for e in ents if e.get("consumed")) + starts_in_future = sum(1 for e in ents if e.get("starts_at") and e["starts_at"] > time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime())) + ends_in_past = sum(1 for e in ents if e.get("ends_at") and e["ends_at"] < time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime())) + print(f" deleted={deleted} consumed={consumed} starts_in_future={starts_in_future} ends_in_past={ends_in_past}") + + print(f"\n[3] Target guild {TARGET_GUILD}:") + target_ents = by_guild.get(TARGET_GUILD, []) + if not target_ents: + print(f" *** NOT FOUND in Discord API response ***") + # Show sample of what we DID get to confirm we're not just paginating wrong + sample_guilds = list(by_guild.keys())[:5] + print(f" Sample of guild_ids returned: {sample_guilds}") + else: + for e in target_ents: + print(f" id={e.get('id')} sku_id={e.get('sku_id')} type={e.get('type')} " + f"deleted={e.get('deleted')} starts_at={e.get('starts_at')} ends_at={e.get('ends_at')}") + + print("\n[4] SKU breakdown across all returned entitlements:") + sku_counts: dict[str, int] = {} + for e in ents: + sku = str(e.get("sku_id") or "") + sku_counts[sku] = sku_counts.get(sku, 0) + 1 + for sku, c in sorted(sku_counts.items(), key=lambda x: -x[1]): + print(f" {sku}: {c}") + + print("\n[5] Recommendation:") + if not target_ents: + print(" Discord is NOT returning the target guild's entitlement.") + print(" Either Discord's API is degraded right now, OR the entitlement no longer exists.") + print(" Re-run this script in a few minutes to see if results change.") + else: + print(" Discord IS returning the target guild's entitlement.") + print(" The bot's cache must be stale or wholesale-replaced by an empty result.") + print(" Force a refresh: restart bot OR call refresh_entitled_guilds(force=True).") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/diag_meta_points.py b/scripts/diag_meta_points.py new file mode 100644 index 0000000..c71ed2e --- /dev/null +++ b/scripts/diag_meta_points.py @@ -0,0 +1,123 @@ +""" +Diagnostic: figure out why /meta shows no points per player. + +Compares Guild_Metas userIDs against the uid keys returned by +obtain_clan_new_points() for the guild's bound squadron, and reports each +plausible failure mode (missing Guilds row, empty API response, uid format +mismatch, squadron-name mismatch). + +Usage (on the server): + cd ~/SREBOT/SREBOT_MEOW + source .venv/bin/activate + python scripts/diag_meta_points.py 1378960248118841507 +""" +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path + +import aiosqlite + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from BOT.game_api import ClanInfoError, obtain_clan_new_points +from BOT.utils import STORAGE_DIR + + +async def diag(guild_id: str) -> None: + meta_db = STORAGE_DIR / "Meta.db" + print(f"== Meta.db: {meta_db} (exists={meta_db.exists()})") + + if not meta_db.exists(): + print("Meta.db missing — wrong STORAGE_DIR?") + return + + async with aiosqlite.connect(meta_db) as db: + cur = await db.execute( + "SELECT squadron_name, squadron_clanID FROM Guilds WHERE guild_id=?", + (guild_id,), + ) + guilds_row = await cur.fetchone() + print(f"== Guilds row for {guild_id}: {guilds_row}") + + cur = await db.execute( + "SELECT COUNT(*) FROM Guild_Metas WHERE guild_id=?", (guild_id,) + ) + gm_count_row = await cur.fetchone() + gm_count = gm_count_row[0] if gm_count_row else 0 + print(f"== Guild_Metas count for {guild_id}: {gm_count}") + + cur = await db.execute( + "SELECT userID, nick, clanName FROM Guild_Metas " + "WHERE guild_id=? ORDER BY nick LIMIT 5", + (guild_id,), + ) + gm_rows = await cur.fetchall() + print("== Guild_Metas sample:") + for uid, nick, clan in gm_rows: + print(f" uid={uid!r} type={type(uid).__name__} nick={nick!r} clan={clan!r}") + + if not guilds_row: + print("\nNo Guilds row — /meta-management was never run for this guild.") + return + + sq_name = guilds_row[0] + print(f"\n== Calling obtain_clan_new_points({sq_name!r})...") + try: + members, total = await obtain_clan_new_points(sq_name) + except ClanInfoError as e: + print(f" ClanInfoError: {e}") + return + except Exception as e: + print(f" {type(e).__name__}: {e}") + return + + print(f"== API returned: {len(members)} members, total_score={total}") + if not members: + print(" Empty members dict — JWT was likely refreshed; rerun the script.") + return + + sample = list(members.items())[:3] + print("== API sample:") + for uid, info in sample: + print(f" uid={uid!r} type={type(uid).__name__} info={info}") + + gm_rows_list = list(gm_rows) + if gm_rows_list: + gm_uid = gm_rows_list[0][0] + print( + f"\n== Membership test for first Guild_Metas uid {gm_uid!r}:\n" + f" raw in api_map -> {gm_uid in members}\n" + f" str(uid) in api_map -> {str(gm_uid) in members}" + ) + + api_uids = set(members.keys()) + gm_uids_all = set() + async with aiosqlite.connect(meta_db) as db: + cur = await db.execute( + "SELECT userID FROM Guild_Metas WHERE guild_id=?", (guild_id,) + ) + gm_uids_all = {str(r[0]) for r in await cur.fetchall()} + api_uids_str = {str(u) for u in api_uids} + overlap = gm_uids_all & api_uids_str + only_gm = gm_uids_all - api_uids_str + only_api = api_uids_str - gm_uids_all + print( + f"\n== Set overlap (str-cast both sides):\n" + f" in both: {len(overlap)}\n" + f" only in Guild_Metas: {len(only_gm)} (left squadron / wrong sq_name?)\n" + f" only in API: {len(only_api)} (never bulk-added)" + ) + + +def main() -> None: + if len(sys.argv) != 2: + print("Usage: python scripts/diag_meta_points.py ") + sys.exit(2) + asyncio.run(diag(sys.argv[1])) + + +if __name__ == "__main__": + main() diff --git a/scripts/leaderboard_score_probe.py b/scripts/leaderboard_score_probe.py new file mode 100644 index 0000000..66d7c6c --- /dev/null +++ b/scripts/leaderboard_score_probe.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Probe the War Thunder clan leaderboard for a specific squadron. + +This mirrors the bot's ``obtain_clans_leaderboard()`` flow: +1. Load the JWT from the storage auth file. +2. Call the clan leaderboard endpoint with ``action=cln_clan_get_leaderboard``. +3. Decode the BLK payload. +4. Print leaderboard counts and the first matching squadron entry. + +Usage: + python3 scripts/leaderboard_score_probe.py + python3 scripts/leaderboard_score_probe.py --needle SCORE --count 1000 + python3 scripts/leaderboard_score_probe.py --auth-file /mnt/.../STORAGE/auth_JWT.json --char-url "$WT_CHAR_URL" +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import sys +from pathlib import Path +from typing import Any + +from dotenv import load_dotenv + + +def _infer_storage_root(auth_file: Path) -> Path: + if auth_file.parent.name == "AUTH": + return auth_file.parent.parent + return auth_file.parent + + +def _prepare_env(storage_root: Path) -> None: + os.environ["SREBOT_STORAGE_VOL_PATH"] = str(storage_root) + + +def _score_hit(clan: dict[str, Any], needle: str) -> bool: + needle_u = needle.upper() + values = ( + str(clan.get("short_name", "")).upper(), + str(clan.get("tag", "")).upper(), + str(clan.get("long_name", "")).upper(), + ) + return any(v == needle_u or needle_u in v for v in values) + + +async def _main() -> int: + repo_root = Path(__file__).resolve().parents[1] + env_path = repo_root / ".env" + if str(repo_root) not in sys.path: + sys.path.insert(0, str(repo_root)) + + parser = argparse.ArgumentParser() + parser.add_argument("--needle", default="SCORE", help="Squadron name to search for") + parser.add_argument("--start", type=int, default=0, help="Leaderboard offset") + parser.add_argument("--count", type=int, default=1000, help="Leaderboard page size") + parser.add_argument( + "--auth-file", + default="", + help="Path to the JWT auth JSON file. If omitted, the script will use a temp file under the storage root.", + ) + parser.add_argument( + "--char-url", + default="", + help="War Thunder char API URL (defaults to WT_CHAR_URL env var)", + ) + args = parser.parse_args() + + if args.auth_file: + auth_file = Path(args.auth_file).expanduser().resolve() + storage_root = _infer_storage_root(auth_file) + else: + storage_root_env = os.environ.get("SREBOT_STORAGE_VOL_PATH", "").strip() + if not storage_root_env: + print( + "SREBOT_STORAGE_VOL_PATH is not set. Export it or pass --auth-file.", + file=sys.stderr, + ) + return 2 + storage_root = Path(storage_root_env) + auth_file = storage_root / "AUTH" / "auth_JWT.json" + + _prepare_env(storage_root) + auth_file.parent.mkdir(parents=True, exist_ok=True) + + load_dotenv(dotenv_path=env_path) + if not args.char_url: + args.char_url = os.environ.get("WT_CHAR_URL", "") + + if args.char_url: + os.environ["WT_CHAR_URL"] = args.char_url + elif not os.environ.get("WT_CHAR_URL"): + print( + "WT_CHAR_URL is not set. Pass --char-url or export WT_CHAR_URL first.", + file=sys.stderr, + ) + return 2 + + # Import after env setup so BOT.game_api picks up the correct paths. + from BOT import game_api # type: ignore + import aiohttp + + game_api.AUTH_FILE = auth_file + bin_blk_to_json = game_api.bin_blk_to_json + + if not auth_file.exists(): + await game_api.get_JWT() + + with auth_file.open("r", encoding="utf-8") as f: + jwt = json.load(f)["jwt"] + + headers = { + "action": "cln_clan_get_leaderboard", + "token": jwt, + "start": str(args.start), + "count": str(args.count), + "sortField": "dr_era5_hist", + "shortMode": "off", + } + + url = os.environ["WT_CHAR_URL"] + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers) as res: + raw = await res.read() + print(f"http_status={res.status}") + if res.status != 200: + print(raw[:1000].decode("utf-8", "replace"), file=sys.stderr) + return 1 + + data = await bin_blk_to_json(raw) + if "root" in data: + data = data["root"] + + clans = data.get("clan") or [] + print(f"returned_clans={len(clans)}") + positive = sum(1 for clan in clans if int((clan.get("astat") or {}).get("dr_era5_hist") or 0) > 0) + print(f"positive_clans={positive}") + + matches = [] + for idx, clan in enumerate(clans, start=1): + if _score_hit(clan, args.needle): + astat = clan.get("astat") or {} + matches.append( + { + "index": idx, + "clan_id": clan.get("_id"), + "tag": clan.get("tag"), + "name": clan.get("name"), + "short_name": clan.get("tag", "")[1:-1] if clan.get("tag") else "", + "clanrating": astat.get("dr_era5_hist"), + "members": clan.get("members_cnt"), + "position": clan.get("pos"), + } + ) + + if not matches: + print(f"needle={args.needle!r} not found") + return 0 + + print("matches=" + json.dumps(matches[:10], ensure_ascii=False)) + first = matches[0]["index"] + lo = max(1, first - 3) + hi = min(len(clans), first + 3) + window = [] + for idx in range(lo, hi + 1): + clan = clans[idx - 1] + astat = clan.get("astat") or {} + window.append( + { + "index": idx, + "tag": clan.get("tag"), + "name": clan.get("name"), + "clanrating": astat.get("dr_era5_hist"), + "position": clan.get("pos"), + } + ) + print("window=" + json.dumps(window, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(_main())) diff --git a/scripts/migrate_clan_id.py b/scripts/migrate_clan_id.py new file mode 100644 index 0000000..5c9ed41 --- /dev/null +++ b/scripts/migrate_clan_id.py @@ -0,0 +1,739 @@ +""" +One-shot migration: switch SREBOT storage to use clan_id (numeric squadron UID) +as the canonical identifier across DB tables and preference JSON files. + +Goals +----- +1. Add `clan_id` to historical tables (player_games_hist, match_summary). +2. Rebuild points.db tables (profile_member_points, profile_totals, + game_cache) so their PKs include `clan_id` instead of squadron text. +3. Rebuild wl.db tables to use clan_id (currently empty so trivial). +4. Add `squadron_name_history` table to squadrons.db (for old-name → clan_id + redirects when a squadron renames). Seed with current squadrons_data. +5. Re-key every PREFERENCES/-preferences.json so squadron entries + are keyed by `str(clan_id)` instead of long_name. Special wildcard keys + ("Global", "everything", "all", "*") are preserved. +6. Backups: write a tarball of STORAGE/ before any change. Each preferences + file gets copied to PREFERENCES_BACKUP_/. + +Run with `--dry-run` first. The script prints exactly what it would do. + +Usage +----- + source .venv/bin/activate + python scripts/migrate_clan_id.py --dry-run + python scripts/migrate_clan_id.py --apply +""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +import shutil +import sqlite3 +import sys +import tarfile +import time +from pathlib import Path +from typing import Any, Optional + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", +) +log = logging.getLogger("migrate_clan_id") + + +# Auto-load the repo's .env so SREBOT_STORAGE_VOL_PATH and friends resolve when +# the script is run directly (PM2/services already get them from .env). +try: + from dotenv import load_dotenv + + _here = Path(__file__).resolve() + for candidate in (_here.parent / ".env", _here.parent.parent / ".env"): + if candidate.exists(): + load_dotenv(candidate) + break +except ImportError: + pass + +_storage_env = os.environ.get("SREBOT_STORAGE_VOL_PATH", "").strip() +if not _storage_env: + raise RuntimeError("SREBOT_STORAGE_VOL_PATH must be set") +STORAGE_DIR = Path(_storage_env) + +SQ_BATTLES_DB = STORAGE_DIR / "sq_battles.db" +SQUADRONS_DB = STORAGE_DIR / "squadrons.db" +POINTS_DB = STORAGE_DIR / "points.db" +WL_DB = STORAGE_DIR / "wl.db" +PREFS_DIR = STORAGE_DIR / "PREFERENCES" + +WILDCARD_KEYS = {"global", "everything", "all", "*"} +PGH_BATCH = 50_000 + + +def backup_storage() -> Path: + ts = time.strftime("%Y%m%d-%H%M%S") + backup_path = STORAGE_DIR.parent / f"STORAGE_BACKUP_{ts}.tar.gz" + log.info("Creating tarball backup at %s (this may take a while)...", backup_path) + with tarfile.open(backup_path, "w:gz") as tar: + for db in (SQ_BATTLES_DB, SQUADRONS_DB, POINTS_DB, WL_DB): + if db.exists(): + tar.add(db, arcname=db.name) + if PREFS_DIR.exists(): + tar.add(PREFS_DIR, arcname=PREFS_DIR.name) + log.info("Backup complete: %s", backup_path) + return backup_path + + +def load_squadron_index() -> tuple[dict[str, int], dict[str, int], dict[int, dict[str, Any]]]: + """Return (long_name_lower -> clan_id, short_name_lower -> clan_id, clan_id -> row).""" + long_to_id: dict[str, int] = {} + short_to_id: dict[str, int] = {} + by_id: dict[int, dict[str, Any]] = {} + con = sqlite3.connect(SQUADRONS_DB) + try: + con.row_factory = sqlite3.Row + for row in con.execute( + "SELECT clan_id, long_name, short_name, tag_name FROM squadrons_data" + ): + cid = int(row["clan_id"]) + by_id[cid] = dict(row) + if row["long_name"]: + long_to_id[row["long_name"].lower()] = cid + if row["short_name"]: + short_to_id[row["short_name"].lower()] = cid + finally: + con.close() + log.info("Loaded %d squadrons from squadrons_data", len(by_id)) + return long_to_id, short_to_id, by_id + + +def ensure_squadron_name_history(apply: bool, by_id: dict[int, dict[str, Any]]) -> None: + """Create squadron_name_history table and seed from current squadrons_data. + + Schema: + clan_id INTEGER NOT NULL + long_name TEXT NOT NULL + first_seen INTEGER NOT NULL (unix) + last_seen INTEGER NOT NULL (unix) + PRIMARY KEY (clan_id, long_name) + """ + con = sqlite3.connect(SQUADRONS_DB) + try: + existing = con.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='squadron_name_history'" + ).fetchone() + if existing: + log.info("squadron_name_history already exists; skipping create") + else: + log.info("Creating squadron_name_history table") + if apply: + con.executescript( + """ + CREATE TABLE squadron_name_history ( + clan_id INTEGER NOT NULL, + long_name TEXT NOT NULL, + first_seen INTEGER NOT NULL, + last_seen INTEGER NOT NULL, + PRIMARY KEY (clan_id, long_name) + ); + CREATE INDEX idx_snh_long_name ON squadron_name_history(long_name COLLATE NOCASE); + CREATE INDEX idx_snh_clan_id ON squadron_name_history(clan_id); + """ + ) + + now = int(time.time()) + rows = [ + (cid, row["long_name"], now, now) + for cid, row in by_id.items() + if row.get("long_name") + ] + log.info("Seeding squadron_name_history with %d (clan_id, long_name) pairs", len(rows)) + if apply and rows: + con.executemany( + """ + INSERT INTO squadron_name_history (clan_id, long_name, first_seen, last_seen) + VALUES (?, ?, ?, ?) + ON CONFLICT(clan_id, long_name) DO UPDATE SET last_seen = excluded.last_seen + """, + rows, + ) + con.commit() + finally: + con.close() + + +def add_squadrons_points_index(apply: bool) -> None: + con = sqlite3.connect(SQUADRONS_DB) + try: + log.info("Adding clan_id index on squadrons_points") + if apply: + con.executescript( + """ + CREATE INDEX IF NOT EXISTS idx_squadrons_points_clanid_time + ON squadrons_points(clan_id, unix_time); + """ + ) + con.commit() + finally: + con.close() + + +def migrate_player_games_hist( + apply: bool, long_to_id: dict[str, int] +) -> None: + """Add clan_id INTEGER column + backfill via long_name lookup. Add index.""" + con = sqlite3.connect(SQ_BATTLES_DB) + try: + con.execute("PRAGMA journal_mode=WAL") + cols = [r[1] for r in con.execute("PRAGMA table_info(player_games_hist)").fetchall()] + if "clan_id" not in cols: + log.info("Adding clan_id column to player_games_hist") + if apply: + con.execute("ALTER TABLE player_games_hist ADD COLUMN clan_id INTEGER") + con.commit() + else: + log.info("player_games_hist.clan_id already present") + + # Refresh after the conditional ALTER. + cols = [r[1] for r in con.execute("PRAGMA table_info(player_games_hist)").fetchall()] + total = con.execute("SELECT COUNT(*) FROM player_games_hist").fetchone()[0] + if "clan_id" in cols: + unbackfilled = con.execute( + "SELECT COUNT(*) FROM player_games_hist WHERE clan_id IS NULL" + ).fetchone()[0] + else: + unbackfilled = total + log.info( + "player_games_hist: %d total rows, %d need backfill", + total, + unbackfilled, + ) + + if apply and unbackfilled: + # Faster path: pre-build a name → clan_id map in Python and run one + # indexed UPDATE per distinct squadron_name. Each UPDATE hits the + # idx_pgh_squadron_name index. ~1500 small ops vs one giant + # correlated subquery. squadron_name is the WT short_name (after + # alphanum strip), but very rarely a long_name leaks through, so + # we accept matches against either. + log.info("Building name → clan_id map in Python...") + sq_con = sqlite3.connect(f"file:{SQUADRONS_DB}?mode=ro", uri=True) + try: + sq_con.row_factory = sqlite3.Row + name_to_id: dict[str, int] = {} + for row in sq_con.execute( + "SELECT clan_id, long_name, short_name FROM squadrons_data" + ): + cid = int(row["clan_id"]) + if row["long_name"]: + name_to_id.setdefault(row["long_name"].lower(), cid) + if row["short_name"]: + name_to_id.setdefault(row["short_name"].lower(), cid) + finally: + sq_con.close() + + log.info("Fetching distinct squadron_name values from player_games_hist...") + distinct_names = [ + r[0] for r in con.execute( + "SELECT DISTINCT squadron_name FROM player_games_hist WHERE clan_id IS NULL" + ).fetchall() + if r[0] + ] + log.info("Distinct unbackfilled squadron_names: %d", len(distinct_names)) + + updates = [ + (name_to_id[n.lower()], n) + for n in distinct_names + if n.lower() in name_to_id + ] + log.info("Will UPDATE %d distinct names that resolve to clan_ids", len(updates)) + if updates: + cur = con.executemany( + "UPDATE player_games_hist SET clan_id = ? " + "WHERE squadron_name = ? AND clan_id IS NULL", + updates, + ) + con.commit() + log.info("Backfill: %s row-updates committed", cur.rowcount) + + cols = [r[1] for r in con.execute("PRAGMA table_info(player_games_hist)").fetchall()] + if "clan_id" in cols: + still_null = con.execute( + "SELECT COUNT(*) FROM player_games_hist WHERE clan_id IS NULL" + ).fetchone()[0] + log.info("player_games_hist orphans (clan_id NULL after backfill): %d", still_null) + else: + log.info("player_games_hist orphans: n/a (column absent in dry-run)") + + log.info("Ensuring index on player_games_hist(clan_id, endtime_unix)") + if apply: + con.execute( + "CREATE INDEX IF NOT EXISTS idx_pgh_clanid_endtime " + "ON player_games_hist(clan_id, endtime_unix)" + ) + con.commit() + finally: + con.close() + + +def migrate_match_summary(apply: bool) -> None: + """Add winning_clan_id + losing_clan_id, backfill via squadrons_data.""" + con = sqlite3.connect(SQ_BATTLES_DB) + try: + con.execute("PRAGMA journal_mode=WAL") + cols = [r[1] for r in con.execute("PRAGMA table_info(match_summary)").fetchall()] + for new_col in ("winning_clan_id", "losing_clan_id"): + if new_col not in cols: + log.info("Adding %s column to match_summary", new_col) + if apply: + con.execute(f"ALTER TABLE match_summary ADD COLUMN {new_col} INTEGER") + con.commit() + else: + log.info("match_summary.%s already present", new_col) + + # Refresh after potential ALTERs. + cols = [r[1] for r in con.execute("PRAGMA table_info(match_summary)").fetchall()] + total = con.execute("SELECT COUNT(*) FROM match_summary").fetchone()[0] + if "winning_clan_id" in cols and "losing_clan_id" in cols: + unbackfilled = con.execute( + "SELECT COUNT(*) FROM match_summary " + "WHERE winning_clan_id IS NULL OR losing_clan_id IS NULL" + ).fetchone()[0] + else: + # Dry-run path: columns don't exist yet, so every row is "to be backfilled". + unbackfilled = total + log.info( + "match_summary: %d total rows, %d need backfill", + total, + unbackfilled, + ) + + if apply and unbackfilled: + con.execute(f"ATTACH DATABASE '{SQUADRONS_DB}' AS sq") + try: + # winning_sq / losing_sq can be either short_name or tag_name + # depending on replay metadata. Try short_name first since that's + # what the autologger writes most often. + for col_in, col_out in ( + ("winning_sq", "winning_clan_id"), + ("losing_sq", "losing_clan_id"), + ): + log.info("Backfilling %s ← %s via short_name then long_name", col_out, col_in) + con.execute( + f""" + UPDATE match_summary + SET {col_out} = ( + SELECT clan_id FROM sq.squadrons_data + WHERE LOWER(squadrons_data.short_name) = LOWER(match_summary.{col_in}) + LIMIT 1 + ) + WHERE {col_out} IS NULL AND {col_in} IS NOT NULL + """ + ) + con.execute( + f""" + UPDATE match_summary + SET {col_out} = ( + SELECT clan_id FROM sq.squadrons_data + WHERE LOWER(squadrons_data.long_name) = LOWER(match_summary.{col_in}) + LIMIT 1 + ) + WHERE {col_out} IS NULL AND {col_in} IS NOT NULL + """ + ) + con.commit() + finally: + con.execute("DETACH DATABASE sq") + + cols = [r[1] for r in con.execute("PRAGMA table_info(match_summary)").fetchall()] + for col, label in (("winning_clan_id", "winning"), ("losing_clan_id", "losing")): + if col not in cols: + log.info("match_summary orphans (%s NULL): n/a (column absent in dry-run)", label) + continue + n = con.execute( + f"SELECT COUNT(*) FROM match_summary WHERE {col} IS NULL" + ).fetchone()[0] + log.info("match_summary orphans (%s NULL): %d", label, n) + + log.info("Ensuring indexes on match_summary clan_id columns") + if apply: + con.execute( + "CREATE INDEX IF NOT EXISTS idx_ms_winning_clanid ON match_summary(winning_clan_id)" + ) + con.execute( + "CREATE INDEX IF NOT EXISTS idx_ms_losing_clanid ON match_summary(losing_clan_id)" + ) + con.commit() + finally: + con.close() + + +def _resolve_squadron_to_clan_id(name: Optional[str], name_to_id: dict[str, int]) -> int: + if not name: + return -1 + return name_to_id.get(name.lower(), -1) + + +def rebuild_points_db(apply: bool, long_to_id: dict[str, int]) -> None: + """Rebuild profile_member_points, profile_totals, game_cache with clan_id columns. + + The squadron text column is preserved for reference but the new tables key + off clan_id for primary keys. Resolution happens in Python so we don't have + to ATTACH squadrons.db (avoids the cross-db lock issue we hit earlier). + """ + # Build name → clan_id map once (long_name AND short_name). + sq_con = sqlite3.connect(f"file:{SQUADRONS_DB}?mode=ro", uri=True) + try: + sq_con.row_factory = sqlite3.Row + name_to_id: dict[str, int] = {} + for row in sq_con.execute( + "SELECT clan_id, long_name, short_name FROM squadrons_data" + ): + cid = int(row["clan_id"]) + if row["long_name"]: + name_to_id.setdefault(row["long_name"].lower(), cid) + if row["short_name"]: + name_to_id.setdefault(row["short_name"].lower(), cid) + finally: + sq_con.close() + + con = sqlite3.connect(POINTS_DB) + try: + con.execute("PRAGMA journal_mode=WAL") + + # profile_member_points ------------------------------------------------- + cols = [r[1] for r in con.execute("PRAGMA table_info(profile_member_points)").fetchall()] + if "clan_id" in cols: + log.info("profile_member_points already migrated; skipping rebuild") + else: + log.info("Rebuilding profile_member_points with clan_id PK") + if apply: + # Idempotency: a previous half-finished run may have left _new behind. + con.execute("DROP TABLE IF EXISTS profile_member_points_new") + con.execute( + """CREATE TABLE profile_member_points_new ( + clan_id INTEGER NOT NULL, + squadron TEXT NOT NULL, + uid TEXT NOT NULL, + points INTEGER NOT NULL, + PRIMARY KEY (clan_id, uid) + )""" + ) + src = con.execute( + "SELECT squadron, uid, points FROM profile_member_points" + ).fetchall() + rows = [ + (_resolve_squadron_to_clan_id(s, name_to_id), s, u, p) + for (s, u, p) in src + ] + con.executemany( + "INSERT OR IGNORE INTO profile_member_points_new " + "(clan_id, squadron, uid, points) VALUES (?, ?, ?, ?)", + rows, + ) + con.commit() + log.info("profile_member_points: copied %d rows", len(rows)) + con.execute("DROP TABLE profile_member_points") + con.execute("ALTER TABLE profile_member_points_new RENAME TO profile_member_points") + con.execute("CREATE INDEX IF NOT EXISTS idx_pmp_squadron ON profile_member_points(squadron)") + con.commit() + + # profile_totals -------------------------------------------------------- + cols = [r[1] for r in con.execute("PRAGMA table_info(profile_totals)").fetchall()] + if "clan_id" in cols: + log.info("profile_totals already migrated; skipping rebuild") + else: + log.info("Rebuilding profile_totals with clan_id PK") + if apply: + con.execute("DROP TABLE IF EXISTS profile_totals_new") + con.execute( + """CREATE TABLE profile_totals_new ( + clan_id INTEGER PRIMARY KEY, + squadron TEXT NOT NULL, + total INTEGER NOT NULL + )""" + ) + src = con.execute("SELECT squadron, total FROM profile_totals").fetchall() + rows = [ + (_resolve_squadron_to_clan_id(s, name_to_id), s, t) + for (s, t) in src + ] + con.executemany( + "INSERT OR IGNORE INTO profile_totals_new (clan_id, squadron, total) VALUES (?, ?, ?)", + rows, + ) + con.commit() + log.info("profile_totals: copied %d rows", len(rows)) + con.execute("DROP TABLE profile_totals") + con.execute("ALTER TABLE profile_totals_new RENAME TO profile_totals") + con.commit() + + # game_cache ------------------------------------------------------------ + cols = [r[1] for r in con.execute("PRAGMA table_info(game_cache)").fetchall()] + if "clan_id" in cols: + log.info("game_cache already migrated; skipping rebuild") + else: + log.info("Rebuilding game_cache with clan_id in PK") + if apply: + con.execute("DROP TABLE IF EXISTS game_cache_new") + con.execute( + """CREATE TABLE game_cache_new ( + game_id TEXT NOT NULL, + clan_id INTEGER NOT NULL, + squadron TEXT NOT NULL, + diffs_json TEXT NOT NULL, + diff_total INTEGER NOT NULL, + updated_json TEXT NOT NULL, + created_at REAL NOT NULL DEFAULT (strftime('%s','now')), + PRIMARY KEY (game_id, clan_id) + )""" + ) + src = con.execute( + "SELECT game_id, squadron, diffs_json, diff_total, updated_json, created_at FROM game_cache" + ).fetchall() + rows = [ + ( + gid, + _resolve_squadron_to_clan_id(s, name_to_id), + s, + dj, + dt, + uj, + ca, + ) + for (gid, s, dj, dt, uj, ca) in src + ] + con.executemany( + "INSERT OR IGNORE INTO game_cache_new " + "(game_id, clan_id, squadron, diffs_json, diff_total, updated_json, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + rows, + ) + con.commit() + log.info("game_cache: copied %d rows", len(rows)) + con.execute("DROP TABLE game_cache") + con.execute("ALTER TABLE game_cache_new RENAME TO game_cache") + con.commit() + finally: + con.close() + + +def rebuild_wl_db(apply: bool) -> None: + """wl_events / wl_standings are empty - swap schemas to clan_id keyed tables.""" + con = sqlite3.connect(WL_DB) + try: + # wl_standings + cols = [r[1] for r in con.execute("PRAGMA table_info(wl_standings)").fetchall()] + if "clan_id" in cols: + log.info("wl_standings already migrated") + else: + log.info("Recreating wl_standings keyed by clan_id (was empty)") + if apply: + con.executescript( + """ + DROP TABLE IF EXISTS wl_standings; + CREATE TABLE wl_standings ( + clan_id INTEGER PRIMARY KEY, + squadron TEXT NOT NULL, + wins INTEGER NOT NULL DEFAULT 0, + losses INTEGER NOT NULL DEFAULT 0 + ); + """ + ) + con.commit() + + # wl_events: store winner_clan_id alongside winner text + cols = [r[1] for r in con.execute("PRAGMA table_info(wl_events)").fetchall()] + if "winner_clan_id" in cols: + log.info("wl_events already migrated") + else: + log.info("Adding winner_clan_id column to wl_events (table is empty)") + if apply: + con.execute("ALTER TABLE wl_events ADD COLUMN winner_clan_id INTEGER") + con.commit() + finally: + con.close() + + +def migrate_preferences( + apply: bool, + long_to_id: dict[str, int], + short_to_id: dict[str, int], + by_id: dict[int, dict[str, Any]], +) -> None: + """Re-key every PREFERENCES/-preferences.json from long_name → str(clan_id). + + Special wildcard keys (Global, everything, all, *) are preserved as-is. + Each migrated entry gets a `Long` field with the squadron's current + long_name so display fallback works if squadrons_data is unavailable. + Original files are copied to PREFERENCES_BACKUP_/. + """ + if not PREFS_DIR.exists(): + log.warning("PREFERENCES dir missing at %s; skipping prefs migration", PREFS_DIR) + return + + ts = time.strftime("%Y%m%d-%H%M%S") + backup_dir = STORAGE_DIR / f"PREFERENCES_BACKUP_{ts}" + if apply: + backup_dir.mkdir(parents=True, exist_ok=True) + + pref_files = sorted(PREFS_DIR.glob("*-preferences.json")) + log.info("Found %d preference files to migrate", len(pref_files)) + + migrated_count = 0 + orphan_count = 0 + skipped_count = 0 + files_changed = 0 + + for pref_file in pref_files: + try: + data = json.loads(pref_file.read_text(encoding="utf-8")) + except Exception as e: + log.warning("Could not read %s: %s", pref_file.name, e) + continue + + if not isinstance(data, dict): + log.warning("Skipping %s: not an object", pref_file.name) + continue + + new_data: dict[str, Any] = {} + any_changes = False + + for key, value in data.items(): + if not isinstance(value, dict): + new_data[key] = value + continue + + key_lower = str(key).lower() + + # Preserve wildcards / Global + if key_lower in WILDCARD_KEYS: + new_data[key] = value + continue + + # Already a numeric clan_id (running migration twice) + if str(key).isdigit(): + new_data[key] = value + continue + + # Try long_name then short_name + cid = long_to_id.get(key_lower) or short_to_id.get(key_lower) + + # Maybe entry has a "Short" hint we can use as fallback + if cid is None and value.get("Short"): + cid = short_to_id.get(str(value["Short"]).lower()) + + if cid is None: + log.warning( + " [%s] orphan key %r — squadron not found in squadrons_data", + pref_file.name, + key, + ) + # Keep the original key so the user doesn't lose their data; + # a future load_guild_preferences will surface it for cleanup. + new_data[key] = value + orphan_count += 1 + continue + + new_key = str(cid) + row = by_id.get(cid, {}) + merged = dict(value) + # Preserve display fields - the bot uses them when squadrons_data is stale + merged.setdefault("Long", row.get("long_name") or key) + if row.get("short_name"): + merged["Short"] = row["short_name"] + + if new_key in new_data: + # Two prefs entries for the same squadron (rare). Merge, + # preferring values from this iteration. + existing = new_data[new_key] + if isinstance(existing, dict): + existing.update(merged) + new_data[new_key] = existing + else: + new_data[new_key] = merged + else: + new_data[new_key] = merged + + if new_key != key: + any_changes = True + migrated_count += 1 + + if not any_changes: + skipped_count += 1 + continue + + if apply: + backup_path = backup_dir / pref_file.name + shutil.copy2(pref_file, backup_path) + tmp = pref_file.with_suffix(".json.tmp") + tmp.write_text(json.dumps(new_data, ensure_ascii=False), encoding="utf-8") + os.replace(tmp, pref_file) + files_changed += 1 + + log.info( + "Preferences migration: %d files changed, %d unchanged, %d entries migrated, %d orphans", + files_changed, + skipped_count, + migrated_count, + orphan_count, + ) + if apply: + log.info("Preference backups saved to %s", backup_dir) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--apply", action="store_true", help="Actually apply changes") + parser.add_argument( + "--dry-run", + action="store_true", + help="Print what would happen without writing anything (default)", + ) + parser.add_argument( + "--skip-backup", + action="store_true", + help="Skip the tarball backup step (use only if you already have one)", + ) + args = parser.parse_args() + + if args.apply and args.dry_run: + log.error("--apply and --dry-run are mutually exclusive") + return 2 + + apply = args.apply + + log.info("STORAGE_DIR=%s", STORAGE_DIR) + log.info("apply=%s", apply) + + for db in (SQ_BATTLES_DB, SQUADRONS_DB, POINTS_DB, WL_DB): + if not db.exists(): + log.error("Required DB missing: %s", db) + return 1 + + if apply and not args.skip_backup: + backup_storage() + + long_to_id, short_to_id, by_id = load_squadron_index() + + ensure_squadron_name_history(apply, by_id) + add_squadrons_points_index(apply) + migrate_player_games_hist(apply, long_to_id) + migrate_match_summary(apply) + rebuild_points_db(apply, long_to_id) + rebuild_wl_db(apply) + migrate_preferences(apply, long_to_id, short_to_id, by_id) + + if not apply: + log.info("DRY RUN complete. Re-run with --apply to actually write changes.") + else: + log.info("Migration applied successfully.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/migrate_replays.py b/scripts/migrate_replays.py new file mode 100644 index 0000000..2ce2fb6 --- /dev/null +++ b/scripts/migrate_replays.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +Move legacy repo-root replays into STORAGE/REPLAYS. + +Legacy directories were named replays/0. The new canonical +layout is STORAGE/REPLAYS/. +""" + +from __future__ import annotations + +import argparse +import os +import re +import shutil +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_SOURCE = REPO_ROOT / "replays" +_storage_env = os.environ.get("SREBOT_STORAGE_VOL_PATH", "").strip() +if not _storage_env: + raise RuntimeError("SREBOT_STORAGE_VOL_PATH must be set") +DEFAULT_STORAGE = Path(_storage_env) + +LEGACY_REPLAY_DIR = re.compile(r"^0([0-9a-fA-F]+)$") +HEX_REPLAY_DIR = re.compile(r"^[0-9a-fA-F]+$") + + +def canonical_name(name: str) -> str | None: + legacy = LEGACY_REPLAY_DIR.fullmatch(name) + if legacy: + return legacy.group(1).lower() + if HEX_REPLAY_DIR.fullmatch(name): + return name.lower() + return None + + +def iter_files(root: Path) -> list[Path]: + return [p for p in root.rglob("*") if p.is_file()] + + +def copy_replay_dir(source: Path, dest: Path, dry_run: bool) -> tuple[int, int]: + copied = 0 + skipped = 0 + for src_file in iter_files(source): + rel = src_file.relative_to(source) + dst_file = dest / rel + if dst_file.exists() and dst_file.stat().st_size == src_file.stat().st_size: + skipped += 1 + continue + copied += 1 + if dry_run: + continue + dst_file.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src_file, dst_file) + return copied, skipped + + +def copied_completely(source: Path, dest: Path) -> bool: + for src_file in iter_files(source): + dst_file = dest / src_file.relative_to(source) + if not dst_file.exists() or dst_file.stat().st_size != src_file.stat().st_size: + return False + return True + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, default=DEFAULT_SOURCE, help="Legacy replays directory.") + parser.add_argument( + "--storage-dir", + type=Path, + default=DEFAULT_STORAGE, + help="Storage root. Destination defaults to /REPLAYS.", + ) + parser.add_argument("--dest", type=Path, help="Destination replay directory.") + parser.add_argument("--execute", action="store_true", help="Actually copy files. Default is dry-run.") + parser.add_argument("--move", action="store_true", help="Remove source dirs after a successful executed copy.") + args = parser.parse_args() + + source = args.source.expanduser().resolve() + dest_root = (args.dest or (args.storage_dir / "REPLAYS")).expanduser().resolve() + dry_run = not args.execute + + if args.move and dry_run: + parser.error("--move requires --execute") + if not source.exists(): + raise SystemExit(f"Source does not exist: {source}") + + planned = 0 + copied_total = 0 + skipped_total = 0 + + print(f"Source: {source}") + print(f"Destination: {dest_root}") + print(f"Mode: {'dry-run' if dry_run else 'execute'}") + + for entry in sorted(source.iterdir()): + if not entry.is_dir(): + continue + new_name = canonical_name(entry.name) + if new_name is None: + print(f"skip non-replay dir: {entry.name}") + continue + + planned += 1 + dest = dest_root / new_name + copied, skipped = copy_replay_dir(entry, dest, dry_run) + copied_total += copied + skipped_total += skipped + print(f"{entry.name} -> {new_name}: copy {copied}, skip {skipped}") + + if args.move and copied_completely(entry, dest): + shutil.rmtree(entry) + print(f"removed source dir: {entry}") + + print(f"Replay dirs: {planned}; files to copy: {copied_total}; already present: {skipped_total}") + if dry_run: + print("Dry-run only. Re-run with --execute to copy files.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/restart-test.sh b/scripts/restart-test.sh new file mode 100755 index 0000000..f6b3e27 --- /dev/null +++ b/scripts/restart-test.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# Restarts srebot-api + srebot-web and times the recovery milestones. +# Usage: bash scripts/restart-test.sh +set -u +T0=$(date +%s.%N) +echo "=== T0: $(date +%H:%M:%S.%3N) restarting srebot-api + srebot-web ===" +pm2 restart srebot-api srebot-web >/dev/null + +echo "--- polling /health (every 0.5s until ready:true) ---" +for i in $(seq 1 120); do + R=$(curl -sS --max-time 2 http://127.0.0.1:6000/health 2>/dev/null || true) + if echo "$R" | grep -q '"ready":true'; then + echo "ready at +$(echo "$(date +%s.%N) - $T0" | bc)s : $R" + break + fi + sleep 0.5 +done + +echo "--- /api/squadrons/1066957 (first cold hit, separate connection) ---" +curl -sS -o /dev/null -w " HTTP=%{http_code} time=%{time_total}s\n" "http://127.0.0.1:6000/api/squadrons/1066957" + +echo "--- 4 leaderboards in parallel (heavyDb concurrent reads) ---" +for ep in stats squadrons players vehicles; do + (curl -sS -o /dev/null -w " $ep: HTTP=%{http_code} time=%{time_total}s\n" "http://127.0.0.1:6000/api/leaderboard/$ep") & +done +wait + +echo "=== full warm at +$(echo "$(date +%s.%N) - $T0" | bc)s ===" +echo "--- pm2 status ---" +pm2 info srebot-api | grep -E "uptime|restart|status" | head -5 diff --git a/server.js b/server.js new file mode 100644 index 0000000..a0b5947 --- /dev/null +++ b/server.js @@ -0,0 +1,5999 @@ +require('dotenv').config(); +const express = require('express'); +const sqlite3 = require('sqlite3').verbose(); +const cors = require('cors'); +const fs = require('fs'); +const path = require('path'); +const zlib = require('zlib'); +const seasonsUtil = require('./web/utils/seasons'); + +/** Parse a JSON column that may be gzip-compressed (Buffer) or plain text (string). */ +function parseJsonColumn(data) { + if (data === null || data === undefined) return null; + if (Buffer.isBuffer(data)) return JSON.parse(zlib.gunzipSync(data).toString('utf-8')); + return JSON.parse(data); +} + +const STORAGE_ROOT = (process.env.SREBOT_STORAGE_VOL_PATH || '').trim(); +if (!STORAGE_ROOT) { + throw new Error('SREBOT_STORAGE_VOL_PATH must be set'); +} +const REPLAYS_PATH = path.join(STORAGE_ROOT, 'REPLAYS'); +const COMPS_PATH = path.join(STORAGE_ROOT, 'COMPS'); +const WL_DB_PATH = path.join(STORAGE_ROOT, 'wl.db'); +const POINTS_DB_PATH = path.join(STORAGE_ROOT, 'points.db'); +fs.mkdirSync(REPLAYS_PATH, { recursive: true }); + +function replayDataPath(sessionId) { + return path.join(REPLAYS_PATH, String(sessionId).toLowerCase(), 'replay_data.json'); +} + +const app = express(); +const PORT = process.env.PORT || 6000; +const API_BEARER_TOKEN = process.env.SREBOT_API_BEARER_TOKEN || ''; +const ADMIN_BEARER_TOKEN = process.env.SREBOT_ADMIN_BEARER_TOKEN || null; +const wlDb = new sqlite3.Database(WL_DB_PATH); +const pointsDb = new sqlite3.Database(POINTS_DB_PATH); + +const log = { + info: (message, extra = {}) => { + console.log(`[${new Date().toISOString()}] [INFO] ${message}`, + extra && Object.keys(extra).length > 0 ? JSON.stringify(extra) : ''); + }, + error: (message, error = null, extra = {}) => { + console.error(`[${new Date().toISOString()}] [ERROR] ${message}`, + error ? error.message || error : '', + extra && Object.keys(extra).length > 0 ? JSON.stringify(extra) : ''); + }, + warn: (message, extra = {}) => { + console.warn(`[${new Date().toISOString()}] [WARN] ${message}`, + extra && Object.keys(extra).length > 0 ? JSON.stringify(extra) : ''); + }, + debug: (message, extra = {}) => { + if (process.env.NODE_ENV === 'development') { + console.log(`[${new Date().toISOString()}] [DEBUG] ${message}`, + extra && Object.keys(extra).length > 0 ? JSON.stringify(extra) : ''); + } + } +}; + +app.use(cors()); +app.use(express.json()); + +function requireApiBearer(req, res, next) { + if (!API_BEARER_TOKEN) return next(); + + const auth = req.get('authorization') || ''; + if (auth === `Bearer ${API_BEARER_TOKEN}`) { + return next(); + } + + return res.status(401).json({ error: 'Unauthorized' }); +} + +function requireAdminBearer(req, res, next) { + if (!ADMIN_BEARER_TOKEN) return res.status(403).json({ error: 'Admin access not configured' }); + const auth = req.get('authorization') || ''; + if (auth === `Bearer ${ADMIN_BEARER_TOKEN}`) return next(); + return res.status(403).json({ error: 'Forbidden' }); +} + +app.use('/api', requireApiBearer); + +// Readiness gate: heavy aggregation endpoints sit behind this so cold-start +// requests don't pile up on the read connection while the DB is still opening +// indexes and the vehicle-list cache is warming. Resolves when boot work +// completes; falls back to a 15s safety timer. +let serverReady = false; +const STARTUP_GRACE_MS = 15000; +const startupReadyAt = Date.now(); +let resolveReady; +const serverReadyPromise = new Promise((resolve) => { resolveReady = resolve; }); +function markServerReady(reason) { + if (serverReady) return; + serverReady = true; + log.info('Server ready', { reason, ms: Date.now() - startupReadyAt }); + resolveReady(); +} +setTimeout(() => markServerReady('grace_timer'), STARTUP_GRACE_MS); + +const HEAVY_PATH_PATTERN = /^\/api\/(leaderboard|analytics)\b/; +app.use((req, res, next) => { + if (serverReady || !HEAVY_PATH_PATTERN.test(req.path)) return next(); + const waitStart = Date.now(); + const onReady = () => { + if (res.headersSent) return; + next(); + }; + const timer = setTimeout(() => { + if (res.headersSent) return; + res.set('Retry-After', '5'); + res.status(503).json({ + error: 'API warming up, retry shortly', + errorCode: 'API_NOT_READY', + waited_ms: Date.now() - waitStart, + }); + }, 8000); + serverReadyPromise.then(() => { clearTimeout(timer); onReady(); }); +}); + +const responseCache = new Map(); +const CACHE_TTL = 5 * 60 * 1000; +const STATS_CACHE_TTL = 30 * 60 * 1000; // Global stats change slowly — cache longer to avoid frequent 6s+ rebuilds +const inflightRequests = new Map(); // dedup: key -> Promise +let squadronLookupCache = null; +let squadronLookupCacheTime = 0; +const SQUADRON_CACHE_TTL = 30 * 60 * 1000; +let hasSquadronColumn = false; +let nickLookupCache = null; +let nickLookupCacheTime = 0; +const performanceBenchmarkCache = new Map(); +const performanceBenchmarkInFlight = new Map(); +const PERFORMANCE_BENCHMARK_CACHE_TTL = 60 * 60 * 1000; + +function getCachedResponse(key, ttl = CACHE_TTL) { + const cached = responseCache.get(key); + if (cached && Date.now() - cached.timestamp < ttl) { + return cached.data; + } + responseCache.delete(key); + return null; +} + +function setCachedResponse(key, data) { + responseCache.set(key, { data, timestamp: Date.now() }); + if (responseCache.size > 100) { + const oldestKey = responseCache.keys().next().value; + responseCache.delete(oldestKey); + } +} + +function runWalCheckpoint(mode, successMessage, errorMessage, logMethod = 'info') { + db.run('PRAGMA busy_timeout = 10000;', (busyErr) => { + if (busyErr) { + log.error(`${errorMessage} (busy timeout setup failed):`, busyErr); + return; + } + db.run(`PRAGMA wal_checkpoint(${mode});`, (err) => { + if (err) { + log.error(errorMessage, err); + } else { + log[logMethod](successMessage); + } + }); + }); +} + +// Dedup wrapper: if a query for this key is already in flight, wait for it +function dedup(key, worker) { + const existing = inflightRequests.get(key); + if (existing) { + log.info(`Dedup: waiting on in-flight request for ${key}`); + return existing; + } + const promise = worker().finally(() => inflightRequests.delete(key)); + inflightRequests.set(key, promise); + return promise; +} + +// Best-effort sync lookup of a squadron name → clan_id using the warm cache. +// Returns null if the cache isn't populated yet or the input doesn't resolve. +// Callers fall back to text matching when this returns null. +function resolveClanIdSync(input) { + if (!squadronLookupCache || !input) return null; + const direct = squadronLookupCache[input] + || squadronLookupCache[String(input)] + || squadronLookupCache[String(input).toLowerCase()]; + if (direct && direct.clan_id != null) return Number(direct.clan_id); + return null; +} + +// Resolve a squadron URL parameter to { clanId, variants } using the warm +// cache. variants is the union of (current long_name, short_name, tag_name, +// the raw input) — used for text-fallback matching against pre-migration +// rows whose clan_id wasn't backfilled. clanId is the canonical id when +// known. Both fields may be null/empty if the cache hasn't loaded yet. +function resolveSquadronFilter(input) { + const variants = new Set(); + const raw = input == null ? '' : String(input).trim(); + if (raw) variants.add(raw); + let clanId = null; + if (squadronLookupCache && raw) { + const row = squadronLookupCache[raw] + || squadronLookupCache[raw.toLowerCase()]; + if (row) { + if (row.clan_id != null) clanId = Number(row.clan_id); + if (row.long_name) variants.add(row.long_name); + if (row.short_name) variants.add(row.short_name); + if (row.tag_name) variants.add(row.tag_name); + } + } + return { clanId, variants: Array.from(variants) }; +} + +// Build a WHERE clause for match_summary that matches matches involving the +// given squadron. Prefers winning_clan_id/losing_clan_id when available; +// falls back to text variants for rows whose clan_id wasn't backfilled. +function matchSummarySquadronWhere(filter) { + const parts = []; + const params = []; + if (filter.clanId != null) { + parts.push('winning_clan_id = ?'); + params.push(filter.clanId); + parts.push('losing_clan_id = ?'); + params.push(filter.clanId); + } + if (filter.variants.length) { + const ph = filter.variants.map(() => '?').join(','); + const fallbackGuard = filter.clanId != null + ? '(winning_clan_id IS NULL OR losing_clan_id IS NULL) AND ' + : ''; + parts.push(`(${fallbackGuard}(winning_sq IN (${ph}) OR losing_sq IN (${ph})))`); + params.push(...filter.variants, ...filter.variants); + } + if (!parts.length) { + return { clause: '0', params: [] }; + } + return { clause: `(${parts.join(' OR ')})`, params }; +} + +// True if the row (with winning_sq/losing_sq/winning_clan_id/losing_clan_id) +// represents a win for the resolved squadron. +function rowIsWinFor(row, filter, variantSet) { + if (filter.clanId != null && row.winning_clan_id != null) { + return Number(row.winning_clan_id) === filter.clanId; + } + return !!(row.winning_sq && variantSet.has(row.winning_sq)); +} + +function rowIsLossFor(row, filter, variantSet) { + if (filter.clanId != null && row.losing_clan_id != null) { + return Number(row.losing_clan_id) === filter.clanId; + } + return !!(row.losing_sq && variantSet.has(row.losing_sq)); +} + +// Build a WHERE fragment for player_games_hist `p` that selects rows for the +// resolved squadron. Prefers p.clan_id when known; falls back to variant text. +function playerGamesHistSquadronWhere(filter, alias = 'p') { + const a = alias ? `${alias}.` : ''; + const parts = []; + const params = []; + if (filter.clanId != null) { + parts.push(`${a}clan_id = ?`); + params.push(filter.clanId); + } + if (filter.variants.length) { + const ph = filter.variants.map(() => '?').join(','); + const fallbackGuard = filter.clanId != null + ? `${a}clan_id IS NULL AND ` + : ''; + parts.push(`(${fallbackGuard}${a}squadron_name IN (${ph}))`); + params.push(...filter.variants); + } + if (!parts.length) return { clause: '0', params: [] }; + return { clause: `(${parts.join(' OR ')})`, params }; +} + +function loadSquadronLookupCached(callback) { + if (squadronLookupCache && Date.now() - squadronLookupCacheTime < SQUADRON_CACHE_TTL) { + return callback(squadronLookupCache); + } + + const squadronsDbPath = path.join(STORAGE_ROOT, 'squadrons.db'); + if (!fs.existsSync(squadronsDbPath)) { + squadronLookupCache = {}; + squadronLookupCacheTime = Date.now(); + return callback({}); + } + + const squadronsDb = new sqlite3.Database(squadronsDbPath, sqlite3.OPEN_READONLY, (err) => { + if (err) { + log.error('Failed to open squadrons database', err); + squadronLookupCache = {}; + squadronLookupCacheTime = Date.now(); + return callback({}); + } + + squadronsDb.all(`SELECT clan_id, long_name, short_name, tag_name, members, clanrating FROM squadrons_data`, [], (err, rows) => { + if (err) { + squadronsDb.close(); + log.error('Error loading squadron lookup data', err); + squadronLookupCache = {}; + squadronLookupCacheTime = Date.now(); + return callback(squadronLookupCache); + } + + const lookup = {}; + const addAlias = (key, row) => { + if (key === undefined || key === null || key === '') return; + lookup[key] = row; + const lower = String(key).toLowerCase(); + if (!(lower in lookup)) { + lookup[lower] = row; + } + }; + rows.forEach(row => { + if (row.long_name) addAlias(row.long_name, row); + if (row.short_name) addAlias(row.short_name, row); + if (row.tag_name) addAlias(row.tag_name, row); + if (row.clan_id !== null && row.clan_id !== undefined) { + addAlias(String(row.clan_id), row); + } + }); + squadronLookupCache = lookup; + squadronLookupCacheTime = Date.now(); // set immediately — prevents thundering herd of concurrent writes + log.info('Squadron lookup cache updated', { entries: rows.length }); + + // Detect renames: any (clan_id, name) pair we've never seen before + // is recorded in squadron_name_history so old-name web URLs can + // resolve to the canonical clan_id via 301 redirect, and so the + // squadron profile can include pre-rename history. We track all + // three name forms (long_name, short_name, tag_name) since + // short tags rename more often than long names. + const now = Math.floor(Date.now() / 1000); + const historyRows = []; + for (const r of rows) { + if (r.clan_id == null) continue; + if (r.long_name) historyRows.push([r.clan_id, 'long', r.long_name, now, now]); + if (r.short_name) historyRows.push([r.clan_id, 'short', r.short_name, now, now]); + if (r.tag_name) historyRows.push([r.clan_id, 'tag', r.tag_name, now, now]); + } + // Call callback immediately — the write below is best-effort name-history + // tracking and must never block callers waiting for the lookup data. + squadronsDb.close(); + callback(squadronLookupCache); + + if (historyRows.length) { + // Background write: record rename history in squadrons.db. + // Runs fully detached from the caller — errors are logged and swallowed. + const writeDb = new sqlite3.Database(squadronsDbPath, sqlite3.OPEN_READWRITE, (werr) => { + if (werr) { + log.warn('squadron_name_history: could not open for writing', { error: werr.message }); + return; + } + + let finished = false; + const finalize = () => { + if (finished) return; + finished = true; + try { writeDb.close(); } catch (_) {} + }; + writeDb.on('error', (e) => { + log.warn('squadron_name_history write failed (likely BUSY); skipping', { error: e.message }); + finalize(); + }); + + writeDb.serialize(() => { + writeDb.run('PRAGMA busy_timeout = 5000', () => {}); + + writeDb.run( + `CREATE TABLE IF NOT EXISTS squadron_name_history ( + clan_id INTEGER NOT NULL, + long_name TEXT NOT NULL, + first_seen INTEGER NOT NULL, + last_seen INTEGER NOT NULL, + PRIMARY KEY (clan_id, long_name) + )`, + () => {} + ); + writeDb.run(`CREATE INDEX IF NOT EXISTS idx_snh_long_name ON squadron_name_history(long_name COLLATE NOCASE)`, () => {}); + writeDb.run( + `CREATE TABLE IF NOT EXISTS squadron_name_aliases ( + clan_id INTEGER NOT NULL, + kind TEXT NOT NULL, + name TEXT NOT NULL, + first_seen INTEGER NOT NULL, + last_seen INTEGER NOT NULL, + PRIMARY KEY (clan_id, kind, name) + )`, + () => {} + ); + writeDb.run(`CREATE INDEX IF NOT EXISTS idx_sna_name ON squadron_name_aliases(name COLLATE NOCASE)`, () => {}); + writeDb.run(`CREATE INDEX IF NOT EXISTS idx_sna_clanid ON squadron_name_aliases(clan_id)`, () => {}); + + const longStmt = writeDb.prepare( + `INSERT INTO squadron_name_history (clan_id, long_name, first_seen, last_seen) + VALUES (?, ?, ?, ?) + ON CONFLICT(clan_id, long_name) DO UPDATE SET last_seen = excluded.last_seen` + ); + for (const r of rows) { + if (r.clan_id != null && r.long_name) { + longStmt.run([r.clan_id, r.long_name, now, now], () => {}); + } + } + longStmt.finalize(() => {}); + + const aliasStmt = writeDb.prepare( + `INSERT INTO squadron_name_aliases (clan_id, kind, name, first_seen, last_seen) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(clan_id, kind, name) DO UPDATE SET last_seen = excluded.last_seen` + ); + historyRows.forEach(args => aliasStmt.run(args, () => {})); + aliasStmt.finalize(() => { finalize(); }); + }); + }); + } + }); + }); +} + +function loadNickLookupCached(callback) { + if (nickLookupCache && Date.now() - nickLookupCacheTime < SQUADRON_CACHE_TTL) { + return callback(nickLookupCache); + } + + const squadronsDbPath = path.join(STORAGE_ROOT, 'squadrons.db'); + if (!fs.existsSync(squadronsDbPath)) { + nickLookupCache = {}; + nickLookupCacheTime = Date.now(); + return callback({}); + } + + const squadronsDb = new sqlite3.Database(squadronsDbPath, sqlite3.OPEN_READONLY, (err) => { + if (err) { + log.error('Failed to open squadrons database for nick lookup', err); + nickLookupCache = {}; + nickLookupCacheTime = Date.now(); + return callback({}); + } + + squadronsDb.all(` + SELECT sm.uid, sm.nick, sm.clan_id AS sm_clan_id, sd.clan_id AS sd_clan_id, sd.tag_name, sd.short_name + FROM squadron_members sm + LEFT JOIN squadrons_data sd ON sm.clan_id = sd.clan_id + `, [], (err, rows) => { + squadronsDb.close(); + + if (err) { + log.error('Error loading nick lookup data', err); + nickLookupCache = {}; + } else { + const lookup = {}; + rows.forEach(row => { + lookup[row.uid] = { + nick: row.nick, + clan_id: row.sd_clan_id || row.sm_clan_id || null, + tag_name: row.tag_name, + short_name: row.short_name + }; + }); + nickLookupCache = lookup; + log.info('Nick lookup cache updated', { entries: rows.length }); + } + + nickLookupCacheTime = Date.now(); + callback(nickLookupCache); + }); + }); +} + +function dbAll(query, params = []) { + return new Promise((resolve, reject) => { + db.all(query, params, (err, rows) => err ? reject(err) : resolve(rows || [])); + }); +} + +function dbAllHeavy(query, params = []) { + return new Promise((resolve, reject) => { + heavyDb.all(query, params, (err, rows) => err ? reject(err) : resolve(rows || [])); + }); +} + +function dbGet(query, params = []) { + return new Promise((resolve, reject) => { + db.get(query, params, (err, row) => err ? reject(err) : resolve(row || null)); + }); +} + +function dateFilterParams(dateFilters) { + return [ + dateFilters.startTimestamp, dateFilters.startTimestamp, + dateFilters.endTimestamp, dateFilters.endTimestamp + ]; +} + +function buildDateClause(alias, dateFilters) { + const prefix = alias ? `${alias}.` : ''; + const parts = []; + const params = []; + if (dateFilters.startTimestamp !== null && dateFilters.startTimestamp !== undefined) { + parts.push(`${prefix}endtime_unix >= ?`); + params.push(dateFilters.startTimestamp); + } + if (dateFilters.endTimestamp !== null && dateFilters.endTimestamp !== undefined) { + parts.push(`${prefix}endtime_unix <= ?`); + params.push(dateFilters.endTimestamp); + } + return { + clause: parts.length ? ` AND ${parts.join(' AND ')}` : '', + params + }; +} + +function performanceBenchmarkKey(dateFilters) { + return `${dateFilters.startTimestamp || 'all'}_${dateFilters.endTimestamp || 'all'}`; +} + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +function normalizeRatingStats(row) { + const games = Math.max(0, Number(row && (row.games ?? row.total_battles)) || 0); + const kills = Math.max(0, Number(row && row.total_kills) || 0); + const deaths = Math.max(0, Number(row && (row.total_deaths ?? row.deaths)) || 0); + const assists = Math.max(0, Number(row && (row.total_assists ?? row.assists)) || 0); + const captures = Math.max(0, Number(row && (row.total_captures ?? row.captures)) || 0); + const wins = Math.max(0, Number(row && row.wins) || 0); + const heavyweight = Math.max(0, Number(row && row.heavy_score) || 0); + return { + games, + kdr: deaths > 0 ? kills / deaths : kills, + kills_per_game: games > 0 ? kills / games : 0, + heavy_rate: games > 0 ? heavyweight / games : 0, + win_rate: games > 0 ? (wins / games) * 100 : 0, + assists_per_game: games > 0 ? assists / games : 0, + captures_per_game: games > 0 ? captures / games : 0, + deaths_per_game: games > 0 ? deaths / games : 0, + games_log: Math.log1p(games) + }; +} + +function sortedMetric(values, metric) { + return values.map(value => Number(value[metric]) || 0).sort((a, b) => a - b); +} + +function buildRatingBenchmark(rows) { + const normalized = (rows || []).map(normalizeRatingStats); + return { + metrics: { + kdr: sortedMetric(normalized, 'kdr'), + kills_per_game: sortedMetric(normalized, 'kills_per_game'), + heavy_rate: sortedMetric(normalized, 'heavy_rate'), + win_rate: sortedMetric(normalized, 'win_rate'), + assists_per_game: sortedMetric(normalized, 'assists_per_game'), + captures_per_game: sortedMetric(normalized, 'captures_per_game'), + deaths_per_game: sortedMetric(normalized, 'deaths_per_game'), + games_log: sortedMetric(normalized, 'games_log') + } + }; +} + +function resolveSquadronRatingKey(name, squadronLookup) { + if (!name) return null; + const resolved = resolveSquadronIdentity(null, { squadron_name: name }, squadronLookup); + if (resolved && resolved.clan_id) { + return `clan:${resolved.clan_id}`; + } + const fallbackName = resolved?.tag_name || resolved?.short_name || name; + return `name:${String(fallbackName).toLowerCase()}`; +} + +function aggregateSquadronBenchmarkRows(rows, squadronLookup) { + const grouped = new Map(); + for (const row of rows || []) { + const ratingName = row?.squadron_name || row?.entity_key; + const key = resolveSquadronRatingKey(ratingName, squadronLookup); + if (!key) continue; + if (!grouped.has(key)) { + grouped.set(key, { + clan_id: null, + squadron_name: ratingName || null, + games: 0, + total_kills: 0, + total_assists: 0, + total_captures: 0, + total_deaths: 0, + wins: 0, + heavy_score: 0 + }); + } + const acc = grouped.get(key); + const resolved = resolveSquadronIdentity(null, { squadron_name: ratingName }, squadronLookup); + if (!acc.clan_id && resolved?.clan_id) { + acc.clan_id = resolved.clan_id; + } + acc.games += Number(row.games) || Number(row.total_battles) || 0; + acc.total_kills += Number(row.total_kills) || 0; + acc.total_assists += Number(row.total_assists) || 0; + acc.total_captures += Number(row.total_captures) || 0; + acc.total_deaths += Number(row.total_deaths) || 0; + acc.wins += Number(row.wins) || 0; + acc.heavy_score += Number(row.heavy_score) || 0; + } + return Array.from(grouped.values()); +} + +function upperBound(values, value) { + let lo = 0; + let hi = values.length; + while (lo < hi) { + const mid = Math.floor((lo + hi) / 2); + if (values[mid] <= value) lo = mid + 1; + else hi = mid; + } + return lo; +} + +function lowerBound(values, value) { + let lo = 0; + let hi = values.length; + while (lo < hi) { + const mid = Math.floor((lo + hi) / 2); + if (values[mid] < value) lo = mid + 1; + else hi = mid; + } + return lo; +} + +function metricPercentile(value, values, lowerIsBetter = false) { + if (!Array.isArray(values) || !values.length) return 0; + if (lowerIsBetter) { + return (values.length - lowerBound(values, value)) / values.length; + } + return upperBound(values, value) / values.length; +} + +function computePerformanceScore(row, benchmarkSet) { + const stats = normalizeRatingStats(row); + const metrics = (benchmarkSet && benchmarkSet.metrics) || {}; + const weighted = + (0.32 * metricPercentile(stats.kdr, metrics.kdr)) + + (0.23 * metricPercentile(stats.heavy_rate, metrics.heavy_rate)) + + (0.14 * metricPercentile(stats.kills_per_game, metrics.kills_per_game)) + + (0.10 * metricPercentile(stats.games_log, metrics.games_log)) + + (0.06 * metricPercentile(stats.win_rate, metrics.win_rate)) + + (0.06 * metricPercentile(stats.assists_per_game, metrics.assists_per_game)) + + (0.04 * metricPercentile(stats.captures_per_game, metrics.captures_per_game)) + + (0.05 * metricPercentile(stats.deaths_per_game, metrics.deaths_per_game, true)); + const sample = clamp(Math.sqrt(stats.games / 10), 0, 1); + return Number(clamp(5 * weighted * sample, 0, 5).toFixed(2)); +} + +function ratingCtes(dateFilters, entityExpression, targetSessionsCte = '') { + const dateClause = buildDateClause('p', dateFilters).clause; + return ` + WITH ${targetSessionsCte ? `${targetSessionsCte},` : ''} + player_sessions AS ( + SELECT + ${entityExpression} AS entity_key, + p.UID, + p.session_id, + SUM(p.ground_kills + p.air_kills) AS kills, + SUM(p.assists) AS assists, + SUM(p.captures) AS captures, + SUM(p.deaths) AS deaths, + MAX(CASE WHEN UPPER(p.victor_bool) = 'WIN' THEN 1 ELSE 0 END) AS win + FROM player_games_hist p + ${targetSessionsCte ? 'JOIN target_sessions ts ON ts.session_id = p.session_id' : ''} + WHERE p.UID IS NOT NULL + AND p.nick NOT LIKE 'coop/%' + ${dateClause} + GROUP BY entity_key, p.UID, p.session_id + ), + leader_stats AS ( + SELECT ps.session_id, MAX(ps.kills) AS max_kills + FROM player_sessions ps + GROUP BY ps.session_id + ), + leader_counts AS ( + SELECT ps.session_id, COUNT(*) AS top_count + FROM player_sessions ps + JOIN leader_stats ls ON ls.session_id = ps.session_id AND ls.max_kills = ps.kills + GROUP BY ps.session_id + ), + second_stats AS ( + SELECT ps.session_id, MAX(ps.kills) AS second_kills + FROM player_sessions ps + JOIN leader_stats ls ON ls.session_id = ps.session_id + WHERE ps.kills < ls.max_kills + GROUP BY ps.session_id + ), + scored_sessions AS ( + SELECT + ps.*, + CASE + WHEN ps.kills >= 3 + AND ps.kills = ls.max_kills + AND lc.top_count = 1 + AND (ps.kills - COALESCE(ss.second_kills, 0)) >= 2 + THEN MIN(1.0, (ps.kills - 2) / 2.0) + WHEN ps.kills >= 3 + AND ps.kills = ls.max_kills + AND lc.top_count = 1 + THEN 0.6 * MIN(1.0, (ps.kills - 2) / 2.0) + ELSE 0 + END AS heavy_score + FROM player_sessions ps + JOIN leader_stats ls ON ls.session_id = ps.session_id + JOIN leader_counts lc ON lc.session_id = ps.session_id + LEFT JOIN second_stats ss ON ss.session_id = ps.session_id + ) + `; +} + +function playerBenchmarkQuery(dateFilters) { + const dateClause = buildDateClause('p', dateFilters); + return { + query: ` + ${ratingCtes(dateFilters, 'p.UID')} + SELECT + entity_key, + entity_key AS squadron_name, + COUNT(*) AS games, + SUM(kills) AS total_kills, + SUM(assists) AS total_assists, + SUM(captures) AS total_captures, + SUM(deaths) AS total_deaths, + SUM(win) AS wins, + SUM(heavy_score) AS heavy_score + FROM scored_sessions + GROUP BY entity_key + HAVING COUNT(*) >= 10 + `, + params: dateClause.params + }; +} + +function squadronBenchmarkQuery(dateFilters) { + const dateClause = buildDateClause('p', dateFilters); + return { + query: ` + ${ratingCtes(dateFilters, 'p.squadron_name')}, + squadron_sessions AS ( + SELECT + entity_key, + session_id, + SUM(kills) AS kills, + SUM(assists) AS assists, + SUM(captures) AS captures, + SUM(deaths) AS deaths, + MAX(win) AS win, + SUM(heavy_score) AS heavy_score + FROM scored_sessions + WHERE entity_key IS NOT NULL AND entity_key != 'UNKNOWN' + GROUP BY entity_key, session_id + ) + SELECT + entity_key, + COUNT(*) AS games, + SUM(kills) AS total_kills, + SUM(assists) AS total_assists, + SUM(captures) AS total_captures, + SUM(deaths) AS total_deaths, + SUM(win) AS wins, + SUM(heavy_score) AS heavy_score + FROM squadron_sessions + GROUP BY entity_key + HAVING COUNT(*) >= 10 + `, + params: dateClause.params + }; +} + +function queryPlayerRatingStats(uid, dateFilters) { + const sessionDate = buildDateClause('src', dateFilters); + const playerDate = buildDateClause('p', dateFilters); + const targetSessionsCte = ` + target_sessions AS ( + SELECT DISTINCT session_id + FROM player_games_hist src + WHERE src.UID = ? + AND src.nick NOT LIKE 'coop/%' + ${sessionDate.clause} + ) + `; + const query = ` + ${ratingCtes(dateFilters, 'p.UID', targetSessionsCte)} + SELECT + entity_key, + COUNT(*) AS games, + SUM(kills) AS total_kills, + SUM(assists) AS total_assists, + SUM(captures) AS total_captures, + SUM(deaths) AS total_deaths, + SUM(win) AS wins, + SUM(heavy_score) AS heavy_score + FROM scored_sessions + WHERE entity_key = ? + GROUP BY entity_key + `; + return dbGet(query, [uid, ...sessionDate.params, ...playerDate.params, uid]).catch(err => { + log.error('Failed to query player ELO stats', err, { uid }); + return null; + }); +} + +function queryPlayerRatingStatsForUids(uids, dateFilters) { + const ids = [...new Set((uids || []).map(uid => String(uid)).filter(Boolean))]; + if (!ids.length) return Promise.resolve(new Map()); + const placeholders = ids.map(() => '?').join(','); + const sessionDate = buildDateClause('src', dateFilters); + const playerDate = buildDateClause('p', dateFilters); + const targetSessionsCte = ` + target_sessions AS ( + SELECT DISTINCT session_id + FROM player_games_hist src + WHERE src.UID IN (${placeholders}) + AND src.nick NOT LIKE 'coop/%' + ${sessionDate.clause} + ) + `; + const query = ` + ${ratingCtes(dateFilters, 'p.UID', targetSessionsCte)} + SELECT + entity_key, + COUNT(*) AS games, + SUM(kills) AS total_kills, + SUM(assists) AS total_assists, + SUM(captures) AS total_captures, + SUM(deaths) AS total_deaths, + SUM(win) AS wins, + SUM(heavy_score) AS heavy_score + FROM scored_sessions + WHERE entity_key IN (${placeholders}) + GROUP BY entity_key + `; + return dbAll(query, [...ids, ...sessionDate.params, ...playerDate.params, ...ids]) + .then(rows => { + const map = new Map(); + rows.forEach(row => map.set(String(row.entity_key), row)); + return map; + }) + .catch(err => { + log.error('Failed to query roster player ELO stats', err, { count: ids.length }); + return new Map(); + }); +} + +function querySquadronRatingStats(variants, dateFilters, clanId = null) { + const names = [...new Set((variants || []).filter(Boolean))]; + if (!names.length) return Promise.resolve(null); + const placeholders = names.map(() => '?').join(','); + const sessionDate = buildDateClause('src', dateFilters); + const playerDate = buildDateClause('p', dateFilters); + const targetSessionsCte = ` + target_sessions AS ( + SELECT DISTINCT session_id + FROM player_games_hist src + WHERE src.squadron_name IN (${placeholders}) + AND src.nick NOT LIKE 'coop/%' + ${sessionDate.clause} + ) + `; + const query = ` + ${ratingCtes(dateFilters, 'p.squadron_name', targetSessionsCte)}, + squadron_sessions AS ( + SELECT + entity_key, + session_id, + SUM(kills) AS kills, + SUM(assists) AS assists, + SUM(captures) AS captures, + SUM(deaths) AS deaths, + MAX(win) AS win, + SUM(heavy_score) AS heavy_score + FROM scored_sessions + WHERE entity_key IN (${placeholders}) + GROUP BY entity_key, session_id + ) + SELECT + 'squadron' AS entity_key, + COUNT(*) AS games, + SUM(kills) AS total_kills, + SUM(assists) AS total_assists, + SUM(captures) AS total_captures, + SUM(deaths) AS total_deaths, + SUM(win) AS wins, + SUM(heavy_score) AS heavy_score + FROM squadron_sessions + `; + return dbGet(query, [...names, ...sessionDate.params, ...playerDate.params]).then(row => { + if (!row) return null; + return { + ...row, + clan_id: clanId + }; + }).catch(err => { + log.error('Failed to query squadron ELO stats', err, { variants: names }); + return null; + }); +} + +function loadPerformanceBenchmarksCached(dateFilters, callback) { + const key = performanceBenchmarkKey(dateFilters); + const cached = performanceBenchmarkCache.get(key); + const isFresh = cached && Date.now() - cached.timestamp < PERFORMANCE_BENCHMARK_CACHE_TTL; + + if (isFresh) { + return callback(cached.data); + } + + // Stale-while-revalidate: return immediately (stale or empty), refresh in background. + // The all-time benchmark query takes ~47s and must not block response time. + const emptyBenchmarks = { players: buildRatingBenchmark([]), squadrons: buildRatingBenchmark([]) }; + callback(cached ? cached.data : emptyBenchmarks); + + if (performanceBenchmarkInFlight.has(key)) return; + + const loadSquadronLookupPromise = () => new Promise(resolve => loadSquadronLookupCached(resolve)); + + const promise = Promise.all([ + loadSquadronLookupPromise(), + (() => { + const benchmarkQuery = playerBenchmarkQuery(dateFilters); + return dbAllHeavy(benchmarkQuery.query, benchmarkQuery.params).catch(err => { + log.error('Failed to load player ELO benchmarks', err); + return []; + }); + })(), + (() => { + const benchmarkQuery = squadronBenchmarkQuery(dateFilters); + return dbAllHeavy(benchmarkQuery.query, benchmarkQuery.params).catch(err => { + log.error('Failed to load squadron ELO benchmarks', err); + return []; + }); + })() + ]).then(([squadronLookup, playerRows, squadronRows]) => { + const data = { + players: buildRatingBenchmark(playerRows), + squadrons: buildRatingBenchmark(aggregateSquadronBenchmarkRows(squadronRows, squadronLookup)) + }; + performanceBenchmarkCache.set(key, { data, timestamp: Date.now() }); + if (performanceBenchmarkCache.size > 12) { + performanceBenchmarkCache.delete(performanceBenchmarkCache.keys().next().value); + } + performanceBenchmarkInFlight.delete(key); + log.info('Performance benchmarks refreshed in background', { key }); + return data; + }).catch(err => { + performanceBenchmarkInFlight.delete(key); + log.error('Failed to load ELO benchmarks', err); + }); + + performanceBenchmarkInFlight.set(key, promise); +} + +function normalizeVehicleName(name) { + if (!name) return name; + // Keep visible decoration glyphs (◢ ▄ ◊ ␗ etc.) — those are country / + // event indicators and the website is expected to display them. Only strip + // the Private Use Area, where older WT variants stored sprite refs that + // render as tofu in any browser font. + return name.replace(/[\uE000-\uF8FF]/g, '').trim(); +} + +function normalizeQueryList(value) { + if (value === undefined || value === null) return []; + const rawValues = Array.isArray(value) ? value : [value]; + return rawValues + .flatMap(item => String(item).split(',')) + .map(item => item.trim()) + .filter(Boolean); +} + +// ─── Vehicle meta cache ──────────────────────────────────────────────── +// Loaded from BOT/utils.py-generated cache files. `vehicle_data_cache.json` +// is an array of [cdk, english_name, icon, tags{}] entries; we use it for +// type abbreviations (T/F/B/AA/L/H) and English fallback names. + +// Tags pulled directly from unittags.blk via vehicle_data_cache.json. Helicopters +// in particular can show up under type_attack_helicopter / type_utility_helicopter, +// not the generic type_helicopter the bot's data_parser injects, so we list every +// concrete type tag we've observed in the cache. +const TAG_TO_ABBREV = { + // SPAA + type_spaa: 'AA', + // Light tanks + type_light_tank: 'L', + // Tanks (heavy/medium/TD/missile) + type_heavy_tank: 'T', + type_medium_tank: 'T', + type_tank_destroyer: 'T', + type_missile_tank: 'T', + type_football_tank: 'T', + type_assault: 'T', + tank: 'T', + // Fighters (incl. interceptors / strike) + type_fighter: 'F', + type_jet_fighter: 'F', + type_interceptor: 'F', + type_aa_fighter: 'F', + type_strike_aircraft: 'F', + type_strike_ucav: 'F', + // Bombers + type_bomber: 'B', + type_jet_bomber: 'B', + type_dive_bomber: 'B', + type_light_bomber: 'B', + type_frontline_bomber: 'B', + type_longrange_bomber: 'B', + // Helicopters + type_attack_helicopter: 'H', + type_utility_helicopter: 'H', + type_helicopter: 'H', + helicopter: 'H', +}; + +let _vehicleMetaCache = null; // cdk (lower) -> { name, type, cdk } + +function loadVehicleMetaCache() { + if (_vehicleMetaCache) return _vehicleMetaCache; + const cachePath = path.join(STORAGE_ROOT, 'CACHE', 'vehicle_data_cache_all.json'); + const fallback = path.join(STORAGE_ROOT, 'CACHE', 'vehicle_data_cache.json'); + const target = fs.existsSync(cachePath) ? cachePath : (fs.existsSync(fallback) ? fallback : null); + if (!target) { + log.error(`Vehicle meta cache not found at ${cachePath} or ${fallback}`); + _vehicleMetaCache = new Map(); + return _vehicleMetaCache; + } + try { + const raw = JSON.parse(fs.readFileSync(target, 'utf-8')); + const map = new Map(); + for (const entry of raw) { + if (!Array.isArray(entry) || entry.length < 4) continue; + const cdk = entry[0]; + const name = entry[1]; + const tags = entry[3] || {}; + // Best match: prefer specific type_* tags over generic ones. + let abbrev = '?'; + let fallbackAbbrev = null; + for (const tag of Object.keys(tags)) { + if (!(tag in TAG_TO_ABBREV)) continue; + if (tag.startsWith('type_')) { abbrev = TAG_TO_ABBREV[tag]; break; } + if (fallbackAbbrev == null) fallbackAbbrev = TAG_TO_ABBREV[tag]; + } + if (abbrev === '?' && fallbackAbbrev) abbrev = fallbackAbbrev; + map.set(String(cdk).toLowerCase(), { name, type: abbrev, cdk }); + } + _vehicleMetaCache = map; + log.info(`Loaded vehicle meta for ${map.size} vehicles from ${path.basename(target)}`); + } catch (e) { + log.error('Failed to load vehicle meta cache', e); + _vehicleMetaCache = new Map(); + } + return _vehicleMetaCache; +} + +function getVehicleType(internal) { + if (!internal) return '?'; + const map = loadVehicleMetaCache(); + const hit = map.get(String(internal).toLowerCase()); + return hit ? hit.type : '?'; +} + +// Same order the bot's /comp uses so notation matches across surfaces. +const COMP_TYPE_ORDER = ['F', 'B', 'H', 'L', 'T', 'AA', '?']; + +function buildCompNotation(typeCounts) { + const parts = []; + for (const code of COMP_TYPE_ORDER) { + const n = typeCounts[code] || 0; + if (n > 0) parts.push(`${n}${code}`); + } + return parts.join(' '); +} + +// Canonicalize a player's squadron identity using the squadrons_data lookup. +// Prefers clan_id from the squadron_members cache (joined to squadrons_data); otherwise +// falls back to a string lookup against long_name / short_name / tag_name. +// Always returns the canonical tag_name + short_name for the matched clan_id when possible, +// so consumers can group/dedupe by clan_id (or short_name) regardless of which raw value was stored. +function resolveSquadronIdentity(cached, fallback, squadronLookup) { + let clanId = null; + let tagName = null; + let shortName = null; + + if (cached && cached.clan_id) { + clanId = cached.clan_id; + tagName = cached.tag_name || null; + shortName = cached.short_name || null; + } + + const rawTag = tagName || (cached ? cached.tag_name : null) || (fallback ? fallback.squadron_name : null); + if ((!clanId || !tagName || !shortName) && rawTag && squadronLookup) { + const sq = squadronLookup[rawTag]; + if (sq) { + clanId = clanId || sq.clan_id || null; + tagName = sq.tag_name || tagName || rawTag; + shortName = sq.short_name || shortName || null; + } else if (!tagName) { + tagName = rawTag; + } + } + + return { clan_id: clanId, tag_name: tagName, short_name: shortName }; +} + +function formatSquadronResolution(input, row, resolvedBy) { + if (!row) { + return { + input, + resolved: false, + resolved_by: resolvedBy, + clan_id: null, + short_name: input, + tag_name: input, + long_name: '', + }; + } + + return { + input, + resolved: true, + resolved_by: resolvedBy, + clan_id: row.clan_id ?? null, + short_name: row.short_name || input, + tag_name: row.tag_name || row.short_name || input, + long_name: row.long_name || '', + }; +} + +function buildSquadronIdentity(team, lookup) { + const keys = [ + team?.squadron, + team?.squadron_tagged, + team?.squadron_long, + ].filter(Boolean); + + let hit = null; + for (const key of keys) { + hit = lookup[key] || lookup[String(key).trim()] || lookup[String(key).trim().toLowerCase()] || lookup[String(key).trim().toUpperCase()]; + if (hit) break; + } + + const clanId = hit?.clan_id ?? null; + return { + clan_id: clanId, + short_name: hit?.short_name || team?.squadron || '', + tag_name: hit?.tag_name || team?.squadron_tagged || team?.squadron || '', + long_name: hit?.long_name || team?.squadron_long || team?.squadron || '', + }; +} + +function jsonError(res, status, message, extra = {}) { + return res.status(status).json({ error: message, ...extra }); +} + +function normalizeUid(value) { + if (value === null || value === undefined || value === '') return null; + return String(value); +} + +function normalizeClanId(value) { + if (value === null || value === undefined || value === '') return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function cleanText(value) { + if (value === null || value === undefined) return null; + const text = String(value).trim(); + return text || null; +} + +function resolveCurrentSquadronIdentity(latestRow, squadronLookup, preferredClanId = null) { + const effectiveClanId = preferredClanId != null + ? Number(preferredClanId) + : normalizeClanId(latestRow?.clan_id); + const lookupByClanId = effectiveClanId != null + ? squadronLookup[`__cid_${effectiveClanId}`] || squadronLookup[String(effectiveClanId)] + : null; + const lookup = lookupByClanId || squadronLookup[latestRow?.squadron_name]; + const squadronName = cleanText(lookup?.tag_name) || cleanText(latestRow?.squadron_name) || ''; + const squadronLongName = cleanText(lookup?.long_name) || cleanText(latestRow?.squadron_name) || ''; + const currentNames = new Set(); + [ + latestRow?.squadron_name, + lookup?.long_name, + lookup?.short_name, + lookup?.tag_name, + ].forEach((name) => { + const cleaned = cleanText(name); + if (cleaned) currentNames.add(cleaned.toLowerCase()); + }); + return { + squadron_name: squadronName, + squadron_long_name: squadronLongName, + squadron_clan_id: lookup?.clan_id != null ? Number(lookup.clan_id) : effectiveClanId, + current_names: currentNames, + }; +} + +async function loadPlayerIdentitySnapshot(uid, squadronLookup, options = {}) { + const latestRow = options.latestRow || await dbGetAsync( + db, + `SELECT nick, squadron_name, clan_id + FROM player_games_hist + WHERE UID = ? AND nick NOT LIKE 'coop/%' + ORDER BY session_id DESC + LIMIT 1`, + [uid] + ); + if (!latestRow) return null; + + const currentIdentity = resolveCurrentSquadronIdentity(latestRow, squadronLookup, options.preferredClanId ?? null); + const [nickRows, squadronRows] = await Promise.all([ + dbAllAsync( + db, + // Pre-2026-01-19 the Spectra API returned auto-generated + // placeholder nicks (e.g. "Dietrich3657") for newly-discovered + // profiles before Gaijin's backend resolved them. Those rows + // pollute previous_nicks with hundreds of garbage entries per + // affected UID, so drop any placeholder-shaped nick whose battle + // ended before that cutoff. Post-cutoff rows are trusted as-is. + `SELECT nick, MAX(endtime_unix) AS last_seen + FROM player_games_hist + WHERE UID = ? + AND nick IS NOT NULL + AND nick <> '' + AND nick NOT LIKE 'coop/%' + AND NOT ( + endtime_unix < 1768780800 + AND nick GLOB '[A-Z][a-z]*[0-9]' + AND nick NOT GLOB '*[^A-Za-z0-9]*' + AND LENGTH(nick) BETWEEN 5 AND 18 + ) + GROUP BY nick + ORDER BY last_seen DESC, nick`, + [uid] + ), + dbAllAsync( + db, + `SELECT squadron_name, clan_id, MAX(endtime_unix) AS last_seen + FROM player_games_hist + WHERE UID = ? + AND squadron_name IS NOT NULL + AND squadron_name <> '' + AND squadron_name <> 'UNKNOWN' + GROUP BY squadron_name, clan_id + ORDER BY last_seen DESC, squadron_name`, + [uid] + ), + ]); + + const currentNickKey = cleanText(latestRow.nick)?.toLowerCase() || null; + const seenNicks = new Set(); + const previous_nicks = []; + for (const row of nickRows) { + const nick = cleanText(row.nick); + if (!nick) continue; + const key = nick.toLowerCase(); + if (key === currentNickKey || seenNicks.has(key)) continue; + seenNicks.add(key); + previous_nicks.push(nick); + } + + const seenSquadrons = new Set(); + const previous_squadron_names = []; + for (const row of squadronRows) { + const lookup = (row.clan_id != null && squadronLookup[`__cid_${row.clan_id}`]) + || squadronLookup[row.squadron_name]; + const displayName = cleanText(lookup?.tag_name) || cleanText(row.squadron_name); + if (!displayName) continue; + const key = displayName.toLowerCase(); + if (currentIdentity.current_names.has(key) || seenSquadrons.has(key)) continue; + seenSquadrons.add(key); + previous_squadron_names.push(displayName); + } + + return { + uid: normalizeUid(uid), + nick: latestRow.nick, + previous_nicks, + squadron_name: currentIdentity.squadron_name, + squadron_long_name: currentIdentity.squadron_long_name, + squadron_clan_id: currentIdentity.squadron_clan_id, + previous_squadron_names, + }; +} + +function normalizeMatchPlayer(player) { + if (!player || typeof player !== 'object') return null; + const vehicleInternal = cleanText(player.vehicle_internal) || cleanText(player.vehicle); + const vehicleDisplay = cleanText(player.vehicle_new) || cleanText(player.vehicle); + const normalized = { + uid: normalizeUid(player.uid), + nick: cleanText(player.nick) || '', + fake_nick: player.fake_nick ?? null, + vehicle_internal: vehicleInternal, + vehicle: vehicleDisplay ? normalizeVehicleName(vehicleDisplay) : vehicleInternal, + air_kills: Number(player.air_kills || 0), + ground_kills: Number(player.ground_kills || 0), + assists: Number(player.assists || 0), + deaths: Number(player.deaths || 0), + captures: Number(player.captures || 0), + score: Number(player.score || 0), + }; + if (player.spawn_order !== undefined) normalized.spawn_order = player.spawn_order; + return normalized; +} + +function normalizeMatchTeam(team, lookup, fallback = {}) { + if (!team || typeof team !== 'object') return null; + const seeded = { + ...team, + squadron: team.squadron ?? fallback.squadron ?? null, + squadron_tagged: team.squadron_tagged ?? fallback.squadron_tagged ?? null, + squadron_long: team.squadron_long ?? fallback.squadron_long ?? null, + clan_id: team.clan_id ?? fallback.clan_id ?? null, + }; + const identity = buildSquadronIdentity(seeded, lookup); + const players = Array.isArray(team.players) + ? team.players.map(normalizeMatchPlayer).filter(Boolean) + : []; + return { + team_index: team.team_index ?? null, + clan_id: normalizeClanId(identity.clan_id), + squadron: cleanText(identity.short_name) || cleanText(seeded.squadron) || '', + squadron_tagged: cleanText(identity.tag_name) || cleanText(seeded.squadron_tagged) || cleanText(seeded.squadron) || '', + squadron_long: cleanText(identity.long_name), + players, + }; +} + +function dbAllAsync(database, sql, params = []) { + return new Promise((resolve, reject) => { + database.all(sql, params, (err, rows) => { + if (err) return reject(err); + resolve(rows || []); + }); + }); +} + +function dbGetAsync(database, sql, params = []) { + return new Promise((resolve, reject) => { + database.get(sql, params, (err, row) => { + if (err) return reject(err); + resolve(row || null); + }); + }); +} + +function readReplayJson(sessionId) { + const replayPath = replayDataPath(sessionId); + if (!fs.existsSync(replayPath)) { + return null; + } + try { + return JSON.parse(fs.readFileSync(replayPath, 'utf-8')); + } catch (err) { + log.warn('Failed to read replay JSON', { sessionId, error: err.message }); + return null; + } +} + +function normalizeCompPlayers(players) { + const order = { F: 0, B: 1, H: 2, L: 3, T: 4, AA: 5, '?': 6 }; + return [...(players || [])].sort((a, b) => { + const av = getVehicleType(a?.vehicle_internal); + const bv = getVehicleType(b?.vehicle_internal); + return (order[av] ?? 99) - (order[bv] ?? 99) || String(a?.nick || '').localeCompare(String(b?.nick || '')); + }).map(player => ({ + uid: normalizeUid(player?.uid ?? player?.UID), + nick: cleanText(player?.nick) || '', + vehicle_internal: cleanText(player?.vehicle_internal), + vehicle: normalizeVehicleName(cleanText(player?.vehicle) || cleanText(player?.vehicle_internal) || ''), + })); +} + +async function loadRecentComps(squadronName, windowSeconds = 3600, limit = 10) { + const squadFile = path.join(COMPS_PATH, `${String(squadronName).toUpperCase()}.json`); + if (!fs.existsSync(squadFile)) { + return null; + } + + let compsData; + try { + compsData = JSON.parse(fs.readFileSync(squadFile, 'utf-8')); + } catch (err) { + throw new Error(`Failed to read comp file: ${err.message}`); + } + + const now = Math.floor(Date.now() / 1000); + const thresholdSeconds = Math.max(0, Number(windowSeconds) || 3600); + const maxComps = Math.max(1, Math.min(50, Number(limit) || 10)); + + const comps = Object.entries(compsData || {}) + .map(([compKey, comp]) => { + const regTs = Number(comp?.reg || 0); + const updTs = Number(comp?.upd || 0); + const lastSeenTs = updTs || regTs; + const ageSeconds = now - lastSeenTs; + const typeCounts = (comp?.Players || []).reduce((acc, player) => { + const code = getVehicleType(player?.vehicle_internal); + acc[code] = (acc[code] || 0) + 1; + return acc; + }, {}); + const notation = COMP_TYPE_ORDER + .map(code => { + const count = typeCounts[code] || 0; + return count > 0 ? `${count}${code}` : null; + }) + .filter(Boolean) + .join(' / ') || 'None'; + + return { + comp_key: compKey, + reg: regTs, + upd: updTs, + last_seen: lastSeenTs, + age_seconds: ageSeconds, + players: normalizeCompPlayers(comp?.Players || []), + notation, + }; + }) + .filter(comp => comp.last_seen && comp.age_seconds <= thresholdSeconds) + .sort((a, b) => (b.last_seen - a.last_seen) || (b.reg - a.reg)) + .slice(0, maxComps); + + return { + squadron: String(squadronName).toUpperCase(), + window_seconds: thresholdSeconds, + limit: maxComps, + total_available: Object.keys(compsData || {}).length, + total_recent: comps.length, + comps, + }; +} + +async function loadScoreboardContext(sessionId) { + const replay = readReplayJson(sessionId); + const matchRow = await dbGetAsync( + db, + `SELECT session_id, map_name, endtime_unix, received_unix, winning_sq, losing_sq, winning_team_json, losing_team_json, game_type + FROM match_summary + WHERE session_id = ?`, + [sessionId] + ); + if (!replay && !matchRow) { + return null; + } + + let teams = Array.isArray(replay?.teams) ? replay.teams.slice(0, 2).map(team => ({ ...(team || {}) })) : []; + if (!teams.length && matchRow) { + try { + const winningTeam = matchRow.winning_team_json ? parseJsonColumn(matchRow.winning_team_json) : null; + const losingTeam = matchRow.losing_team_json ? parseJsonColumn(matchRow.losing_team_json) : null; + teams = [winningTeam, losingTeam].filter(Boolean).map(team => ({ ...(team || {}) })); + } catch (err) { + log.warn('Failed to synthesize scoreboard teams from match_summary', { sessionId, error: err.message }); + } + } + const squadronIdentities = await new Promise((resolve) => { + loadSquadronLookupCached((lookup) => { + try { + resolve(teams.map(team => buildSquadronIdentity(team, lookup))); + } catch (err) { + log.warn('Failed to resolve squadron lookup for scoreboard context', { sessionId, error: err.message }); + resolve(teams.map(team => ({ + clan_id: null, + short_name: team?.squadron || '', + tag_name: team?.squadron_tagged || team?.squadron || '', + long_name: team?.squadron_long || team?.squadron || '', + }))); + } + }); + }); + + const enrichedTeams = teams.map((team, idx) => { + const identity = squadronIdentities[idx] || { + clan_id: null, + short_name: team?.squadron || '', + tag_name: team?.squadron_tagged || team?.squadron || '', + long_name: team?.squadron_long || team?.squadron || '', + }; + return { + ...(normalizeMatchTeam(team, {}, { + squadron: identity.short_name, + squadron_tagged: identity.tag_name, + squadron_long: identity.long_name, + clan_id: identity.clan_id, + }) || {}), + squadron_identity: identity, + }; + }); + + // Prefer clan_id (rename-stable); fall back to long_name text for orphans + // whose clan_id wasn't backfilled. + const teamClanIds = enrichedTeams + .map(team => (team?.clan_id != null ? Number(team.clan_id) : null)) + .filter(cid => cid != null); + const longNames = enrichedTeams.map(team => String(team?.squadron_long || '').trim()).filter(Boolean); + const pointsClauses = []; + const pointsParams = [sessionId]; + if (teamClanIds.length) { + pointsClauses.push(`clan_id IN (${teamClanIds.map(() => '?').join(',')})`); + pointsParams.push(...teamClanIds); + } + if (longNames.length) { + pointsClauses.push(`(clan_id IS NULL AND squadron IN (${longNames.map(() => '?').join(',')}))`); + pointsParams.push(...longNames); + } + const pointsRows = pointsClauses.length + ? await dbAllAsync( + pointsDb, + `SELECT clan_id, squadron, diffs_json, diff_total, updated_json + FROM game_cache + WHERE game_id = ? AND (${pointsClauses.join(' OR ')})`, + pointsParams + ) + : []; + + const points_diffs = {}; + for (const row of pointsRows) { + const identity = squadronIdentities.find(entry => + (row.clan_id != null && entry.clan_id != null && Number(entry.clan_id) === Number(row.clan_id)) + || entry.long_name === row.squadron + ) || { + clan_id: row.clan_id != null ? Number(row.clan_id) : null, + short_name: row.squadron, + tag_name: row.squadron, + long_name: row.squadron, + }; + const diffKey = String(identity.short_name || row.squadron); + try { + points_diffs[diffKey] = { + squadron_identity: identity, + points_diff: parseJsonColumn(row.diffs_json), + diff_total: Number(row.diff_total || 0), + current_points: parseJsonColumn(row.updated_json), + key: diffKey, + }; + } catch (err) { + points_diffs[diffKey] = { + squadron_identity: identity, + points_diff: {}, + diff_total: 0, + current_points: {}, + key: diffKey, + }; + } + } + + for (const team of enrichedTeams) { + const identity = team?.squadron_identity || { + clan_id: null, + short_name: team?.squadron || '', + tag_name: team?.squadron_tagged || team?.squadron || '', + long_name: team?.squadron_long || team?.squadron || '', + }; + const diffKey = String(identity.short_name || team?.squadron || ''); + if (identity.long_name && !points_diffs[diffKey]) { + points_diffs[diffKey] = { + squadron_identity: identity, + points_diff: {}, + diff_total: 0, + current_points: {}, + key: diffKey, + }; + } + } + + // Prefer clan_id (rename-stable); the text fallback is gated on a NULL + // clan_id so we don't pull a different clan that happens to share a short + // tag. Post-migration wl_standings.clan_id is always populated, so the + // fallback is only here as a safety net. + const wlShortTexts = teams.map(team => String(team?.squadron || '').trim()).filter(Boolean); + const wlClauses = []; + const wlParams = []; + if (teamClanIds.length) { + wlClauses.push(`clan_id IN (${teamClanIds.map(() => '?').join(',')})`); + wlParams.push(...teamClanIds); + } + if (wlShortTexts.length) { + wlClauses.push(`(clan_id IS NULL AND squadron IN (${wlShortTexts.map(() => '?').join(',')}))`); + wlParams.push(...wlShortTexts); + } + const wlRows = wlClauses.length + ? await dbAllAsync( + wlDb, + `SELECT clan_id, squadron, wins, losses + FROM wl_standings + WHERE ${wlClauses.join(' OR ')}`, + wlParams + ) + : []; + + const wl = {}; + for (const team of enrichedTeams) { + const identity = team?.squadron_identity || { + clan_id: null, + short_name: team?.squadron || '', + tag_name: team?.squadron_tagged || team?.squadron || '', + long_name: team?.squadron_long || team?.squadron || '', + }; + const key = String(identity.short_name || team?.squadron || ''); + wl[key] = { + squadron_identity: identity, + wins: 0, + losses: 0, + key, + }; + } + for (const row of wlRows) { + const identity = squadronIdentities.find(entry => + (row.clan_id != null && entry.clan_id != null && Number(entry.clan_id) === Number(row.clan_id)) + || entry.short_name === row.squadron + || entry.long_name === row.squadron + || entry.tag_name === row.squadron + ) || { + clan_id: row.clan_id != null ? Number(row.clan_id) : null, + short_name: row.squadron, + tag_name: row.squadron, + long_name: row.squadron, + }; + const key = String(identity.short_name || row.squadron); + wl[key] = { + squadron_identity: identity, + wins: Number(row.wins || 0), + losses: Number(row.losses || 0), + key, + }; + } + + const winner = replay?.winning_team_squadron || matchRow?.winning_sq || null; + const loser = replay?.losing_team_squadron || matchRow?.losing_sq || null; + const isDraw = Boolean(replay?.draw || replay?.is_draw); + const winnerIdentity = squadronIdentities.find(entry => entry.short_name === winner || entry.tag_name === winner || entry.long_name === winner) || null; + const loserIdentity = squadronIdentities.find(entry => entry.short_name === loser || entry.tag_name === loser || entry.long_name === loser) || null; + + return { + session_id: String(sessionId), + match_details: { + utc_timestamp: Number(matchRow?.endtime_unix || replay?.end_ts || 0), + session_id: String(sessionId), + received_unix: Number(matchRow?.received_unix || 0) || undefined, + }, + map_name: matchRow?.map_name || replay?.map || null, + game_type: matchRow?.game_type || replay?.mode || null, + winner, + loser, + winner_identity: winnerIdentity, + loser_identity: loserIdentity, + winner_clan_id: winnerIdentity?.clan_id ?? null, + loser_clan_id: loserIdentity?.clan_id ?? null, + is_draw: isDraw, + teams: enrichedTeams, + squadrons: squadronIdentities, + replay: replay || { + available: false, + chat_log: [], + battle_log: [], + teams: enrichedTeams, + }, + wl, + points_diffs, + }; +} + +function parseDateFilterValue(rawValue, isEndBoundary = false) { + if (rawValue == null || rawValue === '') return null; + + const value = String(rawValue).trim(); + if (!value) return null; + + // Accept epoch timestamps from frontend filters. 10 digits = seconds, + // 13 digits = milliseconds. + if (/^\d+$/.test(value)) { + const numeric = Number(value); + if (Number.isFinite(numeric)) { + if (value.length >= 13) return Math.floor(numeric / 1000); + return numeric; + } + return null; + } + + const date = new Date(value); + if (isNaN(date.getTime())) return null; + + // Date-only strings should include the full UTC day for end boundaries. + if (isEndBoundary && /^\d{4}-\d{2}-\d{2}$/.test(value)) { + date.setUTCHours(23, 59, 59, 999); + } + + return Math.floor(date.getTime() / 1000); +} + +// Helper function to parse date filters from query parameters +function parseDateFilters(req) { + const { start_date, end_date, season, week } = req.query; + + let startTimestamp = null; + let endTimestamp = null; + let filterDescription = null; + + // Frontend already converts seasons to concrete timestamps/dates, so just + // normalize the passed values here. + startTimestamp = parseDateFilterValue(start_date, false); + endTimestamp = parseDateFilterValue(end_date, true); + + // Build description + if (startTimestamp && endTimestamp) { + filterDescription = `${start_date} to ${end_date}`; + } else if (startTimestamp) { + filterDescription = `from ${start_date}`; + } else if (endTimestamp) { + filterDescription = `up to ${end_date}`; + } + + return { + startTimestamp, + endTimestamp, + filterDescription, + hasFilter: startTimestamp !== null || endTimestamp !== null + }; +} + +// Helper function to create date filter metadata for responses +function createDateFilterMetadata(filters, start_date, end_date, season, week) { + if (!filters.hasFilter) { + return { + applied: false, + description: "All-time stats" + }; + } + + return { + applied: true, + start_date: filters.startTimestamp ? new Date(filters.startTimestamp * 1000).toISOString() : null, + end_date: filters.endTimestamp ? new Date(filters.endTimestamp * 1000).toISOString() : null, + season: season || null, + week: week ? parseInt(week) : null, + description: filters.filterDescription || "Custom date range" + }; +} + +app.use((req, res, next) => { + const start = Date.now(); + log.info(`${req.method} ${req.url}`, { + ip: req.ip || req.connection.remoteAddress, + userAgent: req.get('User-Agent'), + params: req.params, + query: req.query + }); + + res.on('finish', () => { + const duration = Date.now() - start; + log.info(`${req.method} ${req.url} - ${res.statusCode}`, { + duration: `${duration}ms`, + size: res.get('Content-Length') || 'unknown' + }); + }); + + next(); +}); + +const DB_PATH = path.join(STORAGE_ROOT, 'sq_battles.db'); + +if (!fs.existsSync(DB_PATH)) { + log.error('Database file does not exist', null, { + dbPath: DB_PATH, + message: 'Run the main bot first to create the database, or create it manually' + }); + process.exit(1); +} + +// Dedicated read-only connection for heavy leaderboard aggregations. SQLite +// serializes statements per Database object, so without this a 60-90s +// leaderboard scan blocks every other API call (player profile, squadron +// details, search). WAL mode supports concurrent readers, so a second +// connection is the cheapest fix. +const heavyDb = new sqlite3.Database(DB_PATH, sqlite3.OPEN_READONLY, (err) => { + if (err) log.warn('Failed to open heavy reader connection, falling back to main db:', err.message); + else log.info('Heavy-reader connection open'); +}); + +const db = new sqlite3.Database(DB_PATH, sqlite3.OPEN_READONLY, (err) => { + if (err) { + log.error('Failed to open database', err, { dbPath: DB_PATH }); + process.exit(1); + } + log.info('Connected to SQLite database in read-only mode', { dbPath: DB_PATH }); + + // Create performance indexes using a separate RW connection (one-time, idempotent) + const rwDb = new sqlite3.Database(DB_PATH, (rwErr) => { + if (rwErr) { log.warn('Could not open DB for index creation:', rwErr.message); return; } + rwDb.serialize(() => { + rwDb.run('CREATE INDEX IF NOT EXISTS idx_pgh_uid_session ON player_games_hist(UID, session_id DESC)', (e) => { + if (!e) log.info('Index ensured: idx_pgh_uid_session'); + }); + rwDb.run('CREATE INDEX IF NOT EXISTS idx_pgh_uid_endtime ON player_games_hist(UID, endtime_unix)', (e) => { + if (!e) log.info('Index ensured: idx_pgh_uid_endtime'); + }); + rwDb.run('CREATE INDEX IF NOT EXISTS idx_pgh_nick ON player_games_hist(nick COLLATE NOCASE)', (e) => { + if (!e) log.info('Index ensured: idx_pgh_nick'); + }); + rwDb.run('CREATE INDEX IF NOT EXISTS idx_pgh_squadron ON player_games_hist(squadron_name)', (e) => { + if (!e) log.info('Index ensured: idx_pgh_squadron'); + }); + rwDb.run('CREATE INDEX IF NOT EXISTS idx_pgh_vehicle_internal_nocase ON player_games_hist(vehicle_internal COLLATE NOCASE)', (e) => { + if (!e) log.info('Index ensured: idx_pgh_vehicle_internal_nocase'); + }); + rwDb.run('CREATE INDEX IF NOT EXISTS idx_ms_session ON match_summary(session_id)', (e) => { + if (!e) log.info('Index ensured: idx_ms_session'); + }); + rwDb.run('CREATE INDEX IF NOT EXISTS idx_ms_map_name ON match_summary(map_name)', (e) => { + if (!e) log.info('Index ensured: idx_ms_map_name'); + }); + rwDb.run('CREATE INDEX IF NOT EXISTS idx_ms_endtime ON match_summary(endtime_unix)', (e) => { + if (!e) log.info('Index ensured: idx_ms_endtime'); + }); + rwDb.run('CREATE INDEX IF NOT EXISTS idx_ms_winning_sq ON match_summary(winning_sq)', (e) => { + if (!e) log.info('Index ensured: idx_ms_winning_sq'); + }); + rwDb.run('CREATE INDEX IF NOT EXISTS idx_ms_losing_sq ON match_summary(losing_sq)', (e) => { + if (!e) log.info('Index ensured: idx_ms_losing_sq'); + }); + // Composite index for the squadron leaderboard's + // GROUP BY clan_id, squadron_name aggregation. Without this the + // query falls back to a temp B-tree sort over all 4M+ rows. + rwDb.run( + 'CREATE INDEX IF NOT EXISTS idx_pgh_clanid_squadron ON player_games_hist(clan_id, squadron_name)', + (e) => { + if (!e) log.info('Index ensured: idx_pgh_clanid_squadron'); + } + ); + rwDb.run( + 'CREATE INDEX IF NOT EXISTS idx_pgh_clanid_uid ON player_games_hist(clan_id, UID)', + (e) => { + if (!e) log.info('Index ensured: idx_pgh_clanid_uid'); + } + ); + rwDb.close(() => log.info('Performance indexes ready')); + }); + }); + + // Use TRUNCATE so the WAL is actively checkpointed once readers clear. + runWalCheckpoint('TRUNCATE', 'WAL checkpoint completed successfully', 'WAL checkpoint failed:'); + + db.get("SELECT name FROM sqlite_master WHERE type='table' AND name='player_games_hist'", (err, row) => { + if (err) { + log.error('Failed to check table existence', err); + process.exit(1); + } else if (!row) { + log.error('CRITICAL: player_games_hist table does not exist', null, { + message: 'Database exists but player_games_hist table is missing', + dbPath: DB_PATH + }); + process.exit(1); + } else { + log.info('Database table verification passed', { table: 'player_games_hist' }); + db.all("PRAGMA table_info(player_games_hist)", (err, cols) => { + if (!err) hasSquadronColumn = cols.some(c => c.name === 'squadron_name'); + log.info('Schema check complete', { hasSquadronColumn }); + }); + } + }); + + db.get("SELECT name FROM sqlite_master WHERE type='table' AND name='match_summary'", (err, row) => { + if (err) { + log.error('Failed to check match_summary table existence', err); + } else if (!row) { + log.warn('match_summary table does not exist - /api/live endpoint will not work', { + message: 'Run the bot to generate match data first', + dbPath: DB_PATH + }); + } else { + log.info('Database table verification passed', { table: 'match_summary' }); + } + }); + + // Pre-warm caches that gate the analytics vehicle search/load flows. + // ensureVehicleList does a GROUP BY across player_games_hist; running it + // at boot means the first user click pays no cold-cache penalty, and we + // use it as the readiness signal for the heavy-path gate. + ensureVehicleList((err, list) => { + if (err) log.warn('Vehicle list pre-warm failed:', err.message); + else log.info('Vehicle list cache pre-warmed', { count: list.length }); + markServerReady(err ? 'vehicle_list_error' : 'vehicle_list_warm'); + }); + try { + const t0 = Date.now(); + const resp = buildTranslationsResponse(); + log.info('Vehicle translations cache pre-warmed', { + source: resp.source, + count: Object.keys(resp.vehicles || {}).length, + ms: Date.now() - t0, + }); + } catch (e) { + log.warn('Vehicle translations pre-warm failed:', e.message); + } +}); + +app.get('/api/player/:uid', (req, res) => { + const { uid } = req.params; + + const cacheKey = `player_${uid}_${req.query.start_date || ''}_${req.query.end_date || ''}_${req.query.season || ''}_${req.query.week || ''}`; + const cached = getCachedResponse(cacheKey); + if (cached) return res.json(cached); + + if (!uid) { + return res.status(400).json({ + error: 'UID parameter is required' + }); + } + + // Parse date filters + const dateFilters = parseDateFilters(req); + const { start_date, end_date, season, week } = req.query; + + const latestNickQuery = ` + SELECT nick, squadron_name, clan_id + FROM player_games_hist + WHERE UID = ? AND nick NOT LIKE 'coop/%' + ORDER BY session_id DESC + LIMIT 1 + `; + + const aggregatedStatsQuery = ` + SELECT + vehicle_internal, + vehicle, + SUM(ground_kills) as total_ground_kills, + SUM(air_kills) as total_air_kills, + SUM(assists) as total_assists, + SUM(captures) as total_captures, + SUM(deaths) as total_deaths, + SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END) as wins, + SUM(CASE WHEN UPPER(victor_bool) = 'LOSS' THEN 1 ELSE 0 END) as losses, + COUNT(*) as total_battles + FROM player_games_hist + WHERE UID = ? + AND (? IS NULL OR endtime_unix >= ?) + AND (? IS NULL OR endtime_unix <= ?) + GROUP BY vehicle_internal + ORDER BY vehicle_internal + `; + + const queryParams = [ + uid, + dateFilters.startTimestamp, + dateFilters.startTimestamp, + dateFilters.endTimestamp, + dateFilters.endTimestamp + ]; + + const nickPromise = new Promise((resolve, reject) => { + db.get(latestNickQuery, [uid], (err, row) => { if (err) reject(err); else resolve(row); }); + }); + const statsPromise = new Promise((resolve, reject) => { + db.all(aggregatedStatsQuery, queryParams, (err, rows) => { if (err) reject(err); else resolve(rows); }); + }); + const lookupPromise = new Promise((resolve) => { + loadSquadronLookupCached(resolve); + }); + // Authoritative current-roster membership. squadron_members is updated + // by the periodic squadron-sync, so this reflects "what squadron this + // player belongs to right now" — beats the most-recent player_games_hist + // row which can be stale post-rename if the player hasn't played a SQB + // since the rename. + const rosterPromise = new Promise((resolve) => { + const squadronsDbPath = path.join(STORAGE_ROOT, 'squadrons.db'); + if (!fs.existsSync(squadronsDbPath)) return resolve(null); + const sdb = new sqlite3.Database(squadronsDbPath, sqlite3.OPEN_READONLY, (err) => { + if (err) return resolve(null); + sdb.get( + 'SELECT clan_id FROM squadron_members WHERE uid = ? LIMIT 1', + [uid], + (qerr, row) => { + sdb.close(); + if (qerr || !row || row.clan_id == null) return resolve(null); + resolve(Number(row.clan_id)); + } + ); + }); + }); + + Promise.all([nickPromise, statsPromise, lookupPromise, rosterPromise]) + .then(async ([nickRow, vehicleRows, squadronLookup, rosterClanId]) => { + if (!nickRow) { + return jsonError(res, 404, 'Player not found', { uid: normalizeUid(uid) }); + } + + const identity = await loadPlayerIdentitySnapshot(uid, squadronLookup, { + latestRow: nickRow, + preferredClanId: rosterClanId, + }); + loadPerformanceBenchmarksCached(dateFilters, (benchmarks) => { + const vehicles = vehicleRows.map(row => { + const wins = row.wins || 0; + const losses = row.losses || 0; + const totalBattles = row.total_battles || 0; + let winRate = '0.0'; + if (totalBattles > 0 && wins >= 0) { + winRate = ((wins / totalBattles) * 100).toFixed(1); + } + return { + vehicle_internal: row.vehicle_internal, + vehicle: normalizeVehicleName(row.vehicle), + stats: { + ground_kills: row.total_ground_kills, + air_kills: row.total_air_kills, + assists: row.total_assists, + captures: row.total_captures, + deaths: row.total_deaths, + wins, + losses, + total_battles: totalBattles, + win_rate: winRate + } + }; + }); + + queryPlayerRatingStats(uid, dateFilters).then(ratingStats => { + const playerPerformance = computePerformanceScore(ratingStats || { games: 0 }, benchmarks.players); + + const response = { + date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week), + uid: normalizeUid(uid), + nick: identity?.nick || nickRow.nick, + previous_nicks: identity?.previous_nicks || [], + squadron_name: identity?.squadron_name || '', + squadron_long_name: identity?.squadron_long_name || '', + squadron_clan_id: identity?.squadron_clan_id ?? null, + previous_squadron_names: identity?.previous_squadron_names || [], + performance: playerPerformance, + vehicles, + total_vehicles: vehicleRows.length + }; + + setCachedResponse(cacheKey, response); + res.json(response); + }); + }); + }) + .catch(err => { + log.error('Database error in player query', err, { uid, endpoint: '/api/player/:uid' }); + jsonError(res, 500, 'Database error occurred', { errorCode: 'DB_PLAYER_QUERY_FAILED' }); + }); +}); + +app.get('/api/player/:uid/games', (req, res) => { + const { uid } = req.params; + + const cacheKey = `games_${uid}_${req.query.start_date || ''}_${req.query.end_date || ''}_${req.query.season || ''}_${req.query.week || ''}`; + const cached = getCachedResponse(cacheKey); + if (cached) return res.json(cached); + + if (!uid) { + return res.status(400).json({ + error: 'UID parameter is required' + }); + } + + // Parse date filters + const dateFilters = parseDateFilters(req); + const { start_date, end_date, season, week } = req.query; + + const latestNickQuery = ` + SELECT nick, squadron_name, clan_id + FROM player_games_hist + WHERE UID = ? AND nick NOT LIKE 'coop/%' + ORDER BY session_id DESC + LIMIT 1 + `; + + const recentGamesQuery = ` + SELECT + session_id, + nick, + squadron_name, + vehicle_internal, + vehicle, + ground_kills, + air_kills, + assists, + captures, + deaths, + victor_bool as result, + endtime_unix + FROM player_games_hist + WHERE UID = ? + AND (? IS NULL OR endtime_unix >= ?) + AND (? IS NULL OR endtime_unix <= ?) + ORDER BY session_id DESC + `; + + db.get(latestNickQuery, [uid], (err, nickRow) => { + if (err) { + log.error('Database error in nick query', err, { + uid: uid, + query: 'latestNickQuery', + endpoint: '/api/player/:uid/games' + }); + return res.status(500).json({ + error: 'Database error occurred', + errorCode: 'DB_NICK_QUERY_FAILED' + }); + } + + if (!nickRow) { + return jsonError(res, 404, 'Player not found', { uid: normalizeUid(uid) }); + } + + const playerNick = nickRow.nick; + const playerSquadron = nickRow.squadron_name; + + // Build query parameters with date filtering + const queryParams = [ + uid, + dateFilters.startTimestamp, + dateFilters.startTimestamp, + dateFilters.endTimestamp, + dateFilters.endTimestamp + ]; + + db.all(recentGamesQuery, queryParams, (err, gameRows) => { + if (err) { + log.error('Database error in games query', err, { + uid: uid, + endpoint: '/api/player/:uid/games' + }); + return res.status(500).json({ + error: 'Database error occurred', + errorCode: 'DB_GAMES_QUERY_FAILED' + }); + } + + const response = { + date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week), + uid: normalizeUid(uid), + nick: playerNick, + squadron_name: playerSquadron, + games: gameRows.map(row => ({ + session_id: row.session_id, + vehicle_internal: row.vehicle_internal, + vehicle: normalizeVehicleName(row.vehicle), + squadron_name: row.squadron_name, + timestamp: row.endtime_unix || 0, + stats: { + ground_kills: row.ground_kills || 0, + air_kills: row.air_kills || 0, + assists: row.assists || 0, + captures: row.captures || 0, + deaths: row.deaths || 0 + }, + result: row.result || 'Unknown' + })), + total_games_returned: gameRows.length + }; + + setCachedResponse(cacheKey, response); + res.json(response); + }); + }); +}); + +app.get('/api/player/:uid/history', (req, res) => { + const { uid } = req.params; + + const dateFilters = parseDateFilters(req); + const { start_date, end_date, season, week } = req.query; + const cacheKey = `history_${uid}_${start_date || ''}_${end_date || ''}_${season || ''}_${week || ''}`; + const cached = getCachedResponse(cacheKey); + if (cached) return res.json(cached); + + const historyQuery = ` + SELECT + date(endtime_unix, 'unixepoch') as period, + COUNT(DISTINCT session_id) as battles, + ROUND(SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1.0 ELSE 0 END) * 100.0 / NULLIF(COUNT(*), 0), 1) as win_rate, + ROUND(CAST(SUM(ground_kills + air_kills) AS REAL) / MAX(SUM(deaths), 1), 2) as kdr + FROM player_games_hist + WHERE UID = ? AND endtime_unix IS NOT NULL + AND (? IS NULL OR endtime_unix >= ?) + AND (? IS NULL OR endtime_unix <= ?) + GROUP BY period + ORDER BY period ASC + `; + const params = [ + uid, + dateFilters.startTimestamp, + dateFilters.startTimestamp, + dateFilters.endTimestamp, + dateFilters.endTimestamp, + ]; + db.all(historyQuery, params, (err, rows) => { + if (err) return jsonError(res, 500, 'DB error'); + const response = { + date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week), + uid: normalizeUid(uid), + days_with_battles_only: true, + history: rows, + }; + setCachedResponse(cacheKey, response); + res.json(response); + }); +}); + +app.get('/api/search/:nickname', (req, res) => { + const { nickname } = req.params; + + if (!nickname || nickname.trim().length === 0) { + return res.status(400).json({ + error: 'Nickname parameter is required' + }); + } + + const cacheKey = `search_${nickname.toLowerCase()}`; + const cached = getCachedResponse(cacheKey); + if (cached) { + log.info('Returning cached search results'); + return res.json(cached); + } + + const searchQuery = ` + SELECT UID + FROM player_games_hist + WHERE nick LIKE ? COLLATE NOCASE + GROUP BY UID + ORDER BY MAX(session_id) DESC, MAX(endtime_unix) DESC + LIMIT 50 + `; + + const latestNickQuery = ` + SELECT nick, squadron_name, clan_id + FROM player_games_hist + WHERE UID = ? AND nick NOT LIKE 'coop/%' + ORDER BY session_id DESC + LIMIT 1 + `; + + const searchTerm = `%${nickname.trim()}%`; + + db.all(searchQuery, [searchTerm], (err, rows) => { + if (err) { + log.error('Database error in search query', err, { + searchTerm: searchTerm, + nickname: nickname, + endpoint: '/api/search/:nickname' + }); + return res.status(500).json({ + error: 'Database error occurred', + errorCode: 'DB_SEARCH_QUERY_FAILED' + }); + } + + loadSquadronLookupCached((squadronLookup) => { + const lookups = rows.map(row => (async () => { + const latestRow = await dbGetAsync(db, latestNickQuery, [row.UID]); + if (!latestRow) { + return { + uid: normalizeUid(row.UID), + nick: '', + previous_nicks: [], + squadron_name: '', + squadron_long_name: '', + squadron_clan_id: null, + previous_squadron_names: [], + }; + } + return loadPlayerIdentitySnapshot(row.UID, squadronLookup, { latestRow }); + })()); + + Promise.all(lookups).then(results => { + const finalResults = results.filter(Boolean); + const response = { + search_term: nickname.trim(), + results: finalResults, + total_found: finalResults.length, + limited_to: 50 + }; + + log.info('Search query executed', { + searchTerm: searchTerm, + nickname: nickname, + resultsFound: finalResults.length, + firstFewResults: finalResults.slice(0, 3) + }); + + setCachedResponse(cacheKey, response); + res.json(response); + }).catch(identityErr => { + log.error('Failed to build player identity search response', identityErr, { + nickname, + endpoint: '/api/search/:nickname' + }); + jsonError(res, 500, 'Database error occurred', { errorCode: 'DB_SEARCH_QUERY_FAILED' }); + }); + }); + }); +}); + +// Guard debug endpoints - only allow from localhost +const debugGuard = (req, res, next) => { + const ip = req.ip || req.connection.remoteAddress || ''; + const isLocal = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'; + if (!isLocal) { + return res.status(404).json({ error: 'Not found' }); + } + next(); +}; +app.use('/api/debug', debugGuard); + +app.get('/api/debug/stats', requireAdminBearer, (req, res) => { + const schemaQuery = `PRAGMA table_info(player_games_hist)`; + + db.all(schemaQuery, (err, schema) => { + if (err) { + log.error('Database error in schema query', err); + return res.status(500).json({ error: 'Database schema error' }); + } + + const statsQuery = ` + SELECT + COUNT(*) as total_records, + COUNT(DISTINCT UID) as unique_players, + COUNT(DISTINCT session_id) as unique_sessions, + MIN(session_id) as oldest_session, + MAX(session_id) as newest_session + FROM player_games_hist + `; + + const sampleQuery = ` + SELECT * + FROM player_games_hist + ORDER BY session_id DESC + LIMIT 3 + `; + + db.get(statsQuery, (err, stats) => { + if (err) { + log.error('Database error in stats query', err); + return res.status(500).json({ error: 'Database stats error' }); + } + + db.all(sampleQuery, (err, samples) => { + if (err) { + log.error('Database error in sample query', err); + return res.status(500).json({ error: 'Database sample error' }); + } + + res.json({ + table_schema: schema.map(col => ({ + name: col.name, + type: col.type, + notnull: col.notnull, + default_value: col.dflt_value + })), + database_stats: stats, + sample_records: samples, + timestamp: new Date().toISOString() + }); + }); + }); + }); +}); + +app.get('/health', (req, res) => { + res.json({ + status: 'OK', + timestamp: new Date().toISOString(), + database: 'Connected', + ready: serverReady, + uptime_ms: Date.now() - startupReadyAt, + }); +}); + +app.get('/api/live', (req, res) => { + const requestStart = Date.now(); + const clientIP = req.ip || req.connection.remoteAddress; + const userAgent = req.get('User-Agent') || 'Unknown'; + + log.info('API request received', { + endpoint: '/api/live', + method: 'GET', + clientIP: clientIP, + userAgent: userAgent, + queryParams: req.query + }); + + // Parse date filters + const dateFilters = parseDateFilters(req); + const { start_date, end_date, season, week } = req.query; + + const limit = parseInt(req.query.limit) || 15; + const maxLimit = 200; + + log.debug('Parsed request parameters', { + requestedLimit: req.query.limit, + parsedLimit: limit, + maxLimit: maxLimit + }); + + if (limit > maxLimit) { + log.warn('Request rejected - limit too high', { + requestedLimit: limit, + maxLimit: maxLimit, + clientIP: clientIP + }); + return res.status(400).json({ + error: `Limit cannot exceed ${maxLimit}` + }); + } + + const query = ` + SELECT + session_id, + map_name, + endtime_unix, + winning_sq, + losing_sq, + winning_team_json, + losing_team_json, + game_type + FROM match_summary + WHERE (? IS NULL OR endtime_unix >= ?) + AND (? IS NULL OR endtime_unix <= ?) + ORDER BY endtime_unix DESC + LIMIT ? + `; + + // Build query parameters with date filtering + const queryParams = [ + dateFilters.startTimestamp, + dateFilters.startTimestamp, + dateFilters.endTimestamp, + dateFilters.endTimestamp, + limit + ]; + + log.debug('Executing database query', { + query: query.replace(/\s+/g, ' ').trim(), + parameters: queryParams + }); + + const queryStart = Date.now(); + db.all(query, queryParams, (err, rows) => { + const queryTime = Date.now() - queryStart; + + if (err) { + log.error('Database error in /api/live', err, { + queryTime: `${queryTime}ms`, + parameters: [limit], + clientIP: clientIP + }); + return res.status(500).json({ + error: 'Database error' + }); + } + + log.info('Database query completed', { + rowsReturned: rows.length, + queryTime: `${queryTime}ms`, + limit: limit + }); + + loadSquadronLookupCached((squadronLookup) => { + let jsonParseErrors = 0; + const matches = rows.map((row, index) => { + let winningTeam, losingTeam; + + try { + winningTeam = row.winning_team_json ? parseJsonColumn(row.winning_team_json) : null; + log.debug('Parsed winning team JSON', { + session_id: row.session_id, + hasWinningTeam: !!winningTeam, + playerCount: winningTeam?.players?.length || 0 + }); + } catch (e) { + jsonParseErrors++; + log.warn('Failed to parse winning_team_json', { + session_id: row.session_id, + error: e.message, + jsonLength: row.winning_team_json?.length || 0 + }); + winningTeam = null; + } + + try { + losingTeam = row.losing_team_json ? parseJsonColumn(row.losing_team_json) : null; + log.debug('Parsed losing team JSON', { + session_id: row.session_id, + hasLosingTeam: !!losingTeam, + playerCount: losingTeam?.players?.length || 0 + }); + } catch (e) { + jsonParseErrors++; + log.warn('Failed to parse losing_team_json', { + session_id: row.session_id, + error: e.message, + jsonLength: row.losing_team_json?.length || 0 + }); + losingTeam = null; + } + + const matchData = { + session_id: row.session_id, + map_name: row.map_name, + endtime_unix: row.endtime_unix, + endtime_iso: new Date(row.endtime_unix * 1000).toISOString(), + winning_squadron: row.winning_sq, + losing_squadron: row.losing_sq, + winning_tag: cleanText(normalizeMatchTeam(winningTeam, squadronLookup, { squadron: row.winning_sq })?.squadron_tagged) || row.winning_sq, + losing_tag: cleanText(normalizeMatchTeam(losingTeam, squadronLookup, { squadron: row.losing_sq })?.squadron_tagged) || row.losing_sq, + winning_team: normalizeMatchTeam(winningTeam, squadronLookup, { squadron: row.winning_sq }), + losing_team: normalizeMatchTeam(losingTeam, squadronLookup, { squadron: row.losing_sq }), + game_type: row.game_type || "", + }; + + log.debug('Processed match record', { + index: index + 1, + session_id: row.session_id, + map: row.map_name, + winner: row.winning_sq, + loser: row.losing_sq, + endtime: matchData.endtime_iso + }); + + return matchData; + }); + + const totalTime = Date.now() - requestStart; + const response = { + date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week), + total_matches: matches.length, + limit: limit, + matches: matches + }; + + log.info('API response sent', { + endpoint: '/api/live', + matchesReturned: matches.length, + jsonParseErrors: jsonParseErrors, + totalRequestTime: `${totalTime}ms`, + queryTime: `${queryTime}ms`, + clientIP: clientIP, + responseSize: JSON.stringify(response).length + }); + + res.json(response); + }); + }); +}); + +// ── Single match detail ── +app.get('/api/match/:sessionId', (req, res) => { + const { sessionId } = req.params; + const clientIP = req.headers['x-forwarded-for'] || req.socket.remoteAddress; + const dateFilters = parseDateFilters(req); + const { start_date, end_date, season, week } = req.query; + + if (!sessionId || !/^[0-9a-fA-F]+$/.test(sessionId)) { + return jsonError(res, 400, 'Invalid session ID format. Must be hexadecimal.'); + } + + const cacheKey = `match_${sessionId}_${start_date || ''}_${end_date || ''}_${season || ''}_${week || ''}`; + const cached = getCachedResponse(cacheKey); + if (cached) return res.json(cached); + + const query = ` + SELECT session_id, map_name, endtime_unix, winning_sq, losing_sq, + winning_team_json, losing_team_json, game_type + FROM match_summary + WHERE session_id = ? + AND (? IS NULL OR endtime_unix >= ?) + AND (? IS NULL OR endtime_unix <= ?) + `; + + const queryParams = [ + sessionId, + dateFilters.startTimestamp, + dateFilters.startTimestamp, + dateFilters.endTimestamp, + dateFilters.endTimestamp, + ]; + + db.get(query, queryParams, (err, row) => { + if (err) { + log.error('Database error in /api/match/:sessionId', err, { sessionId }); + return jsonError(res, 500, 'Database error'); + } + + if (!row) { + return jsonError(res, 404, 'Match not found', { session_id: sessionId }); + } + + let winningTeam = null, losingTeam = null; + try { winningTeam = row.winning_team_json ? parseJsonColumn(row.winning_team_json) : null; } catch (e) { log.warn('Failed to parse winning_team_json', { sessionId }); } + try { losingTeam = row.losing_team_json ? parseJsonColumn(row.losing_team_json) : null; } catch (e) { log.warn('Failed to parse losing_team_json', { sessionId }); } + + const replayPath = replayDataPath(sessionId); + const replayAvailable = fs.existsSync(replayPath); + + loadSquadronLookupCached((squadronLookup) => { + const response = { + date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week), + session_id: row.session_id, + map_name: row.map_name, + endtime_unix: row.endtime_unix, + endtime_iso: new Date(row.endtime_unix * 1000).toISOString(), + mode: row.game_type || '', + winning_squadron: row.winning_sq, + losing_squadron: row.losing_sq, + winning_tag: cleanText(normalizeMatchTeam(winningTeam, squadronLookup, { squadron: row.winning_sq })?.squadron_tagged) || row.winning_sq, + losing_tag: cleanText(normalizeMatchTeam(losingTeam, squadronLookup, { squadron: row.losing_sq })?.squadron_tagged) || row.losing_sq, + winning_team: normalizeMatchTeam(winningTeam, squadronLookup, { squadron: row.winning_sq }), + losing_team: normalizeMatchTeam(losingTeam, squadronLookup, { squadron: row.losing_sq }), + game_type: row.game_type || '', + replay_available: replayAvailable + }; + + setCachedResponse(cacheKey, response); + log.info('Match detail served', { sessionId, clientIP }); + res.json(response); + }); + }); +}); + +// ── Match replay data (chat log, battle log, etc.) ── +app.get('/api/match/:sessionId/replay', (req, res) => { + const { sessionId } = req.params; + + if (!sessionId || !/^[0-9a-fA-F]+$/.test(sessionId)) { + return res.status(400).json({ error: 'Invalid session ID format.' }); + } + + const replayPath = replayDataPath(sessionId); + + if (!fs.existsSync(replayPath)) { + return res.json({ available: false, session_id: sessionId }); + } + + try { + const data = JSON.parse(fs.readFileSync(replayPath, 'utf-8')); + res.json({ + available: true, + session_id: sessionId, + map: data.map || null, + mode: data.mode || null, + duration: data.duration || null, + draw: data.draw || false, + chat_log: data.chat_log || [], + battle_log: data.battle_log || [], + teams: data.teams || [], + winning_team_squadron: data.winning_team_squadron || null, + losing_team_squadron: data.losing_team_squadron || null + }); + } catch (e) { + log.error('Failed to read replay data', e, { sessionId }); + res.status(500).json({ error: 'Failed to read replay data' }); + } +}); + +app.get('/api/match/:sessionId/scoreboard', async (req, res) => { + const { sessionId } = req.params; + + if (!sessionId || !/^[0-9a-fA-F]+$/.test(sessionId)) { + return jsonError(res, 400, 'Invalid session ID format.'); + } + + try { + const context = await loadScoreboardContext(sessionId); + if (!context) { + return jsonError(res, 404, 'Match not found', { session_id: sessionId }); + } + + res.json({ + session_id: context.session_id, + match_details: context.match_details, + map_name: context.map_name, + game_type: context.game_type, + mode: context.mode || context.game_type || context.replay?.mode || null, + winner: context.winner, + loser: context.loser, + is_draw: context.is_draw, + teams: context.teams, + wl: context.wl, + points_diffs: context.points_diffs, + replay: context.replay, + }); + } catch (err) { + log.error('Failed to load scoreboard context', err, { sessionId }); + res.status(500).json({ error: 'Failed to load scoreboard context' }); + } +}); + +app.get('/api/squadrons/:squadronname/comps', async (req, res) => { + const { squadronname } = req.params; + if (!squadronname) { + return res.status(400).json({ error: 'Squadron name parameter is required' }); + } + + const windowSeconds = parseInt(req.query.window_seconds) || 3600; + const limit = parseInt(req.query.limit) || 10; + + try { + const response = await loadRecentComps(squadronname, windowSeconds, limit); + if (!response) { + return res.status(404).json({ + error: 'Comp history not found', + squadron: String(squadronname).toUpperCase(), + }); + } + res.json(response); + } catch (err) { + log.error('Failed to load squadron comps', err, { squadronname }); + res.status(500).json({ error: 'Failed to load squadron comps' }); + } +}); + +// ── Search games by player name/UID, map, squadron, and/or time ── +app.get('/api/games/search', (req, res) => { + const { player, map, squadron, time_from, time_to } = req.query; + const clientIP = req.headers['x-forwarded-for'] || req.socket.remoteAddress; + let limit = parseInt(req.query.limit) || 50; + if (limit > 200) limit = 200; + + const isUid = player && /^\d+$/.test(player); + + // Parse time filters (unix seconds) + const timeFrom = time_from ? parseInt(time_from) : null; + const timeTo = time_to ? parseInt(time_to) : null; + + // Build the pipeline: find session_ids from player if given, then fetch from match_summary + const findSessions = (callback) => { + if (!player) return callback(null, null); // null means no player filter + + if (isUid) { + db.all( + 'SELECT DISTINCT session_id FROM player_games_hist WHERE UID = ? ORDER BY endtime_unix DESC LIMIT ?', + [player, limit], + (err, rows) => callback(err, rows ? rows.map(r => r.session_id) : []) + ); + } else { + // Try exact match first (fast, uses index), then prefix, then substring fallback + db.all( + 'SELECT DISTINCT session_id FROM player_games_hist WHERE nick = ? COLLATE NOCASE ORDER BY endtime_unix DESC LIMIT ?', + [player, limit], + (err, rows) => { + if (err) return callback(err, []); + if (rows && rows.length > 0) return callback(null, rows.map(r => r.session_id)); + // Fallback: prefix match (can use index) + db.all( + 'SELECT DISTINCT session_id FROM player_games_hist WHERE nick LIKE ? COLLATE NOCASE ORDER BY endtime_unix DESC LIMIT ?', + [`${player}%`, limit], + (err2, rows2) => { + if (err2) return callback(err2, []); + if (rows2 && rows2.length > 0) return callback(null, rows2.map(r => r.session_id)); + // Last resort: substring match (full scan, but only if exact/prefix found nothing) + db.all( + 'SELECT DISTINCT session_id FROM player_games_hist WHERE nick LIKE ? COLLATE NOCASE ORDER BY endtime_unix DESC LIMIT ?', + [`%${player}%`, limit], + (err3, rows3) => callback(err3, rows3 ? rows3.map(r => r.session_id) : []) + ); + } + ); + } + ); + } + }; + + findSessions((err, sessionIds) => { + if (err) { + log.error('Database error searching player sessions', err); + return res.status(500).json({ error: 'Database error' }); + } + + // If player filter returned no results + if (sessionIds !== null && sessionIds.length === 0) { + return res.json({ total_matches: 0, matches: [] }); + } + + let query, params; + const selectCols = 'session_id, map_name, endtime_unix, winning_sq, losing_sq, winning_team_json, losing_team_json, game_type'; + + // Build WHERE conditions and params for match_summary filters + const conditions = []; + const condParams = []; + + if (sessionIds !== null) { + const placeholders = sessionIds.map(() => '?').join(','); + conditions.push(`session_id IN (${placeholders})`); + condParams.push(...sessionIds); + } + if (map) { + conditions.push('map_name = ?'); + condParams.push(map); + } + // Squadron filter: text LIKE handles fuzzy/orphan matches; if the input + // resolves to a clan_id we also include matches by winning/losing + // clan_id so renamed squadrons stay attached. + if (squadron) { + const sqClanId = resolveClanIdSync(squadron); + if (sqClanId != null) { + conditions.push( + '(winning_sq LIKE ? COLLATE NOCASE OR losing_sq LIKE ? COLLATE NOCASE ' + + 'OR winning_clan_id = ? OR losing_clan_id = ?)' + ); + condParams.push(`%${squadron}%`, `%${squadron}%`, sqClanId, sqClanId); + } else { + conditions.push('(winning_sq LIKE ? COLLATE NOCASE OR losing_sq LIKE ? COLLATE NOCASE)'); + condParams.push(`%${squadron}%`, `%${squadron}%`); + } + } + if (timeFrom) { + conditions.push('endtime_unix >= ?'); + condParams.push(timeFrom); + } + if (timeTo) { + conditions.push('endtime_unix <= ?'); + condParams.push(timeTo); + } + + const where = conditions.length > 0 ? ' WHERE ' + conditions.join(' AND ') : ''; + const limitClause = sessionIds !== null ? '' : ` LIMIT ?`; + query = `SELECT ${selectCols} FROM match_summary${where} ORDER BY endtime_unix DESC${limitClause}`; + params = sessionIds !== null ? condParams : [...condParams, limit]; + + db.all(query, params, (err, rows) => { + if (err) { + log.error('Database error searching matches', err); + return res.status(500).json({ error: 'Database error' }); + } + + loadSquadronLookupCached((squadronLookup) => { + const matches = (rows || []).map(row => { + let winningTeam = null, losingTeam = null; + try { winningTeam = row.winning_team_json ? parseJsonColumn(row.winning_team_json) : null; } catch (e) {} + try { losingTeam = row.losing_team_json ? parseJsonColumn(row.losing_team_json) : null; } catch (e) {} + + return { + session_id: row.session_id, + map_name: row.map_name, + endtime_unix: row.endtime_unix, + endtime_iso: new Date(row.endtime_unix * 1000).toISOString(), + winning_squadron: row.winning_sq, + losing_squadron: row.losing_sq, + winning_tag: cleanText(normalizeMatchTeam(winningTeam, squadronLookup, { squadron: row.winning_sq })?.squadron_tagged) || row.winning_sq, + losing_tag: cleanText(normalizeMatchTeam(losingTeam, squadronLookup, { squadron: row.losing_sq })?.squadron_tagged) || row.losing_sq, + winning_team: normalizeMatchTeam(winningTeam, squadronLookup, { squadron: row.winning_sq }), + losing_team: normalizeMatchTeam(losingTeam, squadronLookup, { squadron: row.losing_sq }), + game_type: row.game_type || '' + }; + }); + + log.info('Games search completed', { player, map, squadron, time_from, time_to, results: matches.length, clientIP }); + res.json({ total_matches: matches.length, matches }); + }); + }); + }); +}); + +// ── Distinct map names ── +app.get('/api/maps', (req, res) => { + const cacheKey = 'maps_list'; + const cached = getCachedResponse(cacheKey); + if (cached) return res.json(cached); + + db.all('SELECT DISTINCT map_name FROM match_summary WHERE map_name IS NOT NULL ORDER BY map_name', (err, rows) => { + if (err) { + log.error('Database error in /api/maps', err); + return res.status(500).json({ error: 'Database error' }); + } + // Deduplicate: strip mode prefix like "[Conquest #1] ", normalize case, filter blanks + const seen = new Map(); // lowercase clean name -> first original clean name + for (const r of rows) { + const raw = (r.map_name || '').trim(); + if (!raw) continue; + const clean = raw.replace(/^\s*\[[^\]]+\]\s*/, '').trim(); + if (!clean) continue; + const key = clean.toLowerCase(); + if (!seen.has(key)) seen.set(key, clean); + } + const maps = [...seen.values()].sort((a, b) => a.localeCompare(b)); + const response = { maps }; + setCachedResponse(cacheKey, response); + res.json(response); + }); +}); + +app.get('/api/seasons', (req, res) => { + try { + res.json(seasonsUtil.getSeasonDetails()); + } catch (err) { + log.error('Error in /api/seasons', err); + res.status(500).json({ error: 'Failed to load season data' }); + } +}); + +app.get('/api/squadrons/resolve', (req, res) => { + const shorts = normalizeQueryList(req.query.short ?? req.query.shorts); + const tags = normalizeQueryList(req.query.tag ?? req.query.tags); + const longs = normalizeQueryList(req.query.long ?? req.query.longs); + // `name=` is alias-aware: tries the live cache first, then squadron_name_aliases + // (covers long/short/tag renames), squadron_name_history (legacy long_name), + // and finally player_games_hist (catches short-tag renames not in the alias + // table). Use it when the input could be a historical name. + const names = normalizeQueryList(req.query.name ?? req.query.names); + + if (!shorts.length && !tags.length && !longs.length && !names.length) { + return res.status(400).json({ + error: 'At least one short, tag, long, or name value is required' + }); + } + + loadSquadronLookupCached((squadronLookup) => { + const results = []; + const seen = new Set(); + const append = (resolved) => { + const dedupeKey = resolved.resolved + ? `clan:${String(resolved.clan_id ?? resolved.short_name ?? resolved.tag_name).toLowerCase()}` + : `input:${resolved.input.toLowerCase()}`; + if (seen.has(dedupeKey)) return; + seen.add(dedupeKey); + results.push(resolved); + }; + + const appendTyped = (value, resolvedBy) => { + const key = String(value || '').trim(); + if (!key) return; + const row = squadronLookup[key] || squadronLookup[key.toLowerCase()] || null; + append(formatSquadronResolution(key, row, resolvedBy)); + }; + + shorts.forEach(value => appendTyped(value, 'short_name')); + tags.forEach(value => appendTyped(value, 'tag_name')); + longs.forEach(value => appendTyped(value, 'long_name')); + + const respond = () => { + res.json({ + requested: { shorts, tags, longs, names }, + total_requested: shorts.length + tags.length + longs.length + names.length, + total_found: results.filter(row => row.resolved).length, + results + }); + }; + + if (!names.length) return respond(); + + const squadronsDbPath = path.join(STORAGE_ROOT, 'squadrons.db'); + const rowForClanId = (cid) => + squadronLookup[`__cid_${cid}`] || squadronLookup[String(cid)] || null; + const longNameForClanId = (cid) => { + const row = rowForClanId(cid); + return row ? row.long_name : null; + }; + + const buildFromClanId = (key, cid, via) => { + const row = rowForClanId(cid); + if (row) { + const r = formatSquadronResolution(key, row, 'name'); + r.via = via; + return r; + } + const r = formatSquadronResolution(key, null, 'name'); + r.resolved = true; + r.clan_id = cid; + r.long_name = longNameForClanId(cid); + r.via = via; + return r; + }; + + const resolveOneName = (rawName) => new Promise((done) => { + const key = String(rawName || '').trim(); + if (!key) return done(null); + + const hit = squadronLookup[key] || squadronLookup[key.toLowerCase()]; + if (hit && hit.clan_id != null) { + const r = formatSquadronResolution(key, hit, 'name'); + r.via = 'squadrons_data'; + return done(r); + } + + const tryPlayerGamesHist = () => { + db.get( + `SELECT clan_id FROM player_games_hist + WHERE squadron_name = ? COLLATE NOCASE AND clan_id IS NOT NULL + ORDER BY endtime_unix DESC LIMIT 1`, + [key], + (qerr, row) => { + if (qerr || !row || row.clan_id == null) { + return done(formatSquadronResolution(key, null, 'name')); + } + done(buildFromClanId(key, row.clan_id, 'player_games_hist')); + } + ); + }; + + if (!fs.existsSync(squadronsDbPath)) return tryPlayerGamesHist(); + + const sdb = new sqlite3.Database(squadronsDbPath, sqlite3.OPEN_READONLY, (oerr) => { + if (oerr) return tryPlayerGamesHist(); + sdb.get( + `SELECT clan_id FROM squadron_name_aliases + WHERE LOWER(name) = LOWER(?) + ORDER BY last_seen DESC LIMIT 1`, + [key], + (aerr, arow) => { + if (!aerr && arow && arow.clan_id != null) { + try { sdb.close(); } catch (_) {} + return done(buildFromClanId(key, arow.clan_id, 'name_aliases')); + } + sdb.get( + `SELECT clan_id FROM squadron_name_history + WHERE LOWER(long_name) = LOWER(?) + ORDER BY last_seen DESC LIMIT 1`, + [key], + (herr, hrow) => { + try { sdb.close(); } catch (_) {} + if (!herr && hrow && hrow.clan_id != null) { + return done(buildFromClanId(key, hrow.clan_id, 'name_history')); + } + tryPlayerGamesHist(); + } + ); + } + ); + }); + }); + + Promise.all(names.map(resolveOneName)).then((nameResults) => { + for (const r of nameResults) { + if (r) append(r); + } + respond(); + }); + }); +}); + +const API_INFO = { + name: "SREBOT Player API", + version: "2.3.0", + endpoints: { + "GET /api/player/:uid": "Get player totals and per-vehicle stats by UID", + "GET /api/player/:uid/games": "Get individual game rows for a player", + "GET /api/player/:uid/history": "Get day-by-day player history", + "GET /api/search/:nickname": "Search for players by nickname", + "GET /api/live": "Get recent match summaries", + "GET /api/match/:sessionId": "Get a single match summary by session ID", + "GET /api/match/:sessionId/replay": "Get replay data for a match if available", + "GET /api/match/:sessionId/scoreboard": "Get the full scoreboard context for a match", + "GET /api/games/search": "Search matches by player, map, squadron, or time range", + "GET /api/maps": "List distinct map names from match history", + "GET /api/seasons": "Return season schedule data for filtering", + "GET /api/squadrons/resolve": "Resolve squadron short/tag/long (current only) or name= (alias-aware: includes renamed squadrons) to canonical metadata", + "GET /api/squadrons/:squadronname/comps": "Get recent comp snapshots for a squadron", + "GET /api/leaderboard/players": "Get global player leaderboards", + "GET /api/leaderboard/squadrons": "Get squadron leaderboards", + "GET /api/leaderboard/vehicles": "Get vehicle-specific leaderboards", + "GET /api/leaderboard/stats": "Get overall leaderboard statistics and top vehicles", + "GET /api/squadrons/:squadronname": "Get squadron roster stats and summary", + "GET /api/squadrons/:squadronname/history": "Get squadron battle and rating history", + "GET /api/squadrons/:squadronname/games": "Get squadron match list", + "GET /health": "Health check endpoint", + "GET /api/info": "API information" + }, + filtering: { + core_query_params: { + start_date: "ISO date string or Unix timestamp, inclusive lower bound", + end_date: "ISO date string or Unix timestamp, inclusive upper bound" + }, + season_filtering: "Use GET /api/seasons to map season/week windows to timestamps before querying the data endpoints.", + response_metadata: "Filtered responses include a date_filter object describing the applied range" + }, + databases: [ + { + file: "sq_battles.db", + purpose: "Primary battle history store and match summary cache", + tables: [ + { + name: "player_games_hist", + description: "One row per player per battle / vehicle. This is the main source for player stats, games, history, search, leaderboards, squadron stats, and battle debug endpoints.", + used_by: [ + "GET /api/player/:uid", + "GET /api/player/:uid/games", + "GET /api/player/:uid/history", + "GET /api/search/:nickname", + "GET /api/games/search", + "GET /api/leaderboard/players", + "GET /api/leaderboard/vehicles", + "GET /api/leaderboard/squadrons", + "GET /api/leaderboard/stats", + "GET /api/squadrons/:squadronname", + "GET /api/squadrons/:squadronname/history", + "GET /api/squadrons/:squadronname/games", + "GET /api/debug/stats", + "GET /api/debug/schema", + "GET /api/debug/player-sample", + "GET /api/debug/player-count/:uid", + "GET /api/debug/migration-status" + ] + }, + { + name: "match_summary", + description: "One row per match. Stores the per-session map, winner/loser, team blobs, timestamps, and replay availability metadata.", + used_by: [ + "GET /api/live", + "GET /api/match/:sessionId", + "GET /api/match/:sessionId/replay", + "GET /api/games/search", + "GET /api/maps", + "GET /api/squadrons/:squadronname/games", + "GET /api/debug/stats" + ] + } + ] + }, + { + file: "squadrons.db", + purpose: "Squadron roster cache, live squadron metadata, and historical squadron point snapshots", + tables: [ + { + name: "squadrons_data", + description: "Canonical squadron directory and latest leaderboard snapshot. Stores clan IDs, names, tags, membership counts, and current rating fields.", + used_by: [ + "GET /api/leaderboard/squadrons", + "GET /api/leaderboard/stats", + "GET /api/squadrons/resolve", + "GET /api/squadrons/:squadronname", + "GET /api/squadrons/:squadronname/history", + "GET /api/squadrons/:squadronname/games", + "GET /api/debug/squadrons-db-schema", + "background squadron sync jobs" + ] + }, + { + name: "squadron_members", + description: "Current per-squadron member roster with cached nick and points values.", + used_by: [ + "GET /api/squadrons/:squadronname", + "GET /api/squadrons/:squadronname/games", + "GET /api/leaderboard/squadrons", + "background squadron member sync jobs" + ] + }, + { + name: "squadrons_points", + description: "Historical squadron point snapshots keyed by unix time. Used for historical squadron point lookups and charts.", + used_by: [ + "GET /api/squadrons/:squadronname", + "GET /api/squadrons/:squadronname/history", + "background squadron points sync jobs" + ] + } + ] + }, + { + file: "wl.db", + purpose: "Read-only win/loss standings for scoreboard rendering and recent match context", + tables: [ + { + name: "wl_events", + description: "Idempotent log of processed battle result events.", + used_by: [ + "GET /api/match/:sessionId/scoreboard" + ] + }, + { + name: "wl_standings", + description: "Current win/loss totals per squadron.", + used_by: [ + "GET /api/match/:sessionId/scoreboard" + ] + } + ] + }, + { + file: "points.db", + purpose: "Read-only cached point diffs for scoreboard rendering", + tables: [ + { + name: "profile_member_points", + description: "Current cached points per squadron member.", + used_by: [ + "internal replay processing", + "scoreboard point diff caching" + ] + }, + { + name: "profile_totals", + description: "Current cached total points per squadron.", + used_by: [ + "internal replay processing", + "scoreboard point diff caching" + ] + }, + { + name: "game_cache", + description: "Per-session cached point diffs and updated snapshots.", + used_by: [ + "GET /api/match/:sessionId/scoreboard", + "internal replay processing" + ] + } + ] + } + ] +}; + +app.get('/api/info', (req, res) => { + res.json(API_INFO); +}); + +app.get('/api/debug/player-sample', requireAdminBearer, (req, res) => { + const sampleQuery = ` + SELECT UID, nick, session_id, vehicle, ground_kills, air_kills, assists, captures, deaths, victor_bool + FROM player_games_hist + WHERE UID IS NOT NULL + LIMIT 10 + `; + + db.all(sampleQuery, [], (err, rows) => { + if (err) { + return res.status(500).json({ error: err.message }); + } + res.json({ sample_data: rows }); + }); +}); + +app.get('/api/debug/player-count/:uid', requireAdminBearer, (req, res) => { + const { uid } = req.params; + const countQuery = ` + SELECT + UID, + COUNT(*) as total_records, + COUNT(DISTINCT session_id) as unique_sessions, + COUNT(DISTINCT vehicle) as unique_vehicles, + GROUP_CONCAT(DISTINCT vehicle) as vehicles_used + FROM player_games_hist + WHERE UID = ? + GROUP BY UID + `; + + db.get(countQuery, [uid], (err, row) => { + if (err) { + return res.status(500).json({ error: err.message }); + } + res.json({ player_analysis: row }); + }); +}); + +app.get('/api/debug/schema', requireAdminBearer, (req, res) => { + const schemaQuery = `PRAGMA table_info(player_games_hist)`; + const sampleQuery = `SELECT * FROM player_games_hist LIMIT 3`; + + db.all(schemaQuery, (err, schema) => { + if (err) { + log.error('Database error in schema query', err); + return res.status(500).json({ error: 'Database schema error', details: err.message }); + } + + db.all(sampleQuery, (err, samples) => { + if (err) { + log.error('Database error in sample query', err); + return res.status(500).json({ error: 'Database sample error', details: err.message }); + } + + const columnNames = samples.length > 0 ? Object.keys(samples[0]) : []; + + res.json({ + table_name: 'player_games_hist', + schema: schema.map(col => ({ + name: col.name, + type: col.type, + notnull: col.notnull, + default_value: col.dflt_value, + primary_key: col.pk + })), + column_names: columnNames, + sample_records: samples, + total_columns: schema.length, + timestamp: new Date().toISOString() + }); + }); + }); +}); + +app.get('/api/debug/squadron-names', requireAdminBearer, (req, res) => { + const squadronNamesQuery = ` + SELECT DISTINCT squadron_name, COUNT(*) as record_count + FROM player_games_hist + WHERE squadron_name IS NOT NULL AND squadron_name != 'UNKNOWN' + GROUP BY squadron_name + ORDER BY squadron_name + LIMIT 50 + `; + + db.all(squadronNamesQuery, [], (err, rows) => { + if (err) { + log.error('Database error in squadron names debug query', err); + return res.status(500).json({ + error: 'Database error occurred', + errorCode: 'DB_SQUADRON_NAMES_DEBUG_FAILED' + }); + } + + const analysis = { + total_unique_names: rows.length, + squadron_names: rows, + patterns: { + bracketed: rows.filter(row => row.squadron_name.includes('[') && row.squadron_name.includes(']')), + non_bracketed: rows.filter(row => !row.squadron_name.includes('[') && !row.squadron_name.includes(']')), + mixed: rows.filter(row => (row.squadron_name.includes('[') || row.squadron_name.includes(']')) && + !(row.squadron_name.includes('[') && row.squadron_name.includes(']'))) + } + }; + + analysis.potential_duplicates = []; + for (let i = 0; i < rows.length; i++) { + for (let j = i + 1; j < rows.length; j++) { + const name1 = rows[i].squadron_name; + const name2 = rows[j].squadron_name; + const normalized1 = name1.replace(/[\[\]]/g, '').trim(); + const normalized2 = name2.replace(/[\[\]]/g, '').trim(); + + if (normalized1.toLowerCase() === normalized2.toLowerCase()) { + analysis.potential_duplicates.push({ + name1: name1, + name2: name2, + normalized: normalized1, + records1: rows[i].record_count, + records2: rows[j].record_count + }); + } + } + } + + res.json(analysis); + }); +}); + +app.get('/api/debug/migration-status', requireAdminBearer, (req, res) => { + const schemaQuery = `PRAGMA table_info(player_games_hist)`; + + db.all(schemaQuery, (err, schema) => { + if (err) { + log.error('Database error in migration status query', err); + return res.status(500).json({ + error: 'Database schema error', + details: err.message + }); + } + + const columns = schema.map(col => col.name); + const hasSquadronColumn = columns.includes('squadron_name'); + + res.json({ + table_name: 'player_games_hist', + has_squadron_column: hasSquadronColumn, + missing_columns: hasSquadronColumn ? [] : ['squadron_name'], + all_columns: columns, + migration_needed: !hasSquadronColumn, + migration_sql: hasSquadronColumn ? null : "ALTER TABLE player_games_hist ADD COLUMN squadron_name TEXT NOT NULL DEFAULT 'UNKNOWN'", + timestamp: new Date().toISOString() + }); + }); +}); + +app.get('/api/debug/squadrons-db-schema', requireAdminBearer, (req, res) => { + const squadronsDbPath = path.join(STORAGE_ROOT, 'squadrons.db'); + + if (!fs.existsSync(squadronsDbPath)) { + return res.status(404).json({ + error: 'Squadrons database not found', + path: squadronsDbPath + }); + } + + const squadronsDb = new sqlite3.Database(squadronsDbPath, sqlite3.OPEN_READONLY, (err) => { + if (err) { + return res.status(500).json({ + error: 'Failed to open squadrons database', + details: err.message + }); + } + + // Get all table names first + const tablesQuery = `SELECT name FROM sqlite_master WHERE type='table'`; + + squadronsDb.all(tablesQuery, (err, tables) => { + if (err) { + squadronsDb.close(); + return res.status(500).json({ + error: 'Failed to get table list', + details: err.message + }); + } + + const tableSchemas = {}; + let completedTables = 0; + + if (tables.length === 0) { + squadronsDb.close(); + return res.json({ + database_path: squadronsDbPath, + tables: {}, + message: 'No tables found in squadrons database' + }); + } + + tables.forEach(table => { + const tableName = table.name; + const schemaQuery = `PRAGMA table_info(${tableName})`; + const sampleQuery = `SELECT * FROM ${tableName} LIMIT 3`; + + squadronsDb.all(schemaQuery, (err, schema) => { + if (err) { + tableSchemas[tableName] = { error: err.message }; + completedTables++; + if (completedTables === tables.length) { + squadronsDb.close(); + res.json({ + database_path: squadronsDbPath, + tables: tableSchemas, + timestamp: new Date().toISOString() + }); + } + return; + } + + squadronsDb.all(sampleQuery, (err, samples) => { + if (err) samples = []; + + tableSchemas[tableName] = { + schema: schema, + sample_records: samples, + record_count: samples.length + }; + + completedTables++; + if (completedTables === tables.length) { + squadronsDb.close(); + res.json({ + database_path: squadronsDbPath, + tables: tableSchemas, + timestamp: new Date().toISOString() + }); + } + }); + }); + }); + }); + }); +}); + +app.get('/api/leaderboard/players', (req, res) => { + const dateFilters = parseDateFilters(req); + const { start_date, end_date, season, week } = req.query; + + // `limit` trims the response array; the full sorted set is cached under a + // limit-agnostic key so different callers share work. Omit `limit` to get + // every player. Cap at 10k to keep payloads sane on accidental large values. + const rawLimit = parseInt(req.query.limit, 10); + const limit = Number.isFinite(rawLimit) && rawLimit > 0 + ? Math.min(rawLimit, 10000) + : null; + + log.info('Player leaderboard request received', { limit: limit ?? 'all' }); + + const applyLimit = (full) => { + if (limit === null) return full; + return { ...full, players: full.players.slice(0, limit), limit, returned: Math.min(limit, full.players.length) }; + }; + + const cacheKey = `leaderboard_players_${start_date || 'all'}_${end_date || 'all'}`; + const cached = getCachedResponse(cacheKey); + if (cached) { + log.info('Returning cached leaderboard response'); + return res.json(applyLimit(cached)); + } + + if (!dateFilters.hasFilter) { + return res.status(400).json({ + error: 'A date filter (start_date/end_date/season/week) is required for uncached all-time player leaderboard queries.', + errorCode: 'FILTER_REQUIRED' + }); + } + + // Dedup: if this exact query is already running, wait for it + dedup(cacheKey, () => new Promise((resolve, reject) => { + // Step 1: Pure aggregation — no nick lookups, single scan + const statsQuery = ` + SELECT + p.UID as uid, + SUM(ground_kills) as total_ground_kills, + SUM(air_kills) as total_air_kills, + SUM(ground_kills + air_kills) as total_kills, + SUM(assists) as total_assists, + SUM(captures) as total_captures, + SUM(deaths) as total_deaths, + COUNT(DISTINCT session_id) as total_battles, + SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END) as wins, + SUM(ground_kills + air_kills + assists * 0.5 + captures * 2) as total_score + FROM player_games_hist p + WHERE p.UID IS NOT NULL + AND (? IS NULL OR endtime_unix >= ?) + AND (? IS NULL OR endtime_unix <= ?) + GROUP BY p.UID + HAVING SUM(ground_kills + air_kills) > 0 + ORDER BY total_score DESC + `; + + const queryParams = [ + dateFilters.startTimestamp, dateFilters.startTimestamp, + dateFilters.endTimestamp, dateFilters.endTimestamp + ]; + + const queryStart = Date.now(); + heavyDb.all(statsQuery, queryParams, (err, statsRows) => { + if (err) { + log.error('Database error in player leaderboard aggregation', err); + reject(err); + return res.status(500).json({ error: 'Database error', errorCode: 'DB_PLAYER_LEADERBOARD_FAILED' }); + } + + log.info('Player leaderboard aggregation done', { rows: statsRows.length, ms: Date.now() - queryStart }); + + // Step 2: Nick/squadron lookup from squadron_members cache (instant, no heavy SQL) + loadNickLookupCached((nickCache) => { + loadSquadronLookupCached((squadronLookup) => { + const uncoveredUids = statsRows.filter(r => !nickCache[r.uid]).map(r => r.uid); + + const buildResponse = (fallbackMap) => { + const response = { + date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week), + timeframe: dateFilters.hasFilter ? "filtered" : "all-time", + total_players: statsRows.length, + players: statsRows.map(row => { + const totalKills = row.total_kills || 0; + const deaths = row.total_deaths || 0; + const wins = row.wins || 0; + const totalBattles = row.total_battles || 0; + const kdr = deaths > 0 ? (totalKills / deaths) : totalKills; + const winRate = totalBattles > 0 ? (wins / totalBattles) * 100 : 0; + + const cached = nickCache[row.uid]; + const fb = fallbackMap[row.uid]; + const nick = cached ? cached.nick : (fb ? fb.nick : row.uid); + const sqId = resolveSquadronIdentity(cached, fb, squadronLookup); + + return { + uid: row.uid, + nick, + squadron_name: sqId.tag_name, + squadron_short_name: sqId.short_name, + squadron_clan_id: sqId.clan_id, + total_kills: totalKills, + ground_kills: row.total_ground_kills || 0, + air_kills: row.total_air_kills || 0, + total_battles: totalBattles, + wins, + win_rate: parseFloat(winRate.toFixed(1)), + kdr: parseFloat(kdr.toFixed(1)), + deaths, + assists: row.total_assists || 0, + captures: row.total_captures || 0, + total_score: Math.round(row.total_score || 0) + }; + }) + }; + + log.info('Player leaderboard complete', { + playersReturned: statsRows.length, + cachedNicks: statsRows.length - uncoveredUids.length, + fallbackNicks: Object.keys(fallbackMap).length, + uncoveredNicks: uncoveredUids.length - Object.keys(fallbackMap).length, + totalMs: Date.now() - queryStart + }); + + setCachedResponse(cacheKey, response); + resolve(response); + res.json(applyLimit(response)); + }; + + // Fallback for players not in any squadron — uses (UID, endtime_unix) index + if (uncoveredUids.length > 0) { + const fbPlaceholders = uncoveredUids.map(() => '?').join(','); + db.all(` + SELECT UID as uid, nick, squadron_name, MAX(endtime_unix) + FROM player_games_hist + WHERE UID IN (${fbPlaceholders}) AND nick NOT LIKE 'coop/%' + GROUP BY UID + `, uncoveredUids, (err, fbRows) => { + const fbMap = {}; + if (!err && fbRows) fbRows.forEach(r => { fbMap[r.uid] = r; }); + if (err) log.warn('Fallback nick lookup failed', { error: err.message }); + buildResponse(fbMap); + }); + } else { + buildResponse({}); + } + }); + }); + }); + })).catch(err => { + if (!res.headersSent) { + res.status(500).json({ error: 'Database error', errorCode: 'DB_PLAYER_LEADERBOARD_FAILED' }); + } + }); +}); + +app.get('/api/leaderboard/vehicles', (req, res) => { + const { vehicle } = req.query; + + const dateFilters = parseDateFilters(req); + const { start_date, end_date, season, week } = req.query; + + // `limit` trims the response array; cache key omits limit so different + // callers share the heavy aggregation. Default unlimited, cap at 10k. + const rawLimit = parseInt(req.query.limit, 10); + const limit = Number.isFinite(rawLimit) && rawLimit > 0 + ? Math.min(rawLimit, 10000) + : null; + + log.info('Vehicle leaderboard request received', { vehicleFilter: vehicle, limit: limit ?? 'all' }); + + const applyLimit = (full) => { + if (limit === null) return full; + return { ...full, vehicles: full.vehicles.slice(0, limit), limit, returned: Math.min(limit, full.vehicles.length) }; + }; + + const cacheKey = `leaderboard_vehicles_${vehicle || 'all'}_${start_date || 'all'}_${end_date || 'all'}`; + const cached = getCachedResponse(cacheKey); + if (cached) { + log.info('Returning cached vehicle leaderboard'); + return res.json(applyLimit(cached)); + } + + if (!vehicle && !dateFilters.hasFilter) { + return res.status(400).json({ + error: 'A date filter (start_date/end_date/season/week) or vehicle filter is required for uncached all-time vehicle leaderboard queries.', + errorCode: 'FILTER_REQUIRED' + }); + } + + dedup(cacheKey, () => new Promise((resolve, reject) => { + let statsQuery; + let queryParams; + + if (vehicle) { + statsQuery = ` + SELECT vehicle, MAX(vehicle_internal) as vehicle_internal, p.UID as player_uid, + SUM(ground_kills) as total_ground_kills, SUM(air_kills) as total_air_kills, + SUM(ground_kills + air_kills) as total_kills, + SUM(assists) as total_assists, SUM(captures) as total_captures, + SUM(deaths) as total_deaths, COUNT(*) as battles, + SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END) as wins + FROM player_games_hist p + WHERE vehicle = ? COLLATE NOCASE AND p.UID IS NOT NULL + AND (? IS NULL OR endtime_unix >= ?) AND (? IS NULL OR endtime_unix <= ?) + GROUP BY p.UID, vehicle + HAVING SUM(ground_kills + air_kills) > 0 + ORDER BY total_kills DESC, battles DESC + `; + queryParams = [vehicle, dateFilters.startTimestamp, dateFilters.startTimestamp, dateFilters.endTimestamp, dateFilters.endTimestamp]; + } else { + statsQuery = ` + SELECT vehicle, MAX(vehicle_internal) as vehicle_internal, p.UID as player_uid, + SUM(ground_kills) as total_ground_kills, SUM(air_kills) as total_air_kills, + SUM(ground_kills + air_kills) as total_kills, + SUM(assists) as total_assists, SUM(captures) as total_captures, + SUM(deaths) as total_deaths, COUNT(*) as battles, + SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END) as wins + FROM player_games_hist p + WHERE p.UID IS NOT NULL + AND (? IS NULL OR endtime_unix >= ?) AND (? IS NULL OR endtime_unix <= ?) + GROUP BY p.UID, vehicle + HAVING SUM(ground_kills + air_kills) > 0 + ORDER BY total_kills DESC, battles DESC + `; + queryParams = [dateFilters.startTimestamp, dateFilters.startTimestamp, dateFilters.endTimestamp, dateFilters.endTimestamp]; + } + + const queryStart = Date.now(); + heavyDb.all(statsQuery, queryParams, (err, vehicleRows) => { + if (err) { + log.error('Database error in vehicle leaderboard', err); + reject(err); + return res.status(500).json({ error: 'Database error', errorCode: 'DB_VEHICLE_LEADERBOARD_FAILED' }); + } + + // Nick/squadron lookup from squadron_members cache (instant) + loadNickLookupCached((nickCache) => { + loadSquadronLookupCached((squadronLookup) => { + const uniqueUids = [...new Set(vehicleRows.map(r => r.player_uid))]; + const uncoveredUids = uniqueUids.filter(uid => !nickCache[uid]); + + const buildResponse = (fallbackMap) => { + const response = { + date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week), + vehicles: vehicleRows.map(row => { + const totalKills = row.total_kills || 0; + const deaths = row.total_deaths || 0; + const wins = row.wins || 0; + const battles = row.battles || 0; + const kdr = deaths > 0 ? (totalKills / deaths) : totalKills; + const winRate = battles > 0 ? (wins / battles) * 100 : 0; + + const cached = nickCache[row.player_uid]; + const fb = fallbackMap[row.player_uid]; + const playerNick = cached ? cached.nick : (fb ? fb.nick : row.player_uid); + const sqId = resolveSquadronIdentity(cached, fb, squadronLookup); + + return { + vehicle: row.vehicle, + vehicle_internal: row.vehicle_internal || null, + player_uid: normalizeUid(row.player_uid), + player_nick: playerNick, + player_squadron_name: sqId.tag_name, + player_squadron_short_name: sqId.short_name, + player_squadron_clan_id: sqId.clan_id, + total_kills: totalKills, + ground_kills: row.total_ground_kills || 0, + air_kills: row.total_air_kills || 0, + battles, wins, + win_rate: parseFloat(winRate.toFixed(1)), + kdr: parseFloat(kdr.toFixed(1)), + deaths, + assists: row.total_assists || 0, + captures: row.total_captures || 0 + }; + }) + }; + + log.info('Vehicle leaderboard complete', { vehiclesReturned: vehicleRows.length, totalMs: Date.now() - queryStart }); + setCachedResponse(cacheKey, response); + resolve(response); + res.json(applyLimit(response)); + }; + + if (uncoveredUids.length > 0) { + const fbPlaceholders = uncoveredUids.map(() => '?').join(','); + db.all(` + SELECT UID as uid, nick, squadron_name, MAX(endtime_unix) + FROM player_games_hist + WHERE UID IN (${fbPlaceholders}) AND nick NOT LIKE 'coop/%' + GROUP BY UID + `, uncoveredUids, (err, fbRows) => { + const fbMap = {}; + if (!err && fbRows) fbRows.forEach(r => { fbMap[r.uid] = r; }); + buildResponse(fbMap); + }); + } else { + buildResponse({}); + } + }); + }); + }); + })).catch(err => { + if (!res.headersSent) { + res.status(500).json({ error: 'Database error', errorCode: 'DB_VEHICLE_LEADERBOARD_FAILED' }); + } + }); +}); + +app.get('/api/leaderboard/squadrons', (req, res) => { + log.info('Squadron leaderboard request received'); + + // Parse date filters + const dateFilters = parseDateFilters(req); + const { start_date, end_date, season, week } = req.query; + const rawLimit = parseInt(req.query.limit, 10); + const limit = Number.isFinite(rawLimit) && rawLimit > 0 + ? Math.min(rawLimit, 10000) + : null; + + const applyLimit = (full) => { + if (limit === null) return full; + return { + ...full, + squadrons: full.squadrons.slice(0, limit), + limit, + returned: Math.min(limit, full.squadrons.length), + }; + }; + + const cacheKey = `leaderboard_squadrons_${start_date || 'all'}_${end_date || 'all'}`; + const cached = getCachedResponse(cacheKey); + if (cached) { + log.info('Returning cached squadron leaderboard'); + return res.json(applyLimit(cached)); + } + + dedup(cacheKey, () => new Promise((resolve, reject) => { + const schemaQuery = `PRAGMA table_info(player_games_hist)`; + + db.all(schemaQuery, (err, schema) => { + if (err) { + log.error('Database error in schema check', err); + return reject({ status: 500, body: { error: 'Database schema error', errorCode: 'DB_SCHEMA_CHECK_FAILED' } }); + } + + const hasSquadronColumn = schema.some(col => col.name === 'squadron_name'); + + if (!hasSquadronColumn) { + log.info('Squadron column missing - returning empty squadron leaderboard'); + const emptyResponse = { + date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week), + timeframe: dateFilters.hasFilter ? "filtered" : "all-time", + total_squadrons: 0, + squadrons: [], + migration_needed: true, + message: 'Squadron data not available - squadron_name column missing from database' + }; + setCachedResponse(cacheKey, emptyResponse); + return resolve(emptyResponse); + } + + // Group by (clan_id, squadron_name) so renamed squadrons stay + // attached via clan_id while orphans (clan_id IS NULL) still group + // by their text. JS-side consolidation collapses by clan_id below. + const squadronStatsQuery = ` + SELECT + clan_id, + squadron_name, + COUNT(DISTINCT UID) as player_count, + SUM(ground_kills) as total_ground_kills, + SUM(air_kills) as total_air_kills, + SUM(ground_kills + air_kills) as total_kills, + SUM(assists) as total_assists, + SUM(captures) as total_captures, + SUM(deaths) as total_deaths, + COUNT(*) as total_battles, + SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END) as wins + FROM player_games_hist + WHERE squadron_name IS NOT NULL + AND squadron_name != 'UNKNOWN' + AND (? IS NULL OR endtime_unix >= ?) + AND (? IS NULL OR endtime_unix <= ?) + GROUP BY clan_id, squadron_name + HAVING SUM(ground_kills + air_kills) > 0 + ORDER BY SUM(ground_kills + air_kills) DESC + `; + + const totalSquadronsQuery = ` + SELECT COUNT(DISTINCT COALESCE(CAST(clan_id AS TEXT), squadron_name)) as total_squadrons + FROM player_games_hist + WHERE squadron_name IS NOT NULL + AND squadron_name != 'UNKNOWN' + AND (? IS NULL OR endtime_unix >= ?) + AND (? IS NULL OR endtime_unix <= ?) + `; + + // Build query parameters with date filtering + const queryParams = [ + dateFilters.startTimestamp, + dateFilters.startTimestamp, + dateFilters.endTimestamp, + dateFilters.endTimestamp + ]; + + heavyDb.get(totalSquadronsQuery, queryParams, (err, totalRow) => { + if (err) { + log.error('Database error in total squadrons query', err); + return reject({ status: 500, body: { error: 'Database error occurred', errorCode: 'DB_TOTAL_SQUADRONS_FAILED' } }); + } + + heavyDb.all(squadronStatsQuery, queryParams, (err, squadronRows) => { + if (err) { + log.error('Database error in squadron leaderboard query', err); + return reject({ status: 500, body: { error: 'Database error occurred', errorCode: 'DB_SQUADRON_LEADERBOARD_FAILED' } }); + } + + log.info('DEBUG: Raw squadron query results', { + rowCount: squadronRows.length, + firstFewRows: squadronRows.slice(0, 3), + queryUsed: squadronStatsQuery.replace(/\s+/g, ' ').trim() + }); + + const squadronsDbPath = path.join(STORAGE_ROOT, 'squadrons.db'); + let squadronLookup = {}; + let totalSquadrons = totalRow.total_squadrons; + + const loadSquadronLookup = (callback) => { + if (!fs.existsSync(squadronsDbPath)) { + return callback(); + } + + const squadronsDb = new sqlite3.Database(squadronsDbPath, sqlite3.OPEN_READONLY, (squadronsErr) => { + if (squadronsErr) { + log.error('Failed to open squadrons database', squadronsErr); + return callback(); + } + + const squadronLookupQuery = `SELECT clan_id, long_name, short_name, tag_name, members, clanrating FROM squadrons_data`; + squadronsDb.all(squadronLookupQuery, [], (err, squadronLookupRows) => { + squadronsDb.close(); + + if (err) { + log.error('Error loading squadron lookup data', err); + return callback(); + } + + squadronLookupRows.forEach(lookup => { + if (lookup.long_name) squadronLookup[lookup.long_name] = lookup; + if (lookup.short_name) squadronLookup[lookup.short_name] = lookup; + if (lookup.tag_name) squadronLookup[lookup.tag_name] = lookup; + if (lookup.clan_id != null) squadronLookup[`__cid_${lookup.clan_id}`] = lookup; + }); + + callback(); + }); + }); + }; + + loadSquadronLookup(() => { + const consolidatedSquadrons = {}; + squadronRows.forEach(row => { + // Prefer clan_id-keyed lookup so renamed squadrons consolidate + // under their current long_name; fall back to text lookup for + // rows whose clan_id wasn't backfilled. + const lookup = (row.clan_id != null && squadronLookup[`__cid_${row.clan_id}`]) + || squadronLookup[row.squadron_name]; + const canonicalName = lookup ? lookup.long_name : row.squadron_name; + const tagName = lookup ? lookup.tag_name : row.squadron_name; + // Group key is clan_id when known; orphans group by name. + const consolidationKey = (lookup && lookup.clan_id != null) + ? `__cid_${lookup.clan_id}` + : canonicalName; + + if (!consolidatedSquadrons[consolidationKey]) { + consolidatedSquadrons[consolidationKey] = { + clan_id: lookup ? lookup.clan_id : null, + tag_name: tagName, + short_name: lookup ? lookup.short_name : null, + long_name: canonicalName, + player_count: lookup ? (lookup.members || 0) : 0, + total_kills: 0, + ground_kills: 0, + air_kills: 0, + total_battles: 0, + wins: 0, + deaths: 0, + assists: 0, + captures: 0 + }; + } + + const consolidated = consolidatedSquadrons[consolidationKey]; + consolidated.total_kills += row.total_kills || 0; + consolidated.ground_kills += row.total_ground_kills || 0; + consolidated.air_kills += row.total_air_kills || 0; + consolidated.total_battles += row.total_battles || 0; + consolidated.wins += row.wins || 0; + consolidated.deaths += row.total_deaths || 0; + consolidated.assists += row.total_assists || 0; + consolidated.captures += row.total_captures || 0; + }); + + // Get points from squadron_members table + const getSquadronPoints = () => { + return new Promise((resolve) => { + if (!fs.existsSync(squadronsDbPath)) { + return resolve({}); + } + + const pointsDb = new sqlite3.Database(squadronsDbPath, sqlite3.OPEN_READONLY, (err) => { + if (err) { + log.error('Failed to open squadrons.db for points lookup', err); + return resolve({}); + } + + pointsDb.all(` + SELECT long_name, clanrating as total_points + FROM squadrons_data + WHERE clanrating IS NOT NULL + `, [], (err, rows) => { + pointsDb.close(); + + if (err) { + log.error('Error querying squadron points', err); + return resolve({}); + } + + const pointsMap = {}; + rows.forEach(row => { + pointsMap[row.long_name] = row.total_points || 0; + }); + resolve(pointsMap); + }); + }); + }); + }; + + getSquadronPoints().then(pointsMap => { + const squadronsArray = Object.values(consolidatedSquadrons).map(squadron => { + const kdr = squadron.deaths > 0 ? (squadron.total_kills / squadron.deaths) : squadron.total_kills; + const winRate = squadron.total_battles > 0 ? (squadron.wins / squadron.total_battles) * 100 : 0; + const totalPoints = pointsMap[squadron.long_name] || 0; + + return { + clan_id: squadron.clan_id, + tag_name: squadron.tag_name, + short_name: squadron.short_name, + long_name: squadron.long_name, + player_count: squadron.player_count, + total_kills: squadron.total_kills, + ground_kills: squadron.ground_kills, + air_kills: squadron.air_kills, + total_battles: squadron.total_battles, + wins: squadron.wins, + win_rate: parseFloat(winRate.toFixed(1)), + kdr: parseFloat(kdr.toFixed(1)), + deaths: squadron.deaths, + assists: squadron.assists, + captures: squadron.captures, + points: { + total_points: totalPoints, + has_points_data: totalPoints > 0 + } + }; + }); + + // Sort by points, fallback to total kills + squadronsArray.sort((a, b) => { + if (a.points.has_points_data && b.points.has_points_data) { + return b.points.total_points - a.points.total_points; + } + if (a.points.has_points_data && !b.points.has_points_data) return -1; + if (!a.points.has_points_data && b.points.has_points_data) return 1; + return b.total_kills - a.total_kills; + }); + + const response = { + date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week), + timeframe: dateFilters.hasFilter ? "filtered" : "all-time", + total_squadrons: squadronsArray.length, + squadrons: squadronsArray + }; + + log.info('Squadron leaderboard query completed with consolidation and points', { + originalSquadrons: squadronRows.length, + consolidatedSquadrons: squadronsArray.length, + totalSquadrons: squadronsArray.length, + squadronsWithPoints: squadronsArray.filter(s => s.points.has_points_data).length + }); + + setCachedResponse(cacheKey, response); + resolve(response); + }); + }); + }); + }); + }); + })).then(response => { + if (!res.headersSent) res.json(applyLimit(response)); + }).catch(err => { + if (res.headersSent) return; + if (err && err.status && err.body) { + return res.status(err.status).json(err.body); + } + log.error('Unhandled error in squadron leaderboard', err); + res.status(500).json({ error: 'Database error occurred', errorCode: 'DB_SQUADRON_LEADERBOARD_FAILED' }); + }); +}); + +app.get('/api/squadrons/:squadronname', (req, res) => { + const { squadronname } = req.params; + + if (!squadronname) { + return res.status(400).json({ + error: 'Squadron name parameter is required' + }); + } + + // Parse date filters + const dateFilters = parseDateFilters(req); + const { start_date, end_date, season, week } = req.query; + + log.info('Squadron details request received', { squadronName: squadronname }); + + // Cache the response for 5 minutes — squadron stats change slowly and + // a single page render fires this endpoint per tab refresh. Without + // caching every load re-runs the 5s+ join. Dedup so concurrent first + // hits share one execution. + const cacheKey = `squadron_detail_${squadronname}_${start_date || ''}_${end_date || ''}_${season || ''}_${week || ''}`; + const cached = getCachedResponse(cacheKey); + if (cached) return res.json(cached); + + if (!hasSquadronColumn) { + log.info('Squadron column missing - returning empty squadron details', { squadronName: squadronname }); + return res.json({ + date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week), + tag_name: squadronname, + long_name: squadronname, + squadron_summary: { + player_count: 0, + total_kills: 0, + ground_kills: 0, + air_kills: 0, + total_battles: 0, + wins: 0, + win_rate: 0, + kdr: 0, + deaths: 0, + assists: 0, + captures: 0, + points: { + total_points: 0, + has_points_data: false + } + }, + players: [], + migration_needed: true, + message: 'Squadron data not available - squadron_name column missing from database' + }); + } + + let canonicalName = squadronname; + let tagName = squadronname; + + loadSquadronLookupCached((squadronLookup) => { + const lookup = squadronLookup[squadronname]; + let memberCount = 0; + const allVariants = []; + if (lookup) { + canonicalName = lookup.long_name; + tagName = lookup.tag_name; + memberCount = lookup.members || 0; + } + allVariants.push(canonicalName); + Object.keys(squadronLookup).forEach(key => { + if (squadronLookup[key].long_name === canonicalName && key !== canonicalName) { + allVariants.push(key); + } + }); + + + const clanId = lookup ? lookup.clan_id : null; + const squadronsDbPath = path.join(STORAGE_ROOT, 'squadrons.db'); + const totalPoints = lookup ? (lookup.clanrating || 0) : 0; + + // Step 1: Get the authoritative member roster from squadron_members + const membersPromise = new Promise((resolve) => { + if (!clanId || !fs.existsSync(squadronsDbPath)) return resolve([]); + const squadronsDb = new sqlite3.Database(squadronsDbPath, sqlite3.OPEN_READONLY, (err) => { + if (err) { log.error('Failed to open squadrons.db for members lookup', err); return resolve([]); } + squadronsDb.all(`SELECT uid, nick, points FROM squadron_members WHERE clan_id = ?`, [clanId], (err, rows) => { + squadronsDb.close(); + if (err) { log.error('Error querying squadron_members', err); return resolve([]); } + resolve(rows); + }); + }); + }); + + // Step 1b: When a date filter has an end timestamp, fetch the + // squadrons_points snapshot at or before that moment so we can + // report each member's points AS OF that historical point. + // clan_pts is gzip-compressed JSON of [members_dict, total_score]. + const snapshotPromise = new Promise((resolve) => { + if (!dateFilters.endTimestamp || !fs.existsSync(squadronsDbPath)) return resolve(null); + const sdb = new sqlite3.Database(squadronsDbPath, sqlite3.OPEN_READONLY, (err) => { + if (err) { resolve(null); return; } + sdb.get( + clanId + ? 'SELECT clan_pts, total_score FROM squadrons_points WHERE clan_id = ? AND unix_time <= ? ORDER BY unix_time DESC LIMIT 1' + : 'SELECT clan_pts, total_score FROM squadrons_points WHERE long_name = ? AND unix_time <= ? ORDER BY unix_time DESC LIMIT 1', + [clanId || canonicalName, dateFilters.endTimestamp], + (qerr, row) => { + sdb.close(); + if (qerr || !row || !row.clan_pts) return resolve(null); + try { + const buf = Buffer.isBuffer(row.clan_pts) ? row.clan_pts : Buffer.from(row.clan_pts, 'binary'); + const decompressed = zlib.gunzipSync(buf); + const parsed = JSON.parse(decompressed.toString('utf8')); + if (Array.isArray(parsed) && parsed[0] && typeof parsed[0] === 'object') { + resolve({ members: parsed[0], total: row.total_score }); + } else { + resolve(null); + } + } catch (e) { + log.error('Failed to decompress clan_pts snapshot', e, { longName: canonicalName }); + resolve(null); + } + } + ); + }); + }); + + Promise.all([membersPromise, snapshotPromise]) + .then(([memberRows, snapshot]) => { + const histMembers = snapshot ? snapshot.members : null; + const histTotal = (snapshot && typeof snapshot.total === 'number') ? snapshot.total : null; + if (!memberRows.length) { + log.info('No members found in squadron_members', { squadronName: squadronname, clanId }); + return res.json({ + date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week), + tag_name: tagName, + long_name: canonicalName, + squadron_summary: { + player_count: 0, total_kills: 0, ground_kills: 0, air_kills: 0, + total_battles: 0, wins: 0, win_rate: 0, kdr: 0, deaths: 0, + assists: 0, captures: 0, points: { total_points: totalPoints, has_points_data: totalPoints > 0 } + }, + players: [], + message: `Squadron '${squadronname}' not found or has no members` + }); + } + + // Build lookup maps from the roster + const memberNicks = {}; + const memberPoints = {}; + const memberUids = memberRows.map(r => { + const uid = String(r.uid); + memberNicks[uid] = r.nick || ''; + memberPoints[uid] = r.points || 0; + return uid; + }); + + // Step 2: Query stats from player_games_hist for roster members, + // but only for battles explicitly attributed to this squadron. + const uidPlaceholders = memberUids.map(() => '?').join(','); + const variantPlaceholders = allVariants.map(() => '?').join(','); + const mainDate = buildDateClause('p', dateFilters); + const fallbackDate = buildDateClause('p', dateFilters); + const summaryDate = buildDateClause('', dateFilters); + + // When clan_id is known (the common case post-migration), + // filter on it directly so SQLite can use idx_pgh_clanid_endtime. + // The OR-with-text-fallback we used to have here was the + // single biggest perf hit on filtered profile queries — + // it forced a full scan because the planner can't OR + // two index plans together cleanly. We drop the fallback; + // truly orphaned rows (clan_id NULL) won't appear, but + // those rows are unrecoverable without a manual backfill + // anyway. + const squadronAttrClause = clanId + ? `p.clan_id = ?` + : `p.squadron_name IN (${variantPlaceholders})`; + const summaryAttrClause = clanId + ? `clan_id = ?` + : `squadron_name IN (${variantPlaceholders})`; + + const playerStatsQuery = ` + SELECT + p.UID as uid, + SUM(ground_kills) as total_ground_kills, + SUM(air_kills) as total_air_kills, + SUM(ground_kills + air_kills) as total_kills, + SUM(assists) as total_assists, + SUM(captures) as total_captures, + SUM(deaths) as total_deaths, + COUNT(*) as total_battles, + SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END) as wins + FROM player_games_hist p + WHERE p.UID IN (${uidPlaceholders}) + AND ${squadronAttrClause} + ${mainDate.clause} + GROUP BY p.UID + `; + + const summaryQuery = ` + SELECT + COUNT(DISTINCT session_id) as total_battles, + COUNT(DISTINCT CASE WHEN UPPER(victor_bool) = 'WIN' THEN session_id END) as wins, + SUM(ground_kills) as total_ground_kills, + SUM(air_kills) as total_air_kills, + SUM(ground_kills + air_kills) as total_kills, + SUM(assists) as total_assists, + SUM(captures) as total_captures, + SUM(deaths) as total_deaths + FROM player_games_hist + WHERE ${summaryAttrClause} + AND UID IS NOT NULL + ${summaryDate.clause} + `; + + const vehicleStatsQuery = ` + SELECT + vehicle_internal, + MAX(vehicle) as vehicle, + SUM(ground_kills) as total_ground_kills, + SUM(air_kills) as total_air_kills, + SUM(ground_kills + air_kills) as total_kills, + SUM(assists) as total_assists, + SUM(captures) as total_captures, + SUM(deaths) as total_deaths, + COUNT(*) as total_battles, + SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END) as wins + FROM player_games_hist + WHERE ${summaryAttrClause} + AND vehicle_internal IS NOT NULL + AND vehicle_internal <> '' + ${summaryDate.clause} + GROUP BY vehicle_internal + ORDER BY total_battles DESC, total_kills DESC + `; + + // Params must match the SQL placeholder order exactly. + // The squadron-attribution clause is either `clan_id = ?` + // (1 param) OR `squadron_name IN (variants)` (V params); + // we only spread the side that's actually in the SQL. + const squadronAttrParams = clanId ? [clanId] : allVariants; + + const statsParams = [ + ...memberUids, + ...squadronAttrParams, + ...mainDate.params + ]; + + const summaryParams = [ + ...squadronAttrParams, + ...summaryDate.params + ]; + const vehicleStatsParams = [ + ...squadronAttrParams, + ...summaryDate.params + ]; + + const fallbackPlayerStatsQuery = ` + WITH roster_sessions AS ( + SELECT + p.UID as uid, + p.session_id, + SUM(p.ground_kills) as total_ground_kills, + SUM(p.air_kills) as total_air_kills, + SUM(p.ground_kills + p.air_kills) as total_kills, + SUM(p.assists) as total_assists, + SUM(p.captures) as total_captures, + SUM(p.deaths) as total_deaths, + MAX(CASE WHEN UPPER(p.victor_bool) = 'WIN' THEN 1 ELSE 0 END) as won + FROM player_games_hist p + WHERE p.UID IN (${uidPlaceholders}) + ${fallbackDate.clause} + GROUP BY p.UID, p.session_id + ) + SELECT + rs.uid as uid, + SUM(rs.total_ground_kills) as total_ground_kills, + SUM(rs.total_air_kills) as total_air_kills, + SUM(rs.total_kills) as total_kills, + SUM(rs.total_assists) as total_assists, + SUM(rs.total_captures) as total_captures, + SUM(rs.total_deaths) as total_deaths, + COUNT(*) as total_battles, + SUM(rs.won) as wins + FROM roster_sessions rs + GROUP BY rs.uid + `; + + const fallbackSummaryQuery = ` + WITH roster_sessions AS ( + SELECT + p.UID as uid, + p.session_id, + SUM(p.ground_kills) as total_ground_kills, + SUM(p.air_kills) as total_air_kills, + SUM(p.ground_kills + p.air_kills) as total_kills, + SUM(p.assists) as total_assists, + SUM(p.captures) as total_captures, + SUM(p.deaths) as total_deaths, + MAX(CASE WHEN UPPER(p.victor_bool) = 'WIN' THEN 1 ELSE 0 END) as won + FROM player_games_hist p + WHERE p.UID IN (${uidPlaceholders}) + AND (? IS NULL OR p.endtime_unix >= ?) + AND (? IS NULL OR p.endtime_unix <= ?) + GROUP BY p.UID, p.session_id + ) + SELECT + COUNT(DISTINCT session_id) as total_battles, + COUNT(DISTINCT CASE WHEN won = 1 THEN session_id END) as wins, + SUM(total_ground_kills) as total_ground_kills, + SUM(total_air_kills) as total_air_kills, + SUM(total_kills) as total_kills, + SUM(total_assists) as total_assists, + SUM(total_captures) as total_captures, + SUM(total_deaths) as total_deaths + FROM roster_sessions + `; + + const fallbackStatsParams = [ + ...memberUids, + ...fallbackDate.params + ]; + + const fallbackSummaryParams = [ + ...memberUids, + ...fallbackDate.params + ]; + + const hasMeaningfulStats = (rows) => Array.isArray(rows) && rows.some(row => + (row.total_battles || 0) > 0 || + (row.total_kills || 0) > 0 || + (row.total_assists || 0) > 0 || + (row.total_captures || 0) > 0 + ); + const allowFallback = !dateFilters.hasFilter; + + const buildAndSendResponse = (statsRows, summaryRow, vehicleRows = [], { usingFallback = false } = {}) => { + const safeStatsRows = statsRows || []; + const safeSummaryRow = summaryRow || {}; + const safeVehicleRows = vehicleRows || []; + + // Index stats by UID for fast lookup + const statsByUid = {}; + safeStatsRows.forEach(row => { statsByUid[String(row.uid)] = row; }); + + // Build player list from roster, attaching member stats + const players = memberUids.map(uid => { + const stats = statsByUid[uid]; + const totalKills = stats ? (stats.total_kills || 0) : 0; + const deaths = stats ? (stats.total_deaths || 0) : 0; + const wins = stats ? (stats.wins || 0) : 0; + const totalBattles = stats ? (stats.total_battles || 0) : 0; + const kdr = deaths > 0 ? (totalKills / deaths) : totalKills; + const winRate = totalBattles > 0 ? (wins / totalBattles) * 100 : 0; + const nick = (stats && stats.hist_nick) ? stats.hist_nick : memberNicks[uid]; + let sqbPoints; + if (dateFilters.endTimestamp) { + const entry = histMembers ? histMembers[String(uid)] : null; + if (entry && typeof entry === 'object') { + sqbPoints = Number(entry.points) || 0; + } else if (typeof entry === 'number') { + sqbPoints = entry; + } else { + sqbPoints = 0; + } + } else { + sqbPoints = memberPoints[uid] || 0; + } + return { + uid, + nick, + sqb_points: sqbPoints, + total_kills: totalKills, + ground_kills: stats ? (stats.total_ground_kills || 0) : 0, + air_kills: stats ? (stats.total_air_kills || 0) : 0, + total_battles: totalBattles, + wins, + win_rate: parseFloat(winRate.toFixed(1)), + kdr: parseFloat(kdr.toFixed(1)), + kps: totalBattles > 0 ? parseFloat((totalKills / totalBattles).toFixed(2)) : 0, + deaths, + assists: stats ? (stats.total_assists || 0) : 0, + captures: stats ? (stats.total_captures || 0) : 0 + }; + }); + + players.sort((a, b) => b.total_kills - a.total_kills); + + const sqTotalKills = safeSummaryRow.total_kills || 0; + const sqGroundKills = safeSummaryRow.total_ground_kills || 0; + const sqAirKills = safeSummaryRow.total_air_kills || 0; + const sqDeaths = safeSummaryRow.total_deaths || 0; + const sqWins = safeSummaryRow.wins || 0; + const sqBattles = safeSummaryRow.total_battles || 0; + const sqAssists = safeSummaryRow.total_assists || 0; + const sqCaptures = safeSummaryRow.total_captures || 0; + const sqKdr = sqDeaths > 0 ? (sqTotalKills / sqDeaths) : sqTotalKills; + const sqWinRate = sqBattles > 0 ? (sqWins / sqBattles) * 100 : 0; + const sqKps = sqBattles > 0 ? parseFloat((sqTotalKills / sqBattles).toFixed(2)) : 0; + loadPerformanceBenchmarksCached(dateFilters, (benchmarks) => { + const playerBenchmark = benchmarks.players; + const squadronBenchmark = benchmarks.squadrons; + + Promise.all([ + queryPlayerRatingStatsForUids(memberUids, dateFilters), + querySquadronRatingStats(allVariants, dateFilters, clanId) + ]).then(([playerRatingStats, squadronRatingStats]) => { + players.forEach(player => { + player.performance = computePerformanceScore( + playerRatingStats.get(String(player.uid)) || { games: 0 }, + playerBenchmark + ); + }); + players.sort((a, b) => (b.performance || 0) - (a.performance || 0) || (b.total_kills || 0) - (a.total_kills || 0)); + + const squadronPerformanceSource = squadronRatingStats && Number(squadronRatingStats.games || 0) > 0 + ? squadronRatingStats + : { + games: sqBattles, + total_kills: sqTotalKills, + total_assists: sqAssists, + total_captures: sqCaptures, + total_deaths: sqDeaths, + wins: sqWins, + heavy_score: 0 + }; + const sqPerformance = computePerformanceScore(squadronPerformanceSource, squadronBenchmark); + + const response = { + date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week), + clan_id: lookup ? lookup.clan_id : null, + tag_name: tagName, + short_name: lookup ? (lookup.short_name || tagName) : tagName, + long_name: canonicalName, + squadron_summary: { + player_count: memberCount, + total_kills: sqTotalKills, + ground_kills: sqGroundKills, + air_kills: sqAirKills, + total_battles: sqBattles, + wins: sqWins, + win_rate: parseFloat(sqWinRate.toFixed(1)), + kdr: parseFloat(sqKdr.toFixed(1)), + kps: sqKps, + deaths: sqDeaths, + assists: sqAssists, + captures: sqCaptures, + performance: sqPerformance, + points: { + total_points: dateFilters.endTimestamp + ? (histTotal != null ? histTotal : 0) + : totalPoints, + has_points_data: dateFilters.endTimestamp + ? (histTotal != null && histTotal > 0) + : (totalPoints > 0) + } + }, + players, + vehicles: safeVehicleRows.map(row => { + const totalKills = row.total_kills || 0; + const deaths = row.total_deaths || 0; + const wins = row.wins || 0; + const totalBattles = row.total_battles || 0; + return { + vehicle_internal: row.vehicle_internal, + vehicle: normalizeVehicleName(row.vehicle) || row.vehicle_internal, + total_kills: totalKills, + ground_kills: row.total_ground_kills || 0, + air_kills: row.total_air_kills || 0, + total_battles: totalBattles, + wins, + win_rate: totalBattles > 0 ? parseFloat(((wins / totalBattles) * 100).toFixed(1)) : 0, + kdr: deaths > 0 ? parseFloat((totalKills / deaths).toFixed(1)) : totalKills, + deaths, + assists: row.total_assists || 0, + captures: row.total_captures || 0, + }; + }) + }; + + log.info('Squadron details query completed', { + squadronName: squadronname, + canonicalName, + variants: allVariants, + rosterSize: memberUids.length, + playersWithStats: Object.keys(statsByUid).length, + totalPoints, + histTotal, + usingSnapshot: !!histMembers, + usingFallback, + squadronBattles: sqBattles + }); + + setCachedResponse(cacheKey, response); + res.json(response); + }); + }); + }; + + const dbAll = (query, params) => new Promise((resolve, reject) => + db.all(query, params, (err, rows) => err ? reject(err) : resolve(rows)) + ); + const dbGet = (query, params) => new Promise((resolve, reject) => + db.get(query, params, (err, row) => err ? reject(err) : resolve(row)) + ); + + Promise.all([ + dbAll(playerStatsQuery, statsParams), + dbGet(summaryQuery, summaryParams), + dbAll(vehicleStatsQuery, vehicleStatsParams) + ]).then(([statsRows, summaryRow, vehicleRows]) => { + if (hasMeaningfulStats(statsRows) || !allowFallback) { + return buildAndSendResponse(statsRows, summaryRow, vehicleRows, { usingFallback: false }); + } + + return Promise.all([ + dbAll(fallbackPlayerStatsQuery, fallbackStatsParams), + dbGet(fallbackSummaryQuery, fallbackSummaryParams) + ]).then(([fallbackStatsRows, fallbackSummaryRow]) => { + buildAndSendResponse(fallbackStatsRows, fallbackSummaryRow, vehicleRows, { usingFallback: true }); + }).catch((fallbackErr) => { + log.error('Database error querying fallback squadron stats', fallbackErr, { squadronName: squadronname }); + buildAndSendResponse(statsRows, summaryRow, vehicleRows, { usingFallback: false }); + }); + }).catch((err) => { + log.error('Database error in squadron queries', err, { squadronName: squadronname }); + res.status(500).json({ error: 'Database error occurred', errorCode: 'DB_SQUADRON_QUERY_FAILED' }); + }); + }) + .catch(err => { + log.error('Database error in squadron queries', err, { squadronName: squadronname }); + res.status(500).json({ error: 'Database error occurred', errorCode: 'DB_SQUADRON_QUERY_FAILED' }); + }); + }); +}); + +app.get('/api/squadrons/:squadronname/history', (req, res) => { + const { squadronname } = req.params; + + if (!squadronname) { + return res.status(400).json({ error: 'Squadron name parameter is required' }); + } + + const cacheKey = `squadron_history_${squadronname}`; + const cached = getCachedResponse(cacheKey); + if (cached) return res.json(cached); + + loadSquadronLookupCached((squadronLookup) => { + const lookup = squadronLookup[squadronname]; + let canonicalName = squadronname; + const clanId = lookup ? lookup.clan_id : null; + + if (lookup) { + canonicalName = lookup.long_name; + } + + const allVariants = [canonicalName]; + Object.keys(squadronLookup).forEach(key => { + if (squadronLookup[key].long_name === canonicalName && key !== canonicalName) { + allVariants.push(key); + } + }); + + const placeholders = allVariants.map(() => '?').join(','); + + // When clan_id is known, filter on it directly so SQLite uses + // idx_pgh_clanid_endtime. The OR-with-text-fallback we used to have + // here forced a full scan because the planner can't OR two index + // plans together cleanly. Same change as /api/squadrons/:name — + // truly orphaned rows (clan_id NULL) are unrecoverable without a + // backfill anyway. + const historyQuery = clanId + ? ` + SELECT + date(endtime_unix, 'unixepoch') as period, + COUNT(DISTINCT session_id) as battles, + ROUND(SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1.0 ELSE 0 END) * 100.0 / NULLIF(COUNT(*), 0), 1) as win_rate, + ROUND(CAST(SUM(ground_kills + air_kills) AS REAL) / MAX(SUM(deaths), 1), 2) as kdr + FROM player_games_hist + WHERE clan_id = ? + AND UID IS NOT NULL + AND endtime_unix IS NOT NULL + GROUP BY period + ORDER BY period ASC` + : ` + SELECT + date(endtime_unix, 'unixepoch') as period, + COUNT(DISTINCT session_id) as battles, + ROUND(SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1.0 ELSE 0 END) * 100.0 / NULLIF(COUNT(*), 0), 1) as win_rate, + ROUND(CAST(SUM(ground_kills + air_kills) AS REAL) / MAX(SUM(deaths), 1), 2) as kdr + FROM player_games_hist + WHERE squadron_name IN (${placeholders}) + AND UID IS NOT NULL + AND endtime_unix IS NOT NULL + GROUP BY period + ORDER BY period ASC + `; + + const historyParams = clanId ? [clanId] : allVariants; + db.all(historyQuery, historyParams, (err, battleRows) => { + if (err) { + log.error('Database error in squadron history query', err, { squadronName: squadronname }); + return res.status(500).json({ error: 'Database error', errorCode: 'DB_SQUADRON_HISTORY_FAILED' }); + } + + // Fetch rating history from squadrons.db and return separately. + // Rating is returned at the raw hourly-snapshot granularity so the + // rating chart can show full detail (season resets, intraday + // spikes); daily aggregation hid transient troughs. + const squadronsDbPath = path.join(STORAGE_ROOT, 'squadrons.db'); + if (!fs.existsSync(squadronsDbPath)) { + return res.json({ history: battleRows, rating_hourly: [] }); + } + + const squadronsDb = new sqlite3.Database(squadronsDbPath, sqlite3.OPEN_READONLY, (err) => { + if (err) { + return res.json({ history: battleRows, rating_hourly: [] }); + } + + // Prefer clan_id so renames don't truncate the rating chart. + const ratingQuery = clanId + ? ` + SELECT unix_time, total_score + FROM squadrons_points + WHERE clan_id = ? + ORDER BY unix_time ASC + ` + : ` + SELECT unix_time, total_score + FROM squadrons_points + WHERE long_name = ? + ORDER BY unix_time ASC + `; + + squadronsDb.all(ratingQuery, [clanId || canonicalName], (err, ratingRows) => { + squadronsDb.close(); + + const buildAndCache = (body) => { + setCachedResponse(cacheKey, body); + res.json(body); + }; + + if (err || !ratingRows) { + return buildAndCache({ history: battleRows, rating_hourly: [] }); + } + + buildAndCache({ + history: battleRows, + rating_hourly: ratingRows.map(r => ({ + t: r.unix_time, + rating: r.total_score, + })), + }); + }); + }); + }); + }); +}); + +app.get('/api/squadrons/:squadronname/games', (req, res) => { + const { squadronname } = req.params; + + if (!squadronname) { + return res.status(400).json({ error: 'Squadron name parameter is required' }); + } + + const dateFilters = parseDateFilters(req); + const cacheKey = `squadron_games_${squadronname}_${dateFilters.startTimestamp || 'null'}_${dateFilters.endTimestamp || 'null'}`; + const cached = getCachedResponse(cacheKey); + if (cached) return res.json(cached); + + if (!hasSquadronColumn) { + return res.json({ + tag_name: squadronname, + long_name: squadronname, + games: [], + total_games_returned: 0, + message: 'Squadron data not available - squadron_name column missing from database' + }); + } + + loadSquadronLookupCached((squadronLookup) => { + const lookup = squadronLookup[squadronname]; + let canonicalName = squadronname; + let tagName = squadronname; + const clanId = lookup ? lookup.clan_id : null; + + if (lookup) { + canonicalName = lookup.long_name; + tagName = lookup.tag_name; + } + + const allVariants = [canonicalName]; + Object.keys(squadronLookup).forEach(key => { + if (squadronLookup[key].long_name === canonicalName && key !== canonicalName) { + allVariants.push(key); + } + }); + + const variantPlaceholders = allVariants.map(() => '?').join(','); + // Same OR-fallback removal as /api/squadrons/:name and /history — + // forces the planner onto idx_pgh_clanid_endtime instead of a scan. + const attrClause = clanId + ? `p.clan_id = ?` + : `p.squadron_name IN (${variantPlaceholders})`; + + const gamesQuery = ` + SELECT + p.session_id, + MAX(p.endtime_unix) as endtime_unix, + GROUP_CONCAT(DISTINCT p.nick) as players, + COUNT(DISTINCT p.UID) as player_count, + SUM(p.ground_kills) as ground_kills, + SUM(p.air_kills) as air_kills, + SUM(p.assists) as assists, + SUM(p.captures) as captures, + SUM(p.deaths) as deaths, + CASE WHEN SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END) * 2 > COUNT(*) THEN 'Win' ELSE 'Loss' END as result, + ms.map_name + FROM player_games_hist p + LEFT JOIN match_summary ms ON ms.session_id = p.session_id + WHERE ${attrClause} + AND p.UID IS NOT NULL + AND p.session_id IS NOT NULL + AND (? IS NULL OR p.endtime_unix >= ?) + AND (? IS NULL OR p.endtime_unix <= ?) + GROUP BY p.session_id + ORDER BY endtime_unix DESC + LIMIT 2000 + `; + + const gamesParams = [ + ...(clanId ? [clanId] : allVariants), + dateFilters.startTimestamp, + dateFilters.startTimestamp, + dateFilters.endTimestamp, + dateFilters.endTimestamp + ]; + + db.all(gamesQuery, gamesParams, (err, rows) => { + if (err) { + log.error('Database error in squadron games query', err, { squadronName: squadronname }); + return res.status(500).json({ error: 'Database error', errorCode: 'DB_SQUADRON_GAMES_FAILED' }); + } + + const response = { + tag_name: tagName, + long_name: canonicalName, + games: (rows || []).map(row => ({ + session_id: row.session_id, + timestamp: row.endtime_unix || 0, + map_name: row.map_name || null, + players: row.players || '', + player_count: row.player_count || 0, + stats: { + ground_kills: row.ground_kills || 0, + air_kills: row.air_kills || 0, + assists: row.assists || 0, + captures: row.captures || 0, + deaths: row.deaths || 0 + }, + result: row.result || 'Unknown' + })), + total_games_returned: (rows || []).length + }; + + setCachedResponse(cacheKey, response); + res.json(response); + }); + }); +}); + +app.get('/api/leaderboard/stats', (req, res) => { + log.info('Leaderboard stats request received'); + const dateFilters = parseDateFilters(req); + const { start_date, end_date, season, week } = req.query; + + const cacheKey = `leaderboard_stats_${start_date || 'all'}_${end_date || 'all'}_${season || 'all'}_${week || 'all'}`; + const cached = getCachedResponse(cacheKey, STATS_CACHE_TTL); + if (cached) { + log.info('Returning cached leaderboard stats'); + return res.json(cached); + } + + // Dedup so the 3 web cluster workers + any concurrent traffic share one + // DB call. Without this we saw 200s+ wall-clock durations on cold start as + // requests serialized on the single read connection. + dedup(cacheKey, () => new Promise((resolve, reject) => { + const overallStatsQuery = ` + SELECT + COUNT(DISTINCT UID) as total_players, + COUNT(DISTINCT vehicle) as total_vehicles_used, + COUNT(*) as total_battles + FROM player_games_hist + WHERE (? IS NULL OR endtime_unix >= ?) + AND (? IS NULL OR endtime_unix <= ?) + `; + + const topVehiclesQuery = ` + SELECT + vehicle as vehicle, + MAX(vehicle_internal) as vehicle_internal, + COUNT(*) as usage_count + FROM player_games_hist + WHERE (? IS NULL OR endtime_unix >= ?) + AND (? IS NULL OR endtime_unix <= ?) + GROUP BY vehicle + ORDER BY usage_count DESC + LIMIT 12 + `; + + const queryParams = [ + dateFilters.startTimestamp, + dateFilters.startTimestamp, + dateFilters.endTimestamp, + dateFilters.endTimestamp, + ]; + const queryStart = Date.now(); + heavyDb.get(overallStatsQuery, queryParams, (err, statsRow) => { + if (err) { + log.error('Database error in overall stats query', err); + return reject({ status: 500, body: { error: 'Database error occurred', errorCode: 'DB_OVERALL_STATS_FAILED' } }); + } + + heavyDb.all(topVehiclesQuery, queryParams, (err, vehicleRows) => { + if (err) { + log.error('Database error in top vehicles query', err); + return reject({ status: 500, body: { error: 'Database error occurred', errorCode: 'DB_TOP_VEHICLES_FAILED' } }); + } + + const response = { + date_filter: createDateFilterMetadata(dateFilters, start_date, end_date, season, week), + total_players: statsRow.total_players, + total_vehicles_used: statsRow.total_vehicles_used, + total_battles: statsRow.total_battles, + last_updated: new Date().toISOString(), + top_vehicles: vehicleRows.map(row => ({ + vehicle: row.vehicle, + vehicle_internal: row.vehicle_internal || null, + usage_count: row.usage_count + })) + }; + + log.info('Leaderboard stats query completed', { + totalPlayers: statsRow.total_players, + totalVehicles: statsRow.total_vehicles_used, + totalBattles: statsRow.total_battles, + topVehiclesReturned: vehicleRows.length, + ms: Date.now() - queryStart, + }); + + setCachedResponse(cacheKey, response); + resolve(response); + }); + }); + })).then(response => res.json(response)) + .catch(err => { + if (res.headersSent) return; + if (err && err.status) return res.status(err.status).json(err.body); + res.status(500).json({ error: 'Database error', errorCode: 'DB_LEADERBOARD_STATS_FAILED' }); + }); +}); + +// ============================================================================ +// ANALYTICS ENDPOINTS +// ============================================================================ + +// Normalize a raw map_name: strip "[mode]" prefixes, leading "Gamemode " token, +// collapse whitespace, and use lowercase as the merge key while preserving a +// human-friendly display form (Title Case). +function normalizeMapName(raw) { + if (!raw) return null; + let s = String(raw).trim(); + if (!s) return null; + s = s.replace(/^\s*\[[^\]]+\]\s*/, ''); // "[Conquest #1] Foo" -> "Foo" + s = s.replace(/^\s*gamemode\s+/i, ''); // "Gamemode Foo" -> "Foo" + s = s.replace(/\s+/g, ' ').trim(); + if (!s) return null; + const key = s.toLowerCase(); + // Title-case display form: capitalize first letter of each word but keep + // already-cased words like "to", "the", and parens intact. + const display = s.replace(/\b([a-z])/g, (m) => m.toUpperCase()); + return { key, display }; +} + +app.get('/api/analytics/maps/:squadron', (req, res) => { + const sq = req.params.squadron; + const startDate = parseInt(req.query.start_date) || 0; + const endDate = parseInt(req.query.end_date) || 0; + loadSquadronLookupCached(() => { + const filter = resolveSquadronFilter(sq); + const variantSet = new Set(filter.variants); + const where = matchSummarySquadronWhere(filter); + const params = [...where.params, startDate]; + let endClause = ''; + if (endDate) { endClause = ' AND endtime_unix <= ?'; params.push(endDate); } + + db.all( + `SELECT map_name, winning_sq, losing_sq, winning_clan_id, losing_clan_id + FROM match_summary + WHERE ${where.clause} AND endtime_unix >= ?${endClause}`, + params, + (err, rows) => { + if (err) return res.status(500).json({ error: 'Database error' }); + + const stats = {}; + for (const row of rows) { + const norm = normalizeMapName(row.map_name) || { key: 'unknown', display: 'Unknown' }; + if (!stats[norm.key]) stats[norm.key] = { map_name: norm.display, wins: 0, losses: 0 }; + if (rowIsWinFor(row, filter, variantSet)) stats[norm.key].wins++; + else if (rowIsLossFor(row, filter, variantSet)) stats[norm.key].losses++; + } + + const result = Object.values(stats).map(r => ({ + ...r, + total: r.wins + r.losses, + win_rate: r.wins + r.losses > 0 ? Math.round(r.wins / (r.wins + r.losses) * 1000) / 10 : 0, + })); + result.sort((a, b) => b.total - a.total); + res.json(result); + } + ); + }); +}); + +app.get('/api/analytics/time/:squadron', (req, res) => { + const sq = req.params.squadron; + const startDate = parseInt(req.query.start_date) || 0; + const endDate = parseInt(req.query.end_date) || 0; + loadSquadronLookupCached(() => { + const filter = resolveSquadronFilter(sq); + const variantSet = new Set(filter.variants); + const where = matchSummarySquadronWhere(filter); + const params = [...where.params, Math.max(startDate, 1)]; + let endClause = ''; + if (endDate) { endClause = ' AND endtime_unix <= ?'; params.push(endDate); } + + db.all( + `SELECT endtime_unix, winning_sq, losing_sq, winning_clan_id, losing_clan_id + FROM match_summary + WHERE ${where.clause} AND endtime_unix >= ?${endClause}`, + params, + (err, rows) => { + if (err) return res.status(500).json({ error: 'Database error' }); + + const hourly = {}; + const daily = {}; + for (const row of rows) { + const d = new Date(row.endtime_unix * 1000); + const hour = d.getUTCHours(); + if (!hourly[hour]) hourly[hour] = { wins: 0, losses: 0 }; + if (rowIsWinFor(row, filter, variantSet)) hourly[hour].wins++; + else if (rowIsLossFor(row, filter, variantSet)) hourly[hour].losses++; + const dayMs = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); + daily[dayMs] = (daily[dayMs] || 0) + 1; + } + + const hourlyOut = {}; + for (const hour of Object.keys(hourly).sort((a, b) => a - b)) { + const s = hourly[hour]; + const total = s.wins + s.losses; + hourlyOut[hour] = { ...s, total, win_rate: total > 0 ? Math.round(s.wins / total * 1000) / 10 : 0 }; + } + const dailyOut = Object.entries(daily) + .map(([day, count]) => ({ day: parseInt(day), count })) + .sort((a, b) => a.day - b.day); + res.json({ hourly: hourlyOut, daily: dailyOut }); + } + ); + }); +}); + +app.get('/api/analytics/consistency/:squadron', (req, res) => { + const sq = req.params.squadron; + const minGames = parseInt(req.query.min_games) || 10; + const startDate = parseInt(req.query.start_date) || 0; + const endDate = parseInt(req.query.end_date) || 0; + + loadSquadronLookupCached(() => { + const filter = resolveSquadronFilter(sq); + const variantSet = new Set(filter.variants); + const pghWhere = playerGamesHistSquadronWhere(filter); + + const params = [...pghWhere.params]; + let dateClause = ''; + if (startDate) { dateClause += ' AND p.endtime_unix >= ?'; params.push(startDate); } + if (endDate) { dateClause += ' AND p.endtime_unix <= ?'; params.push(endDate); } + + db.all( + `SELECT p.UID, + p.nick, + p.session_id, + SUM(p.ground_kills + p.air_kills) as total_kills, + SUM(p.deaths) as total_deaths, + m.winning_sq, + m.losing_sq, + m.winning_clan_id, + m.losing_clan_id + FROM player_games_hist p + LEFT JOIN match_summary m ON m.session_id = p.session_id + WHERE ${pghWhere.clause}${dateClause} + GROUP BY p.UID, p.session_id`, + params, + (err, rows) => { + if (err) return res.status(500).json({ error: 'Database error' }); + + const players = {}; + for (const row of rows) { + const UID = row.UID; + if (!players[UID]) players[UID] = { uid: UID, nick: row.nick, kills: [], deaths: [], wins: 0, losses: 0 }; + players[UID].nick = row.nick; + players[UID].kills.push(row.total_kills || 0); + players[UID].deaths.push(row.total_deaths || 0); + if (rowIsWinFor(row, filter, variantSet)) players[UID].wins++; + else if (rowIsLossFor(row, filter, variantSet)) players[UID].losses++; + } + + const result = Object.values(players) + .filter(p => p.kills.length >= minGames) + .map(p => { + const n = p.kills.length; + const sumK = p.kills.reduce((a, b) => a + b, 0); + const sumD = p.deaths.reduce((a, b) => a + b, 0); + const avgK = sumK / n; + const avgD = sumD / n; + const kd = sumD > 0 ? sumK / sumD : sumK; + const decided = p.wins + p.losses; + const winRate = decided > 0 ? (p.wins / decided) * 100 : 0; + return { + uid: p.uid, + nick: p.nick, + games: n, + wins: p.wins, + losses: p.losses, + avg_kills: Math.round(avgK * 100) / 100, + avg_deaths: Math.round(avgD * 100) / 100, + kd: Math.round(kd * 100) / 100, + win_rate: Math.round(winRate * 10) / 10, + }; + }) + .sort((a, b) => b.games - a.games); + + res.json(result); + } + ); + }); +}); + +app.get('/api/analytics/matchup/:squadron', (req, res) => { + const sq = req.params.squadron; + const startDate = parseInt(req.query.start_date) || 0; + const endDate = parseInt(req.query.end_date) || 0; + loadSquadronLookupCached(() => { + const filter = resolveSquadronFilter(sq); + const variantSet = new Set(filter.variants); + const where = matchSummarySquadronWhere(filter); + const params = [...where.params, startDate]; + let endClause = ''; + if (endDate) { endClause = ' AND endtime_unix <= ?'; params.push(endDate); } + + db.all( + `SELECT winning_sq, losing_sq, winning_clan_id, losing_clan_id + FROM match_summary + WHERE ${where.clause} AND endtime_unix >= ?${endClause}`, + params, + (err, rows) => { + if (err) return res.status(500).json({ error: 'Database error' }); + + const stats = {}; + for (const row of rows) { + const { winning_sq, losing_sq } = row; + if (!winning_sq || !losing_sq || winning_sq === losing_sq) continue; + const isWin = rowIsWinFor(row, filter, variantSet); + const isLoss = rowIsLossFor(row, filter, variantSet); + if (!isWin && !isLoss) continue; + const opponent = isWin ? losing_sq : winning_sq; + if (!opponent) continue; + if (!stats[opponent]) stats[opponent] = { opponent, wins: 0, losses: 0 }; + if (isWin) stats[opponent].wins++; + else stats[opponent].losses++; + } + + const enriched = Object.values(stats).map(r => { + const total = r.wins + r.losses; + return { + ...r, + total, + win_rate: total > 0 ? Math.round(r.wins / total * 1000) / 10 : 0, + }; + }); + + const wonAgainst = [...enriched] + .sort((a, b) => (b.wins - a.wins) || (b.total - a.total)) + .slice(0, 10); + const lostAgainst = [...enriched] + .sort((a, b) => (b.losses - a.losses) || (b.total - a.total)) + .slice(0, 10); + + res.json({ + won_against: wonAgainst, + lost_against: lostAgainst, + total_opponents: enriched.length, + }); + } + ); + }); +}); + +app.get('/api/analytics/comps/:squadron', (req, res) => { + const sq = req.params.squadron; + const startDate = parseInt(req.query.start_date) || 0; + const endDate = parseInt(req.query.end_date) || 0; + const minSize = Math.max(1, parseInt(req.query.min_size) || 8); + + loadSquadronLookupCached(() => { + const filter = resolveSquadronFilter(sq); + const variantSet = new Set(filter.variants); + const pghWhere = playerGamesHistSquadronWhere(filter); + const params = [...pghWhere.params]; + let dateClause = ''; + if (startDate) { dateClause += ' AND p.endtime_unix >= ?'; params.push(startDate); } + if (endDate) { dateClause += ' AND p.endtime_unix <= ?'; params.push(endDate); } + + db.all( + `SELECT p.session_id, p.UID, p.vehicle, p.vehicle_internal, + p.ground_kills, p.air_kills, p.deaths, + m.winning_sq, m.losing_sq, m.winning_clan_id, m.losing_clan_id + FROM player_games_hist p + LEFT JOIN match_summary m ON m.session_id = p.session_id + WHERE ${pghWhere.clause}${dateClause}`, + params, + (err, rows) => { + if (err) return res.status(500).json({ error: 'Database error' }); + + // Outcome per session and the multiset of vehicle internals brought. + const sessionOutcome = new Map(); // sid -> 'win' | 'loss' | 'unknown' + const sessionVehicles = new Map(); // sid -> Map + + // Per-vehicle aggregates keyed by internal id (so type lookup works + // and dedup of localized aliases is correct). wins/losses count + // sessions; spawns/kills/deaths count individual rows. + const vehStats = new Map(); // internal -> { display, type, spawns, kills, deaths, sessions: Map } + + for (const r of rows) { + if (!r.session_id) continue; + // Lowercase the internal so case-only duplicates (e.g. + // germ_leopard_I vs germ_leopard_i) collapse into one row. + const rawInternal = (r.vehicle_internal && r.vehicle_internal !== 'DISCONNECTED') ? r.vehicle_internal : null; + if (!rawInternal) continue; + const internal = rawInternal.toLowerCase(); + const display = normalizeVehicleName(r.vehicle) || internal; + + if (!sessionOutcome.has(r.session_id)) { + let outcome = 'unknown'; + if (rowIsWinFor(r, filter, variantSet)) outcome = 'win'; + else if (rowIsLossFor(r, filter, variantSet)) outcome = 'loss'; + sessionOutcome.set(r.session_id, outcome); + } + const outcome = sessionOutcome.get(r.session_id); + + let vMap = sessionVehicles.get(r.session_id); + if (!vMap) { vMap = new Map(); sessionVehicles.set(r.session_id, vMap); } + vMap.set(internal, (vMap.get(internal) || 0) + 1); + + let s = vehStats.get(internal); + if (!s) { + s = { + display, + type: getVehicleType(internal), + spawns: 0, kills: 0, deaths: 0, + sessions: new Map(), + }; + vehStats.set(internal, s); + } + s.spawns++; + s.kills += (r.ground_kills || 0) + (r.air_kills || 0); + s.deaths += r.deaths || 0; + if (!s.sessions.has(r.session_id)) s.sessions.set(r.session_id, outcome); + } + + const totalSessions = sessionOutcome.size; + + const topVehicles = [...vehStats.entries()] + .map(([internal, s]) => { + let wins = 0, losses = 0; + for (const o of s.sessions.values()) { + if (o === 'win') wins++; + else if (o === 'loss') losses++; + } + const decided = wins + losses; + const sessionsCount = s.sessions.size; + return { + vehicle_internal: internal, + vehicle: s.display, + type: s.type, + spawns: s.spawns, + sessions: sessionsCount, + share_pct: totalSessions > 0 ? Math.round((sessionsCount / totalSessions) * 1000) / 10 : 0, + kills: s.kills, + deaths: s.deaths, + kd: s.deaths > 0 ? Math.round((s.kills / s.deaths) * 100) / 100 : s.kills, + wins, + losses, + win_rate: decided > 0 ? Math.round((wins / decided) * 1000) / 10 : 0, + }; + }) + .sort((a, b) => b.spawns - a.spawns) + .slice(0, 50); + + // Match-level compositions: keyed by the multiset of vehicles fielded + // per session. We aggregate across matches that fielded the *exact* + // same multiset, plus a parallel aggregation by type-notation + // (e.g. "3F 4T 1AA") for the search-bar presets. + const compStats = new Map(); // sig -> { vehicles, types, notation, size, games, wins, losses } + const notationStats = new Map(); // notation -> { types, size, games, wins, losses } + + for (const [sid, vMap] of sessionVehicles) { + const flat = []; + const typeCounts = {}; + for (const [internal, n] of vMap) { + const t = vehStats.get(internal).type; + for (let i = 0; i < n; i++) flat.push(internal); + typeCounts[t] = (typeCounts[t] || 0) + n; + } + if (flat.length < minSize) continue; + flat.sort(); + const sig = flat.join('||'); + const notation = buildCompNotation(typeCounts); + const outcome = sessionOutcome.get(sid); + + let c = compStats.get(sig); + if (!c) { + // De-aggregate to (internal, count) pairs for the response. + const counts = new Map(); + for (const id of flat) counts.set(id, (counts.get(id) || 0) + 1); + const vehiclesArr = [...counts.entries()].map(([id, count]) => ({ + vehicle_internal: id, + vehicle: vehStats.get(id).display, + type: vehStats.get(id).type, + count, + })).sort((a, b) => a.vehicle.localeCompare(b.vehicle)); + c = { + vehicles: vehiclesArr, + types: typeCounts, + notation, + size: flat.length, + games: 0, wins: 0, losses: 0, + }; + compStats.set(sig, c); + } + c.games++; + if (outcome === 'win') c.wins++; + else if (outcome === 'loss') c.losses++; + + let nc = notationStats.get(notation); + if (!nc) { + nc = { notation, types: { ...typeCounts }, size: flat.length, games: 0, wins: 0, losses: 0 }; + notationStats.set(notation, nc); + } + nc.games++; + if (outcome === 'win') nc.wins++; + else if (outcome === 'loss') nc.losses++; + } + + // Keep all distinct comps (so preset notations always have something + // to match against). Cap at 500 as a sanity ceiling — even a year of + // active play rarely produces more distinct vehicle multisets than + // that with the size>=8 filter. + const compositions = [...compStats.values()] + .map(c => { + const decided = c.wins + c.losses; + return { + vehicles: c.vehicles, + types: c.types, + notation: c.notation, + size: c.size, + games: c.games, + wins: c.wins, + losses: c.losses, + win_rate: decided > 0 ? Math.round((c.wins / decided) * 1000) / 10 : 0, + }; + }) + .sort((a, b) => (b.games - a.games) || (b.win_rate - a.win_rate)) + .slice(0, 500); + + const notations = [...notationStats.values()] + .map(n => { + const decided = n.wins + n.losses; + return { + notation: n.notation, + types: n.types, + size: n.size, + games: n.games, + wins: n.wins, + losses: n.losses, + win_rate: decided > 0 ? Math.round((n.wins / decided) * 1000) / 10 : 0, + }; + }) + .sort((a, b) => b.games - a.games); + + res.json({ + min_size: minSize, + notations, + total_sessions: totalSessions, + top_vehicles: topVehicles, + compositions, + }); + } + ); + }); +}); + +// ─── PLAYER-SCOPED ANALYTICS ─────────────────────────────────────────────── +// All endpoints take :uid plus optional ?start_date / ?end_date (unix seconds). +// We group by session because a player can have multiple rows per session +// (one per vehicle); for the per-session squadron we use MAX(squadron_name) +// — players don't switch squadrons mid-match. + +function playerSessionDateClause(req) { + const startDate = parseInt(req.query.start_date) || 0; + const endDate = parseInt(req.query.end_date) || 0; + let clause = ''; + const params = []; + if (startDate) { clause += ' AND p.endtime_unix >= ?'; params.push(startDate); } + if (endDate) { clause += ' AND p.endtime_unix <= ?'; params.push(endDate); } + return { clause, params }; +} + +app.get('/api/analytics/player/maps/:uid', (req, res) => { + const uid = req.params.uid; + const { clause, params } = playerSessionDateClause(req); + + db.all( + `SELECT p.session_id, + MAX(p.squadron_name) AS sq, + m.map_name, + m.winning_sq, + m.losing_sq + FROM player_games_hist p + JOIN match_summary m ON m.session_id = p.session_id + WHERE p.UID = ?${clause} + GROUP BY p.session_id`, + [uid, ...params], + (err, rows) => { + if (err) return res.status(500).json({ error: 'Database error' }); + const stats = {}; + for (const { sq, map_name, winning_sq, losing_sq } of rows) { + if (!sq) continue; + const norm = normalizeMapName(map_name) || { key: 'unknown', display: 'Unknown' }; + if (!stats[norm.key]) stats[norm.key] = { map_name: norm.display, wins: 0, losses: 0 }; + if (winning_sq === sq) stats[norm.key].wins++; + else if (losing_sq === sq) stats[norm.key].losses++; + } + const result = Object.values(stats).map(r => ({ + ...r, + total: r.wins + r.losses, + win_rate: (r.wins + r.losses) > 0 ? Math.round(r.wins / (r.wins + r.losses) * 1000) / 10 : 0, + })).sort((a, b) => b.total - a.total); + res.json(result); + } + ); +}); + +app.get('/api/analytics/player/time/:uid', (req, res) => { + const uid = req.params.uid; + const { clause, params } = playerSessionDateClause(req); + + db.all( + `SELECT p.session_id, + MAX(p.squadron_name) AS sq, + MAX(p.endtime_unix) AS endtime_unix, + m.winning_sq, + m.losing_sq + FROM player_games_hist p + JOIN match_summary m ON m.session_id = p.session_id + WHERE p.UID = ?${clause} + GROUP BY p.session_id`, + [uid, ...params], + (err, rows) => { + if (err) return res.status(500).json({ error: 'Database error' }); + const hourly = {}; + const daily = {}; + for (const { sq, endtime_unix, winning_sq, losing_sq } of rows) { + if (!sq || !endtime_unix) continue; + const d = new Date(endtime_unix * 1000); + const hour = d.getUTCHours(); + if (!hourly[hour]) hourly[hour] = { wins: 0, losses: 0 }; + if (winning_sq === sq) hourly[hour].wins++; + else if (losing_sq === sq) hourly[hour].losses++; + + const dayMs = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); + daily[dayMs] = (daily[dayMs] || 0) + 1; + } + const hourlyOut = {}; + for (const hour of Object.keys(hourly).sort((a, b) => a - b)) { + const s = hourly[hour]; + const total = s.wins + s.losses; + hourlyOut[hour] = { ...s, total, win_rate: total > 0 ? Math.round(s.wins / total * 1000) / 10 : 0 }; + } + const dailyOut = Object.entries(daily) + .map(([day, count]) => ({ day: parseInt(day), count })) + .sort((a, b) => a.day - b.day); + res.json({ hourly: hourlyOut, daily: dailyOut }); + } + ); +}); + +app.get('/api/analytics/player/timeline/:uid', (req, res) => { + // Returns one row per (session, vehicle) the player used. Frontend groups + // by session for the K/D & WR lines and by week × vehicle for the stacked bar. + const uid = req.params.uid; + const { clause, params } = playerSessionDateClause(req); + + db.all( + `SELECT p.session_id, + p.endtime_unix, + p.vehicle, + p.ground_kills, + p.air_kills, + p.deaths, + p.squadron_name AS sq, + m.winning_sq, + m.losing_sq + FROM player_games_hist p + LEFT JOIN match_summary m ON m.session_id = p.session_id + WHERE p.UID = ?${clause} + ORDER BY p.endtime_unix ASC`, + [uid, ...params], + (err, rows) => { + if (err) return res.status(500).json({ error: 'Database error' }); + const result = rows.filter(r => r.endtime_unix).map(r => ({ + session_id: r.session_id, + endtime_unix: r.endtime_unix, + vehicle: r.vehicle || null, + kills: (r.ground_kills || 0) + (r.air_kills || 0), + deaths: r.deaths || 0, + won: r.winning_sq === r.sq ? 1 : (r.losing_sq === r.sq ? 0 : null), + })); + res.json(result); + } + ); +}); + +app.get('/api/analytics/player/squadmates/:uid', (req, res) => { + const uid = req.params.uid; + const { clause, params } = playerSessionDateClause(req); + const limit = Math.max(1, Math.min(parseInt(req.query.limit) || 20, 50)); + + db.all( + `WITH shared_sessions AS ( + SELECT p.UID AS teammate_uid, + p.session_id, + me.squadron_name AS me_sq, + m.winning_sq, + m.losing_sq + FROM player_games_hist p + JOIN player_games_hist me + ON me.session_id = p.session_id + AND me.squadron_name = p.squadron_name + LEFT JOIN match_summary m ON m.session_id = p.session_id + WHERE me.UID = ?${clause} + AND p.UID != ? + AND p.UID IS NOT NULL + AND p.UID != '' + AND p.nick NOT LIKE 'coop/%' + AND me.nick NOT LIKE 'coop/%' + ) + SELECT teammate_uid AS uid, + COUNT(DISTINCT session_id) AS shared, + SUM(CASE WHEN winning_sq = me_sq THEN 1 ELSE 0 END) AS wins, + SUM(CASE WHEN losing_sq = me_sq THEN 1 ELSE 0 END) AS losses + FROM shared_sessions + GROUP BY teammate_uid + HAVING shared > 0 + ORDER BY shared DESC, wins DESC, uid ASC + LIMIT ?`, + [uid, ...params, uid, limit], + (err, rows) => { + if (err) return res.status(500).json({ error: 'Database error' }); + const teammateUids = rows.map(r => r.uid).filter(Boolean); + if (!teammateUids.length) return res.json([]); + + const placeholders = teammateUids.map(() => '?').join(','); + db.all( + `SELECT UID AS uid, nick, COUNT(*) AS cnt, MAX(endtime_unix) AS last_seen + FROM player_games_hist + WHERE UID IN (${placeholders}) + AND nick NOT LIKE 'coop/%' + GROUP BY UID, nick`, + teammateUids, + (nickErr, nickRows) => { + if (nickErr) return res.status(500).json({ error: 'Database error' }); + const bestNicks = new Map(); + for (const row of nickRows) { + const current = bestNicks.get(row.uid); + const candidate = { + nick: row.nick || row.uid, + cnt: row.cnt || 0, + last_seen: row.last_seen || 0, + }; + if (!current || candidate.cnt > current.cnt || (candidate.cnt === current.cnt && candidate.last_seen > current.last_seen)) { + bestNicks.set(row.uid, candidate); + } + } + + const result = rows.map(row => { + const wins = Number(row.wins) || 0; + const losses = Number(row.losses) || 0; + const shared = Number(row.shared) || 0; + const resolved = wins + losses; + return { + uid: row.uid, + nick: (bestNicks.get(row.uid) || {}).nick || row.uid, + shared, + wins, + losses, + win_rate: resolved > 0 ? Math.round((wins / resolved) * 1000) / 10 : 0, + }; + }); + + res.json(result); + } + ); + } + ); +}); + +app.get('/api/analytics/player/matchup/:uid', (req, res) => { + const uid = req.params.uid; + const { clause, params } = playerSessionDateClause(req); + + db.all( + `SELECT p.session_id, + MAX(p.squadron_name) AS sq, + m.winning_sq, + m.losing_sq + FROM player_games_hist p + JOIN match_summary m ON m.session_id = p.session_id + WHERE p.UID = ?${clause} + GROUP BY p.session_id`, + [uid, ...params], + (err, rows) => { + if (err) return res.status(500).json({ error: 'Database error' }); + const stats = {}; + for (const { sq, winning_sq, losing_sq } of rows) { + if (!sq || !winning_sq || !losing_sq || winning_sq === losing_sq) continue; + const opponent = winning_sq === sq ? losing_sq : (losing_sq === sq ? winning_sq : null); + if (!opponent) continue; + if (!stats[opponent]) stats[opponent] = { opponent, wins: 0, losses: 0 }; + if (winning_sq === sq) stats[opponent].wins++; + else stats[opponent].losses++; + } + const enriched = Object.values(stats).map(r => { + const total = r.wins + r.losses; + return { ...r, total, win_rate: total > 0 ? Math.round(r.wins / total * 1000) / 10 : 0 }; + }); + const wonAgainst = [...enriched] + .sort((a, b) => (b.wins - a.wins) || (b.total - a.total)) + .slice(0, 10); + const lostAgainst = [...enriched] + .sort((a, b) => (b.losses - a.losses) || (b.total - a.total)) + .slice(0, 10); + res.json({ won_against: wonAgainst, lost_against: lostAgainst, total_opponents: enriched.length }); + } + ); +}); + +// ─── VEHICLE-SCOPED ANALYTICS ────────────────────────────────────────────── + +function vehicleDateClause(req) { + const startDate = parseInt(req.query.start_date) || 0; + const endDate = parseInt(req.query.end_date) || 0; + let clause = ''; + const params = []; + if (startDate) { clause += ' AND p.endtime_unix >= ?'; params.push(startDate); } + if (endDate) { clause += ' AND p.endtime_unix <= ?'; params.push(endDate); } + return { clause, params }; +} + +// Set of vehicle_internal ids that have data, with their total spawn counts. +// The display name is *not* served here — the client looks it up in the +// translation map (/api/i18n/vehicles), which is keyed by internal and stores +// per-language names with country/event glyphs (▄ ◢ ◊ ␗) baked in (see +// BOT/utils.init_vehicle_translation_cache, strip_decorations=False). That map +// is the single source of truth for display. +let vehicleListCache = null; +function ensureVehicleList(cb) { + if (vehicleListCache) return cb(null, vehicleListCache); + heavyDb.all( + `SELECT LOWER(vehicle_internal) AS vehicle_internal, COUNT(*) AS total + FROM player_games_hist + WHERE vehicle_internal IS NOT NULL AND vehicle_internal != '' + GROUP BY vehicle_internal COLLATE NOCASE`, + [], + (err, rows) => { + if (err) return cb(err); + vehicleListCache = rows + .filter(r => r.vehicle_internal && r.vehicle_internal !== 'disconnected') + .map(r => ({ vehicle_internal: r.vehicle_internal, total: r.total || 0 })) + .sort((a, b) => b.total - a.total); + cb(null, vehicleListCache); + } + ); +} + +app.get('/api/analytics/vehicle-list', (req, res) => { + const cacheKey = 'analytics_vehicle_list'; + const cached = getCachedResponse(cacheKey); + if (cached) return res.json(cached); + + ensureVehicleList((err, list) => { + if (err) return res.status(500).json({ error: 'Database error' }); + const out = { vehicles: list }; + setCachedResponse(cacheKey, out); + res.json(out); + }); +}); + +// Per-vehicle endpoints key on vehicle_internal (case-insensitive) so casing +// duplicates in player_games_hist collapse, and country variants stay distinct. +app.get('/api/analytics/vehicle/stats/:internal', (req, res) => { + const internal = req.params.internal; + const { clause, params } = vehicleDateClause(req); + db.all( + `SELECT p.UID, p.session_id, p.squadron_name AS sq, + p.ground_kills, p.air_kills, p.deaths, + m.winning_sq, m.losing_sq + FROM player_games_hist p + LEFT JOIN match_summary m ON m.session_id = p.session_id + WHERE p.vehicle_internal = ? COLLATE NOCASE${clause}`, + [internal, ...params], + (err, rows) => { + if (err) return res.status(500).json({ error: 'Database error' }); + let kills = 0, deaths = 0, wins = 0, losses = 0; + const players = new Set(), sessions = new Set(); + for (const r of rows) { + kills += (r.ground_kills || 0) + (r.air_kills || 0); + deaths += r.deaths || 0; + if (r.UID) players.add(r.UID); + if (r.session_id) sessions.add(r.session_id); + if (r.sq && r.winning_sq === r.sq) wins++; + else if (r.sq && r.losing_sq === r.sq) losses++; + } + const decided = wins + losses; + res.json({ + spawns: rows.length, + unique_players: players.size, + sessions: sessions.size, + kills, deaths, + kd: deaths > 0 ? Math.round((kills / deaths) * 100) / 100 : kills, + ks: rows.length > 0 ? Math.round((kills / rows.length) * 100) / 100 : 0, + win_rate: decided > 0 ? Math.round((wins / decided) * 1000) / 10 : 0, + wins, losses, + }); + } + ); +}); + +app.get('/api/analytics/vehicle/players/:internal', (req, res) => { + const internal = req.params.internal; + const minGames = parseInt(req.query.min_games) || 3; + const { clause, params } = vehicleDateClause(req); + db.all( + `SELECT p.UID, p.nick, p.squadron_name AS sq, + p.session_id, + p.ground_kills, p.air_kills, p.deaths, + m.winning_sq, m.losing_sq + FROM player_games_hist p + LEFT JOIN match_summary m ON m.session_id = p.session_id + WHERE p.vehicle_internal = ? COLLATE NOCASE${clause}`, + [internal, ...params], + (err, rows) => { + if (err) return res.status(500).json({ error: 'Database error' }); + const stats = {}; + for (const r of rows) { + if (!r.UID) continue; + let s = stats[r.UID]; + if (!s) { s = { uid: r.UID, nick: r.nick, sq: r.sq, kills: 0, deaths: 0, wins: 0, losses: 0, sessions: new Set() }; stats[r.UID] = s; } + s.nick = r.nick; + s.sq = r.sq; + s.kills += (r.ground_kills || 0) + (r.air_kills || 0); + s.deaths += r.deaths || 0; + if (r.session_id) s.sessions.add(r.session_id); + if (r.sq && r.winning_sq === r.sq) s.wins++; + else if (r.sq && r.losing_sq === r.sq) s.losses++; + } + const out = Object.values(stats) + .map(s => { + const decided = s.wins + s.losses; + return { + uid: s.uid, + nick: s.nick, + squadron: s.sq || null, + games: s.sessions.size, + spawns: s.kills + s.deaths > 0 ? undefined : undefined, // placeholder + kills: s.kills, deaths: s.deaths, + kd: s.deaths > 0 ? Math.round((s.kills / s.deaths) * 100) / 100 : s.kills, + win_rate: decided > 0 ? Math.round((s.wins / decided) * 1000) / 10 : 0, + wins: s.wins, losses: s.losses, + }; + }) + .filter(s => s.games >= minGames) + .sort((a, b) => b.games - a.games) + .slice(0, 500); + res.json(out); + } + ); +}); + +app.get('/api/analytics/vehicle/squadrons/:internal', (req, res) => { + const internal = req.params.internal; + const minGames = parseInt(req.query.min_games) || 3; + const { clause, params } = vehicleDateClause(req); + db.all( + `SELECT p.squadron_name AS sq, + p.session_id, + p.ground_kills, p.air_kills, p.deaths, + m.winning_sq, m.losing_sq + FROM player_games_hist p + LEFT JOIN match_summary m ON m.session_id = p.session_id + WHERE p.vehicle_internal = ? COLLATE NOCASE${clause}`, + [internal, ...params], + (err, rows) => { + if (err) return res.status(500).json({ error: 'Database error' }); + const stats = {}; + for (const r of rows) { + if (!r.sq) continue; + let s = stats[r.sq]; + if (!s) { s = { squadron: r.sq, kills: 0, deaths: 0, wins: 0, losses: 0, sessions: new Set() }; stats[r.sq] = s; } + s.kills += (r.ground_kills || 0) + (r.air_kills || 0); + s.deaths += r.deaths || 0; + if (r.session_id) s.sessions.add(r.session_id); + if (r.winning_sq === r.sq) s.wins++; + else if (r.losing_sq === r.sq) s.losses++; + } + const out = Object.values(stats) + .map(s => { + const decided = s.wins + s.losses; + return { + squadron: s.squadron, + games: s.sessions.size, + kills: s.kills, deaths: s.deaths, + kd: s.deaths > 0 ? Math.round((s.kills / s.deaths) * 100) / 100 : s.kills, + win_rate: decided > 0 ? Math.round((s.wins / decided) * 1000) / 10 : 0, + wins: s.wins, losses: s.losses, + }; + }) + .filter(s => s.games >= minGames) + .sort((a, b) => b.games - a.games) + .slice(0, 500); + res.json(out); + } + ); +}); + +app.get('/api/analytics/vehicle/maps/:internal', (req, res) => { + const internal = req.params.internal; + const { clause, params } = vehicleDateClause(req); + db.all( + `SELECT p.session_id, p.squadron_name AS sq, + m.map_name, m.winning_sq, m.losing_sq + FROM player_games_hist p + JOIN match_summary m ON m.session_id = p.session_id + WHERE p.vehicle_internal = ? COLLATE NOCASE${clause} + GROUP BY p.session_id, p.squadron_name`, + [internal, ...params], + (err, rows) => { + if (err) return res.status(500).json({ error: 'Database error' }); + const stats = {}; + for (const { sq, map_name, winning_sq, losing_sq } of rows) { + if (!sq) continue; + const norm = normalizeMapName(map_name) || { key: 'unknown', display: 'Unknown' }; + if (!stats[norm.key]) stats[norm.key] = { map_name: norm.display, wins: 0, losses: 0 }; + if (winning_sq === sq) stats[norm.key].wins++; + else if (losing_sq === sq) stats[norm.key].losses++; + } + const result = Object.values(stats).map(r => ({ + ...r, + total: r.wins + r.losses, + win_rate: (r.wins + r.losses) > 0 ? Math.round(r.wins / (r.wins + r.losses) * 1000) / 10 : 0, + })).sort((a, b) => b.total - a.total); + res.json(result); + } + ); +}); + +// ─── i18n: vehicle translation map ──────────────────────────────────── +// Serves a flat object: { internal_id: { en: "...", ru: "...", ... } } +// produced by BOT/utils.init_vehicle_translation_cache() (Python). Falls back +// to the English-only `vehicle_data_cache.json` so the page works even before +// the multi-lang cache has been generated. + +let _vehicleTranslationsResponse = null; +let _vehicleTranslationsMtime = 0; + +function buildTranslationsResponse() { + const fullPath = path.join(STORAGE_ROOT, 'CACHE', 'vehicle_translations.json'); + const englishPath = path.join(STORAGE_ROOT, 'CACHE', 'vehicle_data_cache_all.json'); + const englishFallback = path.join(STORAGE_ROOT, 'CACHE', 'vehicle_data_cache.json'); + + if (fs.existsSync(fullPath)) { + const stat = fs.statSync(fullPath); + if (_vehicleTranslationsResponse && stat.mtimeMs === _vehicleTranslationsMtime) { + return _vehicleTranslationsResponse; + } + const data = JSON.parse(fs.readFileSync(fullPath, 'utf-8')); + _vehicleTranslationsResponse = { source: 'multilang', vehicles: data }; + _vehicleTranslationsMtime = stat.mtimeMs; + return _vehicleTranslationsResponse; + } + + const target = fs.existsSync(englishPath) ? englishPath : (fs.existsSync(englishFallback) ? englishFallback : null); + if (!target) return { source: 'none', vehicles: {} }; + const stat = fs.statSync(target); + if (_vehicleTranslationsResponse && stat.mtimeMs === _vehicleTranslationsMtime) { + return _vehicleTranslationsResponse; + } + const raw = JSON.parse(fs.readFileSync(target, 'utf-8')); + const vehicles = {}; + for (const entry of raw) { + if (!Array.isArray(entry) || entry.length < 2) continue; + const cdk = entry[0]; + const englishName = entry[1]; + vehicles[cdk] = { en: englishName }; + } + _vehicleTranslationsResponse = { source: 'english_only', vehicles }; + _vehicleTranslationsMtime = stat.mtimeMs; + return _vehicleTranslationsResponse; +} + +app.get('/api/i18n/vehicles', (req, res) => { + res.set('Cache-Control', 'public, max-age=3600'); + res.json(buildTranslationsResponse()); +}); + +app.get('/api/i18n/vehicle-types', (req, res) => { + const map = loadVehicleMetaCache(); + const types = {}; + for (const [internal, meta] of map.entries()) { + types[internal] = meta && meta.type ? meta.type : '?'; + } + res.set('Cache-Control', 'public, max-age=3600'); + res.json({ source: 'vehicle_meta_cache', types }); +}); + +app.use((err, req, res, next) => { + console.error('Unhandled error:', err); + res.status(500).json({ + error: 'Internal server error' + }); +}); + +app.use((req, res) => { + res.status(404).json({ + error: 'Endpoint not found', + availableEndpoints: [ + 'GET /api/player/:uid', + 'GET /api/player/:uid/games', + 'GET /api/search/:nickname', + 'GET /api/live', + 'GET /api/leaderboard/players', + 'GET /api/leaderboard/squadrons', + 'GET /api/leaderboard/vehicles', + 'GET /api/leaderboard/stats', + 'GET /api/squadrons/:squadronname', + 'GET /api/analytics/maps/:squadron', + 'GET /api/analytics/player/squadmates/:uid', + 'GET /api/analytics/time/:squadron', + 'GET /api/analytics/comps/:squadron', + 'GET /api/analytics/consistency/:squadron', + 'GET /api/i18n/vehicles', + 'GET /api/analytics/matchup/:squadron', + 'GET /api/debug/schema', + 'GET /health', + 'GET /api/info', + 'GET /api/i18n/vehicle-types' + ] + }); +}); + + +// Periodic database liveness check (lightweight query, every 5 minutes) +setInterval(() => { + db.get("SELECT 1", (err) => { + if (err) log.error('Database liveness check failed', err); + }); +}, 300000); + +// Periodic WAL checkpoint every 10 min. PASSIVE mode is intentional: TRUNCATE +// blocks db's worker thread while waiting for heavyDb readers (vehicle/player +// leaderboards run 47–133s), which serializes all db queries behind it. +// PASSIVE never blocks — it checkpoints what it can and skips if readers are active. +setTimeout(() => { + setInterval(() => { + runWalCheckpoint('PASSIVE', 'Periodic WAL checkpoint completed', 'Periodic WAL checkpoint failed:', 'debug'); + }, 600000); // Every 10 minutes +}, 150000); // Start after 2.5 min offset + +app.listen(PORT, () => { + console.log(`SREBOT Player API server running on port ${PORT}`); + console.log(`Health check: http://localhost:${PORT}/health`); + console.log(`API info: http://localhost:${PORT}/api/info`); + log.info('Database refresh interval set to 60 seconds'); +}); + +// All-time benchmark warmup used to run here at +5s. Removed: the dual +// CTE-heavy benchmark queries (player + squadron, each ~minutes on the full +// 4M-row table) were the dominant startup cost and blocked the read connection +// against incoming user traffic. The cache fills lazily on first request now +// (TTL is 1 hour, see PERFORMANCE_BENCHMARK_CACHE_TTL). + +// Last-resort guards. SQLite BUSY/IOERR errors that escape callback handling +// would otherwise tear down the API and put PM2 into a restart loop (we hit +// 127 restarts in one afternoon from a BUSY on squadrons.db). Log and keep +// running — every code path that can produce these has its own handler now, +// and a real bug should surface in the logs rather than via a crash. +process.on('uncaughtException', (err) => { + log.error('Uncaught exception (continuing)', err, { message: err && err.message, code: err && err.code }); +}); +process.on('unhandledRejection', (reason) => { + const r = reason instanceof Error ? reason : new Error(String(reason)); + log.error('Unhandled rejection (continuing)', r, { message: r.message, code: r.code }); +}); + +process.on('SIGINT', () => { + console.log('\nShutting down server...'); + db.close((err) => { + if (err) { + console.error('Error closing database:', err.message); + } else { + console.log('Database connection closed.'); + } + process.exit(0); + }); +}); diff --git a/srebot.service b/srebot.service new file mode 100644 index 0000000..6101f65 --- /dev/null +++ b/srebot.service @@ -0,0 +1,30 @@ +[Unit] +Description=SREBOT Discord Bot for War Thunder +After=network.target +Wants=network.target + +[Service] +Type=simple +User=sre +Group=sre +WorkingDirectory=/home/sre/SREBOT/BOT +ExecStart=/home/sre/SREBOT/BOT/venv/bin/python bot_runner.py +Restart=always +RestartSec=10 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=srebot + +# Environment +Environment=PYTHONUNBUFFERED=1 +Environment=SREBOT_STORAGE_VOL_PATH=/mnt/HC_Volume_105581488/STORAGE + +# Security +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=false +ReadWritePaths=/home/sre/SREBOT /mnt/HC_Volume_105581488 + +[Install] +WantedBy=multi-user.target diff --git a/start_bot.py b/start_bot.py new file mode 100644 index 0000000..e60d841 --- /dev/null +++ b/start_bot.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +"""Entry point for SREBOT Discord Bot.""" +import sys +from pathlib import Path + +# Ensure project root is in path +sys.path.insert(0, str(Path(__file__).parent)) + +from BOT.botscript import bot, TOKEN + +if __name__ == "__main__": + if TOKEN: + bot.run(TOKEN) + else: + print("ERROR: DISCORD_KEY not set in environment") + sys.exit(1) diff --git a/test_spectra.py b/test_spectra.py new file mode 100644 index 0000000..368e005 --- /dev/null +++ b/test_spectra.py @@ -0,0 +1,69 @@ +"""Quick test to check Spectra host connectivity — WS + HTTP.""" +import asyncio +import os +import aiohttp +from dotenv import load_dotenv +from websockets.asyncio.client import connect + +load_dotenv() + +API_KEY = os.getenv("SPECTRA_API_KEY", "") +BASE_URL = os.getenv("SPECTRA_API_URL", "") +WS_URL = os.getenv("SPECTRA_WS_SQB_URL", "") +WS_GOB_URL = os.getenv("SPECTRA_WS_GOB_URL", "") + + +async def test_http(): + """Test HTTP API endpoint.""" + url = f"{BASE_URL}/v1/replays/sort" + auth = API_KEY if API_KEY.startswith("Bearer ") else f"Bearer {API_KEY}" + headers = {"accept": "application/json", "Authorization": auth, "sortField": "sqb", "id": "463391108263081012"} + print(f"[HTTP] POST {url}") + try: + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=headers, timeout=aiohttp.ClientTimeout(total=10)) as resp: + print(f"[HTTP] Status: {resp.status}") + text = await resp.text() + print(f"[HTTP] Body: {text[:500]}") + except Exception as e: + print(f"[HTTP] Failed: {type(e).__name__}: {e}") + + +async def test_ws_sqb(): + """Test SQB WebSocket connection.""" + url = WS_URL + print(f"\n[WS-SQB] Connecting to {url}") + try: + async with connect(url, additional_headers={"Authorization": API_KEY}, open_timeout=15) as ws: + print("[WS-SQB] Connected! Waiting for message (10s)...") + msg = await asyncio.wait_for(ws.recv(), timeout=10) + print(f"[WS-SQB] Got message: {str(msg)[:300]}") + except Exception as e: + print(f"[WS-SQB] Failed: {type(e).__name__}: {e}") + + +async def test_ws_gob(): + """Test GOB WebSocket connection.""" + url = WS_GOB_URL + print(f"\n[WS-GOB] Connecting to {url}") + try: + async with connect(url, additional_headers={"Authorization": API_KEY}, open_timeout=15) as ws: + print("[WS-GOB] Connected! Waiting for message (10s)...") + msg = await asyncio.wait_for(ws.recv(), timeout=10) + print(f"[WS-GOB] Got message ({len(msg)} bytes)") + except Exception as e: + print(f"[WS-GOB] Failed: {type(e).__name__}: {e}") + + +async def main(): + print(f"API Key configured: {'Yes' if API_KEY else 'No'}") + print(f"Base URL: {BASE_URL}") + print(f"WS URL: {WS_URL}") + print(f"WS GOB URL: {WS_GOB_URL}\n") + await test_http() + await test_ws_sqb() + await test_ws_gob() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..99752f6 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,18 @@ +# Environment +NODE_ENV=production + +# Server Configuration +PORT=3000 + +# Domain Configuration (CORS) +PRODUCTION_DOMAIN=https://srebot-meow.ing + +# External API Configuration +EXTERNAL_API_URL=http://localhost:6000 + +# Logging Configuration +LOG_LEVEL=info + +# Rate Limiting +RATE_LIMIT_WINDOW_MS=60000 +RATE_LIMIT_MAX_REQUESTS=100 diff --git a/web/.gitattributes b/web/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/web/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..3cca02c --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,55 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov + +# Operating System Files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Editor directories and files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Temporary files +*.tmp +*.temp +temp/ + +# Debug and test files +debug-*.js +test-*.js + +# Build output (obfuscated files and generated CSS) +public/js/dist/ +public/css/output.css.map \ No newline at end of file diff --git a/web/Fonts/symbols_skyquake.ttf b/web/Fonts/symbols_skyquake.ttf new file mode 100644 index 0000000..76fc5d3 Binary files /dev/null and b/web/Fonts/symbols_skyquake.ttf differ diff --git a/web/ICONS/assists_icon.png b/web/ICONS/assists_icon.png new file mode 100644 index 0000000..99f298a Binary files /dev/null and b/web/ICONS/assists_icon.png differ diff --git a/web/ICONS/cap_icon.png b/web/ICONS/cap_icon.png new file mode 100644 index 0000000..6ea7440 Binary files /dev/null and b/web/ICONS/cap_icon.png differ diff --git a/web/ICONS/deaths_icon.png b/web/ICONS/deaths_icon.png new file mode 100644 index 0000000..f45b739 Binary files /dev/null and b/web/ICONS/deaths_icon.png differ diff --git a/web/ICONS/disconnected.png b/web/ICONS/disconnected.png new file mode 100644 index 0000000..9cde811 Binary files /dev/null and b/web/ICONS/disconnected.png differ diff --git a/web/ICONS/fighter_icon.png b/web/ICONS/fighter_icon.png new file mode 100644 index 0000000..93197e7 Binary files /dev/null and b/web/ICONS/fighter_icon.png differ diff --git a/web/ICONS/tank_icon.png b/web/ICONS/tank_icon.png new file mode 100644 index 0000000..60a395a Binary files /dev/null and b/web/ICONS/tank_icon.png differ diff --git a/web/LICENSE b/web/LICENSE new file mode 100644 index 0000000..51ee659 --- /dev/null +++ b/web/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Sop + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/web/MAPS/38th_parallel.jpg b/web/MAPS/38th_parallel.jpg new file mode 100644 index 0000000..5b57b9b Binary files /dev/null and b/web/MAPS/38th_parallel.jpg differ diff --git a/web/MAPS/abandoned_factory.jpg b/web/MAPS/abandoned_factory.jpg new file mode 100644 index 0000000..202c0b2 Binary files /dev/null and b/web/MAPS/abandoned_factory.jpg differ diff --git a/web/MAPS/abandoned_town.jpg b/web/MAPS/abandoned_town.jpg new file mode 100644 index 0000000..6de0ceb Binary files /dev/null and b/web/MAPS/abandoned_town.jpg differ diff --git a/web/MAPS/advance_to_the_rhine.jpg b/web/MAPS/advance_to_the_rhine.jpg new file mode 100644 index 0000000..7dc56b9 Binary files /dev/null and b/web/MAPS/advance_to_the_rhine.jpg differ diff --git a/web/MAPS/alaska.jpg b/web/MAPS/alaska.jpg new file mode 100644 index 0000000..c668dd6 Binary files /dev/null and b/web/MAPS/alaska.jpg differ diff --git a/web/MAPS/american_desert.jpg b/web/MAPS/american_desert.jpg new file mode 100644 index 0000000..c5fbc07 Binary files /dev/null and b/web/MAPS/american_desert.jpg differ diff --git a/web/MAPS/aral_sea.jpg b/web/MAPS/aral_sea.jpg new file mode 100644 index 0000000..3708c98 Binary files /dev/null and b/web/MAPS/aral_sea.jpg differ diff --git a/web/MAPS/arctic._pier.jpg b/web/MAPS/arctic._pier.jpg new file mode 100644 index 0000000..9a0e7a7 Binary files /dev/null and b/web/MAPS/arctic._pier.jpg differ diff --git a/web/MAPS/arctic._polar_base.jpg b/web/MAPS/arctic._polar_base.jpg new file mode 100644 index 0000000..8dd43ea Binary files /dev/null and b/web/MAPS/arctic._polar_base.jpg differ diff --git a/web/MAPS/arctic.jpg b/web/MAPS/arctic.jpg new file mode 100644 index 0000000..8dd43ea Binary files /dev/null and b/web/MAPS/arctic.jpg differ diff --git a/web/MAPS/ardennes.jpg b/web/MAPS/ardennes.jpg new file mode 100644 index 0000000..50e03c8 Binary files /dev/null and b/web/MAPS/ardennes.jpg differ diff --git a/web/MAPS/ardennes_(winter).jpg b/web/MAPS/ardennes_(winter).jpg new file mode 100644 index 0000000..45da224 Binary files /dev/null and b/web/MAPS/ardennes_(winter).jpg differ diff --git a/web/MAPS/ash_river.jpg b/web/MAPS/ash_river.jpg new file mode 100644 index 0000000..d8794c8 Binary files /dev/null and b/web/MAPS/ash_river.jpg differ diff --git a/web/MAPS/attica.jpg b/web/MAPS/attica.jpg new file mode 100644 index 0000000..9981fb7 Binary files /dev/null and b/web/MAPS/attica.jpg differ diff --git a/web/MAPS/battle_of_hürtgen_forest.jpg b/web/MAPS/battle_of_hürtgen_forest.jpg new file mode 100644 index 0000000..8dcc1d4 Binary files /dev/null and b/web/MAPS/battle_of_hürtgen_forest.jpg differ diff --git a/web/MAPS/berlin.jpg b/web/MAPS/berlin.jpg new file mode 100644 index 0000000..1085b90 Binary files /dev/null and b/web/MAPS/berlin.jpg differ diff --git a/web/MAPS/breslau.jpg b/web/MAPS/breslau.jpg new file mode 100644 index 0000000..4dd9043 Binary files /dev/null and b/web/MAPS/breslau.jpg differ diff --git a/web/MAPS/campania.jpg b/web/MAPS/campania.jpg new file mode 100644 index 0000000..0c1d91e Binary files /dev/null and b/web/MAPS/campania.jpg differ diff --git a/web/MAPS/cargo_port.jpg b/web/MAPS/cargo_port.jpg new file mode 100644 index 0000000..e155262 Binary files /dev/null and b/web/MAPS/cargo_port.jpg differ diff --git a/web/MAPS/carpathians.jpg b/web/MAPS/carpathians.jpg new file mode 100644 index 0000000..d5ae331 Binary files /dev/null and b/web/MAPS/carpathians.jpg differ diff --git a/web/MAPS/eastern_europe.jpg b/web/MAPS/eastern_europe.jpg new file mode 100644 index 0000000..1b0741c Binary files /dev/null and b/web/MAPS/eastern_europe.jpg differ diff --git a/web/MAPS/european_province.jpg b/web/MAPS/european_province.jpg new file mode 100644 index 0000000..ff5d78a Binary files /dev/null and b/web/MAPS/european_province.jpg differ diff --git a/web/MAPS/fields_of_normandy.jpg b/web/MAPS/fields_of_normandy.jpg new file mode 100644 index 0000000..0c9ca63 Binary files /dev/null and b/web/MAPS/fields_of_normandy.jpg differ diff --git a/web/MAPS/fields_of_poland.jpg b/web/MAPS/fields_of_poland.jpg new file mode 100644 index 0000000..32460b8 Binary files /dev/null and b/web/MAPS/fields_of_poland.jpg differ diff --git a/web/MAPS/fields_of_poland_(winter).jpg b/web/MAPS/fields_of_poland_(winter).jpg new file mode 100644 index 0000000..55d8da5 Binary files /dev/null and b/web/MAPS/fields_of_poland_(winter).jpg differ diff --git a/web/MAPS/finland.jpg b/web/MAPS/finland.jpg new file mode 100644 index 0000000..7225c25 Binary files /dev/null and b/web/MAPS/finland.jpg differ diff --git a/web/MAPS/fire_arc.jpg b/web/MAPS/fire_arc.jpg new file mode 100644 index 0000000..cd75410 Binary files /dev/null and b/web/MAPS/fire_arc.jpg differ diff --git a/web/MAPS/flanders.jpg b/web/MAPS/flanders.jpg new file mode 100644 index 0000000..fb6e4b8 Binary files /dev/null and b/web/MAPS/flanders.jpg differ diff --git a/web/MAPS/frozen_pass.jpg b/web/MAPS/frozen_pass.jpg new file mode 100644 index 0000000..7c5e4ec Binary files /dev/null and b/web/MAPS/frozen_pass.jpg differ diff --git a/web/MAPS/fulda.jpg b/web/MAPS/fulda.jpg new file mode 100644 index 0000000..8a00539 Binary files /dev/null and b/web/MAPS/fulda.jpg differ diff --git a/web/MAPS/golden_quarry.jpg b/web/MAPS/golden_quarry.jpg new file mode 100644 index 0000000..4d16899 Binary files /dev/null and b/web/MAPS/golden_quarry.jpg differ diff --git a/web/MAPS/ground_zero.jpg b/web/MAPS/ground_zero.jpg new file mode 100644 index 0000000..daecfef Binary files /dev/null and b/web/MAPS/ground_zero.jpg differ diff --git a/web/MAPS/iberian_castle.jpg b/web/MAPS/iberian_castle.jpg new file mode 100644 index 0000000..30f4e4d Binary files /dev/null and b/web/MAPS/iberian_castle.jpg differ diff --git a/web/MAPS/japan.jpg b/web/MAPS/japan.jpg new file mode 100644 index 0000000..98b9204 Binary files /dev/null and b/web/MAPS/japan.jpg differ diff --git a/web/MAPS/jungle.jpg b/web/MAPS/jungle.jpg new file mode 100644 index 0000000..c5964a0 Binary files /dev/null and b/web/MAPS/jungle.jpg differ diff --git a/web/MAPS/karelia.jpg b/web/MAPS/karelia.jpg new file mode 100644 index 0000000..e272624 Binary files /dev/null and b/web/MAPS/karelia.jpg differ diff --git a/web/MAPS/kuban.jpg b/web/MAPS/kuban.jpg new file mode 100644 index 0000000..153e77d Binary files /dev/null and b/web/MAPS/kuban.jpg differ diff --git a/web/MAPS/maginot_line.jpg b/web/MAPS/maginot_line.jpg new file mode 100644 index 0000000..537e51d Binary files /dev/null and b/web/MAPS/maginot_line.jpg differ diff --git a/web/MAPS/maginot_line_(winter).jpg b/web/MAPS/maginot_line_(winter).jpg new file mode 100644 index 0000000..db88b4a Binary files /dev/null and b/web/MAPS/maginot_line_(winter).jpg differ diff --git a/web/MAPS/middle_east.jpg b/web/MAPS/middle_east.jpg new file mode 100644 index 0000000..55bc056 Binary files /dev/null and b/web/MAPS/middle_east.jpg differ diff --git a/web/MAPS/mozdok.jpg b/web/MAPS/mozdok.jpg new file mode 100644 index 0000000..85d3a78 Binary files /dev/null and b/web/MAPS/mozdok.jpg differ diff --git a/web/MAPS/normandy.jpg b/web/MAPS/normandy.jpg new file mode 100644 index 0000000..b43609f Binary files /dev/null and b/web/MAPS/normandy.jpg differ diff --git a/web/MAPS/north_holland.jpg b/web/MAPS/north_holland.jpg new file mode 100644 index 0000000..744b656 Binary files /dev/null and b/web/MAPS/north_holland.jpg differ diff --git a/web/MAPS/poland.jpg b/web/MAPS/poland.jpg new file mode 100644 index 0000000..5db7fd9 Binary files /dev/null and b/web/MAPS/poland.jpg differ diff --git a/web/MAPS/poland_(winter).jpg b/web/MAPS/poland_(winter).jpg new file mode 100644 index 0000000..ae8a111 Binary files /dev/null and b/web/MAPS/poland_(winter).jpg differ diff --git a/web/MAPS/port_novorossiysk.jpg b/web/MAPS/port_novorossiysk.jpg new file mode 100644 index 0000000..a5069a6 Binary files /dev/null and b/web/MAPS/port_novorossiysk.jpg differ diff --git a/web/MAPS/pradesh.jpg b/web/MAPS/pradesh.jpg new file mode 100644 index 0000000..db40f35 Binary files /dev/null and b/web/MAPS/pradesh.jpg differ diff --git a/web/MAPS/red_desert.jpg b/web/MAPS/red_desert.jpg new file mode 100644 index 0000000..759ec5f Binary files /dev/null and b/web/MAPS/red_desert.jpg differ diff --git a/web/MAPS/sands_of_sinai.jpg b/web/MAPS/sands_of_sinai.jpg new file mode 100644 index 0000000..ff22678 Binary files /dev/null and b/web/MAPS/sands_of_sinai.jpg differ diff --git a/web/MAPS/second_battle_of_el_alamein.jpg b/web/MAPS/second_battle_of_el_alamein.jpg new file mode 100644 index 0000000..8ac693c Binary files /dev/null and b/web/MAPS/second_battle_of_el_alamein.jpg differ diff --git a/web/MAPS/seversk-13.jpg b/web/MAPS/seversk-13.jpg new file mode 100644 index 0000000..d2d93e4 Binary files /dev/null and b/web/MAPS/seversk-13.jpg differ diff --git a/web/MAPS/seversk-13_(winter).jpg b/web/MAPS/seversk-13_(winter).jpg new file mode 100644 index 0000000..a70b521 Binary files /dev/null and b/web/MAPS/seversk-13_(winter).jpg differ diff --git a/web/MAPS/sinai.jpg b/web/MAPS/sinai.jpg new file mode 100644 index 0000000..e95e706 Binary files /dev/null and b/web/MAPS/sinai.jpg differ diff --git a/web/MAPS/spaceport.jpg b/web/MAPS/spaceport.jpg new file mode 100644 index 0000000..d5b3298 Binary files /dev/null and b/web/MAPS/spaceport.jpg differ diff --git a/web/MAPS/stalingrad.jpg b/web/MAPS/stalingrad.jpg new file mode 100644 index 0000000..d16d896 Binary files /dev/null and b/web/MAPS/stalingrad.jpg differ diff --git a/web/MAPS/sun_city.jpg b/web/MAPS/sun_city.jpg new file mode 100644 index 0000000..19f3f1b Binary files /dev/null and b/web/MAPS/sun_city.jpg differ diff --git a/web/MAPS/surroundings_of_volokolamsk.jpg b/web/MAPS/surroundings_of_volokolamsk.jpg new file mode 100644 index 0000000..2ed917a Binary files /dev/null and b/web/MAPS/surroundings_of_volokolamsk.jpg differ diff --git a/web/MAPS/sweden.jpg b/web/MAPS/sweden.jpg new file mode 100644 index 0000000..376878c Binary files /dev/null and b/web/MAPS/sweden.jpg differ diff --git a/web/MAPS/test_site-2271.jpg b/web/MAPS/test_site-2271.jpg new file mode 100644 index 0000000..1152cc7 Binary files /dev/null and b/web/MAPS/test_site-2271.jpg differ diff --git a/web/MAPS/tunisia.jpg b/web/MAPS/tunisia.jpg new file mode 100644 index 0000000..7ca8bf2 Binary files /dev/null and b/web/MAPS/tunisia.jpg differ diff --git a/web/MAPS/vietnam.jpg b/web/MAPS/vietnam.jpg new file mode 100644 index 0000000..06eb6a7 Binary files /dev/null and b/web/MAPS/vietnam.jpg differ diff --git a/web/MAPS/volokolamsk.jpg b/web/MAPS/volokolamsk.jpg new file mode 100644 index 0000000..e89a624 Binary files /dev/null and b/web/MAPS/volokolamsk.jpg differ diff --git a/web/MAPS/white_rock_fortress.jpg b/web/MAPS/white_rock_fortress.jpg new file mode 100644 index 0000000..0e9cf0f Binary files /dev/null and b/web/MAPS/white_rock_fortress.jpg differ diff --git a/web/build-css.js b/web/build-css.js new file mode 100644 index 0000000..416adbb --- /dev/null +++ b/web/build-css.js @@ -0,0 +1,56 @@ +const fs = require('fs'); +const postcss = require('postcss'); +const tailwindcss = require('tailwindcss'); +const autoprefixer = require('autoprefixer'); +const cssnano = require('cssnano'); + +const inputPath = './public/css/tailwind.css'; +const outputPath = './public/css/output.css'; +const isProduction = process.env.NODE_ENV === 'production'; + +async function buildCSS() { + console.log('[CSS Build] Reading input file...'); + const css = fs.readFileSync(inputPath, 'utf8'); + + console.log(`[CSS Build] Processing with PostCSS and Tailwind... (${isProduction ? 'production' : 'development'} mode)`); + + const plugins = [ + tailwindcss(), + autoprefixer() + ]; + + // Only minify in production + if (isProduction) { + plugins.push(cssnano({ + preset: ['default', { + discardComments: { + removeAll: true, + }, + normalizeWhitespace: true, + }] + })); + } + + const result = await postcss(plugins).process(css, { + from: inputPath, + to: outputPath, + map: !isProduction ? { inline: false } : false + }); + + console.log('[CSS Build] Writing output file...'); + fs.writeFileSync(outputPath, result.css); + + if (result.map && !isProduction) { + fs.writeFileSync(outputPath + '.map', result.map.toString()); + } + + const sizeKB = (Buffer.byteLength(result.css, 'utf8') / 1024).toFixed(2); + console.log('[CSS Build] ✓ CSS build complete!'); + console.log(`[CSS Build] Output: ${outputPath} (${sizeKB} KB)`); +} + +buildCSS().catch(err => { + console.error('[CSS Build] Error:', err); + process.exit(1); +}); + diff --git a/web/build.js b/web/build.js new file mode 100644 index 0000000..2ed76a5 --- /dev/null +++ b/web/build.js @@ -0,0 +1,81 @@ +const JavaScriptObfuscator = require('javascript-obfuscator'); +const fs = require('fs'); +const path = require('path'); + +const PUBLIC_JS_DIR = path.join(__dirname, 'public', 'js'); +const OUTPUT_DIR = path.join(__dirname, 'public', 'js', 'dist'); + +// Obfuscation options - balanced between security and performance +const obfuscationOptions = { + compact: true, + controlFlowFlattening: true, + controlFlowFlatteningThreshold: 0.75, + deadCodeInjection: true, + deadCodeInjectionThreshold: 0.4, + debugProtection: false, + debugProtectionInterval: 0, + disableConsoleOutput: true, + identifierNamesGenerator: 'hexadecimal', + log: false, + numbersToExpressions: true, + renameGlobals: false, + selfDefending: true, + simplify: true, + splitStrings: true, + splitStringsChunkLength: 10, + stringArray: true, + stringArrayCallsTransform: true, + stringArrayEncoding: ['base64'], + stringArrayIndexShift: true, + stringArrayRotate: true, + stringArrayShuffle: true, + stringArrayWrappersCount: 2, + stringArrayWrappersChainedCalls: true, + stringArrayWrappersParametersMaxCount: 4, + stringArrayWrappersType: 'function', + stringArrayThreshold: 0.75, + transformObjectKeys: true, + unicodeEscapeSequence: false +}; + +// Create output directory if it doesn't exist +if (!fs.existsSync(OUTPUT_DIR)) { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); +} + +console.log('[BUILD] Starting JavaScript obfuscation...'); + +// Get all JS files in the public/js directory +const SKIP_FILES = ['replay-canvas.js']; +const jsFiles = fs.readdirSync(PUBLIC_JS_DIR).filter(file => + file.endsWith('.js') && !file.startsWith('.') && !SKIP_FILES.includes(file) +); + +let obfuscatedCount = 0; + +jsFiles.forEach(file => { + const inputPath = path.join(PUBLIC_JS_DIR, file); + const outputPath = path.join(OUTPUT_DIR, file); + + try { + console.log(`[BUILD] Obfuscating ${file}...`); + + const sourceCode = fs.readFileSync(inputPath, 'utf8'); + const obfuscationResult = JavaScriptObfuscator.obfuscate(sourceCode, obfuscationOptions); + + fs.writeFileSync(outputPath, obfuscationResult.getObfuscatedCode()); + + const originalSize = (fs.statSync(inputPath).size / 1024).toFixed(2); + const obfuscatedSize = (fs.statSync(outputPath).size / 1024).toFixed(2); + + console.log(`[BUILD] ✓ ${file} (${originalSize}KB → ${obfuscatedSize}KB)`); + obfuscatedCount++; + + } catch (error) { + console.error(`[BUILD] ✗ Failed to obfuscate ${file}:`, error.message); + } +}); + +console.log(`\n[BUILD] Obfuscation complete! ${obfuscatedCount}/${jsFiles.length} files processed.`); +console.log(`[BUILD] Obfuscated files saved to: ${OUTPUT_DIR}`); +console.log('[BUILD] To use obfuscated files in production, set NODE_ENV=production'); diff --git a/web/constants/seasons b/web/constants/seasons new file mode 100644 index 0000000..3af3c3b --- /dev/null +++ b/web/constants/seasons @@ -0,0 +1,35 @@ +2026-I + +week 1 (01.01 — 07.01) max BR 14.3 +week 2 (08.01 — 14.01) max BR 12.0 +week 3 (15.01 — 21.01) max BR 11.0 +week 4 (22.01 — 28.01) max BR 10.0 +week 5 (29.01 — 04.02) max BR 9.0 +week 6 (05.02 — 11.02) max BR 8.0 +week 7 (12.02 — 18.02) max BR 7.0 +week 8 (19.02 — 23.02) max BR 6.0 +until eos (24.02 — 28.02) max BR 5.0 + +2026-II + +week 1 (01.03 — 08.03) max BR 14.3 +week 2 (09.03 — 15.03) max BR 12.0 +week 3 (16.03 — 22.03) max BR 10.7 +week 4 (23.03 — 29.03) max BR 9.7 +week 5 (30.03 — 05.04) max BR 8.7 +week 6 (06.04 — 12.04) max BR 7.3 +week 7 (13.04 — 19.04) max BR 6.3 +week 8 (20.04 — 26.04) max BR 5.7 +until eos (27.04 — 30.04) max BR 4.7 + +2026-III + +week 1 (01.05 — 07.05) max BR 14.3 +week 2 (08.05 — 14.05) max BR 12.0 +week 3 (15.05 — 21.05) max BR 11.0 +week 4 (22.05 — 28.05) max BR 10.0 +week 5 (29.05 — 04.06) max BR 9.0 +week 6 (05.06 — 11.06) max BR 8.0 +week 7 (12.06 — 18.06) max BR 7.0 +week 8 (19.06 — 25.06) max BR 6.0 +until eos (26.06 — 30.06) max BR 5.0 \ No newline at end of file diff --git a/web/deploy.sh b/web/deploy.sh new file mode 100644 index 0000000..03cba34 --- /dev/null +++ b/web/deploy.sh @@ -0,0 +1,65 @@ +#!/bin/bash + +echo "[DEPLOY] Starting deployment process..." +echo "[DEPLOY] Deployment started at: $(date)" + +cd "$(dirname "$0")" + +echo "" +echo "[DEPLOY] Pulling latest changes from Git..." +git pull origin main + +if [ $? -ne 0 ]; then + echo "[ERROR] Git pull failed!" + exit 1 +fi + +echo "" +echo "[DEPLOY] Checking for package.json changes..." +PACKAGE_CHANGED=$(git diff HEAD@{1} HEAD --name-only | grep -q "package.json" && echo "yes" || echo "no") + +if [ "$PACKAGE_CHANGED" = "yes" ]; then + echo "[DEPLOY] package.json has changed, running npm install..." + npm install + + if [ $? -ne 0 ]; then + echo "[ERROR] npm install failed!" + exit 1 + fi + + echo "" + echo "[DEPLOY] Running npm audit fix..." + npm audit fix +else + echo "[DEPLOY] No package.json changes detected, skipping npm install" +fi + +echo "" +echo "[DEPLOY] Building CSS with Tailwind..." +npm run build:css + +if [ $? -ne 0 ]; then + echo "[ERROR] CSS build failed!" + exit 1 +fi + +echo "" +echo "[DEPLOY] Building obfuscated JavaScript files..." +npm run build + +if [ $? -ne 0 ]; then + echo "[WARN] JS build failed, but continuing deployment..." +fi + +echo "" +echo "[DEPLOY] Restarting PM2 process 3..." +pm2 restart 3 + +if [ $? -ne 0 ]; then + echo "[ERROR] PM2 restart failed!" + exit 1 +fi + +echo "" +echo "[DEPLOY] Deployment completed successfully!" +echo "[DEPLOY] Deployment finished at: $(date)" diff --git a/web/documentation/API_DOCUMENTATION.txt b/web/documentation/API_DOCUMENTATION.txt new file mode 100644 index 0000000..947ac0a --- /dev/null +++ b/web/documentation/API_DOCUMENTATION.txt @@ -0,0 +1,1069 @@ +================================================================================ + SREBOT PLAYER API DOCUMENTATION + Version 1.2.0 +================================================================================ + +TABLE OF CONTENTS +-------------------------------------------------------------------------------- +1. Overview +2. Base URL & Connection +3. Response Format +4. Error Codes +5. Player Endpoints +6. Leaderboard Endpoints +7. Squadron Endpoints +8. Live Match Endpoints +9. Search Endpoints +10. Debug Endpoints +11. Utility Endpoints +12. Rate Limiting & Best Practices + +================================================================================ +1. OVERVIEW +================================================================================ + +The SREBOT Player API provides access to War Thunder squadron battle statistics +including player data, vehicle performance, squadron rankings, live match +results, and real-time squadron points from the War Thunder API. All data is +stored in SQLite databases and served via a RESTful API. + +Database: SQLite (read-only) +Primary DB: sq_battles.db (player_games_hist, match_summary tables) +Lookup DB: squadrons.db (squadrons_data table) - optional +Port: 6000 (default) +Protocol: HTTP +Response Format: JSON +External Integration: War Thunder API (via Python Points_API module) + +================================================================================ +2. BASE URL & CONNECTION +================================================================================ + +Local Development: + http://localhost:6000 + +Production: + Configure via environment variable PORT (default: 6000) + +Health Check: + GET http://localhost:6000/health + +API Information: + GET http://localhost:6000/api/info + +================================================================================ +3. RESPONSE FORMAT +================================================================================ + +All successful responses return JSON with appropriate data structures. +All timestamps are in Unix epoch format (seconds) unless otherwise noted. + +Standard Success Response: +{ + "data": { ... }, + "additional_fields": "..." +} + +Standard Error Response: +{ + "error": "Error message", + "errorCode": "ERROR_CODE" (optional) +} + +================================================================================ +4. ERROR CODES +================================================================================ + +HTTP Status Codes: + 200 - OK + 400 - Bad Request (invalid parameters) + 404 - Not Found (resource doesn't exist) + 500 - Internal Server Error (database or server error) + 503 - Service Unavailable (database connection error) + +Custom Error Codes: + DB_NICK_QUERY_FAILED - Failed to retrieve player nickname + DB_GAMES_QUERY_FAILED - Failed to retrieve player games + DB_SEARCH_QUERY_FAILED - Failed to execute search + DB_PLAYER_LEADERBOARD_FAILED - Failed to generate player leaderboard + DB_VEHICLE_LEADERBOARD_FAILED - Failed to generate vehicle leaderboard + DB_SQUADRON_LEADERBOARD_FAILED - Failed to generate squadron leaderboard + DB_SQUADRON_SUMMARY_FAILED - Failed to get squadron summary + DB_SQUADRON_PLAYERS_FAILED - Failed to get squadron players + DB_TOTAL_PLAYERS_FAILED - Failed to count total players + DB_TOTAL_SQUADRONS_FAILED - Failed to count total squadrons + DB_SCHEMA_CHECK_FAILED - Failed to check database schema + DB_OVERALL_STATS_FAILED - Failed to get overall statistics + DB_TOP_VEHICLES_FAILED - Failed to get top vehicles + +================================================================================ +5. PLAYER ENDPOINTS +================================================================================ + +5.1 GET /api/player/:uid +-------------------------------------------------------------------------------- +Retrieve player data and aggregated vehicle statistics by UID. + +Parameters: + :uid (path) - Player unique identifier (required) + start_date (query) - Optional. ISO date string (YYYY-MM-DD). Filter from this date. + end_date (query) - Optional. ISO date string (YYYY-MM-DD). Filter until end of this date. + +Response: +{ + "uid": "12345", + "nick": "PlayerName", + "squadron_name": "SQUAD", + "total_vehicles": 5, + "vehicles": [ + { + "vehicle_internal": "germ_pzkpfw_VI_ausf_h1_tiger", + "vehicle": "Tiger H1", + "stats": { + "ground_kills": 150, + "air_kills": 25, + "assists": 40, + "captures": 10, + "deaths": 50, + "wins": 60, + "losses": 40, + "total_battles": 100, + "win_rate": "60.0" + } + } + ] +} + +Examples: + GET /api/player/123456789 + GET /api/player/123456789?start_date=2025-11-01&end_date=2025-11-30 + GET /api/player/123456789?start_date=2025-11-01 + +Notes: + - Date filtering aggregates stats only from games within the specified date range + - start_date filters from 00:00:00 of the specified date + - end_date filters until 23:59:59 of the specified date + - Both parameters are optional and can be used independently + +Errors: + 400 - UID parameter is required + 404 - Player not found + 500 - DB_NICK_QUERY_FAILED, database error + +-------------------------------------------------------------------------------- + +5.2 GET /api/player/:uid/games +-------------------------------------------------------------------------------- +Retrieve individual game records for a specific player. + +Parameters: + :uid (path) - Player unique identifier (required) + +Response: +{ + "uid": "12345", + "nick": "PlayerName", + "squadron_name": "SQUAD", + "total_games_returned": 150, + "games": [ + { + "session_id": "20250101_120000", + "vehicle_internal": "germ_pzkpfw_VI_ausf_h1_tiger", + "vehicle": "Tiger H1", + "squadron_name": "SQUAD", + "timestamp": 1704110400, + "stats": { + "ground_kills": 3, + "air_kills": 1, + "assists": 2, + "captures": 1, + "deaths": 1 + }, + "result": "WIN" + } + ] +} + +Example: + GET /api/player/123456789/games + +Errors: + 400 - UID parameter is required + 404 - Player not found + 500 - DB_GAMES_QUERY_FAILED + +================================================================================ +6. LEADERBOARD ENDPOINTS +================================================================================ + +6.1 GET /api/leaderboard/players +-------------------------------------------------------------------------------- +Retrieve global player leaderboards with aggregated statistics. + +Parameters: + start_date (query) - Optional. ISO date string (YYYY-MM-DD). Filter from this date. + end_date (query) - Optional. ISO date string (YYYY-MM-DD). Filter until end of this date. + +Response: +{ + "timeframe": "all-time", + "total_players": 1500, + "players": [ + { + "uid": "12345", + "nick": "TopPlayer", + "squadron_name": "ELITE", + "total_kills": 5000, + "ground_kills": 4000, + "air_kills": 1000, + "total_battles": 800, + "wins": 500, + "win_rate": 62.5, + "kdr": 10.0, + "deaths": 500, + "assists": 1200, + "captures": 300, + "total_score": 25000 + } + ] +} + +Notes: + - Returns ALL players in database sorted by total_score + - Score calculation: ground_kills + air_kills + (assists * 0.5) + (captures * 2) + - Only includes players with at least 1 kill + - KDR = total_kills / deaths (or total_kills if deaths = 0) + - Date filtering aggregates stats only from games within the specified date range + +Examples: + GET /api/leaderboard/players + GET /api/leaderboard/players?start_date=2025-11-01&end_date=2025-11-30 + GET /api/leaderboard/players?start_date=2025-11-01 + +Errors: + 500 - DB_PLAYER_LEADERBOARD_FAILED + +-------------------------------------------------------------------------------- + +6.2 GET /api/leaderboard/vehicles +-------------------------------------------------------------------------------- +Retrieve vehicle-specific leaderboards showing top players for each vehicle. + +Parameters: + vehicle (query) - Optional. Filter by specific vehicle name (case-insensitive) + start_date (query) - Optional. ISO date string (YYYY-MM-DD). Filter from this date. + end_date (query) - Optional. ISO date string (YYYY-MM-DD). Filter until end of this date. + +Response (without filter): +{ + "vehicles": [ + { + "vehicle": "Tiger H1", + "player_uid": "12345", + "player_nick": "TankAce", + "player_squadron_name": "TANKS", + "total_kills": 450, + "ground_kills": 400, + "air_kills": 50, + "battles": 120, + "wins": 75, + "win_rate": 62.5, + "kdr": 9.0, + "deaths": 50, + "assists": 100, + "captures": 30 + } + ] +} + +Response (with filter): + Same structure, but only includes entries for specified vehicle + +Examples: + GET /api/leaderboard/vehicles + GET /api/leaderboard/vehicles?vehicle=Tiger H1 + GET /api/leaderboard/vehicles?start_date=2025-11-01&end_date=2025-11-30 + GET /api/leaderboard/vehicles?vehicle=Tiger H1&start_date=2025-11-01 + +Errors: + 500 - DB_VEHICLE_LEADERBOARD_FAILED + +Notes: + - Returns ALL vehicle-player combinations + - Sorted by total_kills DESC, then battles DESC + - Only includes combinations with at least 1 kill + - Date filtering aggregates stats only from games within the specified date range + +-------------------------------------------------------------------------------- + +6.3 GET /api/leaderboard/squadrons +-------------------------------------------------------------------------------- +Retrieve squadron leaderboards with aggregated stats across all members. + +Parameters: + start_date (query) - Optional. ISO date string (YYYY-MM-DD). Filter from this date. + end_date (query) - Optional. ISO date string (YYYY-MM-DD). Filter until end of this date. + +Response: +{ + "timeframe": "all-time", + "total_squadrons": 50, + "squadrons": [ + { + "tag_name": "[ELITE]", + "long_name": "Elite Squadron", + "player_count": 25, + "total_kills": 50000, + "ground_kills": 40000, + "air_kills": 10000, + "total_battles": 8000, + "wins": 5000, + "win_rate": 62.5, + "kdr": 10.0, + "deaths": 5000, + "assists": 12000, + "captures": 3000, + "points": { + "total_points": 125000, + "has_points_data": true + } + } + ] +} + +Notes: + - Requires squadron_name column in database + - Excludes squadrons named 'UNKNOWN' + - Sorted by points if available, otherwise by total_kills DESC + - Only includes squadrons with at least 1 kill + - Uses squadrons.db to consolidate squadron name variants (tag_name, long_name, short_name) + - Fetches squadron points data from War Thunder API via Python script + - Points data fetching has 10-second timeout per squadron + - Squadrons without points data (has_points_data: false) are sorted after those with points + - Date filtering aggregates stats only from games within the specified date range + +Examples: + GET /api/leaderboard/squadrons + GET /api/leaderboard/squadrons?start_date=2025-11-01&end_date=2025-11-30 + GET /api/leaderboard/squadrons?start_date=2025-11-01 + +Special Response (if squadron_name column missing): +{ + "timeframe": "all-time", + "total_squadrons": 0, + "squadrons": [], + "migration_needed": true, + "message": "Squadron data not available - squadron_name column missing from database" +} + +Errors: + 500 - DB_SQUADRON_LEADERBOARD_FAILED, DB_SCHEMA_CHECK_FAILED + +-------------------------------------------------------------------------------- + +6.4 GET /api/leaderboard/stats +-------------------------------------------------------------------------------- +Retrieve overall leaderboard statistics and top vehicles. + +Parameters: + None + +Response: +{ + "total_players": 1500, + "total_vehicles_used": 250, + "total_battles": 50000, + "last_updated": "2025-01-15T12:00:00.000Z", + "top_vehicles": [ + { + "vehicle": "Tiger H1", + "usage_count": 5000 + }, + { + "vehicle": "T-34-85", + "usage_count": 4500 + } + ] +} + +Notes: + - Returns top 12 most-used vehicles + - last_updated is ISO 8601 format + - usage_count = total number of battles in that vehicle + +Example: + GET /api/leaderboard/stats + +Errors: + 500 - DB_OVERALL_STATS_FAILED, DB_TOP_VEHICLES_FAILED + +================================================================================ +7. SQUADRON ENDPOINTS +================================================================================ + +7.1 GET /api/squadrons/:squadronname +-------------------------------------------------------------------------------- +Retrieve all players in a specific squadron and their individual statistics. + +Parameters: + :squadronname (path) - Squadron name (required, accepts tag_name, long_name, or short_name) + start_date (query) - Optional. ISO date string (YYYY-MM-DD). Filter from this date. + end_date (query) - Optional. ISO date string (YYYY-MM-DD). Filter until end of this date. + +Response: +{ + "tag_name": "[ELITE]", + "long_name": "Elite Squadron", + "squadron_summary": { + "player_count": 25, + "total_kills": 50000, + "ground_kills": 40000, + "air_kills": 10000, + "total_battles": 8000, + "wins": 5000, + "win_rate": 62.5, + "kdr": 10.0, + "deaths": 5000, + "assists": 12000, + "captures": 3000, + "points": { + "total_points": 125000, + "has_points_data": true + } + }, + "players": [ + { + "uid": "12345", + "nick": "PlayerOne", + "total_kills": 2000, + "ground_kills": 1600, + "air_kills": 400, + "total_battles": 320, + "wins": 200, + "win_rate": 62.5, + "kdr": 10.0, + "deaths": 200, + "assists": 480, + "captures": 120 + } + ] +} + +Notes: + - Requires squadron_name column in database + - Players sorted by total_kills DESC + - Includes all players with 0 or more kills + - Uses squadrons.db to resolve squadron name variants to canonical long_name and tag_name + - Consolidates stats from all squadron name variants (e.g., "ELITE", "[ELITE]", "Elite Squadron") + - Fetches squadron points data from War Thunder API via Python script + - Points data fetching has 10-second timeout + - Date filtering aggregates stats only from games within the specified date range + +Examples: + GET /api/squadrons/ELITE + GET /api/squadrons/[ELITE] + GET /api/squadrons/Elite%20Squadron + GET /api/squadrons/ELITE?start_date=2025-11-01&end_date=2025-11-30 + GET /api/squadrons/ELITE?start_date=2025-11-01 + +Special Response (squadron not found): +{ + "tag_name": "UNKNOWN", + "long_name": "UNKNOWN", + "squadron_summary": { + /* all zeros with points: { total_points: 0, has_points_data: false } */ + }, + "players": [], + "message": "Squadron 'UNKNOWN' not found or has no players" +} + +Special Response (if squadron_name column missing): +{ + "tag_name": "ELITE", + "long_name": "ELITE", + "squadron_summary": { + /* all zeros with points: { total_points: 0, has_points_data: false } */ + }, + "players": [], + "migration_needed": true, + "message": "Squadron data not available - squadron_name column missing from database" +} + +Errors: + 400 - Squadron name parameter is required + 500 - DB_SQUADRON_SUMMARY_FAILED, DB_SQUADRON_PLAYERS_FAILED + +================================================================================ +8. LIVE MATCH ENDPOINTS +================================================================================ + +8.1 GET /api/live +-------------------------------------------------------------------------------- +Retrieve live feed of recent match results. + +Parameters: + limit (query, optional) - Number of matches to return (default: 15, max: 200) + +Response: +{ + "total_matches": 15, + "limit": 15, + "matches": [ + { + "session_id": "20250115_143000", + "map_name": "Normandy", + "endtime_unix": 1705329000, + "endtime_iso": "2025-01-15T14:30:00.000Z", + "winning_squadron": "ELITE", + "losing_squadron": "NOOBS", + "winning_team": { + "squadron": "ELITE", + "players": [ + { + "uid": "12345", + "nick": "PlayerOne", + "squadron_name": "ELITE", + "vehicle": "Tiger H1", + "vehicle_new": "Tiger H1", + "ground_kills": 5, + "air_kills": 1, + "assists": 3, + "captures": 2, + "deaths": 1 + } + ] + }, + "losing_team": { + "squadron": "NOOBS", + "players": [ /* same structure */ ] + } + } + ] +} + +Notes: + - Requires match_summary table in database + - Matches sorted by endtime_unix DESC (newest first) + - winning_team_json and losing_team_json are parsed into objects + - If JSON parsing fails, team data will be null + +Examples: + GET /api/live + GET /api/live?limit=50 + GET /api/live?limit=200 + +Errors: + 400 - Limit cannot exceed 200 + 500 - Database error + +Logging: + - Request details (IP, user agent, query params) + - Query execution time + - JSON parse errors + - Response size and timing + +================================================================================ +9. SEARCH ENDPOINTS +================================================================================ + +9.1 GET /api/search/:nickname +-------------------------------------------------------------------------------- +Search for players by nickname (case-insensitive partial match). + +Parameters: + :nickname (path) - Player nickname to search for (required, min 1 char) + +Response: +{ + "search_term": "player", + "total_found": 5, + "limited_to": 50, + "results": [ + { + "uid": "12345", + "nick": "PlayerOne", + "squadron_name": "ELITE" + }, + { + "uid": "67890", + "nick": "TopPlayer", + "squadron_name": "PROS" + } + ] +} + +Notes: + - Case-insensitive search + - Partial match (e.g., "play" matches "PlayerOne", "TopPlayer") + - Returns latest nickname for each UID + - Limited to 50 results + - Sorted alphabetically by nickname + +Examples: + GET /api/search/player + GET /api/search/ace + GET /api/search/top + +Errors: + 400 - Nickname parameter is required + 500 - DB_SEARCH_QUERY_FAILED + +================================================================================ +10. DEBUG ENDPOINTS +================================================================================ + +10.1 GET /api/debug/stats +-------------------------------------------------------------------------------- +Get database schema and statistics for player_games_hist table. + +Response: +{ + "table_schema": [ + { + "name": "UID", + "type": "TEXT", + "notnull": 1, + "default_value": null + } + ], + "database_stats": { + "total_records": 50000, + "unique_players": 1500, + "unique_sessions": 8000, + "oldest_session": "20240101_120000", + "newest_session": "20250115_143000" + }, + "sample_records": [ /* 3 recent records */ ], + "timestamp": "2025-01-15T14:30:00.000Z" +} + +-------------------------------------------------------------------------------- + +10.2 GET /api/debug/schema +-------------------------------------------------------------------------------- +Get detailed database schema and sample records. + +Response: +{ + "table_name": "player_games_hist", + "schema": [ /* detailed column info */ ], + "column_names": ["UID", "nick", "session_id", ...], + "sample_records": [ /* 3 sample records */ ], + "total_columns": 15, + "timestamp": "2025-01-15T14:30:00.000Z" +} + +-------------------------------------------------------------------------------- + +10.3 GET /api/debug/migration-status +-------------------------------------------------------------------------------- +Check if squadron_name column exists and if migration is needed. + +Response: +{ + "table_name": "player_games_hist", + "has_squadron_column": true, + "missing_columns": [], + "all_columns": ["UID", "nick", "squadron_name", ...], + "migration_needed": false, + "migration_sql": null, + "timestamp": "2025-01-15T14:30:00.000Z" +} + +If migration needed: +{ + "migration_needed": true, + "migration_sql": "ALTER TABLE player_games_hist ADD COLUMN squadron_name TEXT NOT NULL DEFAULT 'UNKNOWN'" +} + +-------------------------------------------------------------------------------- + +10.4 GET /api/debug/player-sample +-------------------------------------------------------------------------------- +Get sample player data (10 records). + +Response: +{ + "sample_data": [ + { + "UID": "12345", + "nick": "PlayerOne", + "session_id": "20250115_143000", + "vehicle": "Tiger H1", + "ground_kills": 3, + "air_kills": 1, + "assists": 2, + "captures": 1, + "deaths": 1, + "victor_bool": "WIN" + } + ] +} + +-------------------------------------------------------------------------------- + +10.5 GET /api/debug/player-count/:uid +-------------------------------------------------------------------------------- +Get detailed analysis of a player's records. + +Parameters: + :uid (path) - Player UID (required) + +Response: +{ + "player_analysis": { + "UID": "12345", + "total_records": 500, + "unique_sessions": 500, + "unique_vehicles": 15, + "vehicles_used": "Tiger H1,T-34-85,Panther D,..." + } +} + +-------------------------------------------------------------------------------- + +10.6 GET /api/debug/squadron-names +-------------------------------------------------------------------------------- +Analyze squadron name patterns and find potential duplicates. + +Parameters: + None + +Response: +{ + "total_unique_names": 50, + "squadron_names": [ + { + "squadron_name": "ELITE", + "record_count": 1500 + }, + { + "squadron_name": "[ELITE]", + "record_count": 800 + } + ], + "patterns": { + "bracketed": [ /* squadrons with [brackets] */ ], + "non_bracketed": [ /* squadrons without brackets */ ], + "mixed": [ /* squadrons with partial brackets */ ] + }, + "potential_duplicates": [ + { + "name1": "ELITE", + "name2": "[ELITE]", + "normalized": "ELITE", + "records1": 1500, + "records2": 800 + } + ] +} + +Notes: + - Limited to 50 squadron names + - Excludes 'UNKNOWN' squadrons + - Identifies potential duplicates by normalizing (removing brackets) + - Useful for identifying squadron name variants that should be consolidated + +-------------------------------------------------------------------------------- + +10.7 GET /api/debug/squadrons-db-schema +-------------------------------------------------------------------------------- +Get squadrons.db database schema and sample records. + +Parameters: + None + +Response: +{ + "database_path": "/path/to/squadrons.db", + "tables": { + "squadrons_data": { + "schema": [ + { + "cid": 0, + "name": "clan_id", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0 + }, + { + "cid": 1, + "name": "long_name", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0 + }, + { + "cid": 2, + "name": "short_name", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0 + }, + { + "cid": 3, + "name": "tag_name", + "type": "TEXT", + "notnull": 0, + "dflt_value": null, + "pk": 0 + } + ], + "sample_records": [ /* 3 sample squadron records */ ], + "record_count": 3 + } + }, + "timestamp": "2025-01-15T14:30:00.000Z" +} + +Special Response (if squadrons.db not found): +{ + "error": "Squadrons database not found", + "path": "/path/to/squadrons.db" +} + +Notes: + - Returns schema for all tables in squadrons.db + - Includes sample records (up to 3) for each table + - Used to verify squadron lookup database structure + +================================================================================ +11. UTILITY ENDPOINTS +================================================================================ + +11.1 GET /health +-------------------------------------------------------------------------------- +Health check endpoint. + +Response: +{ + "status": "OK", + "timestamp": "2025-01-15T14:30:00.000Z", + "database": "Connected" +} + +-------------------------------------------------------------------------------- + +11.2 GET /api/info +-------------------------------------------------------------------------------- +API information and available endpoints. + +Response: +{ + "name": "SREBOT Player API", + "version": "1.2.0", + "endpoints": { + "GET /api/player/:uid": "Get player data and aggregated vehicle statistics by UID", + "GET /api/player/:uid/games": "Get individual game records for a player (recent games)", + "GET /api/search/:nickname": "Search for players by nickname", + "GET /api/live": "Get live feed of recent match results (supports ?limit parameter, max 200)", + "GET /api/leaderboard/players": "Get global player leaderboards with aggregated stats", + "GET /api/leaderboard/squadrons": "Get squadron leaderboards with aggregated stats across all squadron members", + "GET /api/leaderboard/vehicles": "Get vehicle-specific leaderboards (supports ?vehicle parameter)", + "GET /api/leaderboard/stats": "Get overall leaderboard statistics and top vehicles", + "GET /api/squadrons/:squadronname": "Get all players in a specific squadron and their individual stats", + "GET /api/debug/stats": "Get database schema and statistics (debug)", + "GET /api/debug/schema": "Get detailed database schema and sample records (debug)", + "GET /api/debug/squadron-names": "Analyze squadron name patterns and find potential duplicates (debug)", + "GET /api/debug/migration-status": "Check if squadron migration is needed (debug)", + "GET /api/debug/squadrons-db-schema": "Get squadrons.db database schema and sample records (debug)", + "GET /health": "Health check endpoint", + "GET /api/info": "API information" + }, + "database": { + "path": "/path/to/sq_battles.db", + "table": "player_games_hist", + "expected_columns": [ + "UID", "nick", "session_id", "vehicle", "vehicle_internal", + "ground_kills", "air_kills", "assists", "captures", "deaths", "victor_bool" + ] + } +} + +================================================================================ +12. RATE LIMITING & BEST PRACTICES +================================================================================ + +Rate Limiting: + - No explicit rate limiting implemented + - Database refreshes every 60 seconds + - Use /api/live with reasonable limit values (15-50 recommended) + +Best Practices: + 1. Cache responses on client side when possible + 2. Use specific endpoints rather than requesting all data + 3. Implement exponential backoff on errors + 4. Use /health endpoint before making data requests + 5. Filter vehicle leaderboards when possible + 6. Limit /api/live requests to necessary number of matches + +Performance Considerations: + - All queries use read-only database connection + - Indexes recommended on: UID, session_id, vehicle, squadron_name + - Large result sets (all players, all vehicles) may take longer + - /api/live with high limits (>100) may be slower + - Squadron endpoints fetch real-time points data via Python API calls + - /api/leaderboard/squadrons may take longer with many squadrons (10s timeout per squadron) + - /api/squadrons/:squadronname includes 10s timeout for points fetching + - Points API calls run in parallel for leaderboards but may still take significant time + +Error Handling: + - Always check HTTP status code + - Parse error JSON for detailed error information + - Implement retry logic for 500-series errors + - Log errorCode for debugging + +Database Maintenance: + - API performs periodic health checks every 60 seconds + - Read-only mode prevents data corruption + - Ensure main bot is running to populate data + - Check /api/debug/migration-status for schema updates + +================================================================================ +APPENDIX A: CALCULATION FORMULAS +================================================================================ + +Win Rate: + win_rate = (wins / total_battles) * 100 + +Kill/Death Ratio (KDR): + kdr = total_kills / deaths + (if deaths = 0, kdr = total_kills) + +Total Kills: + total_kills = ground_kills + air_kills + +Total Score (Player/Squadron): + total_score = ground_kills + air_kills + (assists * 0.5) + (captures * 2) + +Average Statistics: + avg_stat = sum(stat) / count(records) + +================================================================================ +APPENDIX B: DATABASE SCHEMA +================================================================================ + +Database: sq_battles.db +----------------------- + +Table: player_games_hist +Columns: + - UID (TEXT) - Player unique identifier + - nick (TEXT) - Player nickname + - squadron_name (TEXT) - Squadron name (may be UNKNOWN or NULL) + - session_id (TEXT) - Match session identifier + - vehicle (TEXT) - Display name of vehicle + - vehicle_internal (TEXT) - Internal vehicle identifier + - ground_kills (INTEGER) - Ground targets destroyed + - air_kills (INTEGER) - Air targets destroyed + - assists (INTEGER) - Kill assists + - captures (INTEGER) - Capture points secured + - deaths (INTEGER) - Number of deaths + - victor_bool (TEXT) - Match result (WIN/LOSS) + - endtime_unix (INTEGER) - Match end time (Unix timestamp) + +Table: match_summary +Columns: + - session_id (TEXT) - Match session identifier + - map_name (TEXT) - Map name + - endtime_unix (INTEGER) - Match end time + - winning_sq (TEXT) - Winning squadron name + - losing_sq (TEXT) - Losing squadron name + - winning_team_json (TEXT) - JSON of winning team data + - losing_team_json (TEXT) - JSON of losing team data + +Database: squadrons.db (optional) +---------------------------------- + +Table: squadrons_data +Columns: + - clan_id (TEXT) - Unique squadron identifier + - long_name (TEXT) - Full squadron name (canonical name) + - short_name (TEXT) - Short squadron name variant + - tag_name (TEXT) - Squadron tag with brackets (e.g., "[ELITE]") + +Notes: + - squadrons.db is used to resolve squadron name variants + - If squadrons.db is missing, API falls back to using raw squadron_name + - Multiple squadron name variants map to the same long_name for consolidation + - API uses this to combine stats from different name formats (e.g., "ELITE", "[ELITE]", "Elite Squadron") + +================================================================================ +APPENDIX C: EXTERNAL DEPENDENCIES +================================================================================ + +Python Integration: +------------------- +The API integrates with a Python module (Points_API.py) to fetch real-time +squadron points data from the War Thunder API. + +Requirements: + - Python 3.7+ installed and accessible via 'python' command + - Points_API.py module in the same directory as the API server + - Points_API module must export: obtain_clan_new_points(squadron_name) async function + +Function Signature: + obtain_clan_new_points(squadron_name: str) -> Tuple[Dict, int] + + Returns: + - member_points: Dictionary of member points data + - total_points: Total squadron points (integer) + +Error Handling: + - Python process timeout: 10 seconds per squadron + - If Python script fails, points will default to 0 + - If Points_API is missing, points.has_points_data will be false + - Errors are logged but do not prevent response + +Implementation Details: + - Uses Node.js child_process.spawn() to execute Python + - Python output is parsed to extract total_points + - Process is killed if timeout exceeds 10 seconds + - Multiple squadron point requests run in parallel for leaderboards + +Notes: + - Points data is fetched in real-time (not cached) + - Squadron leaderboard response time increases with squadron count + - For best performance, ensure Python and dependencies are optimized + +================================================================================ +APPENDIX D: VERSION HISTORY +================================================================================ + +Version 1.2.0 (Current): + - Added squadron points integration from War Thunder API + - Squadron endpoints now return tag_name and long_name fields + - Added points object to squadron responses (total_points, has_points_data) + - Implemented squadron name consolidation using squadrons.db lookup + - Squadron leaderboards now sorted by points (if available) + - Added /api/debug/squadron-names endpoint for squadron name analysis + - Added /api/debug/squadrons-db-schema endpoint to inspect squadrons.db + - Enhanced squadron endpoints to handle multiple name variants + - Python script integration for real-time points fetching + - 10-second timeout per squadron for points API calls + +Version 1.1.0: + - Added squadron endpoints (/api/leaderboard/squadrons, /api/squadrons/:squadronname) + - Added squadron_name field to player responses + - Added migration status endpoint + - Enhanced logging with structured log format + - Added debug endpoints for schema inspection + +Version 1.0.0: + - Initial release + - Player statistics endpoints + - Vehicle leaderboards + - Live match feed + - Search functionality + - Debug endpoints + +================================================================================ + END OF DOCUMENTATION +================================================================================ + +For support or questions, check the API info endpoint or contact the development team. + +Last Updated: September 30, 2025 diff --git a/web/env.example b/web/env.example new file mode 100644 index 0000000..09fa8f9 --- /dev/null +++ b/web/env.example @@ -0,0 +1,46 @@ +# ============================================ +# Environment Configuration Example +# Copy this file to .env and fill in your actual values +# ============================================ + +# Server Configuration +NODE_ENV=production +PORT=3001 + +# External API Configuration +# For Docker: use host.docker.internal to reach services on host machine +# For Docker Compose with API in same network: use the service name +EXTERNAL_API_URL=http://localhost:6000 + +# Domain Config (used for CORS) +PRODUCTION_DOMAIN=https://srebot-meow.ing + +# API Security (optional - auto-generates if not set) +# Generate with: openssl rand -hex 32 +API_SECRET= + +# IP Whitelist (optional - comma-separated IPs for production) +ALLOWED_IPS= + +# Webhook Configuration (optional - for GitHub auto-deployment) +# Generate a secure random string for this +WEBHOOK_SECRET= + +# ============================================ +# PM2 Commands: +# ============================================ +# Start with PM2: +# npm run pm2:start +# +# Other PM2 commands: +# npm run pm2:stop - Stop the app +# npm run pm2:restart - Restart the app +# npm run pm2:reload - Zero-downtime reload +# npm run pm2:logs - View logs +# npm run pm2:monit - Monitor dashboard +# npm run pm2:delete - Remove from PM2 +# +# Auto-start on reboot: +# pm2 startup +# pm2 save +# ============================================ diff --git a/web/locales/cs.json b/web/locales/cs.json new file mode 100644 index 0000000..086db09 --- /dev/null +++ b/web/locales/cs.json @@ -0,0 +1,906 @@ +{ + "nav": { + "home": "Domov", + "live": "Live", + "leaderboards": "Zebricek", + "docs": "Dokumentace", + "terms": "Podminky", + "premium": "Premium", + "support": "Podpora", + "addToDiscord": "Pridat na Discord", + "games": "Zapasy", + "squadrons": "Svazy", + "donate": "Prispet", + "analytics": "Analytika" + }, + "footer": { + "services": "Sluzby", + "matchFeed": "Popis zápasu", + "vehicleStats": "Statistiky vozidel", + "analytics": "Analyzy", + "squadronHub": "Centrum svazu", + "comparison": "Porovnani", + "resources": "Zdroje", + "documentation": "Dokumentace", + "inviteBot": "Pozvat bota", + "legal": "Pravni informace", + "termsOfService": "Podminky sluzby", + "privacyPolicy": "Zasady ochrany soukromi", + "termsAndPrivacy": "Podminky a soukromi", + "meowing": "Meowing", + "websiteBy": "Web vytvoril", + "andToothless": "a Toothless" + }, + "common": { + "loading": "Nacitani...", + "retry": "Zkusit znovu", + "backToHome": "Zpět na domovní stránku", + "battles": "Bitvy", + "wins": "Vyhry", + "winRate": "Uspesnost", + "kills": "Zabiti", + "totalKills": "Celkem zabiti", + "groundKills": "Pozemni zabiti", + "airKills": "Vzdusna zabiti", + "assists": "Asistence", + "deaths": "Umrti", + "captures": "Obsazeni", + "kdr": "KDR", + "kps": "KPS", + "rank": "Poradi", + "player": "Hrac", + "players": "Hraci", + "playersCount": "hracu", + "vehicle": "Vozidlo", + "vehicles": "Vozidla", + "squadron": "Svaz", + "squadrons": "Svazy", + "statistics": "Statistiky", + "comparison": "Porovnani", + "date": "Datum", + "result": "Vysledek", + "totalBattles": "Celkem bitev", + "totalWins": "Celkem vyhry", + "points": "Body", + "members": "Clenove", + "membersCount": "clenu", + "rating": "Hodnoceni", + "searchPlayerByName": "Hledat hrace podle jmena...", + "noPlayersFound": "Zadni hraci nebyli nalezeni", + "noSquadronsFound": "Žádné svazy nebyly nalezeny", + "noVehiclesFound": "Zadna vozidla nebyla nalezena", + "failedToLoad": "Nacteni dat se nezdarilo. Zkuste to prosim pozdeji.", + "recordingSince": "Data zaznamenavame od 01/01/2026", + "vs": "VS", + "map": "Mapa" + }, + "home": { + "squadronBattles": "Boje Svazu", + "madeSimple": "Jednoduše", + "addToDiscord": "Pridat na Discord", + "learnMore": "Zjistit vice", + "searchBySquadron": "HLEDAT PODLE SVAZU", + "typeSquadronName": "Zadejte název svazu...", + "orByPlayer": "NEBO PODLE HRACE", + "typePlayerName": "Zadejte jmeno hrace...", + "liveFeed": "Zápasový přenos", + "realTimeMatches": "Aktuální bitvy", + "topPlayers": "Nejlepsi hraci", + "vehicleStatsCard": "Statistiky vozidel", + "performanceMetrics": "Metriky vykonu", + "analyticsCard": "Analyzy", + "globalStatistics": "Globalni statistiky", + "squadronHubCard": "Centrum svazu", + "squadronStats": "Statistiky svazu", + "comparisonCard": "Porovnani", + "compareStats": "Porovnat statistiky", + "joinServers": "Pridejte se k 500+ serverum pouzivajicim naseho bota ke sledovani jejich vykonu", + "noSquadronsFound": "Žádné svazy nebyly nalezeny", + "noPlayersFound": "Zadni hraci nebyli nalezeni", + "searchPlayersIn": "Hledat hrace v", + "ctaElev8": "Jsi připraven vést svůj svaz?", + "ctaReign": "Jsi připraven znovu vládnout?", + "ctaMeow": "Meowww", + "ctaPurr": "Purrr", + "ctaRawr": "Rawr" + }, + "docs": { + "title": "Dokumentace", + "subtitle": "Vse, co potrebujete vedet o", + "quickNavigation": "Rychlý přístup", + "gettingStarted": "Zacit", + "commands": "Prikazy", + "serverSetup": "Nastaveni serveru", + "features": "Funkce", + "examples": "Priklady", + "troubleshooting": "Reseni problemu", + "stackManager": "Spravce stacku", + "welcomeMessage": "Postupujte podle techto kroku pro spusteni.", + "inviteTheBot": "Pozvat bota", + "inviteBotDesc": "Klikněte na \"přidat na server\" a vyberte si server vašeho svazu. Bot pošle uvítací zprávu s nápovědou spustit /setup.", + "runSetupWizard": "Spustit pruvodce nastavenim", + "setupWizardDesc": "Pruvodce nastavenim vas provede konfiguraci svazu, kanalu pro logy a kanalu pro body v jednom toku:", + "setupEasiest": "Toto je nejjednodussi zpusob, jak zacit. Provede vas nastavenim svazu a vyberem kanalu krok za krokem.", + "youreDone": "Hotovo!", + "doneDesc": "Bot zacne automaticky zverejnovat body a aktualizace zebricku. Pouzijte /autolog-management pro pozdeji upravit nastaveni notifikaci.", + "premiumNote": "Automaticke herní logy (plne tabulky vysledku po kazdem zapasu) vyzaduji predplatne Premium. Spustte /unlock pro predplaceni — 2,99 $/mesic na server, fakturace pres Discord.", + "manualSetup": "Rucni nastaveni (alternativa)", + "manualSetupDesc": "Pokud preferujete manuální konfiguraci, muzete misto toho pouzit tyto prikazy:", + "allCommandsSlash": "Vsechny prikazy pouzivaji system lomitkovych prikazu Discord. Zadejte / pro zobrazeni dostupnych prikazu.", + "serverSetupAdmin": "Nastaveni serveru a sprava", + "importantNote": "Dulezita poznamka", + "verifyFirst": "Nejdřív si ověřte pokud bot může najít váš svaz v databázi War Thunderu pomocí příkazu /sq-info (v případě na nižších pozicích).", + "cantFindSquadron": "Pokud bot nemuze najít váš svaz pomoci /sq-info, prikazy pro nastaveni nebudou fungovat spravne.", + "botNotResponding": "Bot neodpovida", + "checkOnline": "Zkontrolujte, zda je bot online (zeleny stav)", + "verifyPermissions": "Overte, ze bot ma potrebna opravneni", + "tryDifferentChannel": "Zkuste pouzit prikazy v jinem kanalu", + "commandsNotWorking": "Prikazy nefunguji", + "ensureSlash": "Ujistěte se, že používáte lomítko před příkazem (/-text-)", + "checkRolePerms": "Zkontrolujte, zda se svojí rolí máte opravnění používat bota.", + "tryRefreshing": "Zkuste obnovit Discord nebo restartovat aplikaci", + "dataNotSaving": "Data se neukládají", + "verifySendMessages": "Overte, ze bot ma opravneni \"Odesilat zpravy\"", + "checkOutages": "Zkontrolujte, zda nedochází k výpadkům Discordu", + "contactSupport": "Kontaktujte podporu, pokud problém přetrvává", + "needMoreHelp": "Potrebujete dalsi pomoc?", + "needMoreHelpDesc": "Pokud potrebujete dalsi pomoc, neváhejte se na nas obratit prostrednictvim nasich kanalu podpory.", + "example": "Priklad", + "supportedLanguages": "Podporovane jazyky", + "setupDesc": "Postup krok za krokem pro konfiguraci bota pro váš server. Nastaví váš svaz, kanal pro logy a kanal pro body naráz.", + "recommendedForNew": "Doporuceno pro nove servery.", + "setSquadronDesc": "Ulozit vychozi svaz pro vas Discord server. Pouziva se pro logovani a jako vychozi hodnota pro dalsi prikazy.", + "quickLogDesc": "Nastavit alarm pro svaz v aktualnim kanalu. Typ muze byt Logs, Points, Leaderboard, nebo Oboje — Oboje zároveň nastaví Logs a Points v jednom prikazu. Vychozi je Logs.", + "quickLogPremiumNote": "Logs (automaticke herní tabulky vysledku) vyzaduji predplatne Premium. Alarmy pro body a zebricek jsou zdarma.", + "autologDesc": "Spravovat notifikace autologu a diagnostikovat opravneni kanalu. Pouzijte toto pro zmenu nastaveni po pocatecnim nastaveni.", + "autologPremiumNote": "Automaticke herní logy vyzaduji predplatne Premium.", + "diagnosePermsDesc": "Okamzite zkontroluje, zda ma bot opravneni, ktera potrebuje v aktualnim kanalu, zobrazuje vase nakonfigurovane kanaly autolougu a zobrazuje stav predplatneho Premium tohoto serveru. Pouzijte toto, pokud se tabulky vysledku nebo body nezverejnuji.", + "squadronInformation": "Informace o svazu", + "sqInfoDesc": "Zobrazit podrobné informace o jakémkoliv svazu. Používá nastavený svaz pokud není žádná specifikace.", + "sqInfoGraphDesc": "Vizualizovat aktuální složení svazu jako sloupcový graf rozdělený na jádro, aktivní a slabé skupiny podle aktivity a procenta výher (aktuální sezóna).", + "compDesc": "Najít poslední známé sestavy pro bitvy daného svazu. Bezplatné servery získají 25 vyhledávání za časový slot; Premium má neomezeně.", + "trackDesc": "Sledovat svaz a porovnat statistiky s poslednim zkontrolovanym stavem.", + "topDesc": "Zobrazit top 20 svazů a jejich aktualni statistiky.", + "sqStatsDesc": "Zobrazit body svazu v case jako interaktivni graf.", + "lossCalculatorDesc": "Vypocitat, kolik bodu by svaz ztratil, kdyby zvoleni hraci odeslí.", + "recentDesc": "Zobrazit poslednich 5 bojů svazu pro svaz.", + "vsDesc": "Zobrazit vzajemny zapas s jiným svazem.", + "leaderboardLinkDesc": "Ziskat odkaz na globalni zebricek hracu SRE Bot.", + "playerStats": "Statistiky hrace", + "playerStatsDesc": "Zobrazit podrobne statistiky vozidel pro hrace s interaktivnim rozbalenim vozidel. Podporuje automaticke doplnovani.", + "viewPlayerGamesDesc": "Zobrazit poslednich 20 her pro hrace. Zobrazuje celkovy pocet výher/proher a uspesnost, souhrn po hrach (vysledek, oponentský svaz, mapa a odehrana sestava) a seznam vsech jedinecnych sestav, ktere hrac odehral. Podporuje automaticke doplnovani.", + "viewMatchDesc": "Zobrazit plnou tabulku vysledku pro konkretni zapas. Poskytnete ID zapasu primo, nebo hledejte podle jmena hrace a prochazte jejich poslednich 100 her a vyberte jednu. Zahrnuje tlacitka Zobrazit replay, Zobrazit cesty, Zaznam chatu a Zaznam boje.", + "examples2": "Priklady", + "compareDesc": "Porovnat souhrnne statistiky SQB mezi dvema nebo vice hraci (az 7). Zobrazuje porovnani vedle sebe s zvyraznenymi nejlepsimi statistikami. Zahrnuje tlacitko grafu pro zobrazeni historii bodu za 90 dni.", + "metaData": "Meta data", + "metaManagementDesc": "Konfigurovat nastaveni pristupu k meta datum pro vas server.", + "metaDesc": "Hledat meta statistiky vašeho svazu podle názvu vozidla.", + "settingsUtilities": "Nastaveni a nastroje", + "languageDesc": "Zmenit vychozi jazyk bota. Ovlivnuje take jazyk vozidel zobrazeny ve vasich bojových logách.", + "scheduleDesc": "Zobrazit aktualni rozvrh sezony BR. Zobrazuje maximalni bojove hodnoceni kazdeho tydne s rozsahem dat, preskrtnutim minulych tydnu a zvyraznenim aktualne aktivniho obdobi.", + "websiteDesc": "Ziskat odkaz na webove stranky SRE Bot pro vyhledavani hracu, zebricku a dalsi.", + "creditsDesc": "Zobrazit tym, ktery je pripoisan za vytvoreni tohoto bota.", + "unlockDesc": "Odemknout automaticke herní logy SQB a neomezené vyhledávání /comp pro tento server. Predplacenim se pridava plne tabulky vysledku zverejnovane automaticky do vaseho nakonfigurovancho kanalu po kazdem zapasu. 2,99 $ / mesic · na server · zrusit kdykoli. Fakturace je riesena vyhradne pres Discord — neni potreba zadny externi ucet.", + "analyticsDesc": "Pokrocile analyzy SQB: mira vyhry na mapach, timove sestavy, konzistence hracu, vykon v ruznych casech a historie soubojů (nejvíce vítězství a proher proti soupeřům).", + "sqCardDesc": "Vygenerovat sezonní přehledovou kartu (PNG) pro svaz — trend ratingu, úspěšnost, nejlepší hráči a další. Sezónu vyber z nabídky automatického doplňování. Podporuje tmavé i světlé téma.", + "cardDesc": "Vygenerovat sezonní přehledovou kartu (PNG) pro hráče. Sezónu vyber z automatického doplňování a hráče podle uživatelského jména. Podporuje tmavé i světlé téma.", + "queryDesc": "[Pouze administrátor] Spustit předdefinované databázové dotazy — statistiky svazu, počet her, nejaktivnější hráči, top mapy a další. Výsledky jsou efemérní (viditelné pouze vám).", + "donateDesc": "Podporte vyvoj SRE Bot pres Ko-fi.", + "botStatusDesc": "Zobrazí, kdy byla přijata poslední hra, a průměrné TTL napříč posledními hrami. Upozorní na pomalé servery Gaijin.", + "premiumBadge": "Premium", + "newsDesc": "Zobrazit nejnovejsi zpravy a oznameni SRE Bot.", + "stackCreateDesc": "Vytvorit stack hracu pro koordinaci tymu pred zapasem. Do kanalu se zverejni trvaly embed zobrazujici aktualni cleny a cekajici ziadatele. Stack trva az 8 hodin a je automaticky vycisten na konci kazdeho casoveho useku SQB.", + "stackRequestToJoin": "Pozadat o vstup — Jakykoli hrac muze pozadat s vozidlem, ktere planuje letit. Zadosti se radi do fronty az 20 mist.", + "stackLeaveWithdraw": "Odejit / Stornovat — Clenove mohou opustit stack; zadatele mohou stahnout svou zadost. Vedouci stacku je vyzvan k prevedeni vlastnictvi jako prvni.", + "stackManagePanel": "Spravovat stack ⚙️ — Panel pouze pro vedouciho se ctyrmi sekcemi:", + "stackAcceptMembers": "Prijmout cleny — Prijmout nebo odmítnout zadatele jednotlive nebo vsechny najednou. Maximalne 8 clenu celkem.", + "stackRemoveMembers": "Odebrat cleny — Odebrat aktivni cleny nebo cekajici zadatele. Moznosti: Odebrat vsechny, Odebrat aktivni, Odebrat cekajici nebo Odebrat vybrane z rozbaleni.", + "stackPingMembers": "Pingovat cleny — Pingovat s volitelnou vlastni zpravou. Moznosti: Pingovat vsechny (cleny + frontu, mimo vedouciho), Pingovat aktivni (pouze cleny), Pingovat cekajici (pouze zadatele) nebo Pingovat vybrane z rozbaleni.", + "stackRenameStack": "Prejmenovat stack — Nastavit vlastni nazev pro stack. Zobrazuje se jako nadpis embedu a v ping zpravach misto vychoziho \"[Vedouci]s Stack\".", + "stackDisbandStack": "Rozpustit stack — Vedouci muze stack ukoncit predcasne.", + "stackManageDesc": "Znovu zverejni vas aktivni stack embed do aktualniho kanalu. Pouzijte toto, pokud byl puvodni embed smazan nebo ztracen po restartu bota. Vsechna existujici data clenu a fronty jsou zachovana.", + "translation": "Preklad", + "translateContextMenu": "Kliknete pravym tlacitkem na zpravu → Aplikace → Prelozit zpravu", + "translateDesc": "Prelozit jakoukoli zpravu pomoci kontextoveho menu Discord. Kliknete pravym tlacitkem (nebo dlouze stisknete na mobilu) na zpravu a vyberte Aplikace → Prelozit zpravu.", + "viewAllLanguages": "Zobrazit vsechny podporovane jazyky", + "serverSetupSubtitle": "Konfigurujte {botName} pro optimalni vykon na Discord serveru vašeho svazu.", + "requiredPermissions": "Pozadovana opravneni", + "sendMessages": "Odesilat zpravy", + "useSlashCommands": "Pouzivat lomitkove prikazy", + "embedLinks": "Vkladat odkazy", + "readMessageHistory": "Cist historii zprav", + "recommendedChannelSetup": "Doporucene nastaveni kanalu", + "recommendedChannelDesc": "Vytvorte vyhrazeny kanal jako #squadron-battles pro sledovani a statistiky. Udrzuje vase bojova data organizovana a snadno pristupna.", + "roleConfiguration": "Konfigurace roli", + "roleConfigurationDesc": "Priradte vhodne role clenum svazu, kteri mohou zaznamenavat vysledky boju. Doporucujeme omezit toto na dustojniky a vedouci svazu.", + "premiumSectionSubtitle": "Automaticke herní logy SQB jsou funkci Premium, odemcene na server pres nativni system predplatneho Discord.", + "whatsIncluded": "Co je zahrnuto", + "premiumInclude1": "Plna tabulka vysledku zverejnovana automaticky do vaseho nakonfigurovancho kanalu po kazdem zapasu SQB", + "premiumInclude2": "Neomezené vyhledávání /comp (bezplatné servery získají 25 za časový slot)", + "premiumInclude3": "Vsechny existujici bezplatne funkce (alarmy pro body, zebricek, prikazy statistik atd.) zustavaji zdarma", + "pricingBilling": "Ceny a fakturace", + "pricingBillingDesc": "2,99 $ / mesic · na server · zrusit kdykoli. Fakturace je spravovana vyhradne pres Discord — zadny externi ucet ani platebni proces. Predplatne se automaticky obnovi a lze je kdykoli zrusit z vaseho nastaveni Discord.", + "howToSubscribe": "Jak se prihlasit k odberu", + "subscribe1": "Spustte /unlock na vasem serveru (pozadovany spravce serveru)", + "subscribe2": "Kliknete na tlacitko Prihlasit se k odberu v odpovedi bota", + "subscribe3": "Dokoncete platbu uvnitr Discord — bot se aktivuje okamzite", + "cancellation": "Zruseni", + "cancellationDesc": "Kdyz predplatne vyprsí nebo je zruseno, bot automaticky prestane zverejnovat herní logy pro dany server pri dalsim cyklu autolougu. Zadna rucni akce neni potreba.", + "realTimeStatistics": "Statistiky v realnem case", + "realTimeStatisticsDesc": "Sledujte vyhry, prohry a metriky vykonu v realnem case.", + "battleHistory": "Historie boju", + "battleHistoryDesc": "Komplexni historie vsech zaznamenanych bojů svazu.", + "leaderboardsFeature": "Zebricek", + "leaderboardsFeatureDesc": "Porovnejte vykon vasi svazu s ostatnimi pomoci /top.", + "playerTracking": "Sledovani hracu", + "playerTrackingDesc": "Individualni statistiky hracu a integrace ThunderSkill.", + "smartAlerts": "Chytre alarmy", + "smartAlertsDesc": "Automaticke alarmy a notifikace pro aktivity svazu.", + "multiLanguageSupport": "Vicejazykova podpora", + "multiLanguageSupportDesc": "Funkce prekladu a prizpusobitelne jazyky vozidel.", + "timeCoordination": "Casova koordinace", + "timeCoordinationDesc": "Využívá se UTC a místní čas pro globální koordinaci svazů.", + "advancedSearch": "Pokrocile vyhledavani", + "advancedSearchDesc": "Najdete souteze hracu a podrobne informace o svazu.", + "dataSecurity": "Zabezpeceni dat", + "dataSecurityDesc": "Vase data jsou zabezpecena a nikdy nesdilena ani neprodavana.", + "usageExamples": "Priklady pouziti", + "quickSetupRecommended": "Rychle nastaveni (doporuceno)", + "quickSetupDesc": "Spustte pruvodce nastavenim. Provede vas nastavenim svazu, vyberem kanalu pro logy a vyberem kanalu pro body — vše naráz.", + "comparingPlayers": "Porovnani hracu", + "comparingPlayersDesc": "Porovnejte az 7 hracu vedle sebe. Nejlepsi statistika v kazde kategorii je zvyraznena a celkove nejlepsi hrac ziska hvezdou. Kliknete na \"Zobrazit graf\" pro zobrazeni historii bodu.", + "checkingSquadronInfo": "Kontrola informací o svazu", + "verifySquadronDesc": "Ověřte že svaz existuje, a zobrazte si jeho podrobnosti. Použijte toto k utvrzení, že bot může najít váš svaz před nastavením.", + "recentBattlesDesc": "Zobrazte poslednich 5 boju odehraných svazem.", + "headToHeadDesc": "Zobrazte vzajemny zapas s jiným svazem.", + "backToHome": "Zpět na domovní stránku", + "termsAndPrivacy": "Podminky a soukromi", + "seasonRecapCardTitle": "Karta přehledu sezóny", + "seasonRecapCardDesc": "Vygenerujte sdílitelnou PNG rekapitulaci sezóny jakéhokoli svazu — křivka hodnocení, klouzavá procenta výher, K/D, nejhranější vozidlo, MVP a další. Dostupné tlačítkem „Sezónní karta“ na profilu každého svazu.", + "playerRecapCardTitle": "Sezónní rekapitulace hráče", + "playerRecapCardDesc": "Vygenerujte sdílitelný PNG souhrn sezóny libovolného hráče — vývoj ratingu napříč svazy, klouzavá úspěšnost, K/D, frekvence bitev, nejlepší zápas a další. Dostupné přes tlačítko „Season Recap“ na profilu každého hráče.", + "tierOverview": "Přehled tarifů", + "tierOverviewDesc": "Auto-logging je k dispozici ve třech tarifech. Každý tarif limituje, kolik svazů může mít Logs a Points aktivní. Svazy nad limitem zůstávají v nastavení a po upgradu automaticky pokračují.", + "tierStandardLine": "$2.99 — až 10 svazů pro Logs i Points, bez wildcard.", + "tierProLine": "až 25 svazů pro Logs i Points, plus podpora wildcard (`*` / `all` / `everything`).", + "tierMaxLine": "neomezené svazy, wildcards, raný přístup k novým funkcím." + }, + "terms": { + "pageTitle": "Podminky sluzby a zasady ochrany soukromi", + "lastUpdated": "Posledni aktualizace: duben 2026", + "termsOfService": "Podminky sluzby", + "byUsing": "Pouzivanjem", + "youAgree": " souhlasíte s nasledujicim:", + "useResponsibly": "Pouzivejte zodpovedne", + "useResponsiblyDesc": "Nepokouste se pretizt, spamovat nebo jinak narusit bota.", + "noFunnyBusiness": "Zadne nezakonne cinnosti", + "noFunnyBusinessDesc": "Nepokouste se o reverzni inzenyrstvi, nebo porusovat podmínky Discord nebo jaka koliv jina pravidla skupiny", + "statsAsIs": "Statistiky jsou tak, jak jsou", + "statsAsIsDesc": "Vsechna data a statistiky jsou poskytovany \"tak, jak jsou\" bez jakychkoliv zaruk, at' uz vyslovenych ci implikovanych. Vynalozime primerene usili k zajisteni presnosti, ale nezarucujeme, ze jsou informace uplne, aktualni nebo bezchybne.", + "uptimeNotGuaranteed": "Dostupnost neni zarucena", + "uptimeNotGuaranteedDesc": "Bot je poskytovan na zaklade \"dostupnosti\". Nezarucujeme nepretrzity nebo bezchybny provoz. Sluzba muze byt docasne nedostupna kvuli udrzbe, aktualizacim nebo technickym problemum.", + "weCanBanYou": "Muzeme vas zabanovat", + "weCanBanYouDesc": "Pokud porusite pravidla, muzeme vas odebrat pristup.", + "privacyPolicy": "Zasady ochrany soukromi", + "infoWeCollect": "1. Informace, ktere shromazd'ujeme", + "collectsFollowing": "shromazd'uje nasledujici informace k poskytovani svych sluzeb:", + "discordUserIds": "Discord uzivatelska ID:", + "discordUserIdsDesc": "Jedinecne identifikatory pro sledovani jednotlivych uzivatelu", + "squadronIds": "ID svazů:", + "squadronIdsDesc": "Identifikatory Discord serveru/gildy pro organizaci dat svazu", + "battleData": "Bojova data:", + "battleDataDesc": "Zaznamy vyhry/prohry, casova razitka boju a souvisejici statistiky", + "commandUsage": "Pouzivani prikazu:", + "commandUsageDesc": "Zakladni logovani pouzitych prikazu bota pro zlepseni sluzby", + "howWeUse": "2. Jak pouzivame vase informace", + "usedExclusively": "Shromazdene informace se pouzivaji vyhradne pro:", + "trackingPerformance": "Sledovani vykonu a statistik boje svazu", + "providingHistorical": "Poskytovani historickych dat a analytiky", + "improvingBot": "Zlepsovani funkcnosti bota a uzivatelske zkusenosti", + "troubleshootingIssues": "Reseni technickych problemu", + "dataStorage": "3. Uloziste dat a bezpecnost", + "dataStoredSecurely": "Vase data jsou bezpecne ulozena s nasledujicimi ochranami:", + "encryptedServers": "Data jsou ulozena na zabezpecen ych serverech se sifrovnaim", + "limitedAccess": "Pristup je omezen pouze na opravneny personal", + "regularBackups": "Pravidelne zalohy zajist'uji integritu dat", + "dataSharing": "4. Sdileni dat", + "weDoNot": "My NESDILÍME:", + "sellData": "Prodavat vase osobni informace tretim stranam", + "shareData": "Sdilet vase data s externimi organizacemi", + "useForAds": "Pouzivat vase data k reklamnim nebo marketingovym ucelum", + "transferData": "Prevest vase data mimo nase zabezpecene systemy", + "dataRetention": "5. Uchovavani dat", + "dataRetentionDesc": "Vase data uchováváme po dobu nezbytnou k poskytovani nasich sluzeb. Bojove statistiky a data svazu jsou uchovavany pro ucely historickych zaznamu a analytiky.", + "discordIntegration": "6. Integrace Discord", + "discordIntegrationDesc": "Tento bot funguje v ekosystemu Discord a podlaha zasadam ochrany soukromi Discord. Pristupujeme pouze k informacim nezbytnym pro funkcnost bota prostrednictvim oficialniho API Discord.", + "ageRestrictions": "7. Vekova omezeni", + "ageRestrictionsDesc": "Bot je urcen pro uzivatele, kteri splnuji minimalni vekove pozadavky Discord (13+ nebo podle mistniho prava).", + "changesToPolicy": "8. Zmeny zasad ochrany soukromi", + "changesToPolicyDesc": "Tyto zasady ochrany soukromi muzeme cas od casu aktualizovat.", + "premiumTitle": "Podminky predplatneho Premium", + "premiumWhatYouGet": "1. Co Premium zahrnuje", + "premiumWhatYouGetDesc": "Predplatne Premium odemyka nasledujici funkce pro prihlaseny Discord server:", + "premiumFeature1": "Automaticke prispevky s vysledky po kazdem SQB zapase", + "premiumFeature2": "Mapy tras / pohybu", + "premiumFeature3": "Chat a bojove zaznamy", + "premiumFeature4": "Vyhledavani replikaci", + "premiumFeature5": "Neomezene vyhledavani /comp (servery zdarma jsou omezeny na 15 za casovy slot)", + "premiumBilling": "2. Fakturace a platby", + "premiumBillingDesc": "Premium se ucuje za $2.99 USD mesicne za Discord server. Platba je zpracovana prostrednictvim nativniho systemu predplatneho Discord nebo pres nase webove stranky prostrednictvim Whop. Jste zodpovedni za zajisteni platnosti platebni metody a dostatecnych prostredku. Predplatne se automaticky obnovuje na konci kazdeho zuctovaci obdobi, pokud neni zruseno.", + "premiumCancellation": "3. Zruseni", + "premiumCancellationDesc": "Predplatne muzete kdykoli zrusit. U predplatneho Discord prejdete do Nastaveni uzivatele → Predplatna v Discordu. U predplatneho pres web spravujte sve vyuctovani na whop.com/billing. Po zruseni zustanou vase Premium funkce aktivni do konce aktualniho zuctovaciho obdobi. Pote se vas server vrati na bezplatnou uroven — zadna data nebudou ztracena.", + "premiumRefunds": "4. Vraceni plateb", + "premiumRefundsDesc": "Poplatky za predplatne jsou nevratne. Castecne vyuziti mesice se neprepocitava pomerne. Pokud zrusite uprostred cyklu, zachovate si pristup do konce daneho zuctovaciho obdobi, ale nemate narok na vraceni platby za zbyvajici cas. Vraceni plateb za chyby ve fakturaci nebo duplicitni platby muze byt provedeno dle naseho uvazeni — kontaktujte podporu s ID vaseho Discord serveru a dokladem o platbe.", + "premiumPriceChanges": "5. Zmeny cen", + "premiumPriceChangesDesc": "Vyhrazujeme si pravo kdykoli zmenit ceny predplatneho. Stavajici predplatitele budou upozorneni alespon 30 dnu pred vstupem zdrazeni v platnost. Pokud se zmenou ceny nesouhlaste, muzete predplatne zrusit pred zacatkem platnosti nove ceny.", + "premiumTermination": "6. Ukonceni pristupu Premium", + "premiumTerminationDesc": "Vyhrazujeme si pravo odebrat pristup Premium bez vraceni platby, pokud server porusi tyto Podminky sluzby, vcetne zneuziti funkci bota, pokusu o obejiti limitu pouziti nebo poruseni Podminek sluzby Discord.", + "serviceAvailabilityTitle": "Dostupnost sluzby a odpovednost", + "serviceNoWarranty": "1. Bez zaruky", + "serviceNoWarrantyDesc": "SLUZBA JE POSKYTOVANA \"TAK, JAK JE\" A \"PODLE DOSTUPNOSTI\" BEZ ZARUK JAKEHOKOLI DRUHU, AT' UZ VYSLOVNYCH, IMPLIKOVANYCH NEBO ZAKONNICH, VCETNE IMPLIKOVANYCH ZARUK PRODEJNOSTI, VHODNOSTI PRO URCITY UCEL A NEPORUSOVANI PRAV. Nezarucujeme, ze sluzba bude nepretrzita, vcasna, bezpecna nebo bezchybna.", + "serviceLiability": "2. Omezeni odpovednosti", + "serviceLiabilityDesc": "V MAXIMALNIM ROZSAHU POVOLENEM PLATNYM PRAVEM V ZADNEM PRIPADE NENESEME ODPOVEDNOST ZA JAKEKOLI NEPRIMA, NAHODNA, SPECIALNI, NASLEDNA NEBO TRESTNI SKODY, ANI ZA JAKOUKOLIV ZTRATU ZISKU NEBO PRIJMU, AT' UZ VZNIKLA PRIMO NEBO NEPRIMO, NEBO ZA JAKOUKOLIV ZTRATU DAT, POUZITI, DOBRE POVESTI NEBO JINYCH NEHMOTNYCH ZTRAT VYPLYVAJICICH Z VASEHO POUZIVANI NEBO NEMOZNOSTI POUZIVAT SLUZBU. Nase celkova odpovednost za jakykoliv narok vyplyvajici z techto podminek nebo sluzby neprevi castku, kterou jste nam zaplatili za tri (3) mesice pred uplatnenim naroku.", + "serviceCredits": "3. Preruseni sluzby a kredity", + "serviceCreditsDesc": "V pripade prodlouzeneho neplanoveho vypadku sluzby postihujiciho funkce Premium po dobu vice nez 72 po sobe jdoucich hodin mohou postizeni predplatitele Premium pozadat o kredit kontaktovanim podpory. Kredity jsou vydavany podle naseho vyhradniho uvazeni a jsou aplikovany jako prodlouzeni aktualniho zuctovaciho obdobi — nikoli jako penezni vraceni. Planovana okna udrzby, vypadky tretich stran (Discord, Gaijin API) a udalosti mimo nasi rozumnou kontrolu jsou vylouceny.", + "serviceForceM": "4. Vyssi moc", + "serviceForceMDesc": "Neneseme odpovednost za jakekoli selhani nebo zdrzeni v plneni vyplyvajici z pricin mimo nasi rozumnou kontrolu, vcetne prirodnich katastrof, valky, terorismu, pandemii, vypadku proudu, preruseni internetu, vypadku platformy Discord, zmen nebo nedostupnosti API Gaijin Entertainment, vladnich opatreni nebo jakekoli jine udalosti vyssi moci.", + "disclaimer": "Prohlaseni o odmitnuti odpovednosti", + "warThunderDisclaimer": "je nezavisly Discord bot a neni spojen s, schvalen ani nema zadnou souvislost se spolecnosti Gaijin Entertainment ani s War Thunder. War Thunder je ochranna znamka spolecnosti Gaijin Entertainment.", + "acknowledgement": "Pouzivanjem", + "acknowledgementEnd": " potvrzujete, ze jste si precetli, pochopili a souhlasíte s temi podmínkami sluzby a zasadami ochrany soukromi." + }, + "premium": { + "upgradeTitle": "Upgradujte svůj svaz", + "heroDesc": "Kazdy vysledek SQB zverejnen do vaseho kanalu ve chvili, kdy skonci — tabulky vysledku, bojove logy, pohybove mapy a replaye, vse bez zasahu.", + "instantScoreboards": "Okamzite tabulky vysledku", + "viewPaths": "Zobrazit cesty", + "chatBattleLogs": "Chat a bojove logy", + "replayLookups": "Vyhledavani replays", + "free": "Zdarma", + "perMonth": "/mes", + "alwaysFree": "Vzdy zdarma", + "noCardNeeded": "karta neni potreba", + "included": "Zahrnuto", + "manualLookups": "Rucni vyhledavani her", + "playerStats": "Statistiky a profily hracu", + "leaderboards": "Zebricek", + "stickWithFree": "Zustat na bezplatnem", + "premiumLabel": "Premium", + "perServer": "na server", + "cancelAnytime": "zrusit kdykoli", + "everythingInFree": "Vse v bezplatnem, plus", + "autoScoreboards": "Automaticke zverejnovani tabulek vysledku", + "pathMaps": "Mapy cest / pohybu", + "chatLogs": "Chat a bojove logy", + "replayLookupsFeature": "Vyhledavani replays", + "unlimitedComp": "Neomezené vyhledávání /comp", + "prioritySupport": "Prioritni podpora", + "subscribeNow": "Prihlasit se k odberu ted", + "comingSoon": "Brzy k dispozici", + "serverIdInfo": "Budete potrebovat sve", + "discordServerId": "Discord ID serveru", + "duringCheckout": "behem placeni.", + "developerMode": "Rezim vyvojare", + "rightClickServer": "Kliknete pravym tlacitkem na server", + "copyId": "Kopirovat ID", + "successTitle": "Premium aktivovano", + "successDesc": "Vase predplatne se nastavuje. Bot bude mit premium pristup pro vas server behem nekolika minut.", + "whatHappensNext": "Co se stane dale", + "autoLogging": "Auto-logovani se aktivuje pro vas server", + "setLogChannel": "Nastavte kanal pro logy pomoci", + "everyResult": "Kazdy vysledek SQB se zverejnuje automaticky", + "readSetupGuide": "Precist pruvodce nastavenim", + "tierStandardName": "Standard", + "tierProName": "Pro", + "tierMaxName": "Max", + "squadCap": "Logujte až {cap} svazů", + "squadCapUnlimited": "Neomezeně svazů", + "everythingInStandard": "Vše ze Standard", + "everythingInPro": "Vše z Pro", + "wildcardSupport": "Wildcard (*, all, everything)", + "noSquadCap": "Bez limitu svazů", + "earlyAccessFeatures": "Raný přístup k novým funkcím" + }, + "player": { + "totalBattles": "Celkem bitev", + "totalWins": "Celkem vyhry", + "vehicleStatistics": "Statistiky vozidel", + "cumulative": "Kumulativni", + "individual": "Individualni", + "filterBy": "Filtrovat podle:", + "allTime": "Vsechny casy", + "dateRange": "Rozsah datumu", + "season": "Sezona", + "week": "Tyden", + "session": "Relace", + "dateType": "Typ datumu:", + "last7Days": "Poslednich 7 dni", + "last30Days": "Poslednich 30 dni", + "last90Days": "Poslednich 90 dni", + "customRange": "Vlastni rozsah", + "specificDate": "Konkretni datum", + "filterType": "Typ filtru:", + "fullSeason": "Cela sezona", + "specificWeek": "Konkretni tyden", + "from": "Od:", + "to": "Do:", + "timeslot": "Čas", + "fullDay": "Celý den", + "selectSeason": "Sezona:", + "selectWeek": "Tyden:", + "selectSeasonFirst": "Nejprve vyberte sezonu", + "pleaseSelect": "Prosim vyberte moznost", + "searchVehicles": "Hledat vozidla...", + "resetFilters": "Resetovat filtry", + "vehiclesShown": "zobrazena vozidla", + "gamesShown": "zobrazene hry", + "noVehicleData": "Zadna data vozidel nejsou k dispozici", + "noVehiclesForRange": "Zadna vozidla nebyla nalezena pro vybrane datove rozmezi, nebo tento hrac jeste nema zadna data.", + "switchToCards": "Prepnout na zobrazeni karet", + "switchToTable": "Prepnout na tabulkove zobrazeni", + "loadingTimeline": "Nacitani casove osy...", + "noTimelineData": "Zatim zadna data casove osy.", + "timelineUnavailable": "Casova osa neni dostupna.", + "loadingGameRecords": "Nacitani herních zaznamu...", + "unableToLoadRecords": "Nelze nacist herní zaznamy", + "failedToFetch": "Nacteni herních dat se nezdarilo. Zkuste to prosim pozdeji.", + "noGameRecords": "Zadne herní zaznamy nenalezeny", + "noGamesYet": "Tento hrac jeste neodehral zadne zaznamenane hry.", + "collapseChart": "Sbalit graf", + "loadingChartData": "Nacitani dat grafu...", + "noHistoricalData": "Zatim zadna historicka data.", + "chartUnavailable": "Graf neni dostupny.", + "relative": "Relativní", + "uidLabel": "UID hráče" + }, + "squadrons": { + "title": "Centrum svazu", + "subtitle": "Objevujte svazy, zobrazujte si jejich statistiky a sledujte jejich výkon", + "findSquadron": "Najít svaz", + "searchPlaceholder": "Hledat svazu podle nazvu...", + "totalSquadrons": "Celkem svazů", + "totalPlayers": "Celkem hracu", + "totalBattles": "Celkem bitev", + "avgWinRate": "Prumerna uspesnost", + "topSquadrons": "Nejlepší svazy", + "viewFullLeaderboard": "Zobrazit plny zebricek", + "loadingSquadrons": "Nacitani dat svazu...", + "noSquadronData": "Zatim zadna data svazu nejsou k dispozici.", + "failedToLoad": "Nacteni dat svazu se nezdarilo. Zkuste to prosim pozdeji.", + "backToSquadronHub": "Zpet do centra svazu", + "squadronPoints": "Body svazu", + "squadronMembers": "Členové svazu", + "performance": "Výkon", + "performanceNoData": "Pro vybraný rozsah nejsou k dispozici žádná data o výkonu.", + "quickDetails": "Rychle detaily", + "noMembersFound": "Zadni clenove nebyli nalezeni", + "noRecordedMembers": "Tento svaz jeste nema zadne zaznamenane cleny.", + "squadronGames": "Hry svazu", + "loadingSquadronGames": "Načítání herních záznamů svazu...", + "noSquadronGames": "Pro tuto svaz nebyly nalezeny žádné herní záznamy.", + "retryLoadGames": "Zkusit znovu", + "searchMapPlaceholder": "Hledat podle mapy..." + }, + "leaderboard": { + "playersTitle": "Zebricek hracu", + "playersSubtitle": "Nejlepsi hraci War Thunder hodnoceni podle vykonu", + "vehiclesTitle": "Zebricek zabiti vozidel", + "vehiclesSubtitle": "Nejlepsi vozidla War Thunder hodnocena podle celkoveho poctu zabiti", + "squadronsTitle": "Žebříček svazů", + "squadronsSubtitle": "Nejlepší svazy War Thunder hodnocene podle vykonu", + "statsTitle": "Globalni statistiky", + "statsSubtitle": "Celkove statistiky bojů svazu a meta informace", + "comparisonTitle": "Nastroj porovnani", + "comparisonSubtitle": "Porovnejte hrace a vozidla vedle sebe", + "comparisonHint": "Porovnejte statistiky k nalezeni nejlepsich hracu a vozidel", + "compareSquadrons": "Porovnat svazy", + "comparePlayers": "Porovnat hrace", + "compareVehicles": "Porovnat vozidla", + "playersAndVehicles": "Hraci + vozidla", + "failedToLoadLeaderboard": "Nacteni dat zebricku se nezdarilo. Zkuste to prosim pozdeji.", + "failedToLoadVehicles": "Nacteni zebricku vozidel se nezdarilo", + "failedToLoadSquadrons": "Nacteni žebříčku svazů se nezdarilo", + "noResultsYet": "Zatim zadne vysledky. Pridejte svazu/hrace pro zahajeni.", + "searchSquadron": "Hledat svaz", + "searchBySquadronName": "Hledat podle názvu svazu...", + "minPlayers": "Min. hracu", + "minPlayersPlaceholder": "Min. hracu", + "resetFilters": "Resetovat filtry", + "squadronsShown": "zobrazené svazy", + "playersShown": "zobrazeni hraci", + "page": "strana", + "of": "z", + "loadingSquadronLeaderboard": "Nacitani žebříčku svazů...", + "loadingPlayerLeaderboard": "Nacitani zebricku hracu...", + "loadingComparisonData": "Nacitani dat porovnani...", + "unableToFetch": "Nelze ziskat data zebricku. Zkuste to prosim znovu.", + "noSquadronsInLeaderboard": "V zebricku nebyly nalezeny žádné svazy.", + "noPlayersInLeaderboard": "V zebricku nebyli nalezeni zadni hraci.", + "loadingGlobalStats": "Nacitani globalnich statistik...", + "failedToLoadStats": "Nacteni statistik se nezdarilo", + "unableToFetchStats": "Nelze ziskat data statistik. Zkuste to prosim znovu.", + "mostPopularVehicles": "Nejoblibenejsi vozidla", + "vehicleKillsLeaderboardTitle": "Zebricek zabiti vozidel", + "avgWinRate": "Prumerna uspesnost", + "avgKillsPerPlayer": "Prumerne zabiti/hrac", + "loadingVehicleKills": "Nacitani dat zabiti vozidel...", + "apiNotLoaded": "Klient API neni spravne nacteny. Prosim obnovte stranku.", + "failedToInitApi": "Inicializace klienta API se nezdarila", + "noStatsData": "Zadna statisticka data nejsou k dispozici", + "totalPlayersCard": "Celkem hracu", + "activePlayers": "Aktivni hraci", + "vehiclesUsed": "Pouzivana vozidla", + "differentVehicles": "Ruzna vozidla", + "squadronBattlesLabel": "Boje svazu", + "noVehicleData": "Zadna data vozidel nejsou k dispozici", + "mostPopular": "Nejoblibenejsi", + "timesUsed": "Pocet pouziti", + "failedToLoadVehicleKills": "Nacteni dat zabiti vozidel se nezdarilo", + "lastUpdated": "Posledni aktualizace", + "searchPlayer": "Hledat hrace", + "searchByPlayerName": "Hledat podle jmena hrace...", + "minimumBattles": "Minimum bitev", + "minBattlesPlaceholder": "Min. bitev", + "searchSquadronsPlaceholder": "Hledat svazu...", + "sortBy": "Seradit podle", + "kdRatio": "Pomer K/D", + "killsPerSpawn": "Zabiti na spawnu", + "caps": "Obsazeni", + "timePeriod": "Casove obdobi", + "allTime": "Vsechny casy", + "dateRange": "Rozsah datumu", + "season": "Sezona", + "week": "Tyden", + "dateType": "Typ datumu", + "last7Days": "Poslednich 7 dni", + "last30Days": "Poslednich 30 dni", + "last90Days": "Poslednich 90 dni", + "customRange": "Vlastni rozsah", + "from": "Od", + "to": "Do", + "timeslot": "Čas", + "fullDay": "Celý den", + "selectSeason": "Vybrat sezonu...", + "selectWeek": "Vybrat tyden...", + "failedToLoadComparison": "Nacteni dat porovnani se nezdarilo", + "pleaseRefresh": "Zkuste prosim obnovit stranku", + "playerComparison": "Porovnani hracu", + "squadronComparison": "Porovnání svazů", + "vehicleComparison": "Porovnani vozidel", + "playersVehiclesComparison": "Porovnani hracu + vozidel", + "addPlayersToCompare": "Pridat hrace k porovnani:", + "addSquadronsToCompare": "Pridat svazu k porovnani:", + "addVehiclesToCompare": "Pridat vozidla k porovnani:", + "addPlayerVehicleCombos": "Pridat kombinace hrac + vozidlo:", + "searchSelectPlayers": "Hledat a vybrat hrace...", + "typeSquadronName": "Zadejte název svazu...", + "searchSelectVehicles": "Hledat a vybrat vozidla...", + "searchForPlayers": "Hledat hrace...", + "selectPlayersToCompare": "Hledejte a vyberte hrace vyse pro porovnani jejich statistik", + "selectSquadronsToCompare": "Hledejte a vyberte svazu vyse pro porovnani jejich statistik", + "selectVehiclesToCompare": "Hledejte a vyberte vozidla vyse pro porovnani jejich statistik", + "selectPlayersVehiclesToCompare": "Hledejte a vyberte hrace vyse, pote vyberte jejich vozidla pro porovnani ruznych kombinaci hrac-vozidlo", + "selectVehicleFor": "Vybrat vozidlo pro", + "selectAVehicle": "-- Vybrat vozidlo --", + "noVehiclesForPlayer": "Pro tohoto hrace nebyla nalezena zadna vozidla", + "noPlayerVehicleSelected": "Zadne kombinace hrac-vozidlo nebyly vybrane", + "noPlayersSelected": "Zadni hraci nebyli vybrani", + "noVehiclesSelected": "Zadna vozidla nebyla vybrana", + "noSquadronsSelected": "Žádné svazy nebyly vybrane", + "statistic": "Statistika", + "totalDeaths": "Celkem umrti", + "totalAssists": "Celkem asistenci", + "totalCaptures": "Celkem obsazeni", + "killsPerSpawnShort": "Zabiti/spawn", + "avgWinRateShort": "Prumerna uspesnost", + "avgKillsPlayerShort": "Prumerne zabiti/hrac", + "avgKillsMember": "Prumerne zabiti/clen", + "avgBattlesMember": "Prumerne bitvy/clen", + "serverError500": "Chyba serveru (500) - API je docasne nedostupne. Zkuste to prosim znovu za chvilku.", + "apiEndpoint404": "Endpoint API nenalezen (404) - Prosim zkontrolujte konfiguraci serveru.", + "networkError": "Sitova chyba - Nelze se pripojit k serveru. Prosim zkontrolujte pripojeni.", + "viewFullLeaderboard": "Zobrazit plny zebricek", + "vehicleName": "Název vozidla", + "searchVehiclePlaceholder": "Hledat konkrétní vozidlo...", + "minKills": "Min. zabití", + "any": "Jakékoliv", + "perPage": "Na stránku", + "clear": "Vymazat", + "vehicleAndPlayer": "Vozidlo a hráč", + "loadingVehicleLeaderboard": "Načítání žebříčku vozidel...", + "vehiclesShown": "vozidel zobrazeno", + "minBattlesPerVehicle": "Vyžadováno minimum 3 bitvy na vozidlo", + "minimumBattlesRequired": "Vyžadováno minimálně 5 bitev", + "allSeasons": "Všechny sezóny", + "allWeeks": "Všechny týdny", + "allBR": "Všechny BR" + }, + "games": { + "title": "Historie zapasu", + "subtitle": "Hledejte a prochazejte zápasy svazu", + "searchPlaceholder": "Hledat podle jmena hrace nebo UID...", + "filterByMap": "Filtrovat podle mapy", + "allMaps": "Vsechny mapy", + "search": "Hledat", + "noResults": "Zadne zapasy nenalezeny", + "matchDetail": "Detail zapasu", + "chatLog": "Zaznam chatu", + "battleLog": "Zaznam boje", + "duration": "Trvani", + "mode": "Rezim", + "winningTeam": "Vitezny tym", + "losingTeam": "Prohravajici tym", + "viewMatch": "Zobrazit zapas", + "loadingMatch": "Nacitani dat zapasu...", + "matchNotFound": "Zapas nenalezen", + "searchingGames": "Hledani her...", + "recentMatches": "Posledni zapasy", + "noChatLog": "Zaznam chatu neni k dispozici", + "noBattleLog": "Zaznam boje neni k dispozici", + "replayVideo": "Video replay", + "generatingVideo": "Generovani videa replay…", + "videoFirstLoad": "Prvni nacteni muze trvat az minutu", + "videoUnavailable": "Video replay neni pro tento zapas k dispozici", + "modeGround": "Pozemni", + "modeAir": "Vzdusny", + "squadronPlaceholder": "Název svazu...", + "loadingReplay": "Načítání záznamu..." + }, + "errors": { + "pageNotFound": "Stranka nenalezena", + "error": "Chyba", + "oopsNotFound": "Oops! Stranka, kterou hledate, neexistuje. Mohla byt presunuta, smazana nebo jste zadali spatnou adresu URL.", + "searchError": "Chyba vyhledavani. Zkuste to prosim znovu." + }, + "js": { + "openingDiscordInvite": "Otevirání pozvánky Discord!", + "errorOpeningInvite": "Chyba pri otevirani odkazu pozvánky. Zkuste to prosim pozdeji.", + "gettingSupportLink": "Ziskavani odkazu na support server...", + "openingSupportServer": "Otevirání support serveru!", + "errorGettingSupport": "Chyba pri ziskavani odkazu podpory. Zkuste to prosim pozdeji.", + "failedToUpdateStats": "Aktualizace statistik se nezdarila", + "konamiActivated": "Odemceno dosazeni: Tajny kod!", + "noPlayersFound": "Zadni hraci nebyli nalezeni", + "searchError": "Chyba vyhledavani. Zkuste to prosim znovu.", + "killsSuffix": "zabiti", + "winRateSuffix": "uspesnost", + "noSquadronsFound": "Žádné svazy nebyly nalezeny" + }, + "index": { + "subtitle1": "Nejlepší nástroj pro letecké bitvy", + "subtitle2": "Boris Stats, ale lepší", + "subtitle3": "Největší datová sada SQB", + "subtitle4": "Veřejné a bezplatné informace pro všechny" + }, + "seasonCard": { + "buttonLabel": "Sezónní karta", + "buttonDisabledTitle": "Vyhledávání svazu neúplné — karta nedostupná", + "modalTitle": "Sezónní karta", + "seasonLabel": "Sezóna", + "themeLabel": "Motiv", + "themeDark": "Tmavý", + "themeLight": "Světlý", + "generate": "Vytvořit", + "loadingSeasons": "Načítání sezón…", + "generating": "Generování…", + "failedSeasons": "Načtení sezón selhalo.", + "failedGenerate": "Vytvoření přehledové karty selhalo.", + "inProgressSuffix": "(probíhá)", + "imgRecapSuffix": "PŘEHLED", + "imgHeroFinalRating": "Konečné hodnocení", + "imgHeroMatches": "Zápasy", + "imgHeroWinRate": "Výhernost", + "imgHeroKD": "K/D", + "imgAxisRating": "Hodnocení", + "imgAxisWinRate": "Výhernost", + "imgStatPeakRating": "Vrcholné hodnocení", + "imgStatRatingChange": "Změna hodnocení", + "imgStatTotalKills": "Zabití celkem", + "imgStatTotalDeaths": "Úmrtí celkem", + "imgStatAssistsCaptures": "Asistence / zajetí", + "imgStatMostPlayedVehicle": "Nejhranější vozidlo", + "imgStatMVP": "MVP", + "imgStatMostActive": "Nejaktivnější", + "imgStatLongestWinStreak": "Nejdelší vítězná série", + "imgStatMostCommonOpponent": "Nejčastější soupeř", + "imgUnitKills": "zabití", + "imgUnitAssists": "asistence", + "imgUnitCaptures": "zajetí", + "imgUnitGames": "her", + "imgUnitMatches": "zápasů", + "imgUnitWins": "V", + "imgUnitLosses": "P", + "imgGroundShort": "G", + "imgAirShort": "A", + "imgFooterGenerated": "vytvořeno", + "imgPlaceholderNoData": "Pro {short} v {season} nejsou data", + "buttonLabelPlayer": "Přehled sezóny", + "buttonDisabledTitlePlayer": "Player lookup incomplete — recap unavailable", + "modalTitlePlayer": "Player Season Recap", + "imgHeroBattles": "Bitvy", + "imgHeroTotalKills": "Zabití celkem", + "imgAxisBattles": "Bitvy (7 d)", + "imgAxisKD": "K/D", + "imgStatBestMatch": "Nejlepší zápas", + "imgStatSquadronsRepresented": "Zastoupené perutě", + "imgStatFrequentTeammate": "Nejčastější spoluhráč", + "imgStatLongestSession": "Nejdelší session", + "imgStatMostActiveDay": "Nejaktivnější den (UTC)", + "imgStatMostCommonOppSquadron": "Nejčastější soupeřící peruť", + "imgStatPeakSquadronRating": "Nejvyšší rating perutě", + "imgUnitSlotNA": "NA", + "imgUnitSlotEU": "EU", + "imgUnitNoSquadron": "bez perutě", + "imgUnitVs": "vs", + "imgUnitTogether": "zápasů společně", + "imgStatKDAC": "Z / S / A / O", + "imgPlaceholderNoDataPlayer": "Žádná data pro {nick} v {season}", + "imgUIDLabel": "UID", + "imgBestMatchLine": "{vehicle} · PZ {gk} / LZ {ak} / A {assists} / O {cap} / S {deaths} · {date}" + }, + "live": { + "air": "VZD", + "gnd": "POZ", + "ast": "ASI", + "dth": "ÚMR", + "cap": "OBS", + "squadronBattle": "Bitva perutí", + "randomBattle": "Náhodná bitva" + }, + "analytics": { + "pageTitle": "SQB Analytika", + "pageSubtitle": "Hluboká analytika pro jakoukoli squadru, hráče nebo vozidlo.", + "modeSquadron": "Squadra", + "modePlayer": "Hráč", + "modeVehicle": "Vozidlo", + "tabMaps": "Úspěšnost map", + "tabSquadmates": "Častí spoluhráči", + "tabComps": "Složení týmu", + "tabConsistency": "Konzistence hráče", + "tabTime": "Denní doba", + "tabMatchups": "Historie zápasů", + "pickSquadron": "Vyhledejte squadru pro zobrazení analýzy", + "pickPlayer": "Vyhledejte hráče pro zobrazení analýzy", + "pickVehicle": "Vyhledejte vozidlo pro zobrazení jeho analýzy", + "noData": "V tomto rozsahu nejsou žádná data.", + "loading": "Načítání…", + "loadError": "Nepodařilo se načíst analytiku.", + "compComingSoon": "Analýza složení týmu brzy.", + "compTopVehiclesTitle": "Nejčastější vozidla", + "compCompositionsTitle": "Opakující se sestavy zápasů", + "compCompositionsMeta": "Sestavy s alespoň {min} vozidly, seřazené podle počtu zápasů", + "compMatchesAnalyzed": "analyzovaných zápasů", + "compNoRepeats": "V tomto rozsahu nebyla nasazena sestava této velikosti.", + "compColVehicle": "Vozidlo", + "compColSpawns": "Nasazení", + "compColMatches": "Zápasy", + "compColShare": "% zápasů", + "compColLineup": "Sestava", + "compColTypes": "Komp", + "compTypeFighters": "Stíhačky", + "compTypeBombers": "Bombardéry", + "compTypeHelicopters": "Vrtulníky", + "compTypeLight": "Lehký tank", + "compTypeTanks": "Tanky", + "compTypeSPAA": "SPAA", + "compTypeSPAATooltip": "Protiletadlové/SPAA", + "compTypeUnknown": "Neznámé", + "compSearchPresetLabel": "Přednastavená komp.", + "compSearchPresetAll": "Všechny komp.", + "compSearchPresetHint": "Vytvořeno z historie komp. tohoto klanu", + "compSearchTypesLabel": "Typy", + "compTypeCapsHint": "Max 8 celkem · max 4 letadla (F + B + H)", + "compSearchRefineLabel": "Upřesnit", + "compRefineHint": "Nastavte počet výše a vyberte konkrétní vozidla pro daný typ.", + "compRefineAny": "Jakékoli {type}", + "compSearchCustomLabel": "Vlastní", + "compSearchAddVehicle": "Konkrétní vozidlo", + "compSearchVehiclesLabel": "Vozidla", + "compSearchApply": "Použít", + "compSearchReset": "Reset", + "compSearchMatches": "Zobrazeno {shown} z {total} komp.", + "compSearchNoMatches": "Žádná komp. neodpovídá filtru.", + "compSearchGamesShort": "her", + "colMap": "Mapa", + "colWins": "V", + "colLosses": "P", + "colWinRate": "%V", + "colBar": "", + "colShared": "Společně", + "colUid": "UID", + "colGames": "Hry", + "colPlayer": "Hráč", + "colAvgKills": "Prům. zabití", + "colAvgDeaths": "Prům. úmrtí", + "colScore": "Skóre", + "colHour": "Hodina (UTC)", + "colSquadron": "Squadra", + "colTotal": "Celkem", + "matchupsWonHeader": "Nejčastěji poraženi", + "matchupsLostHeader": "Nejčastější prohry s", + "uniqueOpponents": "unikátních soupeřů", + "euTimeslot": "EU čas", + "naTimeslot": "NA čas", + "offPeak": "Mimo špičku", + "radarMetaMaps": "top {shown} z {total} map · min {min} her", + "radarMetaSquadmates": "top {shown} z {total} spoluhráčů · min {min} společných her", + "radarTooFewMaps": "Nedostatek dat pro vykreslení grafu — v tomto filtru jsou potřeba alespoň 3 mapy s {min}+ hrami.", + "radarTooFewSquadmates": "Nedostatek dat pro vykreslení grafu — v tomto filtru jsou potřeba alespoň 3 spoluhráči s {min}+ společnými hrami.", + "radarFootnoteMaps": "{count} map s menším počtem her skryto — viz plná tabulka", + "radarFootnoteSquadmates": "{count} spoluhráčů s menším počtem her skryto — viz plná tabulka", + "tabTimeline": "Časová osa K/D", + "tabTopPlayers": "Nejlepší hráči", + "tabTopSquadrons": "Nejlepší squadry" + }, + "playerModal": { + "viewFullProfile": "Zobrazit celý profil →", + "close": "Zavřít", + "overview": "Přehled", + "vehicles": "Vozidla", + "sessions": "Sezení", + "loadingPlayerData": "Načítání dat hráče...", + "kdr": "K/D", + "kps": "K/S", + "winRate": "Úspěšnost výher", + "battles": "Bitvy", + "wins": "Výhry", + "totalBattles": "Bitvy celkem", + "totalKills": "Zničení celkem", + "airKills": "Vzdušná zničení", + "groundKills": "Pozemní zničení", + "assists": "Asistence", + "deaths": "Úmrtí", + "captures": "Obsazení", + "clickToSwitchMetric": "Kliknutím změnit metriku", + "clickToCycle": "kliknutím přepínat", + "noChartData": "Žádná data grafu", + "noVehicleData": "Žádná data vozidel", + "noSessionData": "Žádná data sezení", + "date": "Datum", + "vehicle": "Vozidlo", + "ground": "Země", + "air": "Vzduch", + "result": "Výsledek", + "unknown": "Neznámé", + "failedToLoadPlayerData": "Nepodařilo se načíst data hráče" + }, + "replay": { + "playPause": "Přehrát/Pauza", + "crashed": "havaroval", + "destroyed": "zničil", + "hit": "zasáhl" + }, + "dateFilter": { + "allTime": "Celé období", + "currentSeason": "Aktuální sezóna", + "bySeason": "Podle sezóny", + "cumulative": "Kumulativně", + "customRange": "Vlastní rozsah", + "selectSeason": "Vybrat sezónu", + "selectSeasonDots": "Vyber sezónu...", + "selectWeek": "Vybrat týden", + "selectWeekDots": "Vyber týden...", + "entireSeason": "Celá sezóna", + "applyFilter": "Použít filtr", + "cumulativeHelp": "Zobraz statistiky nasbírané do konkrétního bodu v čase", + "season": "Sezóna", + "upToWeek": "Do týdne", + "applyCumulativeFilter": "Použít kumulativní filtr", + "startDate": "Datum začátku", + "endDate": "Datum konce", + "applyCustomRange": "Použít vlastní rozsah", + "activeFilter": "Aktivní filtr:", + "clear": "Vymazat", + "allTimeStatistics": "Statistiky za celé období", + "currentSeasonValue": "Aktuální sezóna: {season}", + "alertSelectSeason": "Vyber sezónu", + "seasonValue": "Sezóna {season}", + "alertSelectSeasonWeek": "Vyber sezónu i týden", + "cumulativeValue": "Kumulativně do {season} - {week}", + "alertSelectDate": "Vyber alespoň jedno datum", + "alertStartBeforeEnd": "Datum začátku musí být před datem konce", + "customRangePrefix": "Vlastní rozsah:", + "fromDate": "Od {date}", + "upToDate": "Do {date}" + } +} diff --git a/web/locales/de.json b/web/locales/de.json new file mode 100644 index 0000000..192db42 --- /dev/null +++ b/web/locales/de.json @@ -0,0 +1,906 @@ +{ + "nav": { + "home": "Startseite", + "live": "Live", + "leaderboards": "Ranglisten", + "docs": "Dokumentation", + "terms": "Nutzungsbedingungen", + "premium": "Premium", + "support": "Support", + "addToDiscord": "Zu Discord hinzufügen", + "games": "Gefechte", + "squadrons": "Kampfgruppen", + "donate": "Spenden", + "analytics": "Analytik" + }, + "footer": { + "services": "Dienste", + "matchFeed": "Gefechtsfeed", + "vehicleStats": "Fahrzeugstatistiken", + "analytics": "Analysen", + "squadronHub": "Kampfgruppen-Wiki", + "comparison": "Vergleich", + "resources": "Ressourcen", + "documentation": "Dokumentation", + "inviteBot": "Bot einladen", + "legal": "Rechtliches", + "termsOfService": "Nutzungsbedingungen", + "privacyPolicy": "Datenschutzerklärung", + "termsAndPrivacy": "Nutzungsbedingungen & Datenschutz", + "meowing": "Miauen", + "websiteBy": "Website von", + "andToothless": "und Toothless" + }, + "common": { + "loading": "Lädt...", + "retry": "Erneut versuchen", + "backToHome": "Zurück zur Startseite", + "battles": "Gefechte", + "wins": "Siege", + "winRate": "Siegesrate", + "kills": "Abschüsse", + "totalKills": "Abschüsse insgesamt", + "groundKills": "Boden-Abschüsse", + "airKills": "Luft-Abschüsse", + "assists": "Abschusshilfen", + "deaths": "Tode", + "captures": "Eroberungen", + "kdr": "KDR", + "kps": "KPS", + "rank": "Rang", + "player": "Spieler", + "players": "Spieler", + "playersCount": "Spieler", + "vehicle": "Fahrzeug", + "vehicles": "Fahrzeuge", + "squadron": "Kampfgruppe", + "squadrons": "Kampfgruppen", + "statistics": "Statistiken", + "comparison": "Vergleichen", + "date": "Datum", + "result": "Ergebnis", + "totalBattles": "Gefechte insgesamt", + "totalWins": "Siege insgesamt", + "points": "Punkte", + "members": "Mitglieder", + "membersCount": "Mitglieder", + "rating": "Wertung", + "searchPlayerByName": "Spieler suchen...", + "noPlayersFound": "Keine Spieler gefunden", + "noSquadronsFound": "Keine Kampfgruppen gefunden", + "noVehiclesFound": "Keine Fahrzeuge gefunden", + "failedToLoad": "Daten konnten nicht geladen werden. Bitte später erneut versuchen.", + "recordingSince": "Daten werden seit dem 01.01.2026 erfasst", + "vs": "VS", + "map": "Karte" + }, + "home": { + "squadronBattles": "Kampfgruppengefechte", + "madeSimple": "Einfach gemacht", + "addToDiscord": "Zu Discord hinzufügen", + "learnMore": "Mehr erfahren", + "searchBySquadron": "NACH KAMPFGRUPPE SUCHEN", + "typeSquadronName": "Kampfgruppen suchen...", + "orByPlayer": "ODER NACH SPIELER", + "typePlayerName": "Spieler suchen...", + "liveFeed": "Gefechtsfeed", + "realTimeMatches": "Deine Gefechte finden", + "topPlayers": "Top-Spieler", + "vehicleStatsCard": "Fahrzeugstatistiken", + "performanceMetrics": "Leistungskennzahlen", + "analyticsCard": "Analysen", + "globalStatistics": "Globale Statistiken", + "squadronHubCard": "Kampfgruppen-Wiki", + "squadronStats": "Kampfgruppenstatistiken", + "comparisonCard": "Vergleich", + "compareStats": "Statistiken vergleichen", + "joinServers": "Schließe dich den 500+ Servern an, die unseren Bot zur Leistungsverfolgung nutzen", + "noSquadronsFound": "Keine Kampfgruppen gefunden", + "noPlayersFound": "Keine Spieler gefunden", + "searchPlayersIn": "Spieler suchen in", + "ctaElev8": "Bereit, deine Kampfgruppe zu ELEV8en?", + "ctaReign": "Bereit, wieder zu R3IGNen?", + "ctaMeow": "Miauu", + "ctaPurr": "Schnurr", + "ctaRawr": "Rawr" + }, + "docs": { + "title": "Dokumentation", + "subtitle": "Alles, was du wissen musst über", + "quickNavigation": "Schnellnavigation", + "gettingStarted": "Erste Schritte", + "commands": "Befehle", + "serverSetup": "Server-Einrichtung", + "features": "Funktionen", + "examples": "Beispiele", + "troubleshooting": "Fehlerbehebung", + "stackManager": "Stack-Manager", + "welcomeMessage": "Folge diesen Schritten, um loszulegen.", + "inviteTheBot": "Bot einladen", + "inviteBotDesc": "Klicke auf die Schaltfläche 'Zum Server hinzufügen' und wähle den Discord-Server deiner Kampfgruppe. Der Bot sendet eine Willkommensnachricht mit einem Hinweis, /setup auszuführen.", + "runSetupWizard": "Einrichtungsassistenten starten", + "setupWizardDesc": "Der Einrichtungsassistent führt dich durch die Konfiguration deiner Kampfgruppe, des Protokollkanals und des Punktekanals in einem Ablauf:", + "setupEasiest": "Dies ist die einfachste Möglichkeit, loszulegen. Er führt dich Schritt für Schritt durch die Einrichtung deiner Kampfgruppe und die Auswahl der Kanäle.", + "youreDone": "Fertig!", + "doneDesc": "Der Bot beginnt automatisch, Punkte- und Ranglisten-Updates zu posten. Verwende /autolog-management, um die Benachrichtigungseinstellungen später anzupassen.", + "premiumNote": "Automatische Spielprotokolle (vollständige Ergebnisanzeigen nach jedem Gefecht) erfordern ein Premium-Abonnement. Führe /unlock aus – 2,99 $/Monat pro Server, abgerechnet über Discord.", + "manualSetup": "Manuelle Einrichtung (Alternative)", + "manualSetupDesc": "Wenn du Dinge lieber einzeln konfigurierst, kannst du stattdessen diese Befehle verwenden:", + "allCommandsSlash": "Alle Befehle verwenden Discords Slash-Befehlssystem. Tippe /, um verfügbare Befehle anzuzeigen.", + "serverSetupAdmin": "Server-Einrichtung & Verwaltung", + "importantNote": "Wichtiger Hinweis", + "verifyFirst": "Immer zuerst überprüfen! Der Befehl /sq-info bestätigt, dass der Bot deine Kampfgruppe in der War Thunder-Datenbank finden kann, auch wenn du sehr niedrig gerankt bist.", + "cantFindSquadron": "Wenn der Bot deine Kampfgruppe mit /sq-info nicht finden kann, funktionieren die Einrichtungsbefehle nicht korrekt.", + "botNotResponding": "Bot antwortet nicht", + "checkOnline": "Prüfen, ob der Bot online ist (grüner Status)", + "verifyPermissions": "Überprüfen, ob der Bot die erforderlichen Berechtigungen hat", + "tryDifferentChannel": "Befehle in einem anderen Kanal ausprobieren", + "commandsNotWorking": "Befehle funktionieren nicht", + "ensureSlash": "Stelle sicher, dass du Slash-Befehle verwendest (beginnen mit /)", + "checkRolePerms": "Überprüfe, ob deine Rolle Berechtigung zur Nutzung von Bot-Befehlen hat", + "tryRefreshing": "Versuche, Discord zu aktualisieren oder die App neu zu starten", + "dataNotSaving": "Daten werden nicht gespeichert", + "verifySendMessages": "Überprüfe, ob der Bot die Berechtigung 'Nachrichten senden' hat", + "checkOutages": "Prüfe, ob es Discord-Ausfälle gibt", + "contactSupport": "Support kontaktieren, wenn das Problem anhält", + "needMoreHelp": "Weitere Hilfe benötigt?", + "needMoreHelpDesc": "Wenn du zusätzliche Unterstützung benötigst, wende dich gerne über unsere Support-Kanäle an uns.", + "example": "Beispiel", + "supportedLanguages": "Unterstützte Sprachen", + "setupDesc": "Schritt-für-Schritt-Assistent zur Konfiguration des Bots für deinen Server. Richtet deine Kampfgruppe, den Protokollkanal und den Punktekanal in einem Ablauf ein.", + "recommendedForNew": "Empfohlen für neue Server.", + "setSquadronDesc": "Eine Standard-Kampfgruppe für deinen Discord-Server festlegen. Wird für die Protokollierung und als Standard für andere Befehle verwendet.", + "quickLogDesc": "Einen Alarm für eine Kampfgruppe im aktuellen Kanal einrichten. Der Typ kann Logs, Points, Leaderboard oder Both sein – Both setzt Logs und Points zusammen in einem Befehl. Standard ist Logs.", + "quickLogPremiumNote": "Logs (automatische Spielprotokolle) erfordern ein Premium-Abonnement. Punkte- und Ranglisten-Alarme sind kostenlos.", + "autologDesc": "Autolog-Benachrichtigungen verwalten und Kanalberechtigungen diagnostizieren. Hiermit können Einstellungen nach der Ersteinrichtung geändert werden.", + "autologPremiumNote": "Automatische Spielprotokolle erfordern ein Premium-Abonnement.", + "diagnosePermsDesc": "Prüft sofort, ob der Bot die benötigten Berechtigungen im aktuellen Kanal hat, zeigt deine konfigurierten Autolog-Kanäle an und zeigt den Premium-Abonnementstatus dieses Servers an. Verwende dies, wenn Ergebnisanzeigen oder Punkte nicht gepostet werden.", + "squadronInformation": "Kampfgruppeninformationen", + "sqInfoDesc": "Detaillierte Informationen über eine Kampfgruppe anzeigen. Verwendet die Standard-Kampfgruppe deines Servers, wenn keines angegeben ist.", + "sqInfoGraphDesc": "Aktuelle Aufstellung als Säulendiagramm darstellen, aufgeteilt in Kern-, Aktiv- und Schwach-Gruppen nach Aktivität und Siegrate (aktuelle Saison).", + "compDesc": "Die zuletzt bekannten Zusammensetzungen für die Gefechte einer Kampfgruppe finden. Kostenlose Server erhalten 25 Abfragen pro Zeitfenster; Premium erhält unbegrenzt.", + "trackDesc": "Eine Kampfgruppe verfolgen und die Statistiken mit dem zuletzt gespeicherten Stand vergleichen.", + "topDesc": "Die Top-20-Kampfgruppen und ihre aktuellen Statistiken anzeigen.", + "sqStatsDesc": "Die Punkte einer Kampfgruppe über die Zeit als interaktives Diagramm anzeigen.", + "lossCalculatorDesc": "Berechnen, wie viele Punkte eine Kampfgruppe verlieren würde, wenn ausgewählte Spieler sie verlassen.", + "recentDesc": "Die letzten 5 Kampfgruppengefechte einer Kampfgruppe anzeigen.", + "vsDesc": "Den direkten Vergleich gegen eine andere Kampfgruppe anzeigen.", + "leaderboardLinkDesc": "Einen Link zur globalen Spieler-Rangliste von SRE Bot abrufen.", + "playerStats": "Spielerstatistiken", + "playerStatsDesc": "Detaillierte Fahrzeugstatistiken für einen Spieler mit interaktivem Fahrzeug-Dropdown anzeigen. Unterstützt Autovervollständigung.", + "viewPlayerGamesDesc": "Die letzten 20 Spiele eines Spielers anzeigen. Zeigt die insgesamten-Siege/Niederlagen-Anzahl und Siegesrate, eine Zusammenfassung pro Spiel (Ergebnis, gegnerische Kampfgruppe, Karte und gespielte Zusammensetzung) sowie eine deduplizierte Liste aller einzigartigen Zusammensetzungen, die der Spieler gespielt hat. Unterstützt Autovervollständigung.", + "viewMatchDesc": "Das vollständige Ergebnisboard für ein bestimmtes Gefecht anzeigen. Direkt eine Gefechts-ID angeben oder nach Spielernamen suchen, um die letzten 100 Spiele zu durchsuchen und eines auszuwählen. Enthält Schaltflächen für Replay ansehen, Pfade ansehen, Chat-Protokoll und Gefechtsbericht.", + "examples2": "Beispiele", + "compareDesc": "Aggregierte SQB-Statistiken zwischen zwei oder mehr Spielern vergleichen (bis zu 7). Zeigt einen direkten Vergleich, bei dem die besten Statistiken hervorgehoben sind. Enthält eine Diagramm-Schaltfläche, um den Punkteverlauf über 90 Tage anzuzeigen.", + "metaData": "Meta-Daten", + "metaManagementDesc": "Meta-Datenzugriffseinstellungen für deinen Server konfigurieren.", + "metaDesc": "Das Meta-Roster deiner Kampfgruppe nach Fahrzeugnamen durchsuchen.", + "settingsUtilities": "Einstellungen & Hilfsmittel", + "languageDesc": "Die Standardsprache des Bots ändern. Beeinflusst auch die Sprache der Fahrzeuge in deinen Gefechtsprotokolle.", + "scheduleDesc": "Den aktuellen Saisonplan nach Kampfwertung anzeigen. Zeigt den maximalen Kampfwertbereich jeder Woche mit Datumsbereich, Durchstreichung vergangener Wochen und Hervorhebung des aktuellen Zeitraums.", + "websiteDesc": "Einen Link zur SRE Bot-Website für Spielersuche, Ranglisten und mehr abrufen.", + "creditsDesc": "Das Team anzeigen, dem der Bau dieses Bots zu verdanken ist.", + "unlockDesc": "Automatische SQB-Spielprotokolle und unbegrenzte /comp-Abfragen für diesen Server freischalten. Mit einem Abonnement werden vollständige Ergebnisanzeigen nach jedem Gefecht automatisch in deinem konfigurierten Kanal gepostet. 2,99 $ / Monat · pro Server · jederzeit kündbar. Die Abrechnung erfolgt vollständig über Discord – kein externer Account erforderlich.", + "analyticsDesc": "Erweiterte SQB-Analysen: Karten-Siegesraten, Team-Zusammensetzungen, Spielerkonsistenz, Tageszeit-Leistung und Begegnungsverlauf (meiste Siege und Niederlagen gegen Gegner).", + "sqCardDesc": "Eine Saison-Rückblick-Karte (PNG) für eine Kampfgruppe erzeugen — Bewertungsverlauf, Siegesrate, Top-Spieler und mehr. Wähle die Saison aus der Autovervollständigung. Unterstützt helles und dunkles Design.", + "cardDesc": "Eine Saison-Rückblick-Karte (PNG) für einen Spieler erzeugen. Wähle die Saison aus der Autovervollständigung und den Spieler per Benutzername. Unterstützt helles und dunkles Design.", + "queryDesc": "[Nur Admin] Vordefinierte Datenbankabfragen ausführen — Kampfgruppen-Statistiken, Spielzahlen, aktivste Spieler, Top-Karten und mehr. Ergebnisse sind ephemer (nur für dich sichtbar).", + "donateDesc": "Die Entwicklung von SRE Bot über Ko-fi unterstützen.", + "botStatusDesc": "Zeigt, wann das letzte Spiel empfangen wurde, und die durchschn. TTL der letzten Spiele. Markiert langsame Gaijin-Server.", + "premiumBadge": "Premium", + "newsDesc": "Die neuesten SRE Bot-Neuigkeiten und Ankündigungen anzeigen.", + "stackCreateDesc": "Einen Spieler-Stack erstellen, um einen Trupp vor einem Gefecht zu koordinieren. Ein dauerhaftes Embed wird im Kanal gepostet, das aktuelle Mitglieder und ausstehende Bewerber anzeigt. Der Stack dauert bis zu 8 Stunden und wird am Ende jedes SQB-Zeitfensters automatisch bereinigt.", + "stackRequestToJoin": "Beitritt anfragen — Jeder Spieler kann sich mit dem Fahrzeug bewerben, das er fliegen möchte. Bewerbungen füllen bis zu 20 Slots.", + "stackLeaveWithdraw": "Verlassen / Zurückziehen — Mitglieder können den Stack verlassen; Bewerber können ihre Bewerbung zurückziehen. Der Stack-Anführer wird aufgefordert, die Eigentümerschaft zuerst zu übertragen.", + "stackManagePanel": "Stack verwalten ⚙️ — Nur für Anführer zugängliches Panel mit vier Abschnitten:", + "stackAcceptMembers": "Mitglieder annehmen — Bewerber einzeln oder alle auf einmal annehmen oder ablehnen. Bis zu 8 Mitglieder insgesamt.", + "stackRemoveMembers": "Mitglieder entfernen — Aktive Mitglieder oder wartende Bewerber entfernen. Optionen: Alle entfernen, Aktive entfernen, Wartende entfernen oder Ausgewählte aus einem Dropdown entfernen.", + "stackPingMembers": "Mitglieder anpingen — Anpingen mit einer optionalen benutzerdefinierten Nachricht. Optionen: Alle anpingen (Mitglieder + Warteschlange, ohne Anführer), Aktive anpingen (nur Mitglieder), Wartende anpingen (nur Bewerber) oder Ausgewählte aus einem Dropdown anpingen.", + "stackRenameStack": "Stack umbenennen — Einen benutzerdefinierten Namen für den Stack festlegen. Erscheint als Embed-Titel und in Ping-Nachrichten anstelle des Standards \"[Anführer]'s Stack\".", + "stackDisbandStack": "Stack auflösen — Der Anführer kann den Stack vorzeitig beenden.", + "stackManageDesc": "Postet dein aktives Stack-Embed erneut im aktuellen Kanal. Verwende dies, wenn das ursprüngliche Embed gelöscht wurde oder nach einem Bot-Neustart verloren gegangen ist. Alle bestehenden Mitglieder- und Warteschlangendaten bleiben erhalten.", + "translation": "Übersetzung", + "translateContextMenu": "Nachricht rechtsklicken → Apps → Nachricht übersetzen", + "translateDesc": "Jede Nachricht über das Kontextmenü von Discord übersetzen. Rechtsklicke (oder halte auf Mobilgeräten gedrückt) eine Nachricht und wähle Apps → Nachricht übersetzen.", + "viewAllLanguages": "Alle unterstützten Sprachen anzeigen", + "serverSetupSubtitle": "{botName} für optimale Leistung im Discord-Server deiner Kampfgruppe konfigurieren.", + "requiredPermissions": "Erforderliche Berechtigungen", + "sendMessages": "Nachrichten senden", + "useSlashCommands": "Slash-Befehle verwenden", + "embedLinks": "Links einbetten", + "readMessageHistory": "Nachrichtenverlauf lesen", + "recommendedChannelSetup": "Empfohlene Kanaleinrichtung", + "recommendedChannelDesc": "Erstelle einen dedizierten Kanal wie #squadron-battles für Verfolgung und Statistiken. Dies hält deine Gefechtsdaten organisiert und leicht zugänglich.", + "roleConfiguration": "Rollenkonfiguration", + "roleConfigurationDesc": "Weise geeignete Rollen den Kampfgruppenmitgliedern zu, die Gefechtergebnisse aufzeichnen können. Wir empfehlen, dies auf Kampfgruppenoffiziere und -anführer zu beschränken.", + "premiumSectionSubtitle": "Automatische SQB-Spielprotokolle sind eine Premium-Funktion, die pro Server über Discords natives Abonnementsystem freigeschaltet wird.", + "whatsIncluded": "Was ist enthalten", + "premiumInclude1": "Vollständiges Ergebnisboard wird automatisch nach jedem SQB-Gefecht in deinem konfigurierten Kanal gepostet", + "premiumInclude2": "Unbegrenzte /comp-Abfragen (kostenlose Server erhalten 25 pro Zeitfenster)", + "premiumInclude3": "Alle bestehenden kostenlosen Funktionen (Punktalarme, Rangliste, Statistik-Befehle usw.) bleiben kostenlos", + "pricingBilling": "Preise & Abrechnung", + "pricingBillingDesc": "2,99 $ / Monat · pro Server · jederzeit kündbar. Die Abrechnung wird vollständig über Discord abgewickelt – kein externer Account oder Zahlungsanbieter erforderlich. Abonnements verlängern sich automatisch und können jederzeit in deinen Discord-Einstellungen gekündigt werden.", + "howToSubscribe": "So abonnierst du", + "subscribe1": "Führe /unlock auf deinem Server aus (Server-Administrator erforderlich)", + "subscribe2": "Klicke auf die Abonnieren-Schaltfläche in der Antwort des Bots", + "subscribe3": "Checkout innerhalb von Discord abschließen – der Bot aktiviert sich sofort", + "cancellation": "Kündigung", + "cancellationDesc": "Wenn ein Abonnement ausläuft oder gekündigt wird, hört der Bot automatisch auf, Spielprotokolle für diesen Server beim nächsten Autolog-Zyklus zu posten. Es ist keine manuelle Aktion erforderlich.", + "realTimeStatistics": "Echtzeit-Statistiken", + "realTimeStatisticsDesc": "Siege, Niederlagen und Leistungskennzahlen in Echtzeit verfolgen.", + "battleHistory": "Gefechtsverlauf", + "battleHistoryDesc": "Die letzten 5 Kämpfe eines Staffels anzeigen.", + "leaderboardsFeature": "Ranglisten", + "leaderboardsFeatureDesc": "Die Leistung deiner Kampfgruppe mit anderen mit /top vergleichen.", + "playerTracking": "Spielerverfolgung", + "playerTrackingDesc": "Individuelle Spielerstatistiken und ThunderSkill-Integration.", + "smartAlerts": "Intelligente Alarme", + "smartAlertsDesc": "Automatisierte Alarme und Benachrichtigungen für Kampfgruppenaktivitäten.", + "multiLanguageSupport": "Mehrsprachige Unterstützung", + "multiLanguageSupportDesc": "Übersetzungsfunktionen und anpassbare Fahrzeugsprachen.", + "timeCoordination": "Zeitkoordination", + "timeCoordinationDesc": "UTC- und Ortszeit-Tools für globale Kampfgruppenkoordination.", + "advancedSearch": "Erweiterte Suche", + "advancedSearchDesc": "Spielerwettbewerbe und detaillierte Kampfgruppeninformationen finden.", + "dataSecurity": "Datensicherheit", + "dataSecurityDesc": "Deine Daten sind sicher und werden niemals weitergegeben oder verkauft.", + "usageExamples": "Anwendungsbeispiele", + "quickSetupRecommended": "Schnelleinrichtung (Empfohlen)", + "quickSetupDesc": "Den Einrichtungsassistenten starten. Er führt dich durch die Einrichtung deiner Kampfgruppe, die Auswahl eines Protokollkanals und die Auswahl eines Punktekanals – alles in einem Ablauf.", + "comparingPlayers": "Spieler vergleichen", + "comparingPlayersDesc": "Bis zu 7 Spieler direkt vergleichen. Die beste Statistik in jeder Kategorie ist hervorgehoben, und der insgesamt beste Spieler erhält einen Stern. Klicke auf 'Diagramm anzeigen', um den Punkteverlauf zu sehen.", + "checkingSquadronInfo": "Kampfgruppeninfo prüfen", + "verifySquadronDesc": "Überprüfen, ob eine Kampfgruppe existiert und seine Details anzeigt werden. Hiermit kannst du bestätigen, dass der Bot deine Kampfgruppe vor der Einrichtung finden kann.", + "recentBattlesDesc": "Die letzten 5 Gefechte einer Kampfgruppe anzeigen.", + "headToHeadDesc": "Den direkten Vergleich gegen eine andere Kampfgruppe anzeigen.", + "backToHome": "Zurück zur Startseite", + "termsAndPrivacy": "Nutzungsbedingungen & Datenschutz", + "seasonRecapCardTitle": "Saison-Rückblick-Karte", + "seasonRecapCardDesc": "Erstelle eine teilbare PNG-Zusammenfassung der Saison jeder Staffel — Ratingkurve, gleitende Siegesrate, K/D, Top-Fahrzeug, MVP und mehr. Erreichbar über die Schaltfläche „Saison-Karte“ auf jeder Staffel-Profilseite.", + "playerRecapCardTitle": "Spieler-Saisonrückblick", + "playerRecapCardDesc": "Erstelle eine teilbare PNG-Zusammenfassung der Saison eines Spielers — Rating-Verlauf über Squadrons hinweg, gleitende Siegesrate, K/D, Schlachtfrequenz, bestes Match und mehr. Verfügbar über die Schaltfläche „Season Recap“ auf jeder Spielerprofilseite.", + "tierOverview": "Tier-Übersicht", + "tierOverviewDesc": "Auto-Logging ist in drei Tarifen verfügbar. Jeder Tarif begrenzt die Anzahl der Geschwader mit aktivierten Logs und Points. Geschwader über dem Limit bleiben in den Einstellungen erhalten und werden nach einem Upgrade automatisch fortgesetzt.", + "tierStandardLine": "$2.99 — bis zu 10 Geschwader jeweils für Logs und Points, ohne Wildcard-Support.", + "tierProLine": "bis zu 25 Geschwader jeweils für Logs und Points, plus Wildcard-Support (`*` / `all` / `everything`).", + "tierMaxLine": "unbegrenzte Geschwader, Wildcards, früher Zugang zu neuen Funktionen." + }, + "terms": { + "pageTitle": "Nutzungsbedingungen & Datenschutzerklärung", + "lastUpdated": "Zuletzt aktualisiert: April 2026", + "termsOfService": "Nutzungsbedingungen", + "byUsing": "Durch die Nutzung von", + "youAgree": " stimmst du folgendem zu:", + "useResponsibly": "Verantwortungsvoller Umgang", + "useResponsiblyDesc": "Versuche nicht, den Bot zu überlasten, zu spammen oder anderweitig zu beschädigen.", + "noFunnyBusiness": "Kein Missbrauch", + "noFunnyBusinessDesc": "Versuche nicht, Reverse Engineering zu betreiben oder gegen die Discord-Nutzungsbedingungen oder andere Server-Regeln zu verstoßen.", + "statsAsIs": "Statistiken ohne Gewähr", + "statsAsIsDesc": "Alle Daten und Statistiken werden \"wie besehen\" ohne jegliche Gewährleistung bereitgestellt, weder ausdrücklich noch stillschweigend. Wir unternehmen angemessene Anstrengungen, um die Genauigkeit sicherzustellen, garantieren jedoch nicht, dass Informationen vollständig, aktuell oder fehlerfrei sind.", + "uptimeNotGuaranteed": "Betriebszeit nicht garantiert", + "uptimeNotGuaranteedDesc": "Der Bot wird auf einer \"wie verfügbar\"-Basis bereitgestellt. Wir garantieren keinen ununterbrochenen oder fehlerfreien Betrieb. Der Dienst kann aufgrund von Wartung, Updates oder technischen Problemen vorübergehend nicht verfügbar sein.", + "weCanBanYou": "Wir können dich sperren", + "weCanBanYouDesc": "Wenn du die Regeln verletzt, können wir deinen Zugang entziehen.", + "privacyPolicy": "Datenschutzerklärung", + "infoWeCollect": "1. Gesammelte Informationen", + "collectsFollowing": "erfasst folgende Informationen, um seine Dienste bereitzustellen:", + "discordUserIds": "Discord-Benutzer-IDs:", + "discordUserIdsDesc": "Eindeutige Kennungen zur Verfolgung einzelner Benutzer", + "squadronIds": "Kampfgruppen-IDs:", + "squadronIdsDesc": "Discord-Server-/Gilde-Kennungen zur Organisation von Kampfgruppendaten", + "battleData": "Gefechtsdaten:", + "battleDataDesc": "Sieg/Niederlage-Aufzeichnungen, Gefechtszeitstempel und zugehörige Statistiken", + "commandUsage": "Befehlsverwendung:", + "commandUsageDesc": "Grundlegende Protokollierung verwendeter Bot-Befehle zur Serviceverbesserung", + "howWeUse": "2. Verwendung deiner Informationen", + "usedExclusively": "Die gesammelten Informationen werden ausschließlich verwendet für:", + "trackingPerformance": "Verfolgung der Kampfgruppengefecht-Leistung und -Statistiken", + "providingHistorical": "Bereitstellung von historischen Daten und Analysen", + "improvingBot": "Verbesserung der Bot-Funktionalität und Benutzererfahrung", + "troubleshootingIssues": "Behebung technischer Probleme", + "dataStorage": "3. Datenspeicherung und -sicherheit", + "dataStoredSecurely": "Deine Daten werden sicher mit folgenden Schutzmaßnahmen gespeichert:", + "encryptedServers": "Daten werden auf sicheren Servern mit Verschlüsselung gespeichert", + "limitedAccess": "Zugang ist auf autorisiertes Personal beschränkt", + "regularBackups": "Regelmäßige Backups gewährleisten die Datenintegrität", + "dataSharing": "4. Datenweitergabe", + "weDoNot": "Wir tun NICHT:", + "sellData": "Deine persönlichen Informationen an Dritte verkaufen", + "shareData": "Deine Daten mit externen Organisationen teilen", + "useForAds": "Deine Daten für Werbe- oder Marketingzwecke verwenden", + "transferData": "Deine Daten außerhalb unserer sicheren Systeme übertragen", + "dataRetention": "5. Datenspeicherdauer", + "dataRetentionDesc": "Wir bewahren deine Daten so lange auf, wie es für die Bereitstellung unserer Dienste erforderlich ist. Gefechtstatistiken und Kampfgruppendaten werden aufbewahrt, um historische Aufzeichnungen und Analysen zu pflegen.", + "discordIntegration": "6. Discord-Integration", + "discordIntegrationDesc": "Dieser Bot arbeitet innerhalb des Discord-Ökosystems und unterliegt Discords Datenschutzerklärung. Wir greifen nur auf Informationen zu, die für die Bot-Funktionalität über Discords offizielle API notwendig sind.", + "ageRestrictions": "7. Altersbeschränkungen", + "ageRestrictionsDesc": "Der Bot richtet sich an Benutzer, die Discords Mindestalteranforderungen erfüllen (13+ oder gemäß lokalem Recht).", + "changesToPolicy": "8. Änderungen der Datenschutzerklärung", + "changesToPolicyDesc": "Wir können diese Datenschutzerklärung von Zeit zu Zeit aktualisieren.", + "premiumTitle": "Premium-Abonnementbedingungen", + "premiumWhatYouGet": "1. Was Premium beinhaltet", + "premiumWhatYouGetDesc": "Ein Premium-Abonnement schaltet die folgenden Funktionen für den abonnierten Discord-Server frei:", + "premiumFeature1": "Automatische Scoreboard-Beiträge nach jedem SQB-Gefecht", + "premiumFeature2": "Pfad-/Bewegungskarten", + "premiumFeature3": "Chat- und Gefechtsprotokolle", + "premiumFeature4": "Replay-Suche", + "premiumFeature5": "Unbegrenzte /comp-Abfragen (kostenlose Server sind auf 15 pro Zeitfenster beschränkt)", + "premiumBilling": "2. Abrechnung und Zahlung", + "premiumBillingDesc": "Premium wird mit $2,99 USD pro Monat pro Discord-Server abgerechnet. Die Zahlung wird über das native Abonnementsystem von Discord oder über unsere Website via Whop abgewickelt. Sie sind dafür verantwortlich, dass Ihre Zahlungsmethode gültig ist und über ausreichende Mittel verfügt. Abonnements verlängern sich am Ende jedes Abrechnungszeitraums automatisch, sofern sie nicht gekündigt werden.", + "premiumCancellation": "3. Kündigung", + "premiumCancellationDesc": "Sie können Ihr Abonnement jederzeit kündigen. Für Discord-Abonnements gehen Sie zu Benutzereinstellungen → Abonnements in Discord. Für Website-Abonnements verwalten Sie Ihre Abrechnung unter whop.com/billing. Nach der Kündigung bleiben Ihre Premium-Funktionen bis zum Ende des aktuellen Abrechnungszeitraums aktiv. Danach kehrt Ihr Server zur kostenlosen Stufe zurück — keine Daten gehen verloren.", + "premiumRefunds": "4. Erstattungen", + "premiumRefundsDesc": "Abonnementgebühren werden nicht erstattet. Eine anteilige Berechnung für Teilmonate erfolgt nicht. Wenn Sie mitten im Zyklus kündigen, behalten Sie den Zugang bis zum Ende des Abrechnungszeitraums, haben jedoch keinen Anspruch auf Erstattung der verbleibenden Zeit. Erstattungen bei Abrechnungsfehlern oder doppelten Abbuchungen können nach unserem Ermessen erfolgen — kontaktieren Sie den Support mit Ihrer Discord-Server-ID und einem Zahlungsnachweis.", + "premiumPriceChanges": "5. Preisänderungen", + "premiumPriceChangesDesc": "Wir behalten uns das Recht vor, die Abonnementpreise jederzeit zu ändern. Bestehende Abonnenten werden mindestens 30 Tage vor Inkrafttreten einer Preiserhöhung benachrichtigt. Wenn Sie mit einer Preisänderung nicht einverstanden sind, können Sie vor Inkrafttreten des neuen Preises kündigen.", + "premiumTermination": "6. Beendigung des Premium-Zugangs", + "premiumTerminationDesc": "Wir behalten uns das Recht vor, den Premium-Zugang ohne Erstattung zu widerrufen, wenn ein Server gegen diese Nutzungsbedingungen verstößt, einschließlich, aber nicht beschränkt auf den Missbrauch von Bot-Funktionen, Versuche, Nutzungsbeschränkungen zu umgehen, oder Verstöße gegen die Discord-Nutzungsbedingungen.", + "serviceAvailabilityTitle": "Dienstverfügbarkeit und Haftung", + "serviceNoWarranty": "1. Keine Gewährleistung", + "serviceNoWarrantyDesc": "DER DIENST WIRD \"WIE BESEHEN\" UND \"WIE VERFÜGBAR\" OHNE GEWÄHRLEISTUNG JEGLICHER ART BEREITGESTELLT, OB AUSDRÜCKLICH, STILLSCHWEIGEND ODER GESETZLICH, EINSCHLIESSLICH, ABER NICHT BESCHRÄNKT AUF STILLSCHWEIGENDE GEWÄHRLEISTUNGEN DER MARKTGÄNGIGKEIT, EIGNUNG FÜR EINEN BESTIMMTEN ZWECK UND NICHTVERLETZUNG. Wir übernehmen keine Garantie dafür, dass der Dienst ununterbrochen, rechtzeitig, sicher oder fehlerfrei ist.", + "serviceLiability": "2. Haftungsbeschränkung", + "serviceLiabilityDesc": "IM MAXIMAL GESETZLICH ZULÄSSIGEN UMFANG HAFTEN WIR IN KEINEM FALL FÜR INDIREKTE, ZUFÄLLIGE, BESONDERE, FOLGE- ODER STRAFSCHÄDEN ODER FÜR ENTGANGENE GEWINNE ODER EINNAHMEN, OB DIREKT ODER INDIREKT ENTSTANDEN, ODER FÜR JEGLICHEN VERLUST VON DATEN, NUTZUNG, GESCHÄFTSWERT ODER ANDEREN IMMATERIELLEN VERLUSTEN, DIE SICH AUS IHRER NUTZUNG ODER UNMÖGLICHKEIT DER NUTZUNG DES DIENSTES ERGEBEN. Unsere Gesamthaftung für Ansprüche aus diesen Bedingungen oder dem Dienst übersteigt nicht den Betrag, den Sie uns in den drei (3) Monaten vor der Geltendmachung des Anspruchs gezahlt haben.", + "serviceCredits": "3. Dienstunterbrechungen und Gutschriften", + "serviceCreditsDesc": "Im Falle eines längeren, ungeplanten Dienstausfalls, der Premium-Funktionen über mehr als 72 aufeinanderfolgende Stunden betrifft, können betroffene Premium-Abonnenten eine Dienstgutschrift durch Kontaktaufnahme mit dem Support anfordern. Gutschriften werden nach unserem alleinigen Ermessen erteilt und als Verlängerung des aktuellen Abrechnungszeitraums angewendet — nicht als Gelderstattung. Geplante Wartungsfenster, Ausfälle von Drittanbietern (Discord, Gaijin API) und Ereignisse außerhalb unserer angemessenen Kontrolle sind ausgenommen.", + "serviceForceM": "4. Höhere Gewalt", + "serviceForceMDesc": "Wir haften nicht für Versäumnisse oder Verzögerungen bei der Leistungserbringung, die auf Ursachen zurückzuführen sind, die außerhalb unserer angemessenen Kontrolle liegen, einschließlich, aber nicht beschränkt auf: Naturkatastrophen, Krieg, Terrorismus, Pandemien, Stromausfälle, Internetunterbrechungen, Ausfälle der Discord-Plattform, Änderungen oder Nichtverfügbarkeit der Gaijin Entertainment API, behördliche Maßnahmen oder andere Ereignisse höherer Gewalt.", + "disclaimer": "Haftungsausschluss", + "warThunderDisclaimer": "ist ein unabhängiger Discord-Bot und steht in keiner Verbindung zu Gaijin Entertainment oder War Thunder und wird von diesen weder unterstützt noch offiziell empfohlen. War Thunder ist eine Marke von Gaijin Entertainment.", + "acknowledgement": "Durch die Nutzung von", + "acknowledgementEnd": " bestätigst du, dass du diese Nutzungsbedingungen und Datenschutzerklärung gelesen, verstanden hast und dich daran gebunden siehst." + }, + "premium": { + "upgradeTitle": "Deine Kampfgruppe aufrüsten", + "heroDesc": "Jedes SQB-Ergebnis wird sofort nach Ende in deinen Kanal gepostet – Ergebnisanzeigen, Gefechtsprotokolle, Bewegungskarten und Replays, vollautomatisch.", + "instantScoreboards": "Sofortige Ergebnisanzeigen", + "viewPaths": "Pfade anzeigen", + "chatBattleLogs": "Chat- & Gefechtsprotokolle", + "replayLookups": "Replay-Suche", + "free": "Kostenlos", + "perMonth": "/Monat", + "alwaysFree": "Immer kostenlos", + "noCardNeeded": "keine Karte erforderlich", + "included": "Enthalten", + "manualLookups": "Manuelle Spielsuche", + "playerStats": "Spielerstatistiken & -profile", + "leaderboards": "Ranglisten", + "stickWithFree": "Kostenlos bleiben", + "premiumLabel": "Premium", + "perServer": "pro Server", + "cancelAnytime": "jederzeit kündbar", + "everythingInFree": "Alles in Kostenlos, plus", + "autoScoreboards": "Automatische Ergebnisanzeigen", + "pathMaps": "Pfad-/Bewegungskarten", + "chatLogs": "Chat- & Gefechtsprotokolle", + "replayLookupsFeature": "Replay-Suche", + "unlimitedComp": "Unbegrenzte /comp-Abfragen", + "prioritySupport": "Prioritätssupport", + "subscribeNow": "Jetzt abonnieren", + "comingSoon": "Demnächst verfügbar", + "serverIdInfo": "Du benötigst deine", + "discordServerId": "Discord-Server-ID", + "duringCheckout": "beim Checkout.", + "developerMode": "Entwicklermodus", + "rightClickServer": "Server rechtsklicken", + "copyId": "ID kopieren", + "successTitle": "Premium aktiviert", + "successDesc": "Dein Abonnement wird eingerichtet. Der Bot hat innerhalb weniger Minuten Premium-Zugang für deinen Server.", + "whatHappensNext": "Was als nächstes passiert", + "autoLogging": "Auto-Protokollierung wird für deinen Server aktiviert", + "setLogChannel": "Protokollkanal festlegen mit", + "everyResult": "Jedes SQB-Ergebnis wird automatisch gepostet", + "readSetupGuide": "Einrichtungsanleitung lesen", + "tierStandardName": "Standard", + "tierProName": "Pro", + "tierMaxName": "Max", + "squadCap": "Bis zu {cap} Geschwader loggen", + "squadCapUnlimited": "Unbegrenzt viele Geschwader loggen", + "everythingInStandard": "Alles aus Standard", + "everythingInPro": "Alles aus Pro", + "wildcardSupport": "Wildcard-Logging (*, all, everything)", + "noSquadCap": "Kein Geschwader-Limit", + "earlyAccessFeatures": "Früher Zugang zu neuen Funktionen" + }, + "player": { + "totalBattles": "Gefechte insgesamt", + "totalWins": "Siege insgesamt", + "vehicleStatistics": "Fahrzeugstatistiken", + "cumulative": "Kumulativ", + "individual": "Einzeln", + "filterBy": "Filtern nach:", + "allTime": "Gesamtzeit", + "dateRange": "Datumsbereich", + "season": "Saison", + "week": "Woche", + "session": "Sitzung", + "dateType": "Datumstyp:", + "last7Days": "Letzte 7 Tage", + "last30Days": "Letzte 30 Tage", + "last90Days": "Letzte 90 Tage", + "customRange": "Benutzerdefinierter Bereich", + "specificDate": "Bestimmtes Datum", + "filterType": "Filtertyp:", + "fullSeason": "Gesamte Saison", + "specificWeek": "Bestimmte Woche", + "from": "Von:", + "to": "Bis:", + "timeslot": "Zeitfenster", + "fullDay": "Ganzer Tag", + "selectSeason": "Saison:", + "selectWeek": "Woche:", + "selectSeasonFirst": "Zuerst Saison auswählen", + "pleaseSelect": "Bitte eine Option auswählen", + "searchVehicles": "Fahrzeuge suchen...", + "resetFilters": "Filter zurücksetzen", + "vehiclesShown": "Fahrzeuge angezeigt", + "gamesShown": "Spiele angezeigt", + "noVehicleData": "Keine Fahrzeugdaten verfügbar", + "noVehiclesForRange": "Keine Fahrzeuge für den ausgewählten Datumsbereich gefunden, oder dieser Spieler hat noch keine Daten.", + "switchToCards": "Zur Kartenansicht wechseln", + "switchToTable": "Zur Tabellenansicht wechseln", + "loadingTimeline": "Zeitachse wird geladen...", + "noTimelineData": "Noch keine Zeitachsendaten vorhanden.", + "timelineUnavailable": "Zeitachse nicht verfügbar.", + "loadingGameRecords": "Spielaufzeichnungen werden geladen...", + "unableToLoadRecords": "Spielaufzeichnungen konnten nicht geladen werden", + "failedToFetch": "Spieldaten konnten nicht abgerufen werden. Bitte später erneut versuchen.", + "noGameRecords": "Keine Spielaufzeichnungen gefunden", + "noGamesYet": "Dieser Spieler hat noch keine aufgezeichneten Spiele gespielt.", + "collapseChart": "Diagramm einklappen", + "loadingChartData": "Diagrammdaten werden geladen...", + "noHistoricalData": "Noch keine historischen Daten vorhanden.", + "chartUnavailable": "Diagramm nicht verfügbar.", + "relative": "Relativ", + "uidLabel": "Spieler-UID" + }, + "squadrons": { + "title": "Kampfgruppen-Wiki", + "subtitle": "Kampfgruppen entdecken, Statistiken anzeigen und Leistung verfolgen", + "findSquadron": "Eine Kampfgruppe finden", + "searchPlaceholder": "Kampfgruppe suchen ...", + "totalSquadrons": "insgesamte anzahl an Kampfgruppen", + "totalPlayers": "insgesamte anzahl an Spieler", + "totalBattles": "insgesamte anzahl an Gefechten", + "avgWinRate": "Durchschn. Siegesrate", + "topSquadrons": "Top-Kampfgruppen", + "viewFullLeaderboard": "Vollständige Rangliste anzeigen", + "loadingSquadrons": "Kampfgruppendaten werden geladen...", + "noSquadronData": "Noch keine Kampfgruppendaten verfügbar.", + "failedToLoad": "Kampfgruppendaten konnten nicht geladen werden. Bitte später erneut versuchen.", + "backToSquadronHub": "Zurück zum Kampfgruppen-Wiki", + "squadronPoints": "Kampfgruppenpunkte", + "squadronMembers": "Kampfgruppenmitglieder", + "performance": "Leistung", + "performanceNoData": "Für den ausgewählten Bereich sind keine Leistungsdaten verfügbar.", + "quickDetails": "Kurzübersicht", + "noMembersFound": "Keine Mitglieder gefunden", + "noRecordedMembers": "Diese Kampfgruppe hat noch keine aufgezeichneten Mitglieder.", + "squadronGames": "Staffelspiele", + "loadingSquadronGames": "Staffel-Spielaufzeichnungen werden geladen...", + "noSquadronGames": "Keine Spielaufzeichnungen für diese Staffel gefunden.", + "retryLoadGames": "Erneut versuchen", + "searchMapPlaceholder": "Nach Karte suchen..." + }, + "leaderboard": { + "playersTitle": "Spieler-Rangliste", + "playersSubtitle": "Top War Thunder-Spieler nach Leistung bewertet", + "vehiclesTitle": "Fahrzeugabschuss-Rangliste", + "vehiclesSubtitle": "Top War Thunder-Fahrzeuge nach insgesamten anzahl abschüssen bewertet", + "squadronsTitle": "Kampfgruppen-Rangliste", + "squadronsSubtitle": "Top War Thunder-Kampfgruppen nach Leistung bewertet", + "statsTitle": "Globale Statistiken", + "statsSubtitle": "Allgemeine Kampfgruppengefechtstatistiken und Meta-Informationen", + "comparisonTitle": "Vergleichstool", + "comparisonSubtitle": "Spieler und Fahrzeuge direkt vergleichen", + "comparisonHint": "Statistiken vergleichen, um die besten Leistungsträger und Fahrzeuge zu finden", + "compareSquadrons": "Kampfgruppen vergleichen", + "comparePlayers": "Spieler vergleichen", + "compareVehicles": "Fahrzeuge vergleichen", + "playersAndVehicles": "Spieler + Fahrzeuge", + "failedToLoadLeaderboard": "Ranglisten-Daten konnten nicht geladen werden. Bitte später erneut versuchen.", + "failedToLoadVehicles": "Fahrzeug-Rangliste konnte nicht geladen werden", + "failedToLoadSquadrons": "Kampfgruppen-Rangliste konnte nicht geladen werden", + "noResultsYet": "Noch keine Ergebnisse. Kampfgruppen/Spieler hinzufügen, um zu beginnen.", + "searchSquadron": "Kampfgruppe suchen", + "searchBySquadronName": "Nach Kampfgruppenname suchen...", + "minPlayers": "Mindestspieler", + "minPlayersPlaceholder": "Mindestspieler", + "resetFilters": "Filter zurücksetzen", + "squadronsShown": "Kampfgruppen angezeigt", + "playersShown": "Spieler angezeigt", + "page": "Seite", + "of": "von", + "loadingSquadronLeaderboard": "Kampfgruppen-Rangliste wird geladen...", + "loadingPlayerLeaderboard": "Spieler-Rangliste wird geladen...", + "loadingComparisonData": "Vergleichsdaten werden geladen...", + "unableToFetch": "Ranglisten-Daten konnten nicht abgerufen werden. Bitte erneut versuchen.", + "noSquadronsInLeaderboard": "Keine Kampfgruppe in der Rangliste gefunden.", + "noPlayersInLeaderboard": "Keine Spieler in der Rangliste gefunden.", + "loadingGlobalStats": "Globale Statistiken werden geladen...", + "failedToLoadStats": "Statistiken konnten nicht geladen werden", + "unableToFetchStats": "Statistikdaten konnten nicht abgerufen werden. Bitte erneut versuchen.", + "mostPopularVehicles": "Beliebteste Fahrzeuge", + "vehicleKillsLeaderboardTitle": "Fahrzeugabschuss-Rangliste", + "avgWinRate": "Durchschn. Siegesrate", + "avgKillsPerPlayer": "Durchschn. Abschüsse/Spieler", + "loadingVehicleKills": "Fahrzeugabschuss-Daten werden geladen...", + "apiNotLoaded": "API-Client nicht ordnungsgemäß geladen. Bitte Seite aktualisieren.", + "failedToInitApi": "API-Client konnte nicht initialisiert werden", + "noStatsData": "Keine Statistikdaten verfügbar", + "totalPlayersCard": "Spieler insgesamt", + "activePlayers": "Aktive Spieler", + "vehiclesUsed": "Verwendete Fahrzeuge", + "differentVehicles": "Verschiedene Fahrzeuge", + "squadronBattlesLabel": "Kampfgruppengefechte", + "noVehicleData": "Keine Fahrzeugdaten verfügbar", + "mostPopular": "Beliebteste", + "timesUsed": "Mal verwendet", + "failedToLoadVehicleKills": "Fahrzeugabschuss-Daten konnten nicht geladen werden", + "lastUpdated": "Zuletzt aktualisiert", + "searchPlayer": "Spieler suchen", + "searchByPlayerName": "Nach Spielername suchen...", + "minimumBattles": "Mindestgefechte", + "minBattlesPlaceholder": "Mindestgefechte", + "searchSquadronsPlaceholder": "Kampfgruppe suchen...", + "sortBy": "Sortieren nach", + "kdRatio": "KD-Verhältnis", + "killsPerSpawn": "Abschüsse pro Spawn", + "caps": "Eroberungen", + "timePeriod": "Zeitraum", + "allTime": "Gesamtzeit", + "dateRange": "Datumsbereich", + "season": "Saison", + "week": "Woche", + "dateType": "Datumstyp", + "last7Days": "Letzte 7 Tage", + "last30Days": "Letzte 30 Tage", + "last90Days": "Letzte 90 Tage", + "customRange": "Benutzerdefinierter Bereich", + "from": "Von", + "to": "Bis", + "timeslot": "Zeitfenster", + "fullDay": "Ganzer Tag", + "selectSeason": "Saison auswählen...", + "selectWeek": "Woche auswählen...", + "failedToLoadComparison": "Vergleichsdaten konnten nicht geladen werden", + "pleaseRefresh": "Bitte versuche, die Seite zu aktualisieren", + "playerComparison": "Spielervergleich", + "squadronComparison": "Kampfgruppenvergleich", + "vehicleComparison": "Fahrzeugvergleich", + "playersVehiclesComparison": "Spieler + Fahrzeuge-Vergleich", + "addPlayersToCompare": "Spieler zum Vergleich hinzufügen:", + "addSquadronsToCompare": "Kampfgruppe zum Vergleich hinzufügen:", + "addVehiclesToCompare": "Fahrzeuge zum Vergleich hinzufügen:", + "addPlayerVehicleCombos": "Spieler + Fahrzeug-Kombinationen hinzufügen:", + "searchSelectPlayers": "Spieler suchen und auswählen...", + "typeSquadronName": "Kampfgruppennamen eingeben...", + "searchSelectVehicles": "Fahrzeuge suchen und auswählen...", + "searchForPlayers": "Nach Spielern suchen...", + "selectPlayersToCompare": "Oben Spieler suchen und auswählen, um ihre Statistiken zu vergleichen", + "selectSquadronsToCompare": "Oben Kampfgruppe suchen und auswählen, um ihre Statistiken zu vergleichen", + "selectVehiclesToCompare": "Oben Fahrzeuge suchen und auswählen, um ihre Statistiken zu vergleichen", + "selectPlayersVehiclesToCompare": "Oben Spieler suchen und auswählen, dann ihre Fahrzeuge auswählen, um verschiedene Spieler-Fahrzeug-Kombinationen zu vergleichen", + "selectVehicleFor": "Fahrzeug auswählen für", + "selectAVehicle": "-- Fahrzeug auswählen --", + "noVehiclesForPlayer": "Keine Fahrzeuge für diesen Spieler gefunden", + "noPlayerVehicleSelected": "Keine Spieler-Fahrzeug-Kombinationen ausgewählt", + "noPlayersSelected": "Keine Spieler ausgewählt", + "noVehiclesSelected": "Keine Fahrzeuge ausgewählt", + "noSquadronsSelected": "Keine Kampfgruppe ausgewählt", + "statistic": "Statistik", + "totalDeaths": "Tode", + "totalAssists": "Abschusshilfen", + "totalCaptures": "Eroberungen", + "killsPerSpawnShort": "Abschüsse/Spawn", + "avgWinRateShort": "Durchschn. Siegesrate", + "avgKillsPlayerShort": "Durchschn. Abschüsse/Spieler", + "avgKillsMember": "Durchschn. Abschüsse/Mitglied", + "avgBattlesMember": "Durchschn. Gefechte/Mitglied", + "serverError500": "Serverfehler (500) – Die API ist vorübergehend nicht verfügbar. Bitte in einigen Momenten erneut versuchen.", + "apiEndpoint404": "API-Endpunkt nicht gefunden (404) – Bitte Serverkonfiguration prüfen.", + "networkError": "Netzwerkfehler – Verbindung zum Server nicht möglich. Bitte Verbindung prüfen.", + "viewFullLeaderboard": "Vollständige Rangliste anzeigen", + "vehicleName": "Fahrzeugname", + "searchVehiclePlaceholder": "Bestimmtes Fahrzeug suchen...", + "minKills": "Min. Abschüsse", + "any": "Alle", + "perPage": "Pro Seite", + "clear": "Zurücksetzen", + "vehicleAndPlayer": "Fahrzeug & Spieler", + "loadingVehicleLeaderboard": "Fahrzeug-Rangliste wird geladen...", + "vehiclesShown": "Fahrzeuge angezeigt", + "minBattlesPerVehicle": "Mindestens 3 Gefechte pro Fahrzeug erforderlich", + "minimumBattlesRequired": "Mindestens 5 Gefechte erforderlich", + "allSeasons": "Alle Saisons", + "allWeeks": "Alle Wochen", + "allBR": "Alle BR" + }, + "games": { + "title": "Gefechtsverlauf", + "subtitle": "Kampfgruppengefechte suchen und durchsuchen", + "searchPlaceholder": "Nach Spielername oder UID suchen...", + "filterByMap": "Nach Karte filtern", + "allMaps": "Alle Karten", + "search": "Suchen", + "noResults": "Keine Gefechte gefunden", + "matchDetail": "Gefechtsdetails", + "chatLog": "Chat-Protokoll", + "battleLog": "Gefechtsbericht", + "duration": "Dauer", + "mode": "Modus", + "winningTeam": "Siegreiches Team", + "losingTeam": "Unterlegenes Team", + "viewMatch": "Gefecht anzeigen", + "loadingMatch": "Gefechtsdaten werden geladen...", + "matchNotFound": "Gefecht nicht gefunden", + "searchingGames": "Spiele werden gesucht...", + "recentMatches": "Aktuelle Gefechte", + "noChatLog": "Kein Chat-Protokoll verfügbar", + "noBattleLog": "Kein Gefechtsbericht verfügbar", + "replayVideo": "Replay-Video", + "generatingVideo": "Replay-Video wird erstellt…", + "videoFirstLoad": "Der erste Ladevorgang kann bis zu einer Minute dauern", + "videoUnavailable": "Replay-Video für dieses Gefecht nicht verfügbar", + "modeGround": "Boden", + "modeAir": "Luft", + "squadronPlaceholder": "Kampfgruppenname...", + "loadingReplay": "Replay wird geladen..." + }, + "errors": { + "pageNotFound": "Seite nicht gefunden", + "error": "Fehler", + "oopsNotFound": "Hoppla! Die gesuchte Seite existiert nicht. Sie wurde möglicherweise verschoben, gelöscht, oder du hast eine falsche URL eingegeben.", + "searchError": "Suchfehler. Bitte erneut versuchen." + }, + "js": { + "openingDiscordInvite": "Discord-Einladung wird geöffnet!", + "errorOpeningInvite": "Fehler beim Öffnen des Einladungslinks. Bitte später erneut versuchen.", + "gettingSupportLink": "Support-Server-Link wird abgerufen...", + "openingSupportServer": "Support-Server wird geöffnet!", + "errorGettingSupport": "Fehler beim Abrufen des Support-Links. Bitte später erneut versuchen.", + "failedToUpdateStats": "Statistiken konnten nicht aktualisiert werden", + "konamiActivated": "Erfolg freigeschaltet: Geheimer Code!", + "noPlayersFound": "Keine Spieler gefunden", + "searchError": "Suchfehler. Bitte erneut versuchen.", + "killsSuffix": "Abschüsse", + "winRateSuffix": "Siegesrate", + "noSquadronsFound": "Keine Kampfgruppen gefunden" + }, + "index": { + "subtitle1": "Das beste Tool für Staffelgefechte", + "subtitle2": "Boris Stats, aber besser", + "subtitle3": "Der größte SQB-Datensatz", + "subtitle4": "Öffentliche und kostenlose Informationen für alle" + }, + "seasonCard": { + "buttonLabel": "Saison-Karte", + "buttonDisabledTitle": "Staffelsuche unvollständig — Karte nicht verfügbar", + "modalTitle": "Saison-Karte", + "seasonLabel": "Saison", + "themeLabel": "Thema", + "themeDark": "Dunkel", + "themeLight": "Hell", + "generate": "Erstellen", + "loadingSeasons": "Saisons werden geladen…", + "generating": "Wird erstellt…", + "failedSeasons": "Saisons konnten nicht geladen werden.", + "failedGenerate": "Recap-Karte konnte nicht erstellt werden.", + "inProgressSuffix": "(läuft)", + "imgRecapSuffix": "RÜCKBLICK", + "imgHeroFinalRating": "Endrating", + "imgHeroMatches": "Spiele", + "imgHeroWinRate": "Siegesrate", + "imgHeroKD": "K/D", + "imgAxisRating": "Wertung", + "imgAxisWinRate": "Siegesrate", + "imgStatPeakRating": "Höchstrating", + "imgStatRatingChange": "Ratingänderung", + "imgStatTotalKills": "Abschüsse insgesamt", + "imgStatTotalDeaths": "Tode insgesamt", + "imgStatAssistsCaptures": "Assists / Eroberungen", + "imgStatMostPlayedVehicle": "Meistgespieltes Fahrzeug", + "imgStatMVP": "MVP", + "imgStatMostActive": "Aktivster", + "imgStatLongestWinStreak": "Längste Siegesserie", + "imgStatMostCommonOpponent": "Häufigster Gegner", + "imgUnitKills": "Abschüsse", + "imgUnitAssists": "Assists", + "imgUnitCaptures": "Eroberungen", + "imgUnitGames": "Spiele", + "imgUnitMatches": "Spiele", + "imgUnitWins": "S", + "imgUnitLosses": "N", + "imgGroundShort": "B", + "imgAirShort": "L", + "imgFooterGenerated": "erstellt", + "imgPlaceholderNoData": "Keine Daten für {short} in {season}", + "buttonLabelPlayer": "Saison-Rückblick", + "buttonDisabledTitlePlayer": "Player lookup incomplete — recap unavailable", + "modalTitlePlayer": "Player Season Recap", + "imgHeroBattles": "Gefechte", + "imgHeroTotalKills": "Abschüsse insgesamt", + "imgAxisBattles": "Gefechte (7 T)", + "imgAxisKD": "K/D", + "imgStatBestMatch": "Bestes Spiel", + "imgStatSquadronsRepresented": "Vertretene Staffeln", + "imgStatFrequentTeammate": "Häufigster Teamkollege", + "imgStatLongestSession": "Längste Session", + "imgStatMostActiveDay": "Aktivster Tag (UTC)", + "imgStatMostCommonOppSquadron": "Häufigste gegnerische Staffel", + "imgStatPeakSquadronRating": "Höchste Staffel-Wertung", + "imgUnitSlotNA": "NA", + "imgUnitSlotEU": "EU", + "imgUnitNoSquadron": "keine Staffel", + "imgUnitVs": "vs", + "imgUnitTogether": "Spiele zusammen", + "imgStatKDAC": "K / T / A / E", + "imgPlaceholderNoDataPlayer": "Keine Daten für {nick} in {season}", + "imgUIDLabel": "UID", + "imgBestMatchLine": "{vehicle} · BA {gk} / LA {ak} / A {assists} / E {cap} / T {deaths} · {date}" + }, + "live": { + "air": "LUF", + "gnd": "BOD", + "ast": "ASS", + "dth": "TOT", + "cap": "EIN", + "squadronBattle": "Staffelgefecht", + "randomBattle": "Zufallsgefecht" + }, + "analytics": { + "pageTitle": "SQB Analytik", + "pageSubtitle": "Tiefgehende Analytik für jede Squadron, Spieler oder Fahrzeug.", + "modeSquadron": "Squadron", + "modePlayer": "Spieler", + "modeVehicle": "Fahrzeug", + "tabMaps": "Karten-Siegquoten", + "tabSquadmates": "Häufige Mitspieler", + "tabComps": "Team-Zusammensetzungen", + "tabConsistency": "Spielerkonsistenz", + "tabTime": "Tageszeit", + "tabMatchups": "Matchup-Historie", + "pickSquadron": "Suche eine Squadron, um ihre Analyse zu sehen", + "pickPlayer": "Suche einen Spieler, um seine Analyse zu sehen", + "pickVehicle": "Suche ein Fahrzeug, um seine Analyse zu sehen", + "noData": "Keine Daten in diesem Zeitraum.", + "loading": "Lädt…", + "loadError": "Analysen konnten nicht geladen werden.", + "compComingSoon": "Team-Zusammensetzungsanalyse demnächst.", + "compTopVehiclesTitle": "Top-Fahrzeuge", + "compCompositionsTitle": "Wiederkehrende Match-Aufstellungen", + "compCompositionsMeta": "Aufstellungen mit mindestens {min} Fahrzeugen, sortiert nach Matches", + "compMatchesAnalyzed": "analysierte Matches", + "compNoRepeats": "In diesem Zeitraum wurde keine Aufstellung dieser Größe eingesetzt.", + "compColVehicle": "Fahrzeug", + "compColSpawns": "Einsätze", + "compColMatches": "Matches", + "compColShare": "% Matches", + "compColLineup": "Aufstellung", + "compColTypes": "Komp", + "compTypeFighters": "Jäger", + "compTypeBombers": "Bomber", + "compTypeHelicopters": "Helikopter", + "compTypeLight": "Leichter Panzer", + "compTypeTanks": "Panzer", + "compTypeSPAA": "SPAA", + "compTypeSPAATooltip": "Flugabwehr/SPAA", + "compTypeUnknown": "Unbekannt", + "compSearchPresetLabel": "Voreinstellung", + "compSearchPresetAll": "Alle Komps", + "compSearchPresetHint": "Aus der eigenen Komp-Historie dieser Squadron", + "compSearchTypesLabel": "Typen", + "compTypeCapsHint": "Max 8 gesamt · max 4 Luft (F + B + H)", + "compSearchRefineLabel": "Verfeinern", + "compRefineHint": "Zähler oben setzen, um konkrete Fahrzeuge für diesen Typ zu wählen.", + "compRefineAny": "Beliebige {type}", + "compSearchCustomLabel": "Benutzerdefiniert", + "compSearchAddVehicle": "Bestimmtes Fahrzeug", + "compSearchVehiclesLabel": "Fahrzeuge", + "compSearchApply": "Anwenden", + "compSearchReset": "Zurücksetzen", + "compSearchMatches": "Zeige {shown} von {total} Komps", + "compSearchNoMatches": "Keine Komp passt zum Filter.", + "compSearchGamesShort": "Spiele", + "colMap": "Karte", + "colWins": "S", + "colLosses": "N", + "colWinRate": "SR", + "colBar": "", + "colShared": "Gemeinsam", + "colUid": "UID", + "colGames": "Spiele", + "colPlayer": "Spieler", + "colAvgKills": "Ø Kills", + "colAvgDeaths": "Ø Tode", + "colScore": "Punkte", + "colHour": "Stunde (UTC)", + "colSquadron": "Squadron", + "colTotal": "insGesamt", + "matchupsWonHeader": "Meiste Siege gegen", + "matchupsLostHeader": "Meiste Niederlagen gegen", + "uniqueOpponents": "einzigartige Gegner", + "euTimeslot": "EU Zeitfenster", + "naTimeslot": "NA Zeitfenster", + "offPeak": "Nebenzeit", + "radarMetaMaps": "Top {shown} von {total} Karten · min. {min} Spiele", + "radarMetaSquadmates": "Top {shown} von {total} Mitspielern · min. {min} gemeinsame Spiele", + "radarTooFewMaps": "Nicht genug Daten, um das Diagramm zu zeichnen — mindestens 3 Karten mit {min}+ Spielen in diesem Filter sind nötig.", + "radarTooFewSquadmates": "Nicht genug Daten, um das Diagramm zu zeichnen — mindestens 3 Mitspieler mit {min}+ gemeinsamen Spielen in diesem Filter sind nötig.", + "radarFootnoteMaps": "{count} Karten mit weniger Spielen ausgeblendet — siehe vollständige Tabelle", + "radarFootnoteSquadmates": "{count} Mitspieler mit weniger Spielen ausgeblendet — siehe vollständige Tabelle", + "tabTimeline": "K/D-Zeitverlauf", + "tabTopPlayers": "Top Spieler", + "tabTopSquadrons": "Top Squadrons" + }, + "playerModal": { + "viewFullProfile": "Vollständiges Profil anzeigen →", + "close": "Schließen", + "overview": "Übersicht", + "vehicles": "Fahrzeuge", + "sessions": "Sitzungen", + "loadingPlayerData": "Spielerdaten werden geladen...", + "kdr": "K/D", + "kps": "K/S", + "winRate": "Siegrate", + "battles": "Gefechte", + "wins": "Siege", + "totalBattles": "Gefechte gesamt", + "totalKills": "Kills gesamt", + "airKills": "Luftkills", + "groundKills": "Bodenkills", + "assists": "Assists", + "deaths": "Tode", + "captures": "Eroberungen", + "clickToSwitchMetric": "Klicken, um Metrik zu wechseln", + "clickToCycle": "klicken zum Wechseln", + "noChartData": "Keine Diagrammdaten", + "noVehicleData": "Keine Fahrzeugdaten", + "noSessionData": "Keine Sitzungsdaten", + "date": "Datum", + "vehicle": "Fahrzeug", + "ground": "Boden", + "air": "Luft", + "result": "Ergebnis", + "unknown": "Unbekannt", + "failedToLoadPlayerData": "Spielerdaten konnten nicht geladen werden" + }, + "replay": { + "playPause": "Abspielen/Pause", + "crashed": "ist abgestürzt", + "destroyed": "zerstörte", + "hit": "traf" + }, + "dateFilter": { + "allTime": "Gesamt", + "currentSeason": "Aktuelle Saison", + "bySeason": "Nach Saison", + "cumulative": "Kumulativ", + "customRange": "Eigener Zeitraum", + "selectSeason": "Saison wählen", + "selectSeasonDots": "Saison wählen...", + "selectWeek": "Woche wählen", + "selectWeekDots": "Woche wählen...", + "entireSeason": "Ganze Saison", + "applyFilter": "Filter anwenden", + "cumulativeHelp": "Statistiken bis zu einem bestimmten Zeitpunkt anzeigen", + "season": "Saison", + "upToWeek": "Bis Woche", + "applyCumulativeFilter": "Kumulativfilter anwenden", + "startDate": "Startdatum", + "endDate": "Enddatum", + "applyCustomRange": "Eigenen Zeitraum anwenden", + "activeFilter": "Aktiver Filter:", + "clear": "Löschen", + "allTimeStatistics": "Gesamtstatistiken", + "currentSeasonValue": "Aktuelle Saison: {season}", + "alertSelectSeason": "Bitte eine Saison wählen", + "seasonValue": "Saison {season}", + "alertSelectSeasonWeek": "Bitte Saison und Woche wählen", + "cumulativeValue": "Kumulativ bis {season} - {week}", + "alertSelectDate": "Bitte mindestens ein Datum wählen", + "alertStartBeforeEnd": "Startdatum muss vor Enddatum liegen", + "customRangePrefix": "Eigener Zeitraum:", + "fromDate": "Ab {date}", + "upToDate": "Bis {date}" + } +} diff --git a/web/locales/en.json b/web/locales/en.json new file mode 100644 index 0000000..053b8b4 --- /dev/null +++ b/web/locales/en.json @@ -0,0 +1,906 @@ +{ + "nav": { + "home": "Home", + "live": "Live", + "leaderboards": "Leaderboards", + "docs": "Docs", + "terms": "Terms", + "premium": "Premium", + "support": "Support", + "addToDiscord": "Add to Discord", + "games": "Games", + "squadrons": "Squadrons", + "donate": "Donate", + "analytics": "Analytics" + }, + "footer": { + "services": "Services", + "matchFeed": "Match Feed", + "vehicleStats": "Vehicle Stats", + "analytics": "Analytics", + "squadronHub": "Squadron Hub", + "comparison": "Comparison", + "resources": "Resources", + "documentation": "Documentation", + "inviteBot": "Invite Bot", + "legal": "Legal", + "termsOfService": "Terms of Service", + "privacyPolicy": "Privacy Policy", + "termsAndPrivacy": "Terms & Privacy", + "meowing": "Meowing", + "websiteBy": "Website by", + "andToothless": "and Toothless" + }, + "common": { + "loading": "Loading...", + "retry": "Retry", + "backToHome": "Back to Home", + "battles": "Battles", + "wins": "Wins", + "winRate": "Win Rate", + "kills": "Kills", + "totalKills": "Total Kills", + "groundKills": "Ground Kills", + "airKills": "Air Kills", + "assists": "Assists", + "deaths": "Deaths", + "captures": "Captures", + "kdr": "KDR", + "kps": "KPS", + "rank": "Rank", + "player": "Player", + "players": "Players", + "playersCount": "players", + "vehicle": "Vehicle", + "vehicles": "Vehicles", + "squadron": "Squadron", + "squadrons": "Squadrons", + "statistics": "Statistics", + "comparison": "Comparison", + "date": "Date", + "result": "Result", + "totalBattles": "Total Battles", + "totalWins": "Total Wins", + "points": "Points", + "members": "Members", + "membersCount": "members", + "rating": "Rating", + "searchPlayerByName": "Search player by name...", + "noPlayersFound": "No players found", + "noSquadronsFound": "No squadrons found", + "noVehiclesFound": "No vehicles found", + "failedToLoad": "Failed to load data. Please try again later.", + "recordingSince": "Recording data since 01/01/2026", + "vs": "VS", + "map": "Map" + }, + "home": { + "squadronBattles": "Squadron Battles", + "madeSimple": "Made Simple", + "addToDiscord": "Add to Discord", + "learnMore": "Learn More", + "searchBySquadron": "SEARCH BY SQUADRON", + "typeSquadronName": "Type a squadron name...", + "orByPlayer": "OR BY PLAYER", + "typePlayerName": "Type a player name...", + "liveFeed": "Match Feed", + "realTimeMatches": "Find Your Matches", + "topPlayers": "Top Players", + "vehicleStatsCard": "Vehicle Stats", + "performanceMetrics": "Performance Metrics", + "analyticsCard": "Analytics", + "globalStatistics": "Global Statistics", + "squadronHubCard": "Squadron Hub", + "squadronStats": "Squadron Stats", + "comparisonCard": "Comparison", + "compareStats": "Compare Stats", + "joinServers": "Join the 500+ servers using our bot to track their performance", + "noSquadronsFound": "No squadrons found", + "noPlayersFound": "No players found", + "searchPlayersIn": "Search players in", + "ctaElev8": "Ready to ELEV8 your squadron?", + "ctaReign": "Ready to R3IGN again?", + "ctaMeow": "Meowww", + "ctaPurr": "Purrr", + "ctaRawr": "Rawr" + }, + "docs": { + "title": "Documentation", + "subtitle": "Everything you need to know about", + "quickNavigation": "Quick Navigation", + "gettingStarted": "Getting Started", + "commands": "Commands", + "serverSetup": "Server Setup", + "features": "Features", + "examples": "Examples", + "troubleshooting": "Troubleshooting", + "stackManager": "Stack Manager", + "welcomeMessage": "Follow these steps to get up and running.", + "inviteTheBot": "Invite the Bot", + "inviteBotDesc": "Click the \"Add to Server\" button and select your squadron's Discord server. The bot will send a welcome message with a hint to run /setup.", + "runSetupWizard": "Run the Setup Wizard", + "setupWizardDesc": "The setup wizard walks you through configuring your squadron, logs channel, and points channel in one flow:", + "setupEasiest": "This is the easiest way to get started. It will guide you through setting your squadron and choosing channels step by step.", + "youreDone": "You're Done!", + "doneDesc": "The bot will start posting points and leaderboard updates automatically. Use /autolog-management to adjust notification settings later.", + "premiumNote": "Automatic game logs (full scoreboards after each match) require a Premium subscription. Run /unlock to subscribe — $2.99/mo per server, billed through Discord.", + "manualSetup": "Manual Setup (Alternative)", + "manualSetupDesc": "If you prefer to configure things individually, you can use these commands instead:", + "allCommandsSlash": "All commands use Discord's slash command system. Type / to see available commands.", + "serverSetupAdmin": "Server Setup & Administration", + "importantNote": "Important Note", + "verifyFirst": "Always verify first! The /sq-info command confirms the bot can find your squadron in War Thunder's database, even if you're ranked very low.", + "cantFindSquadron": "If the bot can't find your squadron with /sq-info, the setup commands won't work properly.", + "botNotResponding": "Bot Not Responding", + "checkOnline": "Check if the bot is online (green status)", + "verifyPermissions": "Verify the bot has necessary permissions", + "tryDifferentChannel": "Try using commands in a different channel", + "commandsNotWorking": "Commands Not Working", + "ensureSlash": "Ensure you're using slash commands (start with /)", + "checkRolePerms": "Check if your role has permission to use bot commands", + "tryRefreshing": "Try refreshing Discord or restarting the app", + "dataNotSaving": "Data Not Saving", + "verifySendMessages": "Verify the bot has \"Send Messages\" permission", + "checkOutages": "Check if there are any Discord outages", + "contactSupport": "Contact support if the issue persists", + "needMoreHelp": "Need More Help?", + "needMoreHelpDesc": "If you need additional assistance, feel free to reach out through our support channels.", + "example": "Example", + "supportedLanguages": "Supported Languages", + "setupDesc": "Step-by-step wizard to configure the bot for your server. Sets your squadron, logs channel, and points channel in one flow.", + "recommendedForNew": "Recommended for new servers.", + "setSquadronDesc": "Store a default squadron for your Discord server. Used for logging and as a default for other commands.", + "quickLogDesc": "Set an alarm for a squadron in the current channel. Type can be Logs, Points, Leaderboard, or Both — Both sets Logs and Points together in one command. Defaults to Logs.", + "quickLogPremiumNote": "Logs (automatic game scoreboards) require a Premium subscription. Points and Leaderboard alerts are free.", + "autologDesc": "Manage autolog notifications and diagnose channel permissions. Use this to change settings after initial setup.", + "autologPremiumNote": "Automatic game logs require a Premium subscription.", + "diagnosePermsDesc": "Instantly checks whether the bot has the permissions it needs in the current channel, shows your configured autolog channels, and displays this server's Premium subscription status. Use this if scoreboards or points aren't posting.", + "squadronInformation": "Squadron Information", + "sqInfoDesc": "View detailed information about any squadron. Uses your server's default squadron if none specified.", + "sqInfoGraphDesc": "Visualise the current roster as a stacked bar chart split into core, active, and weak groups by activity and win rate (current season).", + "compDesc": "Find the last known compositions for a given squadron's battles. Free servers get 25 lookups per timeslot; Premium gets unlimited.", + "trackDesc": "Track a squadron and compare stats against the last time you checked.", + "topDesc": "Display the top 20 squadrons and their current stats.", + "sqStatsDesc": "Display a squadron's points over time as an interactive graph.", + "lossCalculatorDesc": "Calculate how many points a squadron would lose if chosen players left.", + "recentDesc": "Show the last 5 squadron battles for a squadron.", + "vsDesc": "View the head-to-head record against another squadron.", + "leaderboardLinkDesc": "Get a link to the SRE Bot global player leaderboard.", + "playerStats": "Player Stats", + "playerStatsDesc": "View detailed vehicle statistics for a player with an interactive vehicle dropdown. Supports autocomplete.", + "viewPlayerGamesDesc": "View the last 20 games for a player. Shows overall win/loss count and winrate, a per-game summary (result, opponent squadron, map, and comp played), and a deduplicated list of every unique comp the player has run. Supports autocomplete.", + "viewMatchDesc": "View the full scoreboard for a specific match. Provide a match ID directly, or search by player name to browse their last 100 games and pick one. Includes View Replay, View Paths, Chat Log, and Battle Log buttons.", + "examples2": "Examples", + "compareDesc": "Compare aggregate SQB stats between two or more players (up to 7). Shows a side-by-side comparison with the best stats highlighted. Includes a graph button to view point history over 90 days.", + "metaData": "Meta Data", + "metaManagementDesc": "Configure meta data access settings for your server.", + "metaDesc": "Search your squadron's meta roster by vehicle name.", + "settingsUtilities": "Settings & Utilities", + "languageDesc": "Change the default language of the bot. Affects the language of vehicles shown in your battle logs as well.", + "scheduleDesc": "View the current season BR schedule. Shows each week's maximum battle rating with its date range, strikethrough on past weeks, and a highlight on the current active period.", + "websiteDesc": "Get a link to the SRE Bot website for player search, leaderboards, and more.", + "creditsDesc": "View the team credited for building this bot.", + "unlockDesc": "Unlock automatic SQB game logs and unlimited /comp lookups for this server. Subscribing adds full scoreboards posted automatically to your configured channel after every match. $2.99 / month · per server · cancel anytime. Billing is handled entirely through Discord — no external account required.", + "analyticsDesc": "Advanced SQB analytics: map win rates, team compositions, player consistency, time-of-day performance, and matchup history (top opponents won against and lost to).", + "sqCardDesc": "Generate a season recap card PNG for a squadron — rating trend, win rate, top players, and more. Pick the season from the autocomplete list. Supports Dark and Light themes.", + "cardDesc": "Generate a season recap card PNG for a player. Pick the season from the autocomplete list and resolve the player by username. Supports Dark and Light themes.", + "queryDesc": "[Admin only] Run predefined database queries — squadron stats, game counts, most active players, top maps, and more. Results are ephemeral (only visible to you).", + "donateDesc": "Support the development of SRE Bot via Ko-fi.", + "botStatusDesc": "Show when the last game was received and the average TTL across recent games. Flags slow Gaijin servers.", + "premiumBadge": "Premium", + "newsDesc": "View the latest SRE Bot news and announcements.", + "stackCreateDesc": "Create a player stack to coordinate a squad before a match. A persistent embed is posted in the channel showing current members and pending applicants. The stack lasts up to 8 hours and is automatically cleaned up at the end of each SQB timeslot.", + "stackRequestToJoin": "Request to Join — Any player can apply with the vehicle they plan to fly. Applications queue up to 20 slots.", + "stackLeaveWithdraw": "Leave / Withdraw — Members can leave the stack; applicants can withdraw their application. The stack leader is prompted to transfer ownership first.", + "stackManagePanel": "Manage Stack ⚙️ — Leader-only panel with four sections:", + "stackAcceptMembers": "Accept Members — Accept or decline applicants individually or all at once. Up to 8 members total.", + "stackRemoveMembers": "Remove Members — Remove active members or queued applicants. Options: Remove All, Remove Active, Remove Queued, or Remove Selected from a dropdown.", + "stackPingMembers": "Ping Members — Ping with an optional custom message. Options: Ping All (members + queue, excluding leader), Ping Active (members only), Ping Queued (applicants only), or Ping Selected from a dropdown.", + "stackRenameStack": "Rename Stack — Set a custom name for the stack. Appears as the embed title and in ping messages instead of the default \"[Leader]'s Stack\".", + "stackDisbandStack": "Disband Stack — Leader can end the stack early.", + "stackManageDesc": "Re-posts your active stack embed to the current channel. Use this if the original embed was deleted or lost after a bot restart. All existing members and queue data are preserved.", + "translation": "Translation", + "translateContextMenu": "Right-click message → Apps → Translate Message", + "translateDesc": "Translate any message using Discord's context menu. Right-click (or long-press on mobile) a message and select Apps → Translate Message.", + "viewAllLanguages": "View All Supported Languages", + "serverSetupSubtitle": "Configure {botName} for optimal performance in your squadron's Discord server.", + "requiredPermissions": "Required Permissions", + "sendMessages": "Send Messages", + "useSlashCommands": "Use Slash Commands", + "embedLinks": "Embed Links", + "readMessageHistory": "Read Message History", + "recommendedChannelSetup": "Recommended Channel Setup", + "recommendedChannelDesc": "Create a dedicated channel like #squadron-battles for tracking and statistics. This keeps your battle data organized and easily accessible.", + "roleConfiguration": "Role Configuration", + "roleConfigurationDesc": "Assign appropriate roles to squadron members who can record battle results. We recommend limiting this to squadron officers and leaders.", + "premiumSectionSubtitle": "Automatic SQB game logs are a Premium feature, unlocked per server via Discord's native subscription system.", + "whatsIncluded": "What's included", + "premiumInclude1": "Full scoreboard posted automatically to your configured channel after every SQB match", + "premiumInclude2": "Unlimited /comp lookups (free servers get 25 per timeslot)", + "premiumInclude3": "All existing free features (points alarms, leaderboard, stats commands, etc.) remain free", + "pricingBilling": "Pricing & billing", + "pricingBillingDesc": "$2.99 / month · per server · cancel anytime. Billing is managed entirely through Discord — no external account or payment processor. Subscriptions auto-renew and can be cancelled at any time from your Discord settings.", + "howToSubscribe": "How to subscribe", + "subscribe1": "Run /unlock in your server (server admin required)", + "subscribe2": "Click the Subscribe button in the bot's reply", + "subscribe3": "Complete checkout inside Discord — the bot activates immediately", + "cancellation": "Cancellation", + "cancellationDesc": "When a subscription lapses or is cancelled, the bot automatically stops posting game logs for that server on the next autolog cycle. No manual action is needed.", + "realTimeStatistics": "Real-time Statistics", + "realTimeStatisticsDesc": "Track wins, losses, and performance metrics in real-time.", + "battleHistory": "Battle History", + "battleHistoryDesc": "Comprehensive history of all recorded Squadron Battles.", + "leaderboardsFeature": "Leaderboards", + "leaderboardsFeatureDesc": "Compare your squadron's performance with others using /top.", + "playerTracking": "Player Tracking", + "playerTrackingDesc": "Individual player statistics and ThunderSkill integration.", + "smartAlerts": "Smart Alerts", + "smartAlertsDesc": "Automated alarms and notifications for squadron activities.", + "multiLanguageSupport": "Multi-language Support", + "multiLanguageSupportDesc": "Translation features and customizable vehicle languages.", + "timeCoordination": "Time Coordination", + "timeCoordinationDesc": "UTC and local time tools for global squadron coordination.", + "advancedSearch": "Advanced Search", + "advancedSearchDesc": "Find player competitions and detailed squadron information.", + "dataSecurity": "Data Security", + "dataSecurityDesc": "Your data is secure and never shared or sold.", + "usageExamples": "Usage Examples", + "quickSetupRecommended": "Quick Setup (Recommended)", + "quickSetupDesc": "Run the setup wizard. It walks you through setting your squadron, choosing a logs channel, and choosing a points channel — all in one flow.", + "comparingPlayers": "Comparing Players", + "comparingPlayersDesc": "Compare up to 7 players side-by-side. The best stat in each category is highlighted, and the overall best player gets a star. Click \"Show Graph\" to see their point history.", + "checkingSquadronInfo": "Checking Squadron Info", + "verifySquadronDesc": "Verify that a squadron exists and view its details. Use this to confirm the bot can find your squadron before setup.", + "recentBattlesDesc": "See the last 5 battles played by a squadron.", + "headToHeadDesc": "View your head-to-head record against another squadron.", + "backToHome": "Back to Home", + "termsAndPrivacy": "Terms & Privacy", + "seasonRecapCardTitle": "Season Recap Card", + "seasonRecapCardDesc": "Generate a shareable PNG summary of any squadron's season — rating curve, rolling win rate, K/D, top vehicle, MVP, and more. Available from the \"Season Card\" button on every squadron's profile page.", + "playerRecapCardTitle": "Player Season Recap", + "playerRecapCardDesc": "Generate a shareable PNG summary of any player's season — rating trail across squadrons, rolling win rate, K/D, battle cadence, best match, and more. Available from the \"Season Recap\" button on every player's profile page.", + "tierOverview": "Tier overview", + "tierOverviewDesc": "Autologging is available on three tiers. Each tier caps how many squadrons can have Logs and Points enabled. Over-cap squadrons remain in your preferences and resume automatically after you upgrade.", + "tierStandardLine": "$2.99 — up to 10 squadrons each for Logs and Points, no wildcard support.", + "tierProLine": "up to 25 squadrons each for Logs and Points, plus wildcard (`*` / `all` / `everything`) support.", + "tierMaxLine": "unlimited squadrons, wildcards, and early access to new features." + }, + "terms": { + "pageTitle": "Terms of Service & Privacy Policy", + "lastUpdated": "Last updated: April 2026", + "termsOfService": "Terms of Service", + "byUsing": "By using", + "youAgree": ", you agree to the following:", + "useResponsibly": "Use Responsibly", + "useResponsiblyDesc": "Do not attempt to overload, spam or otherwise break the bot.", + "noFunnyBusiness": "No Funny Business", + "noFunnyBusinessDesc": "Do not attempt to reverse engineer, or violate the Discord ToS or any other Guild Rules", + "statsAsIs": "Stats Are As-Is", + "statsAsIsDesc": "All data and statistics are provided \"as is\" without warranty of any kind, express or implied. We make reasonable efforts to ensure accuracy but do not guarantee that any information is complete, current, or error-free.", + "uptimeNotGuaranteed": "Uptime Not Guaranteed", + "uptimeNotGuaranteedDesc": "The bot is provided on an \"as available\" basis. We do not guarantee uninterrupted or error-free operation. The service may be temporarily unavailable due to maintenance, updates, or technical issues.", + "weCanBanYou": "We Can Ban You", + "weCanBanYouDesc": "If you break the rules, we can remove your access.", + "privacyPolicy": "Privacy Policy", + "infoWeCollect": "1. Information We Collect", + "collectsFollowing": "collects the following information to provide its services:", + "discordUserIds": "Discord User IDs:", + "discordUserIdsDesc": "Unique identifiers to track individual users", + "squadronIds": "Squadron IDs:", + "squadronIdsDesc": "Discord server/guild identifiers to organize squadron data", + "battleData": "Battle Data:", + "battleDataDesc": "Win/loss records, battle timestamps, and related statistics", + "commandUsage": "Command Usage:", + "commandUsageDesc": "Basic logging of bot commands used for service improvement", + "howWeUse": "2. How We Use Your Information", + "usedExclusively": "The collected information is used exclusively for:", + "trackingPerformance": "Tracking Squadron Battle performance and statistics", + "providingHistorical": "Providing historical data and analytics", + "improvingBot": "Improving bot functionality and user experience", + "troubleshootingIssues": "Troubleshooting technical issues", + "dataStorage": "3. Data Storage and Security", + "dataStoredSecurely": "Your data is stored securely with the following protections:", + "encryptedServers": "Data is stored on secure servers with encryption", + "limitedAccess": "Access is limited to authorized personnel only", + "regularBackups": "Regular backups ensure data integrity", + "dataSharing": "4. Data Sharing", + "weDoNot": "We do NOT:", + "sellData": "Sell your personal information to third parties", + "shareData": "Share your data with external organizations", + "useForAds": "Use your data for advertising or marketing purposes", + "transferData": "Transfer your data outside our secure systems", + "dataRetention": "5. Data Retention", + "dataRetentionDesc": "We retain your data for as long as necessary to provide our services. Battle statistics and squadron data are kept to maintain historical records and analytics.", + "discordIntegration": "6. Discord Integration", + "discordIntegrationDesc": "This Bot operates within Discord's ecosystem and is subject to Discord's Privacy Policy. We only access information necessary for bot functionality through Discord's official API.", + "ageRestrictions": "7. Age Restrictions", + "ageRestrictionsDesc": "The Bot is intended for users who meet Discord's minimum age requirements (13+ or as required by local law).", + "changesToPolicy": "8. Changes to Privacy Policy", + "changesToPolicyDesc": "We may update this Privacy Policy from time to time.", + "premiumTitle": "Premium Subscription Terms", + "premiumWhatYouGet": "1. What Premium Includes", + "premiumWhatYouGetDesc": "A Premium subscription unlocks the following features for the subscribed Discord server:", + "premiumFeature1": "Automatic scoreboard posts after every SQB match", + "premiumFeature2": "Path / movement maps", + "premiumFeature3": "Chat & battle logs", + "premiumFeature4": "Replay lookups", + "premiumFeature5": "Unlimited /comp lookups (free servers are limited to 15 per timeslot)", + "premiumBilling": "2. Billing & Payment", + "premiumBillingDesc": "Premium is billed at $2.99 USD per month per Discord server. Payment is processed through Discord's native subscription system or through our website via Whop. You are responsible for ensuring your payment method is valid and has sufficient funds. Subscriptions auto-renew at the end of each billing period unless cancelled.", + "premiumCancellation": "3. Cancellation", + "premiumCancellationDesc": "You may cancel your subscription at any time. For Discord subscriptions, go to User Settings → Subscriptions in Discord. For website subscriptions, manage your billing at whop.com/billing. Upon cancellation, your Premium features remain active until the end of your current billing period. After that, your server reverts to the free tier — no data is lost.", + "premiumRefunds": "4. Refunds", + "premiumRefundsDesc": "Subscription fees are non-refundable. Partial-month usage is not prorated. If you cancel mid-cycle, you retain access until the end of that billing period but are not entitled to a refund for the remaining time. Refunds for billing errors or duplicate charges may be issued at our discretion — contact support with your Discord Server ID and proof of payment.", + "premiumPriceChanges": "5. Price Changes", + "premiumPriceChangesDesc": "We reserve the right to change subscription pricing at any time. Existing subscribers will be given at least 30 days' notice before any price increase takes effect. If you do not agree with a price change, you may cancel before the new price applies.", + "premiumTermination": "6. Termination of Premium Access", + "premiumTerminationDesc": "We reserve the right to revoke Premium access without refund if a server violates these Terms of Service, including but not limited to abuse of bot features, attempts to circumvent usage limits, or violation of Discord's Terms of Service.", + "serviceAvailabilityTitle": "Service Availability & Liability", + "serviceNoWarranty": "1. No Warranty", + "serviceNoWarrantyDesc": "THE SERVICE IS PROVIDED \"AS IS\" AND \"AS AVAILABLE\" WITHOUT WARRANTIES OF ANY KIND, WHETHER EXPRESS, IMPLIED, OR STATUTORY, INCLUDING BUT NOT LIMITED TO IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. We do not warrant that the service will be uninterrupted, timely, secure, or error-free.", + "serviceLiability": "2. Limitation of Liability", + "serviceLiabilityDesc": "TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT SHALL WE BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS OR REVENUE, WHETHER INCURRED DIRECTLY OR INDIRECTLY, OR ANY LOSS OF DATA, USE, GOODWILL, OR OTHER INTANGIBLE LOSSES, RESULTING FROM YOUR USE OF OR INABILITY TO USE THE SERVICE. Our total liability for any claim arising from these terms or the service shall not exceed the amount you paid us in the three (3) months preceding the claim.", + "serviceCredits": "3. Service Interruptions & Credits", + "serviceCreditsDesc": "In the event of an extended, unplanned service outage affecting Premium features for more than 72 consecutive hours, affected Premium subscribers may request a service credit by contacting support. Credits are issued at our sole discretion and are applied as an extension of the current billing period — not as monetary refunds. Scheduled maintenance windows, third-party outages (Discord, Gaijin API), and events beyond our reasonable control are excluded.", + "serviceForceM": "4. Force Majeure", + "serviceForceMDesc": "We shall not be liable for any failure or delay in performance resulting from causes beyond our reasonable control, including but not limited to: natural disasters, war, terrorism, pandemics, power outages, internet disruptions, Discord platform outages, Gaijin Entertainment API changes or unavailability, government actions, or any other force majeure event.", + "disclaimer": "Disclaimer", + "warThunderDisclaimer": "is an independent Discord bot and is not affiliated with, endorsed by, or associated with Gaijin Entertainment or War Thunder. War Thunder is a trademark of Gaijin Entertainment.", + "acknowledgement": "By using", + "acknowledgementEnd": ", you acknowledge that you have read, understood, and agree to be bound by these Terms of Service and Privacy Policy." + }, + "premium": { + "upgradeTitle": "Upgrade Your Squadron", + "heroDesc": "Every SQB result posted to your channel the moment it ends — scoreboards, battle logs, movement maps, and replays, all hands-free.", + "instantScoreboards": "Instant Scoreboards", + "viewPaths": "View Paths", + "chatBattleLogs": "Chat & Battle Logs", + "replayLookups": "Replay Lookups", + "free": "Free", + "perMonth": "/mo", + "alwaysFree": "Always free", + "noCardNeeded": "no card needed", + "included": "Included", + "manualLookups": "Manual Game Lookups", + "playerStats": "Player Stats & Profiles", + "leaderboards": "Leaderboards", + "stickWithFree": "Stick with Free", + "premiumLabel": "Premium", + "perServer": "per server", + "cancelAnytime": "cancel anytime", + "everythingInFree": "Everything in Free, Plus", + "autoScoreboards": "Auto Scoreboard Posts", + "pathMaps": "Path / Movement Maps", + "chatLogs": "Chat & Battle Logs", + "replayLookupsFeature": "Replay Lookups", + "unlimitedComp": "Unlimited /comp Lookups", + "prioritySupport": "Priority Support", + "subscribeNow": "Subscribe Now", + "comingSoon": "Coming Soon", + "serverIdInfo": "You'll need your", + "discordServerId": "Discord Server ID", + "duringCheckout": "during checkout.", + "developerMode": "Developer Mode", + "rightClickServer": "Right-click server", + "copyId": "Copy ID", + "successTitle": "Premium Activated", + "successDesc": "Your subscription is being set up. The bot will have premium access for your server within a few minutes.", + "whatHappensNext": "What happens next", + "autoLogging": "Auto-logging activates for your server", + "setLogChannel": "Set your log channel with", + "everyResult": "Every SQB result posts automatically", + "readSetupGuide": "Read the setup guide", + "tierStandardName": "Standard", + "tierProName": "Pro", + "tierMaxName": "Max", + "squadCap": "Log up to {cap} squadrons", + "squadCapUnlimited": "Log unlimited squadrons", + "everythingInStandard": "Everything in Standard", + "everythingInPro": "Everything in Pro", + "wildcardSupport": "Wildcard logging (*, all, everything)", + "noSquadCap": "No squadron cap", + "earlyAccessFeatures": "Early access to new features" + }, + "player": { + "totalBattles": "Total Battles", + "totalWins": "Total Wins", + "vehicleStatistics": "Vehicle Statistics", + "cumulative": "Cumulative", + "individual": "Individual", + "filterBy": "Filter by:", + "allTime": "All Time", + "dateRange": "Date Range", + "season": "Season", + "week": "Week", + "session": "Session", + "dateType": "Date Type:", + "last7Days": "Last 7 Days", + "last30Days": "Last 30 Days", + "last90Days": "Last 90 Days", + "customRange": "Custom Range", + "specificDate": "Specific Date", + "filterType": "Filter Type:", + "fullSeason": "Full Season", + "specificWeek": "Specific Week", + "from": "From:", + "to": "To:", + "timeslot": "Timeslot", + "fullDay": "Full Day", + "selectSeason": "Season:", + "selectWeek": "Week:", + "selectSeasonFirst": "Select season first", + "pleaseSelect": "Please select an option", + "searchVehicles": "Search vehicles...", + "resetFilters": "Reset Filters", + "vehiclesShown": "vehicles shown", + "gamesShown": "games shown", + "noVehicleData": "No vehicle data available", + "noVehiclesForRange": "No vehicles found for the selected date range, or this player has no data yet.", + "switchToCards": "Switch to Card View", + "switchToTable": "Switch to Table View", + "loadingTimeline": "Loading timeline...", + "noTimelineData": "No timeline data yet.", + "timelineUnavailable": "Timeline unavailable.", + "loadingGameRecords": "Loading game records...", + "unableToLoadRecords": "Unable to load game records", + "failedToFetch": "Failed to fetch game data. Please try again later.", + "noGameRecords": "No game records found", + "noGamesYet": "This player hasn't played any recorded games yet.", + "collapseChart": "Collapse chart", + "loadingChartData": "Loading chart data...", + "noHistoricalData": "No historical data yet.", + "chartUnavailable": "Chart unavailable.", + "relative": "Relative", + "uidLabel": "Player UID" + }, + "squadrons": { + "title": "Squadron Hub", + "subtitle": "Discover squadrons, view statistics, and track performance", + "findSquadron": "Find a Squadron", + "searchPlaceholder": "Search squadrons by name...", + "totalSquadrons": "Total Squadrons", + "totalPlayers": "Total Players", + "totalBattles": "Total Battles", + "avgWinRate": "Avg Win Rate", + "topSquadrons": "Top Squadrons", + "viewFullLeaderboard": "View Full Leaderboard", + "loadingSquadrons": "Loading squadron data...", + "noSquadronData": "No squadron data available yet.", + "failedToLoad": "Failed to load squadron data. Please try again later.", + "backToSquadronHub": "Back to Squadron Hub", + "squadronPoints": "Squadron Points", + "squadronMembers": "Squadron Members", + "performance": "Performance", + "performanceNoData": "No performance data available for the selected range.", + "quickDetails": "Quick Details", + "noMembersFound": "No members found", + "noRecordedMembers": "This squadron has no recorded members yet.", + "squadronGames": "Squadron Games", + "loadingSquadronGames": "Loading squadron game records...", + "noSquadronGames": "No game records found for this squadron.", + "retryLoadGames": "Retry", + "searchMapPlaceholder": "Search by map..." + }, + "leaderboard": { + "playersTitle": "Players Leaderboard", + "playersSubtitle": "Top War Thunder players ranked by performance", + "vehiclesTitle": "Vehicle Kills Leaderboard", + "vehiclesSubtitle": "Top War Thunder vehicles ranked by total kills", + "squadronsTitle": "Squadron Leaderboard", + "squadronsSubtitle": "Top War Thunder squadrons ranked by performance", + "statsTitle": "Global Statistics", + "statsSubtitle": "Overall squadron battle statistics and meta information", + "comparisonTitle": "Comparison Tool", + "comparisonSubtitle": "Compare players and vehicles side-by-side", + "comparisonHint": "Compare stats to find the best performers and vehicles", + "compareSquadrons": "Compare Squadrons", + "comparePlayers": "Compare Players", + "compareVehicles": "Compare Vehicles", + "playersAndVehicles": "Players + Vehicles", + "failedToLoadLeaderboard": "Failed to load leaderboard data. Please try again later.", + "failedToLoadVehicles": "Failed to load vehicle leaderboard", + "failedToLoadSquadrons": "Failed to load squadron leaderboard", + "noResultsYet": "No results yet. Add squadrons/players to begin.", + "searchSquadron": "Search Squadron", + "searchBySquadronName": "Search by squadron name...", + "minPlayers": "Min Players", + "minPlayersPlaceholder": "Min players", + "resetFilters": "Reset Filters", + "squadronsShown": "squadrons shown", + "playersShown": "players shown", + "page": "page", + "of": "of", + "loadingSquadronLeaderboard": "Loading squadron leaderboard...", + "loadingPlayerLeaderboard": "Loading player leaderboard...", + "loadingComparisonData": "Loading comparison data...", + "unableToFetch": "Unable to fetch leaderboard data. Please try again.", + "noSquadronsInLeaderboard": "No squadrons found in leaderboard.", + "noPlayersInLeaderboard": "No players found in leaderboard.", + "loadingGlobalStats": "Loading global statistics...", + "failedToLoadStats": "Failed to load statistics", + "unableToFetchStats": "Unable to fetch statistics data. Please try again.", + "mostPopularVehicles": "Most Popular Vehicles", + "vehicleKillsLeaderboardTitle": "Vehicle Kills Leaderboard", + "avgWinRate": "Average Win Rate", + "avgKillsPerPlayer": "Average Kills/Player", + "loadingVehicleKills": "Loading vehicle kills data...", + "apiNotLoaded": "API client not properly loaded. Please refresh the page.", + "failedToInitApi": "Failed to initialize API client", + "noStatsData": "No statistics data available", + "totalPlayersCard": "Total Players", + "activePlayers": "Active Players", + "vehiclesUsed": "Vehicles Used", + "differentVehicles": "Different Vehicles", + "squadronBattlesLabel": "Squadron Battles", + "noVehicleData": "No vehicle data available", + "mostPopular": "Most Popular", + "timesUsed": "Times Used", + "failedToLoadVehicleKills": "Failed to load vehicle kills data", + "lastUpdated": "Last updated", + "searchPlayer": "Search Player", + "searchByPlayerName": "Search by player name...", + "minimumBattles": "Minimum Battles", + "minBattlesPlaceholder": "Min battles", + "searchSquadronsPlaceholder": "Search squadrons...", + "sortBy": "Sort By", + "kdRatio": "KD Ratio", + "killsPerSpawn": "Kills Per Spawn", + "caps": "Caps", + "timePeriod": "Time Period", + "allTime": "All Time", + "dateRange": "Date Range", + "season": "Season", + "week": "Week", + "dateType": "Date Type", + "last7Days": "Last 7 Days", + "last30Days": "Last 30 Days", + "last90Days": "Last 90 Days", + "customRange": "Custom Range", + "from": "From", + "to": "To", + "timeslot": "Timeslot", + "fullDay": "Full Day", + "selectSeason": "Select season...", + "selectWeek": "Select week...", + "failedToLoadComparison": "Failed to load comparison data", + "pleaseRefresh": "Please try refreshing the page", + "playerComparison": "Player Comparison", + "squadronComparison": "Squadron Comparison", + "vehicleComparison": "Vehicle Comparison", + "playersVehiclesComparison": "Players + Vehicles Comparison", + "addPlayersToCompare": "Add Players to Compare:", + "addSquadronsToCompare": "Add Squadrons to Compare:", + "addVehiclesToCompare": "Add Vehicles to Compare:", + "addPlayerVehicleCombos": "Add Player + Vehicle Combinations:", + "searchSelectPlayers": "Search and select players...", + "typeSquadronName": "Type a squadron name...", + "searchSelectVehicles": "Search and select vehicles...", + "searchForPlayers": "Search for players...", + "selectPlayersToCompare": "Search and select players above to compare their stats", + "selectSquadronsToCompare": "Search and select squadrons above to compare their stats", + "selectVehiclesToCompare": "Search and select vehicles above to compare their stats", + "selectPlayersVehiclesToCompare": "Search and select players above, then choose their vehicles to compare different player-vehicle combinations", + "selectVehicleFor": "Select vehicle for", + "selectAVehicle": "-- Select a vehicle --", + "noVehiclesForPlayer": "No vehicles found for this player", + "noPlayerVehicleSelected": "No player-vehicle combinations selected", + "noPlayersSelected": "No players selected", + "noVehiclesSelected": "No vehicles selected", + "noSquadronsSelected": "No squadrons selected", + "statistic": "Statistic", + "totalDeaths": "Total Deaths", + "totalAssists": "Total Assists", + "totalCaptures": "Total Captures", + "killsPerSpawnShort": "Kills/Spawn", + "avgWinRateShort": "Avg Win Rate", + "avgKillsPlayerShort": "Avg Kills/Player", + "avgKillsMember": "Avg Kills/Member", + "avgBattlesMember": "Avg Battles/Member", + "serverError500": "Server error (500) - The API is temporarily unavailable. Please try again in a few moments.", + "apiEndpoint404": "API endpoint not found (404) - Please check server configuration.", + "networkError": "Network error - Unable to connect to server. Please check your connection.", + "viewFullLeaderboard": "View Full Leaderboard", + "vehicleName": "Vehicle Name", + "searchVehiclePlaceholder": "Search specific vehicle...", + "minKills": "Min Kills", + "any": "Any", + "perPage": "Per Page", + "clear": "Clear", + "vehicleAndPlayer": "Vehicle & Player", + "loadingVehicleLeaderboard": "Loading vehicle leaderboard...", + "vehiclesShown": "vehicles shown", + "minBattlesPerVehicle": "Minimum 3 battles per vehicle required", + "minimumBattlesRequired": "Minimum 5 battles required", + "allSeasons": "All Seasons", + "allWeeks": "All Weeks", + "allBR": "All BR" + }, + "games": { + "title": "Match History", + "subtitle": "Search and browse squadron battle matches", + "searchPlaceholder": "Search by player name or UID...", + "filterByMap": "Filter by Map", + "allMaps": "All Maps", + "search": "Search", + "noResults": "No matches found", + "matchDetail": "Match Detail", + "chatLog": "Chat Log", + "battleLog": "Battle Log", + "duration": "Duration", + "mode": "Mode", + "winningTeam": "Winning Team", + "losingTeam": "Losing Team", + "viewMatch": "View Match", + "loadingMatch": "Loading match data...", + "matchNotFound": "Match not found", + "searchingGames": "Searching games...", + "recentMatches": "Recent Matches", + "noChatLog": "No chat log available", + "noBattleLog": "No battle log available", + "replayVideo": "Replay Video", + "generatingVideo": "Generating replay video…", + "videoFirstLoad": "First load may take up to a minute", + "videoUnavailable": "Replay video unavailable for this match", + "modeGround": "Ground", + "modeAir": "Air", + "squadronPlaceholder": "Squadron name...", + "loadingReplay": "Loading Replay..." + }, + "errors": { + "pageNotFound": "Page Not Found", + "error": "Error", + "oopsNotFound": "Oops! The page you're looking for doesn't exist. It might have been moved, deleted, or you entered the wrong URL.", + "searchError": "Search error. Please try again." + }, + "js": { + "openingDiscordInvite": "Opening Discord invite!", + "errorOpeningInvite": "Error opening invite link. Please try again later.", + "gettingSupportLink": "Getting support server link...", + "openingSupportServer": "Opening support server!", + "errorGettingSupport": "Error getting support link. Please try again later.", + "failedToUpdateStats": "Failed to update stats", + "konamiActivated": "Achievement Unlocked: Secret Code!", + "noPlayersFound": "No players found", + "searchError": "Search error. Please try again.", + "killsSuffix": "kills", + "winRateSuffix": "win rate", + "noSquadronsFound": "No squadrons found" + }, + "index": { + "subtitle1": "The Best Squadron Battles Utility", + "subtitle2": "Boris Stats but Better", + "subtitle3": "The largest SQB dataset", + "subtitle4": "Public and Free information for all" + }, + "seasonCard": { + "buttonLabel": "Season Card", + "buttonDisabledTitle": "Squadron lookup incomplete — card unavailable", + "modalTitle": "Season Card", + "seasonLabel": "Season", + "themeLabel": "Theme", + "themeDark": "Dark", + "themeLight": "Light", + "generate": "Generate", + "loadingSeasons": "Loading seasons…", + "generating": "Generating…", + "failedSeasons": "Failed to load seasons.", + "failedGenerate": "Failed to generate recap card.", + "inProgressSuffix": "(in progress)", + "imgRecapSuffix": "RECAP", + "imgHeroFinalRating": "Final Rating", + "imgHeroMatches": "Matches", + "imgHeroWinRate": "Win Rate", + "imgHeroKD": "K/D", + "imgAxisRating": "Rating", + "imgAxisWinRate": "Win Rate (%)", + "imgStatPeakRating": "Peak rating", + "imgStatRatingChange": "Rating change", + "imgStatTotalKills": "Total kills", + "imgStatTotalDeaths": "Total deaths", + "imgStatAssistsCaptures": "Assists / captures", + "imgStatMostPlayedVehicle": "Most-played vehicle", + "imgStatMVP": "MVP", + "imgStatMostActive": "Most active", + "imgStatLongestWinStreak": "Longest win streak", + "imgStatMostCommonOpponent": "Most common opponent", + "imgUnitKills": "kills", + "imgUnitAssists": "assists", + "imgUnitCaptures": "captures", + "imgUnitGames": "games", + "imgUnitMatches": "matches", + "imgUnitWins": "Wins", + "imgUnitLosses": "Losses", + "imgGroundShort": "G", + "imgAirShort": "A", + "imgFooterGenerated": "generated", + "imgPlaceholderNoData": "No data for {short} in {season}", + "buttonLabelPlayer": "Season Recap", + "buttonDisabledTitlePlayer": "Player lookup incomplete — recap unavailable", + "modalTitlePlayer": "Player Season Recap", + "imgHeroBattles": "Battles", + "imgHeroTotalKills": "Total Kills", + "imgAxisBattles": "Battles (7d)", + "imgAxisKD": "K/D", + "imgStatBestMatch": "Best match", + "imgStatSquadronsRepresented": "Squadrons represented", + "imgStatFrequentTeammate": "Most frequent teammate", + "imgStatLongestSession": "Longest session", + "imgStatMostActiveDay": "Most active day (UTC)", + "imgStatMostCommonOppSquadron": "Most common opposing squadron", + "imgStatPeakSquadronRating": "Peak squadron rating", + "imgUnitSlotNA": "NA", + "imgUnitSlotEU": "EU", + "imgUnitNoSquadron": "no sq", + "imgUnitVs": "vs", + "imgUnitTogether": "matches together", + "imgStatKDAC": "K / D / A / C", + "imgPlaceholderNoDataPlayer": "No data for {nick} in {season}", + "imgUIDLabel": "UID", + "imgBestMatchLine": "{vehicle} · GK {gk} / AK {ak} / AS {assists} / CAP {cap} / D {deaths} · {date}" + }, + "live": { + "air": "AIR", + "gnd": "GND", + "ast": "AST", + "dth": "DTH", + "cap": "CAP", + "squadronBattle": "Squadron Battle", + "randomBattle": "Random Battle" + }, + "analytics": { + "pageTitle": "SQB Analytics", + "pageSubtitle": "Deep-dive analytics for any squadron, player, or vehicle.", + "modeSquadron": "Squadron", + "modePlayer": "Player", + "modeVehicle": "Vehicle", + "tabMaps": "Map Win Rates", + "tabSquadmates": "Common Squadmates", + "tabComps": "Team Compositions", + "tabConsistency": "Player Consistency", + "tabTime": "Time of Day", + "tabMatchups": "Matchup History", + "pickSquadron": "Search any squadron to see their analysis", + "pickPlayer": "Search any player to view their analysis", + "pickVehicle": "Search a vehicle to view its analysis", + "noData": "No data in this range.", + "loading": "Loading…", + "loadError": "Failed to load analytics.", + "compComingSoon": "Team composition analysis coming soon.", + "compTopVehiclesTitle": "Top Vehicles", + "compCompositionsTitle": "Recurring Match Compositions", + "compCompositionsMeta": "Lineups with at least {min} vehicles, sorted by matches played", + "compMatchesAnalyzed": "matches analyzed", + "compNoRepeats": "No lineup of that size was fielded in this range.", + "compColVehicle": "Vehicle", + "compColSpawns": "Spawns", + "compColMatches": "Matches", + "compColShare": "Match %", + "compColLineup": "Lineup", + "compColTypes": "Comp", + "compTypeFighters": "Fighters", + "compTypeBombers": "Bombers", + "compTypeHelicopters": "Helicopters", + "compTypeLight": "Light Tank", + "compTypeTanks": "Tanks", + "compTypeSPAA": "SPAA", + "compTypeSPAATooltip": "Anti-Aircraft/SPAA", + "compTypeUnknown": "Unknown", + "compSearchPresetLabel": "Preset comp", + "compSearchPresetAll": "All comps", + "compSearchPresetHint": "Built from this squadron's own comp history", + "compSearchTypesLabel": "Types", + "compTypeCapsHint": "Max 8 total · max 4 aviation (F + B + H)", + "compSearchRefineLabel": "Refine by", + "compRefineHint": "Set a count above to pick specific vehicles for that type.", + "compRefineAny": "Any {type}", + "compSearchCustomLabel": "Custom", + "compSearchAddVehicle": "Specific vehicle", + "compSearchVehiclesLabel": "Vehicles", + "compSearchApply": "Apply", + "compSearchReset": "Reset", + "compSearchMatches": "Showing {shown} of {total} comps", + "compSearchNoMatches": "No comps match the selected filter.", + "compSearchGamesShort": "games", + "colMap": "Map", + "colWins": "W", + "colLosses": "L", + "colWinRate": "WR", + "colBar": "", + "colShared": "Shared", + "colUid": "UID", + "colGames": "Games", + "colPlayer": "Player", + "colAvgKills": "Avg K", + "colAvgDeaths": "Avg D", + "colScore": "Score", + "colHour": "Hour (UTC)", + "colSquadron": "Squadron", + "colTotal": "Total", + "matchupsWonHeader": "Most Won Against", + "matchupsLostHeader": "Most Lost To", + "uniqueOpponents": "unique opponents", + "euTimeslot": "EU Timeslot", + "naTimeslot": "NA Timeslot", + "offPeak": "Off-Peak", + "radarMetaMaps": "top {shown} of {total} maps · min {min} games", + "radarMetaSquadmates": "top {shown} of {total} squadmates · min {min} shared games", + "radarTooFewMaps": "Not enough data to render the graph — need at least 3 maps with {min}+ games in this filter.", + "radarTooFewSquadmates": "Not enough data to render the graph — need at least 3 squadmates with {min}+ shared games in this filter.", + "radarFootnoteMaps": "{count} less-played maps hidden — see full table", + "radarFootnoteSquadmates": "{count} less-played squadmates hidden — see full table", + "tabTimeline": "K/D Timeline", + "tabTopPlayers": "Top Players", + "tabTopSquadrons": "Top Squadrons" + }, + "playerModal": { + "viewFullProfile": "View Full Profile →", + "close": "Close", + "overview": "Overview", + "vehicles": "Vehicles", + "sessions": "Sessions", + "loadingPlayerData": "Loading player data...", + "kdr": "KDR", + "kps": "KPS", + "winRate": "Win Rate", + "battles": "Battles", + "wins": "Wins", + "totalBattles": "Total Battles", + "totalKills": "Total Kills", + "airKills": "Air Kills", + "groundKills": "Ground Kills", + "assists": "Assists", + "deaths": "Deaths", + "captures": "Captures", + "clickToSwitchMetric": "Click to switch metric", + "clickToCycle": "click to cycle", + "noChartData": "No chart data", + "noVehicleData": "No vehicle data", + "noSessionData": "No session data", + "date": "Date", + "vehicle": "Vehicle", + "ground": "Ground", + "air": "Air", + "result": "Result", + "unknown": "Unknown", + "failedToLoadPlayerData": "Failed to load player data" + }, + "replay": { + "playPause": "Play/Pause", + "crashed": "crashed", + "destroyed": "destroyed", + "hit": "hit" + }, + "dateFilter": { + "allTime": "All Time", + "currentSeason": "Current Season", + "bySeason": "By Season", + "cumulative": "Cumulative", + "customRange": "Custom Range", + "selectSeason": "Select Season", + "selectSeasonDots": "Select a season...", + "selectWeek": "Select Week", + "selectWeekDots": "Select a week...", + "entireSeason": "Entire Season", + "applyFilter": "Apply Filter", + "cumulativeHelp": "View statistics accumulated up to a specific point in time", + "season": "Season", + "upToWeek": "Up to Week", + "applyCumulativeFilter": "Apply Cumulative Filter", + "startDate": "Start Date", + "endDate": "End Date", + "applyCustomRange": "Apply Custom Range", + "activeFilter": "Active Filter:", + "clear": "Clear", + "allTimeStatistics": "All Time Statistics", + "currentSeasonValue": "Current Season: {season}", + "alertSelectSeason": "Please select a season", + "seasonValue": "Season {season}", + "alertSelectSeasonWeek": "Please select both season and week", + "cumulativeValue": "Cumulative up to {season} - {week}", + "alertSelectDate": "Please select at least one date", + "alertStartBeforeEnd": "Start date must be before end date", + "customRangePrefix": "Custom Range:", + "fromDate": "From {date}", + "upToDate": "Up to {date}" + } +} diff --git a/web/locales/es.json b/web/locales/es.json new file mode 100644 index 0000000..8de0cc0 --- /dev/null +++ b/web/locales/es.json @@ -0,0 +1,906 @@ +{ + "nav": { + "home": "Inicio", + "live": "En vivo", + "leaderboards": "Clasificaciones", + "docs": "Documentación", + "terms": "Términos", + "premium": "Premium", + "support": "Soporte", + "addToDiscord": "Añadir a Discord", + "games": "Partidas", + "squadrons": "Escuadrones", + "donate": "Donar", + "analytics": "Análisis" + }, + "footer": { + "services": "Servicios", + "matchFeed": "Feed de partidas", + "vehicleStats": "Estadísticas de vehículos", + "analytics": "Análisis", + "squadronHub": "Hub de escuadrón", + "comparison": "Comparación", + "resources": "Recursos", + "documentation": "Documentación", + "inviteBot": "Invitar Bot", + "legal": "Legal", + "termsOfService": "Términos de servicio", + "privacyPolicy": "Política de privacidad", + "termsAndPrivacy": "Términos y privacidad", + "meowing": "Maullando", + "websiteBy": "Sitio web por", + "andToothless": "y Toothless" + }, + "common": { + "loading": "Cargando...", + "retry": "Reintentar", + "backToHome": "Volver al inicio", + "battles": "Batallas", + "wins": "Victorias", + "winRate": "Tasa de victoria", + "kills": "Eliminaciones", + "totalKills": "Total de eliminaciones", + "groundKills": "Eliminaciones terrestres", + "airKills": "Eliminaciones aéreas", + "assists": "Asistencias", + "deaths": "Muertes", + "captures": "Capturas", + "kdr": "KDR", + "kps": "KPS", + "rank": "Clasificación", + "player": "Jugador", + "players": "Jugadores", + "playersCount": "jugadores", + "vehicle": "Vehículo", + "vehicles": "Vehículos", + "squadron": "Escuadrón", + "squadrons": "Escuadrones", + "statistics": "Estadísticas", + "comparison": "Comparación", + "date": "Fecha", + "result": "Resultado", + "totalBattles": "Total de batallas", + "totalWins": "Total de victorias", + "points": "Puntos", + "members": "Miembros", + "membersCount": "miembros", + "rating": "Puntuación", + "searchPlayerByName": "Buscar jugador por nombre...", + "noPlayersFound": "No se encontraron jugadores", + "noSquadronsFound": "No se encontraron escuadrones", + "noVehiclesFound": "No se encontraron vehículos", + "failedToLoad": "Error al cargar los datos. Por favor, inténtalo de nuevo más tarde.", + "recordingSince": "Registrando datos desde 01/01/2026", + "vs": "VS", + "map": "Mapa" + }, + "home": { + "squadronBattles": "Batallas de Escuadrón", + "madeSimple": "Sin complicaciones", + "addToDiscord": "Añadir a Discord", + "learnMore": "Saber más", + "searchBySquadron": "BUSCAR POR ESCUADRÓN", + "typeSquadronName": "Escribe el nombre de un escuadrón...", + "orByPlayer": "O POR JUGADOR", + "typePlayerName": "Escribe el nombre de un jugador...", + "liveFeed": "Feed de partidas", + "realTimeMatches": "Encuentra tus partidas", + "topPlayers": "Mejores jugadores", + "vehicleStatsCard": "Estadísticas de vehículos", + "performanceMetrics": "Métricas de rendimiento", + "analyticsCard": "Análisis", + "globalStatistics": "Estadísticas globales", + "squadronHubCard": "Hub de escuadrón", + "squadronStats": "Estadísticas de escuadrón", + "comparisonCard": "Comparación", + "compareStats": "Comparar estadísticas", + "joinServers": "Únete a los 500+ servidores que usan nuestro bot para seguir su rendimiento", + "noSquadronsFound": "No se encontraron escuadrones", + "noPlayersFound": "No se encontraron jugadores", + "searchPlayersIn": "Buscar jugadores en", + "ctaElev8": "¿Listo para ELEV8 tu escuadrón?", + "ctaReign": "¿Listo para R3IGN de nuevo?", + "ctaMeow": "Meowww", + "ctaPurr": "Purrr", + "ctaRawr": "Rawr" + }, + "docs": { + "title": "Documentación", + "subtitle": "Todo lo que necesitas saber sobre", + "quickNavigation": "Navegación rápida", + "gettingStarted": "Primeros pasos", + "commands": "Comandos", + "serverSetup": "Configuración del servidor", + "features": "Características", + "examples": "Ejemplos", + "troubleshooting": "Solución de problemas", + "stackManager": "Gestor de Stack", + "welcomeMessage": "Sigue estos pasos para empezar a funcionar.", + "inviteTheBot": "Invitar al Bot", + "inviteBotDesc": "Haz clic en el botón \"Añadir al Servidor\" y selecciona el servidor de Discord de tu escuadrón. El bot enviará un mensaje de bienvenida con una sugerencia para ejecutar /setup.", + "runSetupWizard": "Ejecutar el asistente de configuración", + "setupWizardDesc": "El asistente de configuración te guía para configurar tu escuadrón, el canal de registros y el canal de puntos en un solo flujo:", + "setupEasiest": "Esta es la forma más fácil de comenzar. Te guiará paso a paso para establecer tu escuadrón y elegir los canales.", + "youreDone": "¡Listo!", + "doneDesc": "El bot comenzará a publicar puntos y actualizaciones de clasificación automáticamente. Usa /autolog-management para ajustar la configuración de notificaciones más adelante.", + "premiumNote": "Los registros automáticos de partidas (marcadores completos después de cada partida) requieren una suscripción Premium. Ejecuta /unlock para suscribirte — $2.99/mes por servidor, facturado a través de Discord.", + "manualSetup": "Configuración manual (alternativa)", + "manualSetupDesc": "Si prefieres configurar las cosas individualmente, puedes usar estos comandos en su lugar:", + "allCommandsSlash": "Todos los comandos usan el sistema de comandos de barra diagonal de Discord. Escribe / para ver los comandos disponibles.", + "serverSetupAdmin": "Configuración y administración del servidor", + "importantNote": "Nota importante", + "verifyFirst": "¡Siempre verifica primero! El comando /sq-info confirma que el bot puede encontrar tu escuadrón en la base de datos de War Thunder, incluso si tienes una clasificación muy baja.", + "cantFindSquadron": "Si el bot no puede encontrar tu escuadrón con /sq-info, los comandos de configuración no funcionarán correctamente.", + "botNotResponding": "El bot no responde", + "checkOnline": "Verifica si el bot está en línea (estado verde)", + "verifyPermissions": "Verifica que el bot tenga los permisos necesarios", + "tryDifferentChannel": "Intenta usar los comandos en un canal diferente", + "commandsNotWorking": "Los comandos no funcionan", + "ensureSlash": "Asegúrate de usar comandos de barra diagonal (comienzan con /)", + "checkRolePerms": "Verifica si tu rol tiene permiso para usar comandos del bot", + "tryRefreshing": "Intenta refrescar Discord o reiniciar la aplicación", + "dataNotSaving": "Los datos no se guardan", + "verifySendMessages": "Verifica que el bot tenga el permiso \"Enviar Mensajes\"", + "checkOutages": "Comprueba si hay interrupciones en Discord", + "contactSupport": "Contacta al soporte si el problema persiste", + "needMoreHelp": "¿Necesitas más ayuda?", + "needMoreHelpDesc": "Si necesitas asistencia adicional, no dudes en contactarnos a través de nuestros canales de soporte.", + "example": "Ejemplo", + "supportedLanguages": "Idiomas compatibles", + "setupDesc": "Asistente paso a paso para configurar el bot en tu servidor. Establece tu escuadrón, el canal de registros y el canal de puntos en un solo flujo.", + "recommendedForNew": "Recomendado para servidores nuevos.", + "setSquadronDesc": "Guarda un escuadrón predeterminado para tu servidor de Discord. Se usa para el registro y como predeterminado para otros comandos.", + "quickLogDesc": "Configura una alarma para un escuadrón en el canal actual. El tipo puede ser Logs, Points, Leaderboard, o Both — Both establece Logs y Points juntos en un solo comando. Por defecto es Logs.", + "quickLogPremiumNote": "Logs (marcadores automáticos de partida) requieren una suscripción Premium. Las alertas de Puntos y Clasificación son gratuitas.", + "autologDesc": "Gestiona las notificaciones de autolog y diagnostica los permisos del canal. Úsalo para cambiar la configuración después de la configuración inicial.", + "autologPremiumNote": "Los registros automáticos de partidas requieren una suscripción Premium.", + "diagnosePermsDesc": "Comprueba instantáneamente si el bot tiene los permisos necesarios en el canal actual, muestra tus canales de autolog configurados y muestra el estado de suscripción Premium de este servidor. Úsalo si los marcadores o los puntos no se publican.", + "squadronInformation": "Información del escuadrón", + "sqInfoDesc": "Ver información detallada sobre cualquier escuadrón. Usa el escuadrón predeterminado del servidor si no se especifica ninguno.", + "sqInfoGraphDesc": "Visualiza la plantilla actual como un gráfico de barras dividido en grupos núcleo, activos y débiles según actividad y tasa de victoria (temporada actual).", + "compDesc": "Encuentra las últimas composiciones conocidas para las batallas de un escuadrón determinado. Los servidores gratuitos obtienen 25 búsquedas por franja horaria; Premium obtiene ilimitadas.", + "trackDesc": "Rastrea un escuadrón y compara estadísticas con la última vez que lo comprobaste.", + "topDesc": "Muestra los 20 mejores escuadrones y sus estadísticas actuales.", + "sqStatsDesc": "Muestra los puntos de un escuadrón a lo largo del tiempo como un gráfico interactivo.", + "lossCalculatorDesc": "Calcula cuántos puntos perdería un escuadrón si ciertos jugadores lo abandonaran.", + "recentDesc": "Muestra las últimas 5 batallas de escuadrón de un equipo.", + "vsDesc": "Ver el historial de enfrentamientos directos contra otro escuadrón.", + "leaderboardLinkDesc": "Obtén un enlace a la clasificación global de jugadores de SRE Bot.", + "playerStats": "Estadísticas de jugador", + "playerStatsDesc": "Ver estadísticas detalladas de vehículos para un jugador con un menú desplegable de vehículos interactivo. Compatible con autocompletado.", + "viewPlayerGamesDesc": "Ver las últimas 20 partidas de un jugador. Muestra el recuento total de victorias/derrotas y el porcentaje de victoria, un resumen por partida (resultado, escuadrón rival, mapa y composición jugada), y una lista deduplicada de cada composición única que el jugador ha usado. Compatible con autocompletado.", + "viewMatchDesc": "Ver el marcador completo de una partida específica. Proporciona un ID de partida directamente o busca por nombre de jugador para explorar sus últimas 100 partidas y elegir una. Incluye botones de Ver Repetición, Ver Rutas, Registro de Chat y Registro de Batalla.", + "examples2": "Ejemplos", + "compareDesc": "Compara estadísticas de SQB agregadas entre dos o más jugadores (hasta 7). Muestra una comparación lado a lado con las mejores estadísticas resaltadas. Incluye un botón de gráfico para ver el historial de puntos durante 90 días.", + "metaData": "Datos meta", + "metaManagementDesc": "Configura los ajustes de acceso a datos meta para tu servidor.", + "metaDesc": "Busca en el roster meta de tu escuadrón por nombre de vehículo.", + "settingsUtilities": "Ajustes y utilidades", + "languageDesc": "Cambia el idioma predeterminado del bot. También afecta al idioma de los vehículos mostrados en tus registros de batalla.", + "scheduleDesc": "Ver el calendario de BR de la temporada actual. Muestra el nivel de batalla máximo de cada semana con su rango de fechas, tachado en semanas pasadas y resaltado en el período activo actual.", + "websiteDesc": "Obtén un enlace al sitio web de SRE Bot para búsqueda de jugadores, clasificaciones y más.", + "creditsDesc": "Ver el equipo acreditado por construir este bot.", + "unlockDesc": "Desbloquea los registros automáticos de partidas SQB y búsquedas /comp ilimitadas para este servidor. Al suscribirte se añaden marcadores completos publicados automáticamente en tu canal configurado después de cada partida. $2.99 / mes · por servidor · cancela cuando quieras. La facturación se gestiona íntegramente a través de Discord — no se requiere cuenta externa.", + "analyticsDesc": "Análisis avanzados de SQB: tasas de victoria por mapa, composiciones de equipo, consistencia de jugadores, rendimiento por hora del día e historial de enfrentamientos (más victorias y derrotas contra rivales).", + "sqCardDesc": "Genera una tarjeta PNG de resumen de temporada para un escuadrón — tendencia de rating, porcentaje de victorias, mejores jugadores y más. Elige la temporada desde el autocompletado. Admite temas oscuro y claro.", + "cardDesc": "Genera una tarjeta PNG de resumen de temporada para un jugador. Elige la temporada desde el autocompletado y resuelve al jugador por nombre de usuario. Admite temas oscuro y claro.", + "queryDesc": "[Solo administrador] Ejecuta consultas predefinidas a la base de datos — estadísticas de escuadrón, recuento de partidas, jugadores más activos, mapas principales y más. Los resultados son efímeros (solo visibles para ti).", + "donateDesc": "Apoya el desarrollo de SRE Bot a través de Ko-fi.", + "botStatusDesc": "Muestra cuándo se recibió la última partida y el TTL promedio de las partidas recientes. Indica servidores Gaijin lentos.", + "premiumBadge": "Premium", + "newsDesc": "Ver las últimas noticias y anuncios de SRE Bot.", + "stackCreateDesc": "Crea un stack de jugadores para coordinar un equipo antes de una partida. Se publica un embed persistente en el canal mostrando los miembros actuales y los solicitantes pendientes. El stack dura hasta 8 horas y se limpia automáticamente al final de cada franja horaria de SQB.", + "stackRequestToJoin": "Solicitar unirse — Cualquier jugador puede aplicar con el vehículo que planea usar. Las solicitudes se acumulan hasta 20 espacios.", + "stackLeaveWithdraw": "Salir / Retirar — Los miembros pueden abandonar el stack; los solicitantes pueden retirar su solicitud. Se le pide al líder del stack que transfiera la propiedad primero.", + "stackManagePanel": "Gestionar stack ⚙️ — Panel exclusivo del líder con cuatro secciones:", + "stackAcceptMembers": "Aceptar miembros — Acepta o rechaza solicitantes individualmente o todos a la vez. Hasta 8 miembros en total.", + "stackRemoveMembers": "Eliminar miembros — Elimina miembros activos o solicitantes en cola. Opciones: Eliminar todos, Eliminar activos, Eliminar en cola, o Eliminar seleccionados de un menú desplegable.", + "stackPingMembers": "Mencionar miembros — Menciona con un mensaje personalizado opcional. Opciones: Mencionar todos (miembros + cola, excluyendo al líder), Mencionar activos (solo miembros), Mencionar en cola (solo solicitantes), o Mencionar seleccionados de un menú desplegable.", + "stackRenameStack": "Renombrar stack — Establece un nombre personalizado para el stack. Aparece como título del embed y en los mensajes de mención en lugar del predeterminado \"Stack de [Líder]\".", + "stackDisbandStack": "Disolver stack — El líder puede terminar el stack antes de tiempo.", + "stackManageDesc": "Vuelve a publicar tu embed de stack activo en el canal actual. Úsalo si el embed original fue eliminado o se perdió tras un reinicio del bot. Todos los datos de miembros y cola existentes se conservan.", + "translation": "Traducción", + "translateContextMenu": "Clic derecho en mensaje → Aplicaciones → Traducir Mensaje", + "translateDesc": "Traduce cualquier mensaje usando el menú contextual de Discord. Haz clic derecho (o mantén presionado en móvil) en un mensaje y selecciona Aplicaciones → Traducir Mensaje.", + "viewAllLanguages": "Ver todos los idiomas compatibles", + "serverSetupSubtitle": "Configura {botName} para un rendimiento óptimo en el servidor de Discord de tu escuadrón.", + "requiredPermissions": "Permisos requeridos", + "sendMessages": "Enviar mensajes", + "useSlashCommands": "Usar comandos de barra diagonal", + "embedLinks": "Insertar enlaces", + "readMessageHistory": "Leer historial de mensajes", + "recommendedChannelSetup": "Configuración de canal recomendada", + "recommendedChannelDesc": "Crea un canal dedicado como #batallas-de-escuadron para el seguimiento y las estadísticas. Esto mantiene tus datos de batalla organizados y de fácil acceso.", + "roleConfiguration": "Configuración de roles", + "roleConfigurationDesc": "Asigna roles apropiados a los miembros del escuadrón que puedan registrar resultados de batalla. Recomendamos limitar esto a los oficiales y líderes del escuadrón.", + "premiumSectionSubtitle": "Los registros automáticos de partidas SQB son una función Premium, desbloqueada por servidor a través del sistema de suscripción nativo de Discord.", + "whatsIncluded": "Qué incluye", + "premiumInclude1": "Marcador completo publicado automáticamente en tu canal configurado después de cada partida SQB", + "premiumInclude2": "Búsquedas /comp ilimitadas (los servidores gratuitos obtienen 25 por franja horaria)", + "premiumInclude3": "Todas las funciones gratuitas existentes (alarmas de puntos, clasificación, comandos de estadísticas, etc.) permanecen gratuitas", + "pricingBilling": "Precios y facturación", + "pricingBillingDesc": "$2.99 / mes · por servidor · cancela cuando quieras. La facturación se gestiona íntegramente a través de Discord — no se requiere cuenta externa ni procesador de pagos. Las suscripciones se renuevan automáticamente y se pueden cancelar en cualquier momento desde tu configuración de Discord.", + "howToSubscribe": "Cómo suscribirse", + "subscribe1": "Ejecuta /unlock en tu servidor (se requiere administrador del servidor)", + "subscribe2": "Haz clic en el botón Suscribirse en la respuesta del bot", + "subscribe3": "Completa el pago dentro de Discord — el bot se activa inmediatamente", + "cancellation": "Cancelación", + "cancellationDesc": "Cuando una suscripción vence o se cancela, el bot deja automáticamente de publicar registros de partidas para ese servidor en el siguiente ciclo de autolog. No se requiere ninguna acción manual.", + "realTimeStatistics": "Estadísticas en tiempo real", + "realTimeStatisticsDesc": "Rastrea victorias, derrotas y métricas de rendimiento en tiempo real.", + "battleHistory": "Historial de batallas", + "battleHistoryDesc": "Historial completo de todas las batallas de escuadrón registradas.", + "leaderboardsFeature": "Clasificaciones", + "leaderboardsFeatureDesc": "Compara el rendimiento de tu escuadrón con otros usando /top.", + "playerTracking": "Seguimiento de jugadores", + "playerTrackingDesc": "Estadísticas individuales de jugadores e integración con ThunderSkill.", + "smartAlerts": "Alertas inteligentes", + "smartAlertsDesc": "Alarmas y notificaciones automatizadas para las actividades del escuadrón.", + "multiLanguageSupport": "Soporte multilingüe", + "multiLanguageSupportDesc": "Funciones de traducción e idiomas de vehículos personalizables.", + "timeCoordination": "Coordinación horaria", + "timeCoordinationDesc": "Herramientas de hora UTC y local para la coordinación global del escuadrón.", + "advancedSearch": "Búsqueda avanzada", + "advancedSearchDesc": "Encuentra composiciones de jugadores e información detallada del escuadrón.", + "dataSecurity": "Seguridad de datos", + "dataSecurityDesc": "Tus datos están seguros y nunca se comparten ni se venden.", + "usageExamples": "Ejemplos de uso", + "quickSetupRecommended": "Configuración rápida (recomendada)", + "quickSetupDesc": "Ejecuta el asistente de configuración. Te guía para establecer tu escuadrón, elegir un canal de registros y un canal de puntos — todo en un solo flujo.", + "comparingPlayers": "Comparar jugadores", + "comparingPlayersDesc": "Compara hasta 7 jugadores lado a lado. La mejor estadística en cada categoría se resalta y el mejor jugador en general recibe una estrella. Haz clic en \"Mostrar Gráfico\" para ver su historial de puntos.", + "checkingSquadronInfo": "Comprobar información del escuadrón", + "verifySquadronDesc": "Verifica que un escuadrón existe y ve sus detalles. Úsalo para confirmar que el bot puede encontrar tu escuadrón antes de la configuración.", + "recentBattlesDesc": "Ve las últimas 5 batallas jugadas por un escuadrón.", + "headToHeadDesc": "Ve tu historial de enfrentamientos directos contra otro escuadrón.", + "backToHome": "Volver al inicio", + "termsAndPrivacy": "Términos y privacidad", + "seasonRecapCardTitle": "Tarjeta de resumen de temporada", + "seasonRecapCardDesc": "Genera un resumen PNG compartible de la temporada de cualquier escuadrón — curva de rating, tasa de victorias móvil, K/D, vehículo principal, MVP y más. Disponible desde el botón «Tarjeta de temporada» en la página de perfil de cada escuadrón.", + "playerRecapCardTitle": "Resumen de temporada del jugador", + "playerRecapCardDesc": "Genera un resumen PNG compartible de la temporada de cualquier jugador — evolución del rating entre escuadrones, ratio de victorias móvil, K/D, ritmo de batallas, mejor partida y más. Disponible desde el botón «Season Recap» en cada perfil de jugador.", + "tierOverview": "Resumen de niveles", + "tierOverviewDesc": "El auto-logging está disponible en tres niveles. Cada nivel limita cuántos escuadrones pueden tener Logs y Points activos. Los escuadrones sobre el límite permanecen en tus preferencias y se reanudan automáticamente tras la mejora.", + "tierStandardLine": "$2.99 — hasta 10 escuadrones cada uno para Logs y Points, sin wildcard.", + "tierProLine": "hasta 25 escuadrones cada uno para Logs y Points, más soporte wildcard (`*` / `all` / `everything`).", + "tierMaxLine": "escuadrones ilimitados, wildcards, acceso anticipado a nuevas funciones." + }, + "terms": { + "pageTitle": "Términos de Servicio y Política de Privacidad", + "lastUpdated": "Última actualización: Abril 2026", + "termsOfService": "Términos de Servicio", + "byUsing": "Al usar", + "youAgree": ", aceptas lo siguiente:", + "useResponsibly": "Uso responsable", + "useResponsiblyDesc": "No intentes sobrecargar, hacer spam ni romper el bot de ninguna otra forma.", + "noFunnyBusiness": "Sin malas prácticas", + "noFunnyBusinessDesc": "No intentes realizar ingeniería inversa, violar los ToS de Discord ni ninguna otra Regla del Servidor.", + "statsAsIs": "Estadísticas tal como son", + "statsAsIsDesc": "Todos los datos y estadísticas se proporcionan \"tal cual\" sin garantía de ningún tipo, expresa o implícita. Hacemos esfuerzos razonables para garantizar la precisión, pero no garantizamos que la información sea completa, actual o esté libre de errores.", + "uptimeNotGuaranteed": "Disponibilidad no garantizada", + "uptimeNotGuaranteedDesc": "El bot se proporciona \"según disponibilidad\". No garantizamos un funcionamiento ininterrumpido ni libre de errores. El servicio puede no estar disponible temporalmente debido a mantenimiento, actualizaciones o problemas técnicos.", + "weCanBanYou": "Podemos banearte", + "weCanBanYouDesc": "Si incumples las reglas, podemos eliminar tu acceso.", + "privacyPolicy": "Política de Privacidad", + "infoWeCollect": "1. Información que recopilamos", + "collectsFollowing": "recopila la siguiente información para proporcionar sus servicios:", + "discordUserIds": "IDs de usuario de Discord:", + "discordUserIdsDesc": "Identificadores únicos para rastrear usuarios individuales", + "squadronIds": "IDs de escuadrón:", + "squadronIdsDesc": "Identificadores de servidor/guild de Discord para organizar los datos del escuadrón", + "battleData": "Datos de batalla:", + "battleDataDesc": "Registros de victorias/derrotas, marcas de tiempo de batallas y estadísticas relacionadas", + "commandUsage": "Uso de comandos:", + "commandUsageDesc": "Registro básico de los comandos del bot utilizados para la mejora del servicio", + "howWeUse": "2. Cómo usamos tu información", + "usedExclusively": "La información recopilada se usa exclusivamente para:", + "trackingPerformance": "Rastrear el rendimiento y las estadísticas de las Batallas de Escuadrón", + "providingHistorical": "Proporcionar datos históricos y análisis", + "improvingBot": "Mejorar la funcionalidad del bot y la experiencia del usuario", + "troubleshootingIssues": "Solucionar problemas técnicos", + "dataStorage": "3. Almacenamiento y seguridad de datos", + "dataStoredSecurely": "Tus datos se almacenan de forma segura con las siguientes protecciones:", + "encryptedServers": "Los datos se almacenan en servidores seguros con cifrado", + "limitedAccess": "El acceso está limitado únicamente al personal autorizado", + "regularBackups": "Las copias de seguridad regulares garantizan la integridad de los datos", + "dataSharing": "4. Compartición de datos", + "weDoNot": "NO:", + "sellData": "No vendemos tu información personal a terceros", + "shareData": "No compartimos tus datos con organizaciones externas", + "useForAds": "No usamos tus datos con fines publicitarios o de marketing", + "transferData": "No transferimos tus datos fuera de nuestros sistemas seguros", + "dataRetention": "5. Retención de datos", + "dataRetentionDesc": "Conservamos tus datos durante el tiempo necesario para proporcionar nuestros servicios. Las estadísticas de batalla y los datos del escuadrón se mantienen para preservar registros históricos y análisis.", + "discordIntegration": "6. Integración con Discord", + "discordIntegrationDesc": "Este Bot opera dentro del ecosistema de Discord y está sujeto a la Política de Privacidad de Discord. Solo accedemos a la información necesaria para la funcionalidad del bot a través de la API oficial de Discord.", + "ageRestrictions": "7. Restricciones de edad", + "ageRestrictionsDesc": "El Bot está destinado a usuarios que cumplan los requisitos de edad mínima de Discord (13+ o según lo exija la ley local).", + "changesToPolicy": "8. Cambios en la política de privacidad", + "changesToPolicyDesc": "Podemos actualizar esta Política de Privacidad de vez en cuando.", + "premiumTitle": "Términos de Suscripción Premium", + "premiumWhatYouGet": "1. Qué incluye Premium", + "premiumWhatYouGetDesc": "Una suscripción Premium desbloquea las siguientes funciones para el servidor de Discord suscrito:", + "premiumFeature1": "Publicaciones automáticas de marcadores después de cada partida SQB", + "premiumFeature2": "Mapas de rutas / movimiento", + "premiumFeature3": "Registros de chat y batalla", + "premiumFeature4": "Búsqueda de repeticiones", + "premiumFeature5": "Búsquedas /comp ilimitadas (los servidores gratuitos están limitados a 15 por franja horaria)", + "premiumBilling": "2. Facturación y pago", + "premiumBillingDesc": "Premium se factura a $2.99 USD por mes por servidor de Discord. El pago se procesa a través del sistema de suscripción nativo de Discord o a través de nuestro sitio web vía Whop. Usted es responsable de asegurarse de que su método de pago sea válido y tenga fondos suficientes. Las suscripciones se renuevan automáticamente al final de cada período de facturación, a menos que se cancelen.", + "premiumCancellation": "3. Cancelación", + "premiumCancellationDesc": "Puede cancelar su suscripción en cualquier momento. Para suscripciones de Discord, vaya a Configuración de usuario → Suscripciones en Discord. Para suscripciones del sitio web, administre su facturación en whop.com/billing. Al cancelar, sus funciones Premium permanecen activas hasta el final de su período de facturación actual. Después de eso, su servidor vuelve al nivel gratuito — no se pierden datos.", + "premiumRefunds": "4. Reembolsos", + "premiumRefundsDesc": "Las tarifas de suscripción no son reembolsables. El uso parcial del mes no se prorratea. Si cancela a mitad del ciclo, conserva el acceso hasta el final de ese período de facturación, pero no tiene derecho a un reembolso por el tiempo restante. Los reembolsos por errores de facturación o cargos duplicados pueden emitirse a nuestra discreción — contacte con soporte con su ID de servidor de Discord y comprobante de pago.", + "premiumPriceChanges": "5. Cambios de precio", + "premiumPriceChangesDesc": "Nos reservamos el derecho de cambiar los precios de suscripción en cualquier momento. Los suscriptores existentes serán notificados con al menos 30 días de anticipación antes de que cualquier aumento de precio entre en vigor. Si no está de acuerdo con un cambio de precio, puede cancelar antes de que se aplique el nuevo precio.", + "premiumTermination": "6. Terminación del acceso Premium", + "premiumTerminationDesc": "Nos reservamos el derecho de revocar el acceso Premium sin reembolso si un servidor viola estos Términos de Servicio, incluyendo, entre otros, el abuso de funciones del bot, intentos de eludir los límites de uso o la violación de los Términos de Servicio de Discord.", + "serviceAvailabilityTitle": "Disponibilidad del servicio y responsabilidad", + "serviceNoWarranty": "1. Sin garantía", + "serviceNoWarrantyDesc": "EL SERVICIO SE PROPORCIONA \"TAL CUAL\" Y \"SEGÚN DISPONIBILIDAD\" SIN GARANTÍAS DE NINGÚN TIPO, YA SEAN EXPRESAS, IMPLÍCITAS O LEGALES, INCLUYENDO, ENTRE OTRAS, LAS GARANTÍAS IMPLÍCITAS DE COMERCIABILIDAD, IDONEIDAD PARA UN PROPÓSITO PARTICULAR Y NO INFRACCIÓN. No garantizamos que el servicio sea ininterrumpido, oportuno, seguro o libre de errores.", + "serviceLiability": "2. Limitación de responsabilidad", + "serviceLiabilityDesc": "EN LA MÁXIMA MEDIDA PERMITIDA POR LA LEY APLICABLE, EN NINGÚN CASO SEREMOS RESPONSABLES POR DAÑOS INDIRECTOS, INCIDENTALES, ESPECIALES, CONSECUENTES O PUNITIVOS, NI POR PÉRDIDA DE BENEFICIOS O INGRESOS, YA SEA INCURRIDA DIRECTA O INDIRECTAMENTE, NI POR PÉRDIDA DE DATOS, USO, FONDO DE COMERCIO U OTRAS PÉRDIDAS INTANGIBLES, DERIVADAS DE SU USO O INCAPACIDAD DE USAR EL SERVICIO. Nuestra responsabilidad total por cualquier reclamación derivada de estos términos o del servicio no excederá la cantidad que nos haya pagado en los tres (3) meses anteriores a la reclamación.", + "serviceCredits": "3. Interrupciones del servicio y créditos", + "serviceCreditsDesc": "En caso de una interrupción prolongada y no planificada del servicio que afecte las funciones Premium durante más de 72 horas consecutivas, los suscriptores Premium afectados pueden solicitar un crédito de servicio contactando con soporte. Los créditos se emiten a nuestra entera discreción y se aplican como una extensión del período de facturación actual — no como reembolsos monetarios. Las ventanas de mantenimiento programado, las interrupciones de terceros (Discord, Gaijin API) y los eventos fuera de nuestro control razonable están excluidos.", + "serviceForceM": "4. Fuerza mayor", + "serviceForceMDesc": "No seremos responsables de ningún incumplimiento o retraso en el cumplimiento que resulte de causas fuera de nuestro control razonable, incluyendo, entre otras: desastres naturales, guerra, terrorismo, pandemias, cortes de energía, interrupciones de internet, interrupciones de la plataforma Discord, cambios o indisponibilidad de la API de Gaijin Entertainment, acciones gubernamentales o cualquier otro evento de fuerza mayor.", + "disclaimer": "Descargo de Responsabilidad", + "warThunderDisclaimer": "es un bot de Discord independiente y no está afiliado, respaldado ni asociado con Gaijin Entertainment o War Thunder. War Thunder es una marca registrada de Gaijin Entertainment.", + "acknowledgement": "Al usar", + "acknowledgementEnd": ", reconoces que has leído, comprendido y aceptas quedar vinculado por estos Términos de Servicio y Política de Privacidad." + }, + "premium": { + "upgradeTitle": "Mejora tu escuadrón", + "heroDesc": "Cada resultado SQB publicado en tu canal en el momento en que termina — marcadores, registros de batalla, mapas de movimiento y repeticiones, todo de forma automática.", + "instantScoreboards": "Marcadores instantáneos", + "viewPaths": "Ver rutas", + "chatBattleLogs": "Registros de chat y batalla", + "replayLookups": "Búsqueda de repeticiones", + "free": "Gratis", + "perMonth": "/mes", + "alwaysFree": "Siempre gratuito", + "noCardNeeded": "sin tarjeta necesaria", + "included": "Incluido", + "manualLookups": "Búsquedas manuales de partidas", + "playerStats": "Estadísticas y perfiles de jugadores", + "leaderboards": "Clasificaciones", + "stickWithFree": "Quedarse con la versión gratuita", + "premiumLabel": "Premium", + "perServer": "por servidor", + "cancelAnytime": "cancela cuando quieras", + "everythingInFree": "Todo lo del plan gratuito, más", + "autoScoreboards": "Publicación automática de marcadores", + "pathMaps": "Mapas de rutas / movimiento", + "chatLogs": "Registros de chat y batalla", + "replayLookupsFeature": "Búsqueda de repeticiones", + "unlimitedComp": "Búsquedas /comp ilimitadas", + "prioritySupport": "Soporte prioritario", + "subscribeNow": "Suscribirse ahora", + "comingSoon": "Próximamente", + "serverIdInfo": "Necesitarás tu", + "discordServerId": "ID del servidor de Discord", + "duringCheckout": "durante el pago.", + "developerMode": "Modo desarrollador", + "rightClickServer": "Clic derecho en el servidor", + "copyId": "Copiar ID", + "successTitle": "Premium activado", + "successDesc": "Tu suscripción está siendo configurada. El bot tendrá acceso premium para tu servidor en unos minutos.", + "whatHappensNext": "Qué sucede a continuación", + "autoLogging": "El registro automático se activa para tu servidor", + "setLogChannel": "Establece tu canal de registros con", + "everyResult": "Cada resultado SQB se publica automáticamente", + "readSetupGuide": "Leer la guía de configuración", + "tierStandardName": "Estándar", + "tierProName": "Pro", + "tierMaxName": "Máx", + "squadCap": "Registrar hasta {cap} escuadrones", + "squadCapUnlimited": "Escuadrones ilimitados", + "everythingInStandard": "Todo lo de Estándar", + "everythingInPro": "Todo lo de Pro", + "wildcardSupport": "Logging wildcard (*, all, everything)", + "noSquadCap": "Sin límite de escuadrones", + "earlyAccessFeatures": "Acceso anticipado a nuevas funciones" + }, + "player": { + "totalBattles": "Total de batallas", + "totalWins": "Total de victorias", + "vehicleStatistics": "Estadísticas de vehículos", + "cumulative": "Acumulado", + "individual": "Individual", + "filterBy": "Filtrar por:", + "allTime": "Histórico", + "dateRange": "Rango de fechas", + "season": "Temporada", + "week": "Semana", + "session": "Sesión", + "dateType": "Tipo de fecha:", + "last7Days": "Últimos 7 días", + "last30Days": "Últimos 30 días", + "last90Days": "Últimos 90 días", + "customRange": "Rango personalizado", + "specificDate": "Fecha específica", + "filterType": "Tipo de filtro:", + "fullSeason": "Temporada completa", + "specificWeek": "Semana específica", + "from": "Desde:", + "to": "Hasta:", + "timeslot": "Horario", + "fullDay": "Día completo", + "selectSeason": "Temporada:", + "selectWeek": "Semana:", + "selectSeasonFirst": "Selecciona la temporada primero", + "pleaseSelect": "Por favor selecciona una opción", + "searchVehicles": "Buscar vehículos...", + "resetFilters": "Restablecer filtros", + "vehiclesShown": "vehículos mostrados", + "gamesShown": "partidas mostradas", + "noVehicleData": "No hay datos de vehículos disponibles", + "noVehiclesForRange": "No se encontraron vehículos para el rango de fechas seleccionado, o este jugador aún no tiene datos.", + "switchToCards": "Cambiar a vista de tarjetas", + "switchToTable": "Cambiar a vista de tabla", + "loadingTimeline": "Cargando línea de tiempo...", + "noTimelineData": "Aún no hay datos de línea de tiempo.", + "timelineUnavailable": "Línea de tiempo no disponible.", + "loadingGameRecords": "Cargando registros de partidas...", + "unableToLoadRecords": "No se pueden cargar los registros de partidas", + "failedToFetch": "Error al obtener datos de partidas. Por favor, inténtalo de nuevo más tarde.", + "noGameRecords": "No se encontraron registros de partidas", + "noGamesYet": "Este jugador aún no ha jugado ninguna partida registrada.", + "collapseChart": "Contraer gráfico", + "loadingChartData": "Cargando datos del gráfico...", + "noHistoricalData": "Aún no hay datos históricos.", + "chartUnavailable": "Gráfico no disponible.", + "relative": "Relativo", + "uidLabel": "UID del jugador" + }, + "squadrons": { + "title": "Hub de Escuadrón", + "subtitle": "Descubre escuadrones, ve estadísticas y sigue el rendimiento", + "findSquadron": "Encontrar un escuadrón", + "searchPlaceholder": "Buscar escuadrones por nombre...", + "totalSquadrons": "Total de escuadrones", + "totalPlayers": "Total de jugadores", + "totalBattles": "Total de batallas", + "avgWinRate": "Porcentaje de victoria promedio", + "topSquadrons": "Mejores escuadrones", + "viewFullLeaderboard": "Ver clasificación completa", + "loadingSquadrons": "Cargando datos de escuadrones...", + "noSquadronData": "Aún no hay datos de escuadrones disponibles.", + "failedToLoad": "Error al cargar los datos del escuadrón. Por favor, inténtalo de nuevo más tarde.", + "backToSquadronHub": "Volver al hub de escuadrón", + "squadronPoints": "Puntos del escuadrón", + "squadronMembers": "Miembros del escuadrón", + "performance": "Rendimiento", + "performanceNoData": "No hay datos de rendimiento disponibles para el rango seleccionado.", + "quickDetails": "Detalles rápidos", + "noMembersFound": "No se encontraron miembros", + "noRecordedMembers": "Este escuadrón aún no tiene miembros registrados.", + "squadronGames": "Partidas del escuadrón", + "loadingSquadronGames": "Cargando registros de partidas del escuadrón...", + "noSquadronGames": "No se encontraron registros de partidas para este escuadrón.", + "retryLoadGames": "Reintentar", + "searchMapPlaceholder": "Buscar por mapa..." + }, + "leaderboard": { + "playersTitle": "Clasificación de Jugadores", + "playersSubtitle": "Mejores jugadores de War Thunder clasificados por rendimiento", + "vehiclesTitle": "Clasificación de eliminaciones por vehículo", + "vehiclesSubtitle": "Mejores vehículos de War Thunder clasificados por total de eliminaciones", + "squadronsTitle": "Clasificación de escuadrones", + "squadronsSubtitle": "Mejores escuadrones de War Thunder clasificados por rendimiento", + "statsTitle": "Estadísticas globales", + "statsSubtitle": "Estadísticas generales de batallas de escuadrón e información meta", + "comparisonTitle": "Herramienta de comparación", + "comparisonSubtitle": "Compara jugadores y vehículos lado a lado", + "comparisonHint": "Compara estadísticas para encontrar los mejores jugadores y vehículos", + "compareSquadrons": "Comparar escuadrones", + "comparePlayers": "Comparar jugadores", + "compareVehicles": "Comparar vehículos", + "playersAndVehicles": "Jugadores + Vehículos", + "failedToLoadLeaderboard": "Error al cargar los datos de clasificación. Por favor, inténtalo de nuevo más tarde.", + "failedToLoadVehicles": "Error al cargar la clasificación de vehículos", + "failedToLoadSquadrons": "Error al cargar la clasificación de escuadrones", + "noResultsYet": "Aún no hay resultados. Añade escuadrones/jugadores para comenzar.", + "searchSquadron": "Buscar escuadrón", + "searchBySquadronName": "Buscar por nombre de escuadrón...", + "minPlayers": "Mín. jugadores", + "minPlayersPlaceholder": "Mín. jugadores", + "resetFilters": "Restablecer filtros", + "squadronsShown": "escuadrones mostrados", + "playersShown": "jugadores mostrados", + "page": "página", + "of": "de", + "loadingSquadronLeaderboard": "Cargando clasificación de escuadrones...", + "loadingPlayerLeaderboard": "Cargando clasificación de jugadores...", + "loadingComparisonData": "Cargando datos de comparación...", + "unableToFetch": "No se pueden obtener los datos de clasificación. Por favor, inténtalo de nuevo.", + "noSquadronsInLeaderboard": "No se encontraron escuadrones en la clasificación.", + "noPlayersInLeaderboard": "No se encontraron jugadores en la clasificación.", + "loadingGlobalStats": "Cargando estadísticas globales...", + "failedToLoadStats": "Error al cargar las estadísticas", + "unableToFetchStats": "No se pueden obtener los datos de estadísticas. Por favor, inténtalo de nuevo.", + "mostPopularVehicles": "Vehículos más populares", + "vehicleKillsLeaderboardTitle": "Clasificación de eliminaciones por vehículo", + "avgWinRate": "Porcentaje de victoria promedio", + "avgKillsPerPlayer": "Eliminaciones promedio/jugador", + "loadingVehicleKills": "Cargando datos de eliminaciones por vehículo...", + "apiNotLoaded": "El cliente de API no está cargado correctamente. Por favor, recarga la página.", + "failedToInitApi": "Error al inicializar el cliente de API", + "noStatsData": "No hay datos de estadísticas disponibles", + "totalPlayersCard": "Total de jugadores", + "activePlayers": "Jugadores activos", + "vehiclesUsed": "Vehículos usados", + "differentVehicles": "Vehículos diferentes", + "squadronBattlesLabel": "Batallas de escuadrón", + "noVehicleData": "No hay datos de vehículos disponibles", + "mostPopular": "Más popular", + "timesUsed": "Veces usado", + "failedToLoadVehicleKills": "Error al cargar los datos de eliminaciones por vehículo", + "lastUpdated": "Última actualización", + "searchPlayer": "Buscar jugador", + "searchByPlayerName": "Buscar por nombre de jugador...", + "minimumBattles": "Batallas mínimas", + "minBattlesPlaceholder": "Mín. batallas", + "searchSquadronsPlaceholder": "Buscar escuadrones...", + "sortBy": "Ordenar por", + "kdRatio": "Ratio KD", + "killsPerSpawn": "Eliminaciones por spawn", + "caps": "Capturas", + "timePeriod": "Período de tiempo", + "allTime": "Histórico", + "dateRange": "Rango de fechas", + "season": "Temporada", + "week": "Semana", + "dateType": "Tipo de fecha", + "last7Days": "Últimos 7 días", + "last30Days": "Últimos 30 días", + "last90Days": "Últimos 90 días", + "customRange": "Rango personalizado", + "from": "Desde", + "to": "Hasta", + "timeslot": "Horario", + "fullDay": "Día completo", + "selectSeason": "Seleccionar temporada...", + "selectWeek": "Seleccionar semana...", + "failedToLoadComparison": "Error al cargar los datos de comparación", + "pleaseRefresh": "Por favor, intenta recargar la página", + "playerComparison": "Comparación de jugadores", + "squadronComparison": "Comparación de escuadrones", + "vehicleComparison": "Comparación de vehículos", + "playersVehiclesComparison": "Comparación de jugadores + vehículos", + "addPlayersToCompare": "Añadir jugadores a comparar:", + "addSquadronsToCompare": "Añadir escuadrones a comparar:", + "addVehiclesToCompare": "Añadir vehículos a comparar:", + "addPlayerVehicleCombos": "Añadir combinaciones jugador + vehículo:", + "searchSelectPlayers": "Buscar y seleccionar jugadores...", + "typeSquadronName": "Escribe el nombre de un escuadrón...", + "searchSelectVehicles": "Buscar y seleccionar vehículos...", + "searchForPlayers": "Buscar jugadores...", + "selectPlayersToCompare": "Busca y selecciona jugadores arriba para comparar sus estadísticas", + "selectSquadronsToCompare": "Busca y selecciona escuadrones arriba para comparar sus estadísticas", + "selectVehiclesToCompare": "Busca y selecciona vehículos arriba para comparar sus estadísticas", + "selectPlayersVehiclesToCompare": "Busca y selecciona jugadores arriba, luego elige sus vehículos para comparar diferentes combinaciones jugador-vehículo", + "selectVehicleFor": "Seleccionar vehículo para", + "selectAVehicle": "-- Seleccionar un vehículo --", + "noVehiclesForPlayer": "No se encontraron vehículos para este jugador", + "noPlayerVehicleSelected": "No hay combinaciones jugador-vehículo seleccionadas", + "noPlayersSelected": "No hay jugadores seleccionados", + "noVehiclesSelected": "No hay vehículos seleccionados", + "noSquadronsSelected": "No hay escuadrones seleccionados", + "statistic": "Estadística", + "totalDeaths": "Total de muertes", + "totalAssists": "Total de asistencias", + "totalCaptures": "Total de capturas", + "killsPerSpawnShort": "Elim./Spawn", + "avgWinRateShort": "% Victoria Prom.", + "avgKillsPlayerShort": "Elim. Prom./Jugador", + "avgKillsMember": "Elim. Prom./Miembro", + "avgBattlesMember": "Batallas Prom./Miembro", + "serverError500": "Error del servidor (500) - La API no está disponible temporalmente. Por favor, inténtalo de nuevo en unos momentos.", + "apiEndpoint404": "Endpoint de API no encontrado (404) - Por favor, verifica la configuración del servidor.", + "networkError": "Error de red - No se puede conectar al servidor. Por favor, verifica tu conexión.", + "viewFullLeaderboard": "Ver clasificación completa", + "vehicleName": "Nombre del vehículo", + "searchVehiclePlaceholder": "Buscar vehículo específico...", + "minKills": "Mín. eliminaciones", + "any": "Cualquiera", + "perPage": "Por página", + "clear": "Limpiar", + "vehicleAndPlayer": "Vehículo y jugador", + "loadingVehicleLeaderboard": "Cargando clasificación de vehículos...", + "vehiclesShown": "vehículos mostrados", + "minBattlesPerVehicle": "Mínimo 3 batallas por vehículo requeridas", + "minimumBattlesRequired": "Mínimo 5 batallas requeridas", + "allSeasons": "Todas las temporadas", + "allWeeks": "Todas las semanas", + "allBR": "Todos los BR" + }, + "games": { + "title": "Historial de Partidas", + "subtitle": "Busca y explora partidas de batallas de escuadrón", + "searchPlaceholder": "Buscar por nombre de jugador o UID...", + "filterByMap": "Filtrar por mapa", + "allMaps": "Todos los mapas", + "search": "Buscar", + "noResults": "No se encontraron partidas", + "matchDetail": "Detalle de partida", + "chatLog": "Registro de chat", + "battleLog": "Registro de batalla", + "duration": "Duración", + "mode": "Modo", + "winningTeam": "Equipo ganador", + "losingTeam": "Equipo perdedor", + "viewMatch": "Ver partida", + "loadingMatch": "Cargando datos de la partida...", + "matchNotFound": "Partida no encontrada", + "searchingGames": "Buscando partidas...", + "recentMatches": "Partidas recientes", + "noChatLog": "No hay registro de chat disponible", + "noBattleLog": "No hay registro de batalla disponible", + "replayVideo": "Video de repetición", + "generatingVideo": "Generando video de repetición…", + "videoFirstLoad": "La primera carga puede tardar hasta un minuto", + "videoUnavailable": "Video de repetición no disponible para esta partida", + "modeGround": "Terrestre", + "modeAir": "Aéreo", + "squadronPlaceholder": "Nombre del escuadrón...", + "loadingReplay": "Cargando repetición..." + }, + "errors": { + "pageNotFound": "Página no encontrada", + "error": "Error", + "oopsNotFound": "¡Ups! La página que buscas no existe. Es posible que haya sido movida, eliminada, o que hayas ingresado una URL incorrecta.", + "searchError": "Error de búsqueda. Por favor, inténtalo de nuevo." + }, + "js": { + "openingDiscordInvite": "¡Abriendo invitación de Discord!", + "errorOpeningInvite": "Error al abrir el enlace de invitación. Por favor, inténtalo de nuevo más tarde.", + "gettingSupportLink": "Obteniendo enlace del servidor de soporte...", + "openingSupportServer": "¡Abriendo servidor de soporte!", + "errorGettingSupport": "Error al obtener el enlace de soporte. Por favor, inténtalo de nuevo más tarde.", + "failedToUpdateStats": "Error al actualizar las estadísticas", + "konamiActivated": "Logro desbloqueado: ¡Código secreto!", + "noPlayersFound": "No se encontraron jugadores", + "searchError": "Error de búsqueda. Por favor, inténtalo de nuevo.", + "killsSuffix": "eliminaciones", + "winRateSuffix": "porcentaje de victoria", + "noSquadronsFound": "No se encontraron escuadrones" + }, + "index": { + "subtitle1": "La mejor herramienta para batallas de escuadrón", + "subtitle2": "Boris Stats pero mejor", + "subtitle3": "El mayor conjunto de datos SQB", + "subtitle4": "Información pública y gratuita para todos" + }, + "seasonCard": { + "buttonLabel": "Tarjeta de temporada", + "buttonDisabledTitle": "Búsqueda de escuadrón incompleta — tarjeta no disponible", + "modalTitle": "Tarjeta de temporada", + "seasonLabel": "Temporada", + "themeLabel": "Tema", + "themeDark": "Oscuro", + "themeLight": "Claro", + "generate": "Generar", + "loadingSeasons": "Cargando temporadas…", + "generating": "Generando…", + "failedSeasons": "No se pudieron cargar las temporadas.", + "failedGenerate": "No se pudo generar la tarjeta de resumen.", + "inProgressSuffix": "(en curso)", + "imgRecapSuffix": "RESUMEN", + "imgHeroFinalRating": "Rating final", + "imgHeroMatches": "Partidas", + "imgHeroWinRate": "Victorias", + "imgHeroKD": "K/D", + "imgAxisRating": "Puntuación", + "imgAxisWinRate": "% Victorias", + "imgStatPeakRating": "Rating máximo", + "imgStatRatingChange": "Cambio de rating", + "imgStatTotalKills": "Bajas totales", + "imgStatTotalDeaths": "Muertes totales", + "imgStatAssistsCaptures": "Asistencias / capturas", + "imgStatMostPlayedVehicle": "Vehículo más usado", + "imgStatMVP": "MVP", + "imgStatMostActive": "Más activo", + "imgStatLongestWinStreak": "Mayor racha", + "imgStatMostCommonOpponent": "Rival más frecuente", + "imgUnitKills": "bajas", + "imgUnitAssists": "asistencias", + "imgUnitCaptures": "capturas", + "imgUnitGames": "partidas", + "imgUnitMatches": "partidas", + "imgUnitWins": "V", + "imgUnitLosses": "D", + "imgGroundShort": "T", + "imgAirShort": "A", + "imgFooterGenerated": "generado", + "imgPlaceholderNoData": "Sin datos para {short} en {season}", + "buttonLabelPlayer": "Resumen de temporada", + "buttonDisabledTitlePlayer": "Player lookup incomplete — recap unavailable", + "modalTitlePlayer": "Player Season Recap", + "imgHeroBattles": "Batallas", + "imgHeroTotalKills": "Muertes totales", + "imgAxisBattles": "Batallas (7 d)", + "imgAxisKD": "K/D", + "imgStatBestMatch": "Mejor partida", + "imgStatSquadronsRepresented": "Escuadrones representados", + "imgStatFrequentTeammate": "Compañero más frecuente", + "imgStatLongestSession": "Sesión más larga", + "imgStatMostActiveDay": "Día más activo (UTC)", + "imgStatMostCommonOppSquadron": "Escuadrón rival más frecuente", + "imgStatPeakSquadronRating": "Puntuación máx. del escuadrón", + "imgUnitSlotNA": "NA", + "imgUnitSlotEU": "EU", + "imgUnitNoSquadron": "sin escuadrón", + "imgUnitVs": "vs", + "imgUnitTogether": "partidas juntos", + "imgStatKDAC": "M / Mt / A / C", + "imgPlaceholderNoDataPlayer": "Sin datos para {nick} en {season}", + "imgUIDLabel": "UID", + "imgBestMatchLine": "{vehicle} · MT {gk} / MA {ak} / A {assists} / C {cap} / Mt {deaths} · {date}" + }, + "live": { + "air": "AIR", + "gnd": "TER", + "ast": "ASI", + "dth": "MUE", + "cap": "CAP", + "squadronBattle": "Batalla de escuadrones", + "randomBattle": "Batalla aleatoria" + }, + "analytics": { + "pageTitle": "Análisis SQB", + "pageSubtitle": "Análisis detallado para cualquier escuadrón, jugador o vehículo.", + "modeSquadron": "Escuadrón", + "modePlayer": "Jugador", + "modeVehicle": "Vehículo", + "tabMaps": "Victorias por mapa", + "tabSquadmates": "Compañeros habituales", + "tabComps": "Composiciones de equipo", + "tabConsistency": "Consistencia del jugador", + "tabTime": "Hora del día", + "tabMatchups": "Historial de enfrentamientos", + "pickSquadron": "Busca cualquier escuadrón para ver su análisis", + "pickPlayer": "Busca cualquier jugador para ver su análisis", + "pickVehicle": "Busca un vehículo para ver su análisis", + "noData": "No hay datos en este rango.", + "loading": "Cargando…", + "loadError": "Error al cargar análisis.", + "compComingSoon": "Análisis de composición de equipo próximamente.", + "compTopVehiclesTitle": "Vehículos más usados", + "compCompositionsTitle": "Composiciones de partida recurrentes", + "compCompositionsMeta": "Alineaciones con al menos {min} vehículos, ordenadas por partidas", + "compMatchesAnalyzed": "partidas analizadas", + "compNoRepeats": "No se desplegó ninguna alineación de ese tamaño en este rango.", + "compColVehicle": "Vehículo", + "compColSpawns": "Apariciones", + "compColMatches": "Partidas", + "compColShare": "% Partidas", + "compColLineup": "Alineación", + "compColTypes": "Comp", + "compTypeFighters": "Cazas", + "compTypeBombers": "Bombarderos", + "compTypeHelicopters": "Helicópteros", + "compTypeLight": "Tanque ligero", + "compTypeTanks": "Tanques", + "compTypeSPAA": "SPAA", + "compTypeSPAATooltip": "Antiaéreo/SPAA", + "compTypeUnknown": "Desconocido", + "compSearchPresetLabel": "Comp predefinida", + "compSearchPresetAll": "Todas las comps", + "compSearchPresetHint": "Construido desde el historial propio del escuadrón", + "compSearchTypesLabel": "Tipos", + "compTypeCapsHint": "Máx 8 en total · máx 4 aéreos (F + B + H)", + "compSearchRefineLabel": "Refinar por", + "compRefineHint": "Pon un número arriba para elegir vehículos específicos de ese tipo.", + "compRefineAny": "Cualquier {type}", + "compSearchCustomLabel": "Personalizada", + "compSearchAddVehicle": "Vehículo específico", + "compSearchVehiclesLabel": "Vehículos", + "compSearchApply": "Aplicar", + "compSearchReset": "Reiniciar", + "compSearchMatches": "Mostrando {shown} de {total} comps", + "compSearchNoMatches": "Ninguna comp coincide con el filtro.", + "compSearchGamesShort": "partidas", + "colMap": "Mapa", + "colWins": "V", + "colLosses": "D", + "colWinRate": "%V", + "colBar": "", + "colShared": "Compartidas", + "colUid": "UID", + "colGames": "Partidas", + "colPlayer": "Jugador", + "colAvgKills": "Bajas prom.", + "colAvgDeaths": "Muertes prom.", + "colScore": "Puntuación", + "colHour": "Hora (UTC)", + "colSquadron": "Escuadrón", + "colTotal": "Total", + "matchupsWonHeader": "Más victorias contra", + "matchupsLostHeader": "Más derrotas contra", + "uniqueOpponents": "oponentes únicos", + "euTimeslot": "Horario EU", + "naTimeslot": "Horario NA", + "offPeak": "Fuera de pico", + "radarMetaMaps": "top {shown} de {total} mapas · mín. {min} partidas", + "radarMetaSquadmates": "top {shown} de {total} compañeros · mín. {min} partidas compartidas", + "radarTooFewMaps": "No hay suficientes datos para mostrar el gráfico: se necesitan al menos 3 mapas con {min}+ partidas en este filtro.", + "radarTooFewSquadmates": "No hay suficientes datos para mostrar el gráfico: se necesitan al menos 3 compañeros con {min}+ partidas compartidas en este filtro.", + "radarFootnoteMaps": "Se ocultaron {count} mapas con menos partidas — mira la tabla completa", + "radarFootnoteSquadmates": "Se ocultaron {count} compañeros con menos partidas — mira la tabla completa", + "tabTimeline": "Línea de tiempo K/D", + "tabTopPlayers": "Mejores jugadores", + "tabTopSquadrons": "Mejores escuadrones" + }, + "playerModal": { + "viewFullProfile": "Ver perfil completo →", + "close": "Cerrar", + "overview": "Resumen", + "vehicles": "Vehículos", + "sessions": "Sesiones", + "loadingPlayerData": "Cargando datos del jugador...", + "kdr": "K/D", + "kps": "K/S", + "winRate": "Tasa de victoria", + "battles": "Batallas", + "wins": "Victorias", + "totalBattles": "Batallas totales", + "totalKills": "Bajas totales", + "airKills": "Bajas aéreas", + "groundKills": "Bajas terrestres", + "assists": "Asistencias", + "deaths": "Muertes", + "captures": "Capturas", + "clickToSwitchMetric": "Clic para cambiar métrica", + "clickToCycle": "clic para alternar", + "noChartData": "Sin datos de gráfico", + "noVehicleData": "Sin datos de vehículos", + "noSessionData": "Sin datos de sesión", + "date": "Fecha", + "vehicle": "Vehículo", + "ground": "Tierra", + "air": "Aire", + "result": "Resultado", + "unknown": "Desconocido", + "failedToLoadPlayerData": "No se pudieron cargar los datos del jugador" + }, + "replay": { + "playPause": "Reproducir/Pausar", + "crashed": "se estrelló", + "destroyed": "destruyó", + "hit": "impactó" + }, + "dateFilter": { + "allTime": "Todo", + "currentSeason": "Temporada actual", + "bySeason": "Por temporada", + "cumulative": "Acumulado", + "customRange": "Rango personalizado", + "selectSeason": "Seleccionar temporada", + "selectSeasonDots": "Selecciona una temporada...", + "selectWeek": "Seleccionar semana", + "selectWeekDots": "Selecciona una semana...", + "entireSeason": "Temporada completa", + "applyFilter": "Aplicar filtro", + "cumulativeHelp": "Ver estadísticas acumuladas hasta un punto específico", + "season": "Temporada", + "upToWeek": "Hasta semana", + "applyCumulativeFilter": "Aplicar filtro acumulado", + "startDate": "Fecha inicial", + "endDate": "Fecha final", + "applyCustomRange": "Aplicar rango personalizado", + "activeFilter": "Filtro activo:", + "clear": "Limpiar", + "allTimeStatistics": "Estadísticas totales", + "currentSeasonValue": "Temporada actual: {season}", + "alertSelectSeason": "Selecciona una temporada", + "seasonValue": "Temporada {season}", + "alertSelectSeasonWeek": "Selecciona temporada y semana", + "cumulativeValue": "Acumulado hasta {season} - {week}", + "alertSelectDate": "Selecciona al menos una fecha", + "alertStartBeforeEnd": "La fecha inicial debe ser anterior a la final", + "customRangePrefix": "Rango personalizado:", + "fromDate": "Desde {date}", + "upToDate": "Hasta {date}" + } +} diff --git a/web/locales/fr.json b/web/locales/fr.json new file mode 100644 index 0000000..15899ef --- /dev/null +++ b/web/locales/fr.json @@ -0,0 +1,906 @@ +{ + "nav": { + "home": "Accueil", + "live": "En direct", + "leaderboards": "Classements", + "docs": "Docs", + "terms": "Conditions", + "premium": "Premium", + "support": "Support", + "addToDiscord": "Ajouter à Discord", + "games": "Matchs", + "squadrons": "Escadrons", + "donate": "Faire un don", + "analytics": "Analytique" + }, + "footer": { + "services": "Services", + "matchFeed": "Flux de matchs", + "vehicleStats": "Stats de véhicules", + "analytics": "Analytiques", + "squadronHub": "Hub d'escadron", + "comparison": "Comparaison", + "resources": "Ressources", + "documentation": "Documentation", + "inviteBot": "Inviter le bot", + "legal": "Mentions légales", + "termsOfService": "Conditions d'utilisation", + "privacyPolicy": "Politique de confidentialité", + "termsAndPrivacy": "Conditions & Confidentialité", + "meowing": "Ronron", + "websiteBy": "Site web par", + "andToothless": "et Toothless" + }, + "common": { + "loading": "Chargement...", + "retry": "Réessayer", + "backToHome": "Retour à l'accueil", + "battles": "Batailles", + "wins": "Victoires", + "winRate": "Taux de victoire", + "kills": "Éliminations", + "totalKills": "Total d'éliminations", + "groundKills": "Éliminations sol", + "airKills": "Éliminations air", + "assists": "Assistances", + "deaths": "Morts", + "captures": "Captures", + "kdr": "KDR", + "kps": "KPS", + "rank": "Rang", + "player": "Joueur", + "players": "Joueurs", + "playersCount": "joueurs", + "vehicle": "Véhicule", + "vehicles": "Véhicules", + "squadron": "Escadron", + "squadrons": "Escadrons", + "statistics": "Statistiques", + "comparison": "Comparaison", + "date": "Date", + "result": "Résultat", + "totalBattles": "Total de batailles", + "totalWins": "Total de victoires", + "points": "Points", + "members": "Membres", + "membersCount": "membres", + "rating": "Évaluation", + "searchPlayerByName": "Rechercher un joueur par nom...", + "noPlayersFound": "Aucun joueur trouvé", + "noSquadronsFound": "Aucun escadron trouvé", + "noVehiclesFound": "Aucun véhicule trouvé", + "failedToLoad": "Impossible de charger les données. Veuillez réessayer plus tard.", + "recordingSince": "Enregistrement des données depuis le 01/01/2026", + "vs": "VS", + "map": "Carte" + }, + "home": { + "squadronBattles": "Batailles d'escadron", + "madeSimple": "Simplifiées", + "addToDiscord": "Ajouter à Discord", + "learnMore": "En savoir plus", + "searchBySquadron": "RECHERCHER PAR ESCADRON", + "typeSquadronName": "Nom d'un escadron...", + "orByPlayer": "OU PAR JOUEUR", + "typePlayerName": "Nom d'un joueur...", + "liveFeed": "Flux de matchs", + "realTimeMatches": "Retrouvez vos matchs", + "topPlayers": "Meilleurs joueurs", + "vehicleStatsCard": "Stats de véhicules", + "performanceMetrics": "Métriques de performance", + "analyticsCard": "Analytiques", + "globalStatistics": "Statistiques globales", + "squadronHubCard": "Hub d'escadron", + "squadronStats": "Stats d'escadron", + "comparisonCard": "Comparaison", + "compareStats": "Comparer les stats", + "joinServers": "Rejoignez les 500+ serveurs qui utilisent notre bot pour suivre leurs performances", + "noSquadronsFound": "Aucun escadron trouvé", + "noPlayersFound": "Aucun joueur trouvé", + "searchPlayersIn": "Rechercher des joueurs dans", + "ctaElev8": "Prêt à ELEV8 votre escadron ?", + "ctaReign": "Prêt à R3IGN à nouveau ?", + "ctaMeow": "Meowww", + "ctaPurr": "Purrr", + "ctaRawr": "Rawr" + }, + "docs": { + "title": "Documentation", + "subtitle": "Tout ce que vous devez savoir sur", + "quickNavigation": "Navigation rapide", + "gettingStarted": "Démarrage", + "commands": "Commandes", + "serverSetup": "Configuration du serveur", + "features": "Fonctionnalités", + "examples": "Exemples", + "troubleshooting": "Dépannage", + "stackManager": "Gestionnaire de stack", + "welcomeMessage": "Suivez ces étapes pour démarrer.", + "inviteTheBot": "Inviter le bot", + "inviteBotDesc": "Cliquez sur le bouton \"Ajouter au serveur\" et sélectionnez le serveur Discord de votre escadron. Le bot enverra un message de bienvenue avec une indication pour exécuter /setup.", + "runSetupWizard": "Lancer l'assistant de configuration", + "setupWizardDesc": "L'assistant de configuration vous guide pour configurer votre escadron, le canal de logs et le canal de points en une seule fois :", + "setupEasiest": "C'est la méthode la plus simple pour démarrer. Il vous guide étape par étape pour définir votre escadron et choisir vos canaux.", + "youreDone": "C'est terminé !", + "doneDesc": "Le bot commencera à publier automatiquement les points et les mises à jour du classement. Utilisez /autolog-management pour ajuster les paramètres de notification ultérieurement.", + "premiumNote": "Les logs de jeu automatiques (tableaux des scores complets après chaque match) nécessitent un abonnement Premium. Exécutez /unlock pour vous abonner — 2,99 $/mois par serveur, facturé via Discord.", + "manualSetup": "Configuration manuelle (alternative)", + "manualSetupDesc": "Si vous préférez configurer les choses individuellement, vous pouvez utiliser ces commandes à la place :", + "allCommandsSlash": "Toutes les commandes utilisent le système de commandes slash de Discord. Tapez / pour voir les commandes disponibles.", + "serverSetupAdmin": "Configuration du serveur & administration", + "importantNote": "Remarque importante", + "verifyFirst": "Vérifiez toujours en premier ! La commande /sq-info confirme que le bot peut trouver votre escadron dans la base de données de War Thunder, même si votre classement est très bas.", + "cantFindSquadron": "Si le bot ne peut pas trouver votre escadron avec /sq-info, les commandes de configuration ne fonctionneront pas correctement.", + "botNotResponding": "Le bot ne répond pas", + "checkOnline": "Vérifiez si le bot est en ligne (statut vert)", + "verifyPermissions": "Vérifiez que le bot dispose des permissions nécessaires", + "tryDifferentChannel": "Essayez d'utiliser les commandes dans un autre canal", + "commandsNotWorking": "Les commandes ne fonctionnent pas", + "ensureSlash": "Assurez-vous d'utiliser les commandes slash (commençant par /)", + "checkRolePerms": "Vérifiez si votre rôle a la permission d'utiliser les commandes du bot", + "tryRefreshing": "Essayez de rafraîchir Discord ou de redémarrer l'application", + "dataNotSaving": "Les données ne s'enregistrent pas", + "verifySendMessages": "Vérifiez que le bot a la permission \"Envoyer des messages\"", + "checkOutages": "Vérifiez s'il y a des pannes Discord", + "contactSupport": "Contactez le support si le problème persiste", + "needMoreHelp": "Besoin d'aide supplémentaire ?", + "needMoreHelpDesc": "Si vous avez besoin d'assistance supplémentaire, n'hésitez pas à nous contacter via nos canaux de support.", + "example": "Exemple", + "supportedLanguages": "Langues prises en charge", + "setupDesc": "Assistant étape par étape pour configurer le bot sur votre serveur. Définit votre escadron, le canal de logs et le canal de points en une seule procédure.", + "recommendedForNew": "Recommandé pour les nouveaux serveurs.", + "setSquadronDesc": "Enregistre un escadron par défaut pour votre serveur Discord. Utilisé pour les logs et comme valeur par défaut pour les autres commandes.", + "quickLogDesc": "Définit une alarme pour un escadron dans le canal actuel. Le type peut être Logs, Points, Leaderboard, ou Both — Both configure Logs et Points ensemble en une seule commande. Par défaut : Logs.", + "quickLogPremiumNote": "Logs (tableaux des scores automatiques) nécessitent un abonnement Premium. Les alertes Points et Classement sont gratuites.", + "autologDesc": "Gérez les notifications autolog et diagnostiquez les permissions des canaux. Utilisez cette commande pour modifier les paramètres après la configuration initiale.", + "autologPremiumNote": "Les logs de jeu automatiques nécessitent un abonnement Premium.", + "diagnosePermsDesc": "Vérifie instantanément si le bot dispose des permissions nécessaires dans le canal actuel, affiche vos canaux autolog configurés et indique le statut d'abonnement Premium de ce serveur. Utilisez cette commande si les tableaux des scores ou les points ne sont pas publiés.", + "squadronInformation": "Informations sur l'escadron", + "sqInfoDesc": "Affiche des informations détaillées sur n'importe quel escadron. Utilise l'escadron par défaut du serveur si aucun n'est spécifié.", + "sqInfoGraphDesc": "Visualisez l'effectif actuel sous forme de graphique en barres réparti entre les groupes noyau, actifs et faibles selon l'activité et le taux de victoire (saison actuelle).", + "compDesc": "Trouve les dernières compositions connues pour les batailles d'un escadron donné. Les serveurs gratuits obtiennent 25 recherches par créneau horaire ; Premium obtient illimité.", + "trackDesc": "Suit un escadron et compare ses stats par rapport à la dernière fois que vous avez vérifié.", + "topDesc": "Affiche le top 20 des escadrons et leurs statistiques actuelles.", + "sqStatsDesc": "Affiche les points d'un escadron au fil du temps sous forme de graphique interactif.", + "lossCalculatorDesc": "Calcule combien de points un escadron perdrait si les joueurs sélectionnés le quittaient.", + "recentDesc": "Affiche les 5 dernières batailles d'escadron pour un escadron.", + "vsDesc": "Consulte le bilan des confrontations directes contre un autre escadron.", + "leaderboardLinkDesc": "Obtient un lien vers le classement mondial des joueurs SRE Bot.", + "playerStats": "Stats du joueur", + "playerStatsDesc": "Affiche des statistiques détaillées de véhicules pour un joueur avec un menu déroulant interactif. Prend en charge la saisie automatique.", + "viewPlayerGamesDesc": "Affiche les 20 dernières parties d'un joueur. Montre le nombre total de victoires/défaites et le taux de victoire, un résumé par partie (résultat, escadron adversaire, carte et composition jouée), et une liste dédoublonnée de toutes les compositions uniques du joueur. Prend en charge la saisie automatique.", + "viewMatchDesc": "Affiche le tableau des scores complet d'un match spécifique. Fournissez un ID de match directement, ou recherchez par nom de joueur pour parcourir ses 100 dernières parties et en choisir une. Inclut les boutons Voir le replay, Voir les déplacements, Journal de chat et Journal de bataille.", + "examples2": "Exemples", + "compareDesc": "Compare les statistiques SQB agrégées entre deux joueurs ou plus (jusqu'à 7). Affiche une comparaison côte à côte avec les meilleures stats en surbrillance. Inclut un bouton graphique pour voir l'historique des points sur 90 jours.", + "metaData": "Données meta", + "metaManagementDesc": "Configure les paramètres d'accès aux données meta pour votre serveur.", + "metaDesc": "Recherche dans le roster meta de votre escadron par nom de véhicule.", + "settingsUtilities": "Paramètres & utilitaires", + "languageDesc": "Change la langue par défaut du bot. Affecte également la langue des véhicules affichés dans vos logs de bataille.", + "scheduleDesc": "Affiche le calendrier de BR de la saison en cours. Montre le BR maximum de chaque semaine avec sa plage de dates, les semaines passées barrées et la période active actuelle mise en évidence.", + "websiteDesc": "Obtient un lien vers le site web SRE Bot pour la recherche de joueurs, les classements et plus encore.", + "creditsDesc": "Affiche l'équipe créditée pour la création de ce bot.", + "unlockDesc": "Déverrouillez les logs automatiques de parties SQB et les recherches /comp illimitées pour ce serveur. L'abonnement ajoute des tableaux des scores complets publiés automatiquement sur votre canal configuré après chaque match. 2,99 $ / mois · par serveur · résiliable à tout moment. La facturation est entièrement gérée via Discord — aucun compte externe requis.", + "analyticsDesc": "Analytiques SQB avancées : taux de victoire par carte, compositions d'équipe, régularité des joueurs, performance selon l'heure et historique des affrontements (plus de victoires et défaites contre les adversaires).", + "sqCardDesc": "Génère une carte récapitulative de saison (PNG) pour un escadron — tendance du rating, taux de victoire, meilleurs joueurs, etc. Choisis la saison dans l'autocomplétion. Thèmes sombre et clair disponibles.", + "cardDesc": "Génère une carte récapitulative de saison (PNG) pour un joueur. Choisis la saison dans l'autocomplétion et le joueur par nom d'utilisateur. Thèmes sombre et clair disponibles.", + "queryDesc": "[Admin uniquement] Exécute des requêtes de base de données prédéfinies — statistiques d'escadron, nombre de parties, joueurs les plus actifs, cartes les plus jouées, et plus. Les résultats sont éphémères (visibles uniquement par vous).", + "donateDesc": "Soutenez le développement de SRE Bot via Ko-fi.", + "botStatusDesc": "Affiche quand la dernière partie a été reçue et le TTL moyen des parties récentes. Signale les serveurs Gaijin lents.", + "premiumBadge": "Premium", + "newsDesc": "Consultez les dernières actualités et annonces de SRE Bot.", + "stackCreateDesc": "Crée une stack de joueurs pour coordonner une équipe avant un match. Un embed persistant est publié dans le canal indiquant les membres actuels et les candidats en attente. La stack dure jusqu'à 8 heures et est automatiquement supprimée à la fin de chaque créneau SQB.", + "stackRequestToJoin": "Demander à rejoindre — N'importe quel joueur peut postuler avec le véhicule qu'il prévoit de piloter. Les candidatures occupent jusqu'à 20 emplacements.", + "stackLeaveWithdraw": "Quitter / Retirer — Les membres peuvent quitter la stack ; les candidats peuvent retirer leur candidature. Le chef de stack est invité à transférer la direction en premier.", + "stackManagePanel": "Gérer la stack ⚙️ — Panneau réservé au chef avec quatre sections :", + "stackAcceptMembers": "Accepter les membres — Acceptez ou refusez les candidats individuellement ou tous à la fois. Jusqu'à 8 membres au total.", + "stackRemoveMembers": "Retirer des membres — Retirez des membres actifs ou des candidats en attente. Options : Retirer tous, Retirer les actifs, Retirer les en attente, ou Retirer les sélectionnés dans une liste déroulante.", + "stackPingMembers": "Mentionner les membres — Mentionnez avec un message personnalisé optionnel. Options : Mentionner tous (membres + file d'attente, sauf le chef), Mentionner les actifs (membres uniquement), Mentionner les en attente (candidats uniquement), ou Mentionner les sélectionnés dans une liste déroulante.", + "stackRenameStack": "Renommer la stack — Définissez un nom personnalisé pour la stack. Apparaît comme titre de l'embed et dans les messages de mention au lieu du nom par défaut \"Stack de [Chef]\".", + "stackDisbandStack": "Dissoudre la stack — Le chef peut mettre fin à la stack prématurément.", + "stackManageDesc": "Republier l'embed de votre stack active dans le canal actuel. Utilisez cette commande si l'embed original a été supprimé ou perdu après un redémarrage du bot. Tous les membres existants et les données de file d'attente sont conservés.", + "translation": "Traduction", + "translateContextMenu": "Clic droit sur le message → Applications → Traduire le message", + "translateDesc": "Traduisez n'importe quel message via le menu contextuel de Discord. Faites un clic droit (ou appuyez longuement sur mobile) sur un message et sélectionnez Applications → Traduire le message.", + "viewAllLanguages": "Voir toutes les langues prises en charge", + "serverSetupSubtitle": "Configurez {botName} pour des performances optimales sur le serveur Discord de votre escadron.", + "requiredPermissions": "Permissions requises", + "sendMessages": "Envoyer des messages", + "useSlashCommands": "Utiliser les commandes slash", + "embedLinks": "Intégrer des liens", + "readMessageHistory": "Lire l'historique des messages", + "recommendedChannelSetup": "Configuration de canal recommandée", + "recommendedChannelDesc": "Créez un canal dédié comme #squadron-battles pour le suivi et les statistiques. Cela permet de garder vos données de bataille organisées et facilement accessibles.", + "roleConfiguration": "Configuration des rôles", + "roleConfigurationDesc": "Attribuez des rôles appropriés aux membres de l'escadron qui peuvent enregistrer les résultats de bataille. Nous recommandons de limiter cela aux officiers et chefs d'escadron.", + "premiumSectionSubtitle": "Les logs automatiques de parties SQB sont une fonctionnalité Premium, déverrouillée par serveur via le système d'abonnement natif de Discord.", + "whatsIncluded": "Ce qui est inclus", + "premiumInclude1": "Tableau des scores complet publié automatiquement sur votre canal configuré après chaque match SQB", + "premiumInclude2": "Recherches /comp illimitées (les serveurs gratuits obtiennent 25 par créneau horaire)", + "premiumInclude3": "Toutes les fonctionnalités gratuites existantes (alarmes de points, classement, commandes de stats, etc.) restent gratuites", + "pricingBilling": "Tarification & facturation", + "pricingBillingDesc": "2,99 $ / mois · par serveur · résiliable à tout moment. La facturation est entièrement gérée via Discord — aucun compte externe ni processeur de paiement requis. Les abonnements se renouvellent automatiquement et peuvent être résiliés à tout moment depuis vos paramètres Discord.", + "howToSubscribe": "Comment s'abonner", + "subscribe1": "Exécutez /unlock sur votre serveur (administrateur du serveur requis)", + "subscribe2": "Cliquez sur le bouton S'abonner dans la réponse du bot", + "subscribe3": "Finalisez le paiement directement dans Discord — le bot s'active immédiatement", + "cancellation": "Résiliation", + "cancellationDesc": "Lorsqu'un abonnement expire ou est résilié, le bot cesse automatiquement de publier les logs de jeu pour ce serveur lors du prochain cycle autolog. Aucune action manuelle n'est nécessaire.", + "realTimeStatistics": "Statistiques en temps réel", + "realTimeStatisticsDesc": "Suivez les victoires, défaites et métriques de performance en temps réel.", + "battleHistory": "Historique des batailles", + "battleHistoryDesc": "Historique complet de toutes les batailles d'escadron enregistrées.", + "leaderboardsFeature": "Classements", + "leaderboardsFeatureDesc": "Comparez les performances de votre escadron avec d'autres grâce à /top.", + "playerTracking": "Suivi des joueurs", + "playerTrackingDesc": "Statistiques individuelles des joueurs et intégration ThunderSkill.", + "smartAlerts": "Alertes intelligentes", + "smartAlertsDesc": "Alarmes automatisées et notifications pour les activités de l'escadron.", + "multiLanguageSupport": "Support multilingue", + "multiLanguageSupportDesc": "Fonctionnalités de traduction et langues de véhicules personnalisables.", + "timeCoordination": "Coordination temporelle", + "timeCoordinationDesc": "Outils UTC et heure locale pour la coordination mondiale de l'escadron.", + "advancedSearch": "Recherche avancée", + "advancedSearchDesc": "Trouvez des compositions de joueurs et des informations détaillées sur les escadrons.", + "dataSecurity": "Sécurité des données", + "dataSecurityDesc": "Vos données sont sécurisées et ne sont jamais partagées ni vendues.", + "usageExamples": "Exemples d'utilisation", + "quickSetupRecommended": "Configuration rapide (recommandée)", + "quickSetupDesc": "Lancez l'assistant de configuration. Il vous guide pour définir votre escadron, choisir un canal de logs et un canal de points — le tout en une seule procédure.", + "comparingPlayers": "Comparer des joueurs", + "comparingPlayersDesc": "Comparez jusqu'à 7 joueurs côte à côte. La meilleure stat dans chaque catégorie est mise en évidence, et le meilleur joueur global reçoit une étoile. Cliquez sur \"Afficher le graphique\" pour voir leur historique de points.", + "checkingSquadronInfo": "Vérifier les infos d'un escadron", + "verifySquadronDesc": "Vérifiez qu'un escadron existe et consultez ses détails. Utilisez cette commande pour confirmer que le bot peut trouver votre escadron avant la configuration.", + "recentBattlesDesc": "Consultez les 5 dernières batailles jouées par un escadron.", + "headToHeadDesc": "Consultez votre bilan en confrontations directes contre un autre escadron.", + "backToHome": "Retour à l'accueil", + "termsAndPrivacy": "Conditions & Confidentialité", + "seasonRecapCardTitle": "Carte récapitulative de saison", + "seasonRecapCardDesc": "Génère un résumé PNG partageable de la saison de n’importe quel escadron — courbe de classement, taux de victoires glissant, K/D, véhicule principal, MVP et plus. Disponible via le bouton « Carte de saison » sur la page de profil de chaque escadron.", + "playerRecapCardTitle": "Récap de saison du joueur", + "playerRecapCardDesc": "Générez un résumé PNG partageable de la saison de n'importe quel joueur — évolution du classement entre escadrons, taux de victoire glissant, K/D, cadence des batailles, meilleur match et plus. Disponible depuis le bouton « Season Recap » sur la page de profil de chaque joueur.", + "tierOverview": "Aperçu des tiers", + "tierOverviewDesc": "L'auto-logging est disponible sur trois tiers. Chaque tier limite le nombre d'escadrons avec Logs et Points activés. Les escadrons au-delà de la limite restent dans vos préférences et reprennent automatiquement après une mise à niveau.", + "tierStandardLine": "2,99 $ — jusqu'à 10 escadrons chacun pour Logs et Points, sans wildcard.", + "tierProLine": "jusqu'à 25 escadrons chacun pour Logs et Points, plus wildcard (`*` / `all` / `everything`).", + "tierMaxLine": "escadrons illimités, wildcards, accès anticipé aux nouvelles fonctionnalités." + }, + "terms": { + "pageTitle": "Conditions d'utilisation & Politique de confidentialité", + "lastUpdated": "Dernière mise à jour : avril 2026", + "termsOfService": "Conditions d'utilisation", + "byUsing": "En utilisant", + "youAgree": ", vous acceptez les conditions suivantes :", + "useResponsibly": "Utilisation responsable", + "useResponsiblyDesc": "Ne tentez pas de surcharger, spammer ou autrement perturber le bot.", + "noFunnyBusiness": "Pas de comportement abusif", + "noFunnyBusinessDesc": "Ne tentez pas de procéder à de la rétro-ingénierie, ou de violer les CGU de Discord ou toute autre règle de serveur.", + "statsAsIs": "Les stats sont fournies telles quelles", + "statsAsIsDesc": "Toutes les données et statistiques sont fournies \"en l'état\" sans garantie d'aucune sorte, expresse ou implicite. Nous faisons des efforts raisonnables pour assurer l'exactitude mais ne garantissons pas que les informations soient complètes, à jour ou exemptes d'erreurs.", + "uptimeNotGuaranteed": "La disponibilité n'est pas garantie", + "uptimeNotGuaranteedDesc": "Le bot est fourni \"selon disponibilité\". Nous ne garantissons pas un fonctionnement ininterrompu ou sans erreur. Le service peut être temporairement indisponible en raison de maintenance, de mises à jour ou de problèmes techniques.", + "weCanBanYou": "Nous pouvons vous bannir", + "weCanBanYouDesc": "Si vous enfreignez les règles, nous pouvons vous retirer l'accès.", + "privacyPolicy": "Politique de confidentialité", + "infoWeCollect": "1. Informations que nous collectons", + "collectsFollowing": "collecte les informations suivantes pour fournir ses services :", + "discordUserIds": "Identifiants Discord :", + "discordUserIdsDesc": "Identifiants uniques pour suivre les utilisateurs individuels", + "squadronIds": "Identifiants d'escadron :", + "squadronIdsDesc": "Identifiants de serveur/guilde Discord pour organiser les données d'escadron", + "battleData": "Données de bataille :", + "battleDataDesc": "Historiques de victoires/défaites, horodatages des batailles et statistiques associées", + "commandUsage": "Utilisation des commandes :", + "commandUsageDesc": "Journalisation de base des commandes du bot utilisées pour améliorer le service", + "howWeUse": "2. Comment nous utilisons vos informations", + "usedExclusively": "Les informations collectées sont utilisées exclusivement pour :", + "trackingPerformance": "Suivre les performances et statistiques des batailles d'escadron", + "providingHistorical": "Fournir des données historiques et des analyses", + "improvingBot": "Améliorer les fonctionnalités du bot et l'expérience utilisateur", + "troubleshootingIssues": "Résoudre les problèmes techniques", + "dataStorage": "3. Stockage et sécurité des données", + "dataStoredSecurely": "Vos données sont stockées de manière sécurisée avec les protections suivantes :", + "encryptedServers": "Les données sont stockées sur des serveurs sécurisés avec chiffrement", + "limitedAccess": "L'accès est limité au personnel autorisé uniquement", + "regularBackups": "Des sauvegardes régulières garantissent l'intégrité des données", + "dataSharing": "4. Partage des données", + "weDoNot": "Nous ne :", + "sellData": "Ne vendons pas vos informations personnelles à des tiers", + "shareData": "Ne partageons pas vos données avec des organisations externes", + "useForAds": "N'utilisons pas vos données à des fins publicitaires ou marketing", + "transferData": "Ne transférons pas vos données hors de nos systèmes sécurisés", + "dataRetention": "5. Conservation des données", + "dataRetentionDesc": "Nous conservons vos données aussi longtemps que nécessaire pour fournir nos services. Les statistiques de bataille et les données d'escadron sont conservées pour maintenir les archives historiques et les analyses.", + "discordIntegration": "6. Intégration Discord", + "discordIntegrationDesc": "Ce bot fonctionne dans l'écosystème Discord et est soumis à la politique de confidentialité de Discord. Nous n'accédons qu'aux informations nécessaires au fonctionnement du bot via l'API officielle de Discord.", + "ageRestrictions": "7. Restrictions d'âge", + "ageRestrictionsDesc": "Le bot est destiné aux utilisateurs qui respectent les conditions d'âge minimum de Discord (13 ans ou plus selon la législation locale).", + "changesToPolicy": "8. Modifications de la politique de confidentialité", + "changesToPolicyDesc": "Nous pouvons mettre à jour cette politique de confidentialité de temps en temps.", + "premiumTitle": "Conditions d'abonnement Premium", + "premiumWhatYouGet": "1. Ce que Premium inclut", + "premiumWhatYouGetDesc": "Un abonnement Premium débloque les fonctionnalités suivantes pour le serveur Discord abonné :", + "premiumFeature1": "Publication automatique des tableaux de scores après chaque match SQB", + "premiumFeature2": "Cartes de trajectoires / mouvements", + "premiumFeature3": "Journaux de chat et de bataille", + "premiumFeature4": "Recherche de replays", + "premiumFeature5": "Recherches /comp illimitées (les serveurs gratuits sont limités à 15 par créneau horaire)", + "premiumBilling": "2. Facturation et paiement", + "premiumBillingDesc": "Premium est facturé à 2,99 $ USD par mois par serveur Discord. Le paiement est traité via le système d'abonnement natif de Discord ou via notre site web par Whop. Vous êtes responsable de vous assurer que votre moyen de paiement est valide et dispose de fonds suffisants. Les abonnements se renouvellent automatiquement à la fin de chaque période de facturation, sauf annulation.", + "premiumCancellation": "3. Annulation", + "premiumCancellationDesc": "Vous pouvez annuler votre abonnement à tout moment. Pour les abonnements Discord, rendez-vous dans Paramètres utilisateur → Abonnements dans Discord. Pour les abonnements via le site web, gérez votre facturation sur whop.com/billing. Après l'annulation, vos fonctionnalités Premium restent actives jusqu'à la fin de votre période de facturation en cours. Ensuite, votre serveur revient au niveau gratuit — aucune donnée n'est perdue.", + "premiumRefunds": "4. Remboursements", + "premiumRefundsDesc": "Les frais d'abonnement ne sont pas remboursables. L'utilisation partielle du mois n'est pas proratisée. Si vous annulez en cours de cycle, vous conservez l'accès jusqu'à la fin de cette période de facturation, mais vous n'avez pas droit à un remboursement pour le temps restant. Les remboursements pour des erreurs de facturation ou des frais en double peuvent être accordés à notre discrétion — contactez le support avec votre ID de serveur Discord et une preuve de paiement.", + "premiumPriceChanges": "5. Modifications de prix", + "premiumPriceChangesDesc": "Nous nous réservons le droit de modifier les tarifs d'abonnement à tout moment. Les abonnés existants seront prévenus au moins 30 jours avant l'entrée en vigueur de toute augmentation de prix. Si vous n'êtes pas d'accord avec une modification de prix, vous pouvez annuler avant l'application du nouveau tarif.", + "premiumTermination": "6. Résiliation de l'accès Premium", + "premiumTerminationDesc": "Nous nous réservons le droit de révoquer l'accès Premium sans remboursement si un serveur viole ces Conditions d'utilisation, y compris, mais sans s'y limiter, l'abus des fonctionnalités du bot, les tentatives de contournement des limites d'utilisation ou la violation des Conditions d'utilisation de Discord.", + "serviceAvailabilityTitle": "Disponibilité du service et responsabilité", + "serviceNoWarranty": "1. Absence de garantie", + "serviceNoWarrantyDesc": "LE SERVICE EST FOURNI « EN L'ÉTAT » ET « SELON DISPONIBILITÉ » SANS GARANTIE D'AUCUNE SORTE, QU'ELLE SOIT EXPRESSE, IMPLICITE OU LÉGALE, Y COMPRIS, MAIS SANS S'Y LIMITER, LES GARANTIES IMPLICITES DE QUALITÉ MARCHANDE, D'ADÉQUATION À UN USAGE PARTICULIER ET DE NON-CONTREFAÇON. Nous ne garantissons pas que le service sera ininterrompu, ponctuel, sécurisé ou exempt d'erreurs.", + "serviceLiability": "2. Limitation de responsabilité", + "serviceLiabilityDesc": "DANS TOUTE LA MESURE PERMISE PAR LA LOI APPLICABLE, EN AUCUN CAS NOUS NE SERONS RESPONSABLES DE DOMMAGES INDIRECTS, ACCESSOIRES, SPÉCIAUX, CONSÉCUTIFS OU PUNITIFS, NI DE TOUTE PERTE DE BÉNÉFICES OU DE REVENUS, QU'ELLE SOIT SUBIE DIRECTEMENT OU INDIRECTEMENT, NI DE TOUTE PERTE DE DONNÉES, D'UTILISATION, DE CLIENTÈLE OU D'AUTRES PERTES IMMATÉRIELLES, RÉSULTANT DE VOTRE UTILISATION OU DE VOTRE INCAPACITÉ À UTILISER LE SERVICE. Notre responsabilité totale pour toute réclamation découlant de ces conditions ou du service ne dépassera pas le montant que vous nous avez versé au cours des trois (3) mois précédant la réclamation.", + "serviceCredits": "3. Interruptions de service et crédits", + "serviceCreditsDesc": "En cas de panne de service prolongée et imprévue affectant les fonctionnalités Premium pendant plus de 72 heures consécutives, les abonnés Premium concernés peuvent demander un crédit de service en contactant le support. Les crédits sont accordés à notre seule discrétion et sont appliqués comme une extension de la période de facturation en cours — et non comme des remboursements monétaires. Les fenêtres de maintenance planifiée, les pannes de tiers (Discord, Gaijin API) et les événements hors de notre contrôle raisonnable sont exclus.", + "serviceForceM": "4. Force majeure", + "serviceForceMDesc": "Nous ne serons pas responsables de tout manquement ou retard dans l'exécution résultant de causes indépendantes de notre volonté raisonnable, y compris, mais sans s'y limiter : catastrophes naturelles, guerre, terrorisme, pandémies, coupures de courant, interruptions d'internet, pannes de la plateforme Discord, modifications ou indisponibilité de l'API Gaijin Entertainment, actions gouvernementales ou tout autre cas de force majeure.", + "disclaimer": "Avertissement", + "warThunderDisclaimer": "est un bot Discord indépendant et n'est pas affilié à, approuvé par, ou associé à Gaijin Entertainment ou War Thunder. War Thunder est une marque déposée de Gaijin Entertainment.", + "acknowledgement": "En utilisant", + "acknowledgementEnd": ", vous reconnaissez avoir lu, compris et accepté d'être lié par ces Conditions d'utilisation et cette Politique de confidentialité." + }, + "premium": { + "upgradeTitle": "Améliorez votre escadron", + "heroDesc": "Chaque résultat SQB publié sur votre canal dès la fin du match — tableaux des scores, logs de bataille, cartes de déplacement et replays, entièrement automatiques.", + "instantScoreboards": "Tableaux des scores instantanés", + "viewPaths": "Voir les déplacements", + "chatBattleLogs": "Logs de chat & bataille", + "replayLookups": "Accès aux replays", + "free": "Gratuit", + "perMonth": "/mois", + "alwaysFree": "Toujours gratuit", + "noCardNeeded": "sans carte bancaire", + "included": "Inclus", + "manualLookups": "Recherches manuelles de parties", + "playerStats": "Stats & profils des joueurs", + "leaderboards": "Classements", + "stickWithFree": "Rester sur le gratuit", + "premiumLabel": "Premium", + "perServer": "par serveur", + "cancelAnytime": "résiliable à tout moment", + "everythingInFree": "Tout ce qui est dans Gratuit, plus", + "autoScoreboards": "Publication automatique des tableaux des scores", + "pathMaps": "Cartes de déplacement", + "chatLogs": "Logs de chat & bataille", + "replayLookupsFeature": "Accès aux replays", + "unlimitedComp": "Recherches /comp illimitées", + "prioritySupport": "Support prioritaire", + "subscribeNow": "S'abonner maintenant", + "comingSoon": "Bientôt disponible", + "serverIdInfo": "Vous aurez besoin de votre", + "discordServerId": "ID de serveur Discord", + "duringCheckout": "lors du paiement.", + "developerMode": "Mode développeur", + "rightClickServer": "Clic droit sur le serveur", + "copyId": "Copier l'identifiant", + "successTitle": "Premium activé", + "successDesc": "Votre abonnement est en cours de configuration. Le bot aura accès au Premium pour votre serveur dans quelques minutes.", + "whatHappensNext": "Que se passe-t-il ensuite", + "autoLogging": "La journalisation automatique s'active pour votre serveur", + "setLogChannel": "Définissez votre canal de logs avec", + "everyResult": "Chaque résultat SQB est publié automatiquement", + "readSetupGuide": "Lire le guide de configuration", + "tierStandardName": "Standard", + "tierProName": "Pro", + "tierMaxName": "Max", + "squadCap": "Jusqu'à {cap} escadrons enregistrés", + "squadCapUnlimited": "Escadrons illimités", + "everythingInStandard": "Tout ce qui est dans Standard", + "everythingInPro": "Tout ce qui est dans Pro", + "wildcardSupport": "Logging wildcard (*, all, everything)", + "noSquadCap": "Pas de limite d'escadrons", + "earlyAccessFeatures": "Accès anticipé aux nouvelles fonctionnalités" + }, + "player": { + "totalBattles": "Total de batailles", + "totalWins": "Total de victoires", + "vehicleStatistics": "Statistiques de véhicules", + "cumulative": "Cumulatif", + "individual": "Individuel", + "filterBy": "Filtrer par :", + "allTime": "Toute période", + "dateRange": "Plage de dates", + "season": "Saison", + "week": "Semaine", + "session": "Session", + "dateType": "Type de date :", + "last7Days": "7 derniers jours", + "last30Days": "30 derniers jours", + "last90Days": "90 derniers jours", + "customRange": "Plage personnalisée", + "specificDate": "Date précise", + "filterType": "Type de filtre :", + "fullSeason": "Saison complète", + "specificWeek": "Semaine précise", + "from": "Du :", + "to": "Au :", + "timeslot": "Créneau", + "fullDay": "Journée complète", + "selectSeason": "Saison :", + "selectWeek": "Semaine :", + "selectSeasonFirst": "Sélectionnez d'abord une saison", + "pleaseSelect": "Veuillez sélectionner une option", + "searchVehicles": "Rechercher des véhicules...", + "resetFilters": "Réinitialiser les filtres", + "vehiclesShown": "véhicules affichés", + "gamesShown": "parties affichées", + "noVehicleData": "Aucune donnée de véhicule disponible", + "noVehiclesForRange": "Aucun véhicule trouvé pour la plage de dates sélectionnée, ou ce joueur n'a pas encore de données.", + "switchToCards": "Basculer en vue cartes", + "switchToTable": "Basculer en vue tableau", + "loadingTimeline": "Chargement de la chronologie...", + "noTimelineData": "Aucune donnée de chronologie disponible.", + "timelineUnavailable": "Chronologie indisponible.", + "loadingGameRecords": "Chargement des enregistrements de parties...", + "unableToLoadRecords": "Impossible de charger les enregistrements de parties", + "failedToFetch": "Impossible de récupérer les données de jeu. Veuillez réessayer plus tard.", + "noGameRecords": "Aucun enregistrement de partie trouvé", + "noGamesYet": "Ce joueur n'a pas encore joué de parties enregistrées.", + "collapseChart": "Réduire le graphique", + "loadingChartData": "Chargement des données du graphique...", + "noHistoricalData": "Aucune donnée historique disponible.", + "chartUnavailable": "Graphique indisponible.", + "relative": "Relatif", + "uidLabel": "UID du joueur" + }, + "squadrons": { + "title": "Hub d'escadron", + "subtitle": "Découvrez des escadrons, consultez des statistiques et suivez les performances", + "findSquadron": "Trouver un escadron", + "searchPlaceholder": "Rechercher des escadrons par nom...", + "totalSquadrons": "Total d'escadrons", + "totalPlayers": "Total de joueurs", + "totalBattles": "Total de batailles", + "avgWinRate": "Taux de victoire moyen", + "topSquadrons": "Meilleurs escadrons", + "viewFullLeaderboard": "Voir le classement complet", + "loadingSquadrons": "Chargement des données d'escadron...", + "noSquadronData": "Aucune donnée d'escadron disponible pour l'instant.", + "failedToLoad": "Impossible de charger les données d'escadron. Veuillez réessayer plus tard.", + "backToSquadronHub": "Retour au hub d'escadron", + "squadronPoints": "Points de l'escadron", + "squadronMembers": "Membres de l'escadron", + "performance": "Performance", + "performanceNoData": "Aucune donnée de performance disponible pour la plage sélectionnée.", + "quickDetails": "Détails rapides", + "noMembersFound": "Aucun membre trouvé", + "noRecordedMembers": "Cet escadron n'a pas encore de membres enregistrés.", + "squadronGames": "Parties de l'escadron", + "loadingSquadronGames": "Chargement des parties de l'escadron...", + "noSquadronGames": "Aucune partie trouvée pour cet escadron.", + "retryLoadGames": "Réessayer", + "searchMapPlaceholder": "Rechercher par carte..." + }, + "leaderboard": { + "playersTitle": "Classement des joueurs", + "playersSubtitle": "Meilleurs joueurs de War Thunder classés par performance", + "vehiclesTitle": "Classement des éliminations par véhicule", + "vehiclesSubtitle": "Meilleurs véhicules de War Thunder classés par total d'éliminations", + "squadronsTitle": "Classement des escadrons", + "squadronsSubtitle": "Meilleurs escadrons de War Thunder classés par performance", + "statsTitle": "Statistiques globales", + "statsSubtitle": "Statistiques globales des batailles d'escadron et informations meta", + "comparisonTitle": "Outil de comparaison", + "comparisonSubtitle": "Comparez joueurs et véhicules côte à côte", + "comparisonHint": "Comparez les stats pour trouver les meilleurs joueurs et véhicules", + "compareSquadrons": "Comparer des escadrons", + "comparePlayers": "Comparer des joueurs", + "compareVehicles": "Comparer des véhicules", + "playersAndVehicles": "Joueurs + Véhicules", + "failedToLoadLeaderboard": "Impossible de charger les données du classement. Veuillez réessayer plus tard.", + "failedToLoadVehicles": "Impossible de charger le classement des véhicules", + "failedToLoadSquadrons": "Impossible de charger le classement des escadrons", + "noResultsYet": "Aucun résultat pour l'instant. Ajoutez des escadrons/joueurs pour commencer.", + "searchSquadron": "Rechercher un escadron", + "searchBySquadronName": "Rechercher par nom d'escadron...", + "minPlayers": "Joueurs min.", + "minPlayersPlaceholder": "Joueurs min.", + "resetFilters": "Réinitialiser les filtres", + "squadronsShown": "escadrons affichés", + "playersShown": "joueurs affichés", + "page": "page", + "of": "sur", + "loadingSquadronLeaderboard": "Chargement du classement des escadrons...", + "loadingPlayerLeaderboard": "Chargement du classement des joueurs...", + "loadingComparisonData": "Chargement des données de comparaison...", + "unableToFetch": "Impossible de récupérer les données du classement. Veuillez réessayer.", + "noSquadronsInLeaderboard": "Aucun escadron trouvé dans le classement.", + "noPlayersInLeaderboard": "Aucun joueur trouvé dans le classement.", + "loadingGlobalStats": "Chargement des statistiques globales...", + "failedToLoadStats": "Impossible de charger les statistiques", + "unableToFetchStats": "Impossible de récupérer les données statistiques. Veuillez réessayer.", + "mostPopularVehicles": "Véhicules les plus populaires", + "vehicleKillsLeaderboardTitle": "Classement des éliminations par véhicule", + "avgWinRate": "Taux de victoire moyen", + "avgKillsPerPlayer": "Éliminations moyennes/Joueur", + "loadingVehicleKills": "Chargement des données d'éliminations par véhicule...", + "apiNotLoaded": "Le client API n'est pas correctement chargé. Veuillez rafraîchir la page.", + "failedToInitApi": "Impossible d'initialiser le client API", + "noStatsData": "Aucune donnée statistique disponible", + "totalPlayersCard": "Total de joueurs", + "activePlayers": "Joueurs actifs", + "vehiclesUsed": "Véhicules utilisés", + "differentVehicles": "Véhicules différents", + "squadronBattlesLabel": "Batailles d'escadron", + "noVehicleData": "Aucune donnée de véhicule disponible", + "mostPopular": "Le plus populaire", + "timesUsed": "Fois utilisé", + "failedToLoadVehicleKills": "Impossible de charger les données d'éliminations par véhicule", + "lastUpdated": "Dernière mise à jour", + "searchPlayer": "Rechercher un joueur", + "searchByPlayerName": "Rechercher par nom de joueur...", + "minimumBattles": "Batailles minimum", + "minBattlesPlaceholder": "Batailles min.", + "searchSquadronsPlaceholder": "Rechercher des escadrons...", + "sortBy": "Trier par", + "kdRatio": "Ratio KD", + "killsPerSpawn": "Éliminations par spawn", + "caps": "Captures", + "timePeriod": "Période", + "allTime": "Toute période", + "dateRange": "Plage de dates", + "season": "Saison", + "week": "Semaine", + "dateType": "Type de date", + "last7Days": "7 derniers jours", + "last30Days": "30 derniers jours", + "last90Days": "90 derniers jours", + "customRange": "Plage personnalisée", + "from": "Du", + "to": "Au", + "timeslot": "Créneau", + "fullDay": "Journée complète", + "selectSeason": "Sélectionner la saison...", + "selectWeek": "Sélectionner la semaine...", + "failedToLoadComparison": "Impossible de charger les données de comparaison", + "pleaseRefresh": "Veuillez essayer de rafraîchir la page", + "playerComparison": "Comparaison de joueurs", + "squadronComparison": "Comparaison d'escadrons", + "vehicleComparison": "Comparaison de véhicules", + "playersVehiclesComparison": "Comparaison joueurs + véhicules", + "addPlayersToCompare": "Ajouter des joueurs à comparer :", + "addSquadronsToCompare": "Ajouter des escadrons à comparer :", + "addVehiclesToCompare": "Ajouter des véhicules à comparer :", + "addPlayerVehicleCombos": "Ajouter des combinaisons joueur + véhicule :", + "searchSelectPlayers": "Rechercher et sélectionner des joueurs...", + "typeSquadronName": "Nom d'un escadron...", + "searchSelectVehicles": "Rechercher et sélectionner des véhicules...", + "searchForPlayers": "Rechercher des joueurs...", + "selectPlayersToCompare": "Recherchez et sélectionnez des joueurs ci-dessus pour comparer leurs stats", + "selectSquadronsToCompare": "Recherchez et sélectionnez des escadrons ci-dessus pour comparer leurs stats", + "selectVehiclesToCompare": "Recherchez et sélectionnez des véhicules ci-dessus pour comparer leurs stats", + "selectPlayersVehiclesToCompare": "Recherchez et sélectionnez des joueurs ci-dessus, puis choisissez leurs véhicules pour comparer différentes combinaisons joueur-véhicule", + "selectVehicleFor": "Sélectionner le véhicule pour", + "selectAVehicle": "-- Sélectionner un véhicule --", + "noVehiclesForPlayer": "Aucun véhicule trouvé pour ce joueur", + "noPlayerVehicleSelected": "Aucune combinaison joueur-véhicule sélectionnée", + "noPlayersSelected": "Aucun joueur sélectionné", + "noVehiclesSelected": "Aucun véhicule sélectionné", + "noSquadronsSelected": "Aucun escadron sélectionné", + "statistic": "Statistique", + "totalDeaths": "Total de morts", + "totalAssists": "Total d'assistances", + "totalCaptures": "Total de captures", + "killsPerSpawnShort": "Élim./Spawn", + "avgWinRateShort": "Taux victoire moy.", + "avgKillsPlayerShort": "Élim. moy./Joueur", + "avgKillsMember": "Élim. moy./Membre", + "avgBattlesMember": "Batailles moy./Membre", + "serverError500": "Erreur serveur (500) - L'API est temporairement indisponible. Veuillez réessayer dans quelques instants.", + "apiEndpoint404": "Point de terminaison API introuvable (404) - Veuillez vérifier la configuration du serveur.", + "networkError": "Erreur réseau - Impossible de se connecter au serveur. Veuillez vérifier votre connexion.", + "viewFullLeaderboard": "Voir le classement complet", + "vehicleName": "Nom du véhicule", + "searchVehiclePlaceholder": "Rechercher un véhicule...", + "minKills": "Élim. min.", + "any": "Tous", + "perPage": "Par page", + "clear": "Effacer", + "vehicleAndPlayer": "Véhicule et joueur", + "loadingVehicleLeaderboard": "Chargement du classement des véhicules...", + "vehiclesShown": "véhicules affichés", + "minBattlesPerVehicle": "Minimum 3 batailles par véhicule requis", + "minimumBattlesRequired": "Minimum 5 batailles requises", + "allSeasons": "Toutes les saisons", + "allWeeks": "Toutes les semaines", + "allBR": "Tous les BR" + }, + "games": { + "title": "Historique des matchs", + "subtitle": "Recherchez et parcourez les matchs de batailles d'escadron", + "searchPlaceholder": "Rechercher par nom de joueur ou UID...", + "filterByMap": "Filtrer par carte", + "allMaps": "Toutes les cartes", + "search": "Rechercher", + "noResults": "Aucun match trouvé", + "matchDetail": "Détail du match", + "chatLog": "Journal de chat", + "battleLog": "Journal de bataille", + "duration": "Durée", + "mode": "Mode", + "winningTeam": "Équipe gagnante", + "losingTeam": "Équipe perdante", + "viewMatch": "Voir le match", + "loadingMatch": "Chargement des données du match...", + "matchNotFound": "Match introuvable", + "searchingGames": "Recherche de parties...", + "recentMatches": "Matchs récents", + "noChatLog": "Aucun journal de chat disponible", + "noBattleLog": "Aucun journal de bataille disponible", + "replayVideo": "Vidéo de replay", + "generatingVideo": "Génération du replay en cours…", + "videoFirstLoad": "Le premier chargement peut prendre jusqu'à une minute", + "videoUnavailable": "Vidéo de replay indisponible pour ce match", + "modeGround": "Sol", + "modeAir": "Air", + "squadronPlaceholder": "Nom de l'escadron...", + "loadingReplay": "Chargement du replay..." + }, + "errors": { + "pageNotFound": "Page introuvable", + "error": "Erreur", + "oopsNotFound": "Oups ! La page que vous recherchez n'existe pas. Elle a peut-être été déplacée, supprimée, ou vous avez saisi une URL incorrecte.", + "searchError": "Erreur de recherche. Veuillez réessayer." + }, + "js": { + "openingDiscordInvite": "Ouverture de l'invitation Discord !", + "errorOpeningInvite": "Erreur lors de l'ouverture du lien d'invitation. Veuillez réessayer plus tard.", + "gettingSupportLink": "Récupération du lien du serveur de support...", + "openingSupportServer": "Ouverture du serveur de support !", + "errorGettingSupport": "Erreur lors de la récupération du lien de support. Veuillez réessayer plus tard.", + "failedToUpdateStats": "Impossible de mettre à jour les statistiques", + "konamiActivated": "Succès débloqué : Code secret !", + "noPlayersFound": "Aucun joueur trouvé", + "searchError": "Erreur de recherche. Veuillez réessayer.", + "killsSuffix": "éliminations", + "winRateSuffix": "taux de victoire", + "noSquadronsFound": "Aucun escadron trouvé" + }, + "index": { + "subtitle1": "Le meilleur outil pour les batailles d'escadron", + "subtitle2": "Boris Stats en mieux", + "subtitle3": "Le plus grand jeu de données SQB", + "subtitle4": "Informations publiques et gratuites pour tous" + }, + "seasonCard": { + "buttonLabel": "Carte de saison", + "buttonDisabledTitle": "Recherche d’escadron incomplète — carte indisponible", + "modalTitle": "Carte de saison", + "seasonLabel": "Saison", + "themeLabel": "Thème", + "themeDark": "Sombre", + "themeLight": "Clair", + "generate": "Générer", + "loadingSeasons": "Chargement des saisons…", + "generating": "Génération…", + "failedSeasons": "Impossible de charger les saisons.", + "failedGenerate": "Impossible de générer la carte récapitulative.", + "inProgressSuffix": "(en cours)", + "imgRecapSuffix": "RÉCAP", + "imgHeroFinalRating": "Classement final", + "imgHeroMatches": "Parties", + "imgHeroWinRate": "Taux de victoire", + "imgHeroKD": "K/D", + "imgAxisRating": "Classement", + "imgAxisWinRate": "Taux de victoire", + "imgStatPeakRating": "Pic de classement", + "imgStatRatingChange": "Évolution du classement", + "imgStatTotalKills": "Éliminations totales", + "imgStatTotalDeaths": "Morts totales", + "imgStatAssistsCaptures": "Assistances / captures", + "imgStatMostPlayedVehicle": "Véhicule le plus joué", + "imgStatMVP": "MVP", + "imgStatMostActive": "Plus actif", + "imgStatLongestWinStreak": "Plus longue série", + "imgStatMostCommonOpponent": "Adversaire fréquent", + "imgUnitKills": "éliminations", + "imgUnitAssists": "assistances", + "imgUnitCaptures": "captures", + "imgUnitGames": "parties", + "imgUnitMatches": "parties", + "imgUnitWins": "V", + "imgUnitLosses": "D", + "imgGroundShort": "T", + "imgAirShort": "A", + "imgFooterGenerated": "généré", + "imgPlaceholderNoData": "Pas de données pour {short} en {season}", + "buttonLabelPlayer": "Récapitulatif de saison", + "buttonDisabledTitlePlayer": "Player lookup incomplete — recap unavailable", + "modalTitlePlayer": "Player Season Recap", + "imgHeroBattles": "Batailles", + "imgHeroTotalKills": "Total des kills", + "imgAxisBattles": "Batailles (7 j)", + "imgAxisKD": "K/D", + "imgStatBestMatch": "Meilleur match", + "imgStatSquadronsRepresented": "Escadrons représentés", + "imgStatFrequentTeammate": "Coéquipier le plus fréquent", + "imgStatLongestSession": "Plus longue session", + "imgStatMostActiveDay": "Jour le plus actif (UTC)", + "imgStatMostCommonOppSquadron": "Escadron adverse le plus fréquent", + "imgStatPeakSquadronRating": "Rating max de l'escadron", + "imgUnitSlotNA": "NA", + "imgUnitSlotEU": "EU", + "imgUnitNoSquadron": "sans escadron", + "imgUnitVs": "vs", + "imgUnitTogether": "matchs ensemble", + "imgStatKDAC": "K / D / A / C", + "imgPlaceholderNoDataPlayer": "Aucune donnée pour {nick} en {season}", + "imgUIDLabel": "UID", + "imgBestMatchLine": "{vehicle} · KS {gk} / KA {ak} / A {assists} / C {cap} / M {deaths} · {date}" + }, + "live": { + "air": "AIR", + "gnd": "SOL", + "ast": "ASS", + "dth": "MRT", + "cap": "CAP", + "squadronBattle": "Bataille d'escadron", + "randomBattle": "Bataille aléatoire" + }, + "analytics": { + "pageTitle": "Analytique SQB", + "pageSubtitle": "Analyses détaillées pour tout escadron, joueur ou véhicule.", + "modeSquadron": "Escadron", + "modePlayer": "Joueur", + "modeVehicle": "Véhicule", + "tabMaps": "Taux de victoire par carte", + "tabSquadmates": "Coéquipiers fréquents", + "tabComps": "Compositions d'équipe", + "tabConsistency": "Constance du joueur", + "tabTime": "Heure de la journée", + "tabMatchups": "Historique des affrontements", + "pickSquadron": "Recherchez un escadron pour voir son analyse", + "pickPlayer": "Recherchez un joueur pour voir son analyse", + "pickVehicle": "Recherchez un véhicule pour voir son analyse", + "noData": "Aucune donnée sur cette période.", + "loading": "Chargement…", + "loadError": "Impossible de charger les analyses.", + "compComingSoon": "Analyse de composition d'équipe bientôt disponible.", + "compTopVehiclesTitle": "Véhicules les plus utilisés", + "compCompositionsTitle": "Compositions de match récurrentes", + "compCompositionsMeta": "Formations avec au moins {min} véhicules, triées par matchs", + "compMatchesAnalyzed": "matchs analysés", + "compNoRepeats": "Aucune formation de cette taille n'a été déployée sur cette période.", + "compColVehicle": "Véhicule", + "compColSpawns": "Apparitions", + "compColMatches": "Matchs", + "compColShare": "% Matchs", + "compColLineup": "Formation", + "compColTypes": "Comp", + "compTypeFighters": "Chasseurs", + "compTypeBombers": "Bombardiers", + "compTypeHelicopters": "Hélicoptères", + "compTypeLight": "Char léger", + "compTypeTanks": "Chars", + "compTypeSPAA": "SPAA", + "compTypeSPAATooltip": "Anti-aérien/SPAA", + "compTypeUnknown": "Inconnu", + "compSearchPresetLabel": "Comp préréglée", + "compSearchPresetAll": "Toutes les comps", + "compSearchPresetHint": "Construit depuis l'historique de comp de cette escadre", + "compSearchTypesLabel": "Types", + "compTypeCapsHint": "Max 8 total · max 4 aérien (F + B + H)", + "compSearchRefineLabel": "Affiner", + "compRefineHint": "Indiquez un nombre ci-dessus pour choisir des véhicules précis pour ce type.", + "compRefineAny": "N'importe quel {type}", + "compSearchCustomLabel": "Personnalisée", + "compSearchAddVehicle": "Véhicule spécifique", + "compSearchVehiclesLabel": "Véhicules", + "compSearchApply": "Appliquer", + "compSearchReset": "Réinitialiser", + "compSearchMatches": "Affichage de {shown} sur {total} comps", + "compSearchNoMatches": "Aucune comp ne correspond au filtre.", + "compSearchGamesShort": "matchs", + "colMap": "Carte", + "colWins": "V", + "colLosses": "D", + "colWinRate": "%V", + "colBar": "", + "colShared": "En commun", + "colUid": "UID", + "colGames": "Parties", + "colPlayer": "Joueur", + "colAvgKills": "Kills moy.", + "colAvgDeaths": "Morts moy.", + "colScore": "Score", + "colHour": "Heure (UTC)", + "colSquadron": "Escadron", + "colTotal": "Total", + "matchupsWonHeader": "Plus de victoires contre", + "matchupsLostHeader": "Plus de défaites contre", + "uniqueOpponents": "adversaires uniques", + "euTimeslot": "Créneau EU", + "naTimeslot": "Créneau NA", + "offPeak": "Heures creuses", + "radarMetaMaps": "top {shown} sur {total} cartes · min {min} parties", + "radarMetaSquadmates": "top {shown} sur {total} coéquipiers · min {min} parties partagées", + "radarTooFewMaps": "Pas assez de données pour afficher le graphique — il faut au moins 3 cartes avec {min}+ parties dans ce filtre.", + "radarTooFewSquadmates": "Pas assez de données pour afficher le graphique — il faut au moins 3 coéquipiers avec {min}+ parties partagées dans ce filtre.", + "radarFootnoteMaps": "{count} cartes avec moins de parties masquées — voir le tableau complet", + "radarFootnoteSquadmates": "{count} coéquipiers avec moins de parties masqués — voir le tableau complet", + "tabTimeline": "Chronologie K/D", + "tabTopPlayers": "Meilleurs joueurs", + "tabTopSquadrons": "Meilleurs escadrons" + }, + "playerModal": { + "viewFullProfile": "Voir le profil complet →", + "close": "Fermer", + "overview": "Vue d'ensemble", + "vehicles": "Véhicules", + "sessions": "Sessions", + "loadingPlayerData": "Chargement des données joueur...", + "kdr": "K/D", + "kps": "K/S", + "winRate": "Taux de victoire", + "battles": "Batailles", + "wins": "Victoires", + "totalBattles": "Batailles totales", + "totalKills": "Kills totaux", + "airKills": "Kills air", + "groundKills": "Kills sol", + "assists": "Assistances", + "deaths": "Morts", + "captures": "Captures", + "clickToSwitchMetric": "Cliquer pour changer de métrique", + "clickToCycle": "cliquer pour faire défiler", + "noChartData": "Aucune donnée de graphique", + "noVehicleData": "Aucune donnée véhicule", + "noSessionData": "Aucune donnée de session", + "date": "Date", + "vehicle": "Véhicule", + "ground": "Sol", + "air": "Air", + "result": "Résultat", + "unknown": "Inconnu", + "failedToLoadPlayerData": "Échec du chargement des données joueur" + }, + "replay": { + "playPause": "Lecture/Pause", + "crashed": "s'est écrasé", + "destroyed": "a détruit", + "hit": "a touché" + }, + "dateFilter": { + "allTime": "Tout", + "currentSeason": "Saison actuelle", + "bySeason": "Par saison", + "cumulative": "Cumulatif", + "customRange": "Plage personnalisée", + "selectSeason": "Sélectionner la saison", + "selectSeasonDots": "Sélectionner une saison...", + "selectWeek": "Sélectionner la semaine", + "selectWeekDots": "Sélectionner une semaine...", + "entireSeason": "Saison entière", + "applyFilter": "Appliquer le filtre", + "cumulativeHelp": "Voir les statistiques cumulées jusqu'à un point précis", + "season": "Saison", + "upToWeek": "Jusqu'à la semaine", + "applyCumulativeFilter": "Appliquer le filtre cumulatif", + "startDate": "Date de début", + "endDate": "Date de fin", + "applyCustomRange": "Appliquer la plage personnalisée", + "activeFilter": "Filtre actif :", + "clear": "Effacer", + "allTimeStatistics": "Statistiques globales", + "currentSeasonValue": "Saison actuelle : {season}", + "alertSelectSeason": "Sélectionne une saison", + "seasonValue": "Saison {season}", + "alertSelectSeasonWeek": "Sélectionne une saison et une semaine", + "cumulativeValue": "Cumul jusqu'à {season} - {week}", + "alertSelectDate": "Sélectionne au moins une date", + "alertStartBeforeEnd": "La date de début doit précéder la date de fin", + "customRangePrefix": "Plage personnalisée :", + "fromDate": "Depuis {date}", + "upToDate": "Jusqu'à {date}" + } +} diff --git a/web/locales/it.json b/web/locales/it.json new file mode 100644 index 0000000..948d51c --- /dev/null +++ b/web/locales/it.json @@ -0,0 +1,906 @@ +{ + "nav": { + "home": "Home", + "live": "Live", + "leaderboards": "Classifiche", + "docs": "Documentazione", + "terms": "Termini", + "premium": "Premium", + "support": "Supporto", + "addToDiscord": "Aggiungi a Discord", + "games": "Partite", + "squadrons": "Squadroni", + "donate": "Dona", + "analytics": "Analisi" + }, + "footer": { + "services": "Servizi", + "matchFeed": "Feed partite", + "vehicleStats": "Statistiche veicoli", + "analytics": "Analytics", + "squadronHub": "Hub squadriglia", + "comparison": "Confronto", + "resources": "Risorse", + "documentation": "Documentazione", + "inviteBot": "Invita il Bot", + "legal": "Legale", + "termsOfService": "Termini di Servizio", + "privacyPolicy": "Informativa sulla Privacy", + "termsAndPrivacy": "Termini e Privacy", + "meowing": "Meowing", + "websiteBy": "Sito web di", + "andToothless": "e Toothless" + }, + "common": { + "loading": "Caricamento...", + "retry": "Riprova", + "backToHome": "Torna alla Home", + "battles": "Battaglie", + "wins": "Vittorie", + "winRate": "Percentuale vittorie", + "kills": "Eliminazioni", + "totalKills": "Eliminazioni totali", + "groundKills": "Eliminazioni terrestri", + "airKills": "Eliminazioni aeree", + "assists": "Assistenze", + "deaths": "Morti", + "captures": "Catture", + "kdr": "KDR", + "kps": "KPS", + "rank": "Posizione", + "player": "Giocatore", + "players": "Giocatori", + "playersCount": "giocatori", + "vehicle": "Veicolo", + "vehicles": "Veicoli", + "squadron": "Squadriglia", + "squadrons": "Squadriglie", + "statistics": "Statistiche", + "comparison": "Confronto", + "date": "Data", + "result": "Risultato", + "totalBattles": "Battaglie totali", + "totalWins": "Vittorie totali", + "points": "Punti", + "members": "Membri", + "membersCount": "membri", + "rating": "Valutazione", + "searchPlayerByName": "Cerca giocatore per nome...", + "noPlayersFound": "Nessun giocatore trovato", + "noSquadronsFound": "Nessuna squadriglia trovata", + "noVehiclesFound": "Nessun veicolo trovato", + "failedToLoad": "Impossibile caricare i dati. Riprova più tardi.", + "recordingSince": "Registrazione dati dal 01/01/2026", + "vs": "VS", + "map": "Mappa" + }, + "home": { + "squadronBattles": "Battaglie di squadriglia", + "madeSimple": "Rese semplici", + "addToDiscord": "Aggiungi a Discord", + "learnMore": "Scopri di più", + "searchBySquadron": "CERCA PER SQUADRIGLIA", + "typeSquadronName": "Inserisci il nome della squadriglia...", + "orByPlayer": "OPPURE PER GIOCATORE", + "typePlayerName": "Inserisci il nome del giocatore...", + "liveFeed": "Feed partite", + "realTimeMatches": "Trova le tue partite", + "topPlayers": "Migliori giocatori", + "vehicleStatsCard": "Statistiche veicoli", + "performanceMetrics": "Metriche di prestazione", + "analyticsCard": "Analytics", + "globalStatistics": "Statistiche globali", + "squadronHubCard": "Hub squadriglia", + "squadronStats": "Statistiche squadriglia", + "comparisonCard": "Confronto", + "compareStats": "Confronta statistiche", + "joinServers": "Unisciti ai 500+ server che usano il nostro bot per tracciare le proprie prestazioni", + "noSquadronsFound": "Nessuna squadriglia trovata", + "noPlayersFound": "Nessun giocatore trovato", + "searchPlayersIn": "Cerca giocatori in", + "ctaElev8": "Pronto a ELEV8 la tua squadriglia?", + "ctaReign": "Pronto a R3IGN di nuovo?", + "ctaMeow": "Meowww", + "ctaPurr": "Purrr", + "ctaRawr": "Rawr" + }, + "docs": { + "title": "Documentazione", + "subtitle": "Tutto quello che devi sapere su", + "quickNavigation": "Navigazione rapida", + "gettingStarted": "Per iniziare", + "commands": "Comandi", + "serverSetup": "Configurazione server", + "features": "Funzionalità", + "examples": "Esempi", + "troubleshooting": "Risoluzione problemi", + "stackManager": "Stack Manager", + "welcomeMessage": "Segui questi passaggi per iniziare.", + "inviteTheBot": "Invita il bot", + "inviteBotDesc": "Clicca sul pulsante \"Aggiungi al server\" e seleziona il server Discord della tua squadriglia. Il bot invierà un messaggio di benvenuto con un suggerimento per eseguire /setup.", + "runSetupWizard": "Esegui la procedura guidata", + "setupWizardDesc": "La procedura guidata ti accompagna nella configurazione della tua squadriglia, del canale dei log e del canale dei punti in un unico flusso:", + "setupEasiest": "Questo è il modo più semplice per iniziare. Ti guiderà passo dopo passo nella configurazione della tua squadriglia e nella scelta dei canali.", + "youreDone": "Hai finito!", + "doneDesc": "Il bot inizierà automaticamente a pubblicare aggiornamenti di punti e classifiche. Usa /autolog-management per modificare le impostazioni di notifica in seguito.", + "premiumNote": "I log di gioco automatici (classifiche complete dopo ogni partita) richiedono un abbonamento Premium. Esegui /unlock per abbonarti — $2.99/mese per server, fatturato tramite Discord.", + "manualSetup": "Configurazione manuale (alternativa)", + "manualSetupDesc": "Se preferisci configurare le cose individualmente, puoi usare questi comandi:", + "allCommandsSlash": "Tutti i comandi usano il sistema di comandi slash di Discord. Digita / per visualizzare i comandi disponibili.", + "serverSetupAdmin": "Configurazione server e amministrazione", + "importantNote": "Nota importante", + "verifyFirst": "Verifica sempre prima! Il comando /sq-info conferma che il bot riesce a trovare la tua squadriglia nel database di War Thunder, anche se sei classificato molto in basso.", + "cantFindSquadron": "Se il bot non riesce a trovare la tua squadriglia con /sq-info, i comandi di configurazione non funzioneranno correttamente.", + "botNotResponding": "Il bot non risponde", + "checkOnline": "Controlla se il bot è online (stato verde)", + "verifyPermissions": "Verifica che il bot abbia le autorizzazioni necessarie", + "tryDifferentChannel": "Prova a usare i comandi in un canale diverso", + "commandsNotWorking": "I comandi non funzionano", + "ensureSlash": "Assicurati di usare i comandi slash (iniziano con /)", + "checkRolePerms": "Controlla se il tuo ruolo ha il permesso di usare i comandi del bot", + "tryRefreshing": "Prova ad aggiornare Discord o a riavviare l'app", + "dataNotSaving": "I dati non vengono salvati", + "verifySendMessages": "Verifica che il bot abbia il permesso \"Invia messaggi\"", + "checkOutages": "Controlla se ci sono interruzioni di Discord", + "contactSupport": "Contatta il supporto se il problema persiste", + "needMoreHelp": "Hai bisogno di ulteriore aiuto?", + "needMoreHelpDesc": "Se hai bisogno di assistenza aggiuntiva, contattaci tramite i nostri canali di supporto.", + "example": "Esempio", + "supportedLanguages": "Lingue supportate", + "setupDesc": "Procedura guidata passo-passo per configurare il bot sul tuo server. Imposta la tua squadriglia, il canale dei log e il canale dei punti in un unico flusso.", + "recommendedForNew": "Consigliato per i nuovi server.", + "setSquadronDesc": "Imposta una squadriglia predefinita per il tuo server Discord. Usata per il logging e come predefinita per altri comandi.", + "quickLogDesc": "Imposta un allarme per una squadriglia nel canale corrente. Il tipo può essere Logs, Points, Leaderboard o Both — Both imposta Logs e Points insieme in un unico comando. Predefinito: Logs.", + "quickLogPremiumNote": "Logs (classifiche automatiche di gioco) richiedono un abbonamento Premium. Gli avvisi di punti e classifica sono gratuiti.", + "autologDesc": "Gestisci le notifiche di autolog e diagnostica i permessi del canale. Usalo per modificare le impostazioni dopo la configurazione iniziale.", + "autologPremiumNote": "I log di gioco automatici richiedono un abbonamento Premium.", + "diagnosePermsDesc": "Verifica immediatamente se il bot ha i permessi necessari nel canale corrente, mostra i tuoi canali autolog configurati e visualizza lo stato dell'abbonamento Premium di questo server. Usalo se le classifiche o i punti non vengono pubblicati.", + "squadronInformation": "Informazioni sulla squadriglia", + "sqInfoDesc": "Visualizza informazioni dettagliate su qualsiasi squadriglia. Usa la squadriglia predefinita del server se non ne viene specificata una.", + "sqInfoGraphDesc": "Visualizza il roster attuale come grafico a barre suddiviso in gruppi nucleo, attivi e deboli per attività e tasso di vittoria (stagione corrente).", + "compDesc": "Trova le ultime composizioni note per le battaglie di una determinata squadriglia. I server gratuiti ottengono 25 ricerche per fascia oraria; Premium è illimitato.", + "trackDesc": "Monitora una squadriglia e confronta le statistiche con l'ultima volta che hai controllato.", + "topDesc": "Mostra le prime 20 squadriglie con le loro statistiche attuali.", + "sqStatsDesc": "Mostra i punti di una squadriglia nel tempo come grafico interattivo.", + "lossCalculatorDesc": "Calcola quanti punti perderebbe una squadriglia se determinati giocatori la lasciassero.", + "recentDesc": "Mostra le ultime 5 battaglie di squadriglia per una squadriglia.", + "vsDesc": "Visualizza il bilancio degli scontri diretti contro un'altra squadriglia.", + "leaderboardLinkDesc": "Ottieni un link alla classifica globale dei giocatori di SRE Bot.", + "playerStats": "Statistiche giocatore", + "playerStatsDesc": "Visualizza statistiche dettagliate sui veicoli per un giocatore con un menu a tendina interattivo. Supporta il completamento automatico.", + "viewPlayerGamesDesc": "Visualizza le ultime 20 partite di un giocatore. Mostra il conteggio generale di vittorie/sconfitte e la percentuale di vittorie, un riepilogo per partita (risultato, squadriglia avversaria, mappa e composizione usata) e un elenco deduplicato di ogni composizione unica eseguita dal giocatore. Supporta il completamento automatico.", + "viewMatchDesc": "Visualizza il tabellone completo per una partita specifica. Fornisci un ID partita direttamente, oppure cerca per nome del giocatore per sfogliare le sue ultime 100 partite e sceglierne una. Include i pulsanti Visualizza Replay, Visualizza Percorsi, Log Chat e Log Battaglia.", + "examples2": "Esempi", + "compareDesc": "Confronta le statistiche SQB aggregate tra due o più giocatori (fino a 7). Mostra un confronto fianco a fianco con le migliori statistiche evidenziate. Include un pulsante per visualizzare il grafico dello storico dei punti negli ultimi 90 giorni.", + "metaData": "Dati meta", + "metaManagementDesc": "Configura le impostazioni di accesso ai dati meta per il tuo server.", + "metaDesc": "Cerca nel roster meta della tua squadriglia per nome del veicolo.", + "settingsUtilities": "Impostazioni e utilità", + "languageDesc": "Cambia la lingua predefinita del bot. Influisce anche sulla lingua dei veicoli mostrati nei log di battaglia.", + "scheduleDesc": "Visualizza il programma BR della stagione corrente. Mostra l'intervallo di valutazione massimo di ciascuna settimana con il relativo intervallo di date, le settimane passate con testo barrato e un'evidenziazione del periodo attivo corrente.", + "websiteDesc": "Ottieni un link al sito web di SRE Bot per la ricerca di giocatori, classifiche e altro ancora.", + "creditsDesc": "Visualizza il team accreditato per la creazione di questo bot.", + "unlockDesc": "Sblocca i log di gioco SQB automatici e le ricerche /comp illimitate per questo server. L'abbonamento aggiunge classifiche complete pubblicate automaticamente nel tuo canale configurato dopo ogni partita. $2.99 / mese · per server · cancella in qualsiasi momento. La fatturazione è gestita interamente tramite Discord — non è richiesto alcun account esterno.", + "analyticsDesc": "Analytics SQB avanzate: percentuali di vittoria per mappa, composizioni di squadra, coerenza dei giocatori, prestazioni per fascia oraria e storico scontri (più vittorie e sconfitte contro gli avversari).", + "sqCardDesc": "Genera una card di riepilogo stagionale (PNG) per una squadriglia — andamento del rating, percentuale vittorie, migliori giocatori e altro. Scegli la stagione dall'elenco di autocompletamento. Supporta tema scuro e chiaro.", + "cardDesc": "Genera una card di riepilogo stagionale (PNG) per un giocatore. Scegli la stagione dall'autocompletamento e il giocatore per nome utente. Supporta tema scuro e chiaro.", + "queryDesc": "[Solo admin] Esegui query predefinite sul database — statistiche squadriglia, conteggio partite, giocatori più attivi, mappe principali e altro. I risultati sono effimeri (visibili solo a te).", + "donateDesc": "Supporta lo sviluppo di SRE Bot tramite Ko-fi.", + "botStatusDesc": "Mostra quando è stata ricevuta l'ultima partita e il TTL medio delle partite recenti. Segnala server Gaijin lenti.", + "premiumBadge": "Premium", + "newsDesc": "Visualizza le ultime notizie e annunci di SRE Bot.", + "stackCreateDesc": "Crea uno stack di giocatori per coordinare una squadra prima di una partita. Un embed persistente viene pubblicato nel canale mostrando i membri attuali e i richiedenti in attesa. Lo stack dura fino a 8 ore e viene automaticamente eliminato al termine di ogni fascia oraria SQB.", + "stackRequestToJoin": "Richiedi di unirti — Qualsiasi giocatore può candidarsi con il veicolo che intende usare. Le candidature accodano fino a 20 slot.", + "stackLeaveWithdraw": "Lascia / Ritira — I membri possono lasciare lo stack; i richiedenti possono ritirare la propria candidatura. Al leader dello stack viene chiesto di trasferire prima la proprietà.", + "stackManagePanel": "Gestisci Stack ⚙️ — Pannello solo per il leader con quattro sezioni:", + "stackAcceptMembers": "Accetta membri — Accetta o rifiuta i richiedenti individualmente o tutti insieme. Fino a 8 membri totali.", + "stackRemoveMembers": "Rimuovi membri — Rimuovi membri attivi o richiedenti in coda. Opzioni: Rimuovi tutti, Rimuovi attivi, Rimuovi in coda o Rimuovi selezionati da un menu a tendina.", + "stackPingMembers": "Notifica membri — Notifica con un messaggio personalizzato opzionale. Opzioni: Notifica tutti (membri + coda, escluso il leader), Notifica attivi (solo membri), Notifica in coda (solo richiedenti) o Notifica selezionati da un menu a tendina.", + "stackRenameStack": "Rinomina stack — Imposta un nome personalizzato per lo stack. Appare come titolo dell'embed e nei messaggi di notifica al posto del predefinito \"Stack di [Leader]\".", + "stackDisbandStack": "Sciogli lo stack — Il leader può terminare lo stack in anticipo.", + "stackManageDesc": "Ripubblica l'embed dello stack attivo nel canale corrente. Usalo se l'embed originale è stato eliminato o perso dopo un riavvio del bot. Tutti i dati esistenti di membri e coda vengono preservati.", + "translation": "Traduzione", + "translateContextMenu": "Tasto destro sul messaggio → App → Traduci messaggio", + "translateDesc": "Traduci qualsiasi messaggio usando il menu contestuale di Discord. Fai clic destro (o tieni premuto su mobile) su un messaggio e seleziona App → Traduci messaggio.", + "viewAllLanguages": "Visualizza tutte le lingue supportate", + "serverSetupSubtitle": "Configura {botName} per prestazioni ottimali nel server Discord della tua squadriglia.", + "requiredPermissions": "Permessi richiesti", + "sendMessages": "Invia messaggi", + "useSlashCommands": "Usa comandi slash", + "embedLinks": "Incorpora link", + "readMessageHistory": "Leggi cronologia messaggi", + "recommendedChannelSetup": "Configurazione canale consigliata", + "recommendedChannelDesc": "Crea un canale dedicato come #squadron-battles per il monitoraggio e le statistiche. Questo mantiene i dati delle tue battaglie organizzati e facilmente accessibili.", + "roleConfiguration": "Configurazione ruoli", + "roleConfigurationDesc": "Assegna ruoli appropriati ai membri della squadriglia che possono registrare i risultati delle battaglie. Consigliamo di limitare questo agli ufficiali e ai leader della squadriglia.", + "premiumSectionSubtitle": "I log di gioco SQB automatici sono una funzionalità Premium, sbloccata per server tramite il sistema di abbonamento nativo di Discord.", + "whatsIncluded": "Cosa è incluso", + "premiumInclude1": "Classifica completa pubblicata automaticamente nel tuo canale configurato dopo ogni partita SQB", + "premiumInclude2": "Ricerche /comp illimitate (i server gratuiti ne ottengono 25 per fascia oraria)", + "premiumInclude3": "Tutte le funzionalità gratuite esistenti (allarmi punti, classifica, comandi statistiche, ecc.) rimangono gratuite", + "pricingBilling": "Prezzi e fatturazione", + "pricingBillingDesc": "$2.99 / mese · per server · cancella in qualsiasi momento. La fatturazione è gestita interamente tramite Discord — nessun account esterno o processore di pagamento. Gli abbonamenti si rinnovano automaticamente e possono essere annullati in qualsiasi momento dalle impostazioni di Discord.", + "howToSubscribe": "Come abbonarsi", + "subscribe1": "Esegui /unlock nel tuo server (richiede admin del server)", + "subscribe2": "Clicca sul pulsante Abbonati nella risposta del bot", + "subscribe3": "Completa il pagamento all'interno di Discord — il bot si attiva immediatamente", + "cancellation": "Cancellazione", + "cancellationDesc": "Quando un abbonamento scade o viene annullato, il bot smette automaticamente di pubblicare i log di gioco per quel server al prossimo ciclo di autolog. Non è richiesta alcuna azione manuale.", + "realTimeStatistics": "Statistiche in tempo reale", + "realTimeStatisticsDesc": "Monitora vittorie, sconfitte e metriche di prestazione in tempo reale.", + "battleHistory": "Storico battaglie", + "battleHistoryDesc": "Storico completo di tutte le battaglie di squadriglia registrate.", + "leaderboardsFeature": "Classifiche", + "leaderboardsFeatureDesc": "Confronta le prestazioni della tua squadriglia con le altre usando /top.", + "playerTracking": "Monitoraggio giocatori", + "playerTrackingDesc": "Statistiche individuali dei giocatori e integrazione con ThunderSkill.", + "smartAlerts": "Avvisi intelligenti", + "smartAlertsDesc": "Allarmi e notifiche automatizzati per le attività della squadriglia.", + "multiLanguageSupport": "Supporto multilingue", + "multiLanguageSupportDesc": "Funzionalità di traduzione e lingue dei veicoli personalizzabili.", + "timeCoordination": "Coordinamento temporale", + "timeCoordinationDesc": "Strumenti UTC e orario locale per il coordinamento globale della squadriglia.", + "advancedSearch": "Ricerca avanzata", + "advancedSearchDesc": "Trova composizioni dei giocatori e informazioni dettagliate sulla squadriglia.", + "dataSecurity": "Sicurezza dei dati", + "dataSecurityDesc": "I tuoi dati sono al sicuro e non vengono mai condivisi o venduti.", + "usageExamples": "Esempi d'uso", + "quickSetupRecommended": "Configurazione rapida (consigliata)", + "quickSetupDesc": "Esegui la procedura guidata. Ti accompagna nella configurazione della tua squadriglia, nella scelta di un canale log e di un canale punti — tutto in un unico flusso.", + "comparingPlayers": "Confronto giocatori", + "comparingPlayersDesc": "Confronta fino a 7 giocatori fianco a fianco. La migliore statistica in ogni categoria viene evidenziata e il giocatore complessivamente migliore riceve una stella. Clicca su \"Mostra grafico\" per vedere lo storico dei loro punti.", + "checkingSquadronInfo": "Verifica informazioni squadriglia", + "verifySquadronDesc": "Verifica che una squadriglia esista e visualizzane i dettagli. Usalo per confermare che il bot riesca a trovare la tua squadriglia prima della configurazione.", + "recentBattlesDesc": "Visualizza le ultime 5 battaglie giocate da una squadriglia.", + "headToHeadDesc": "Visualizza il tuo bilancio di scontri diretti contro un'altra squadriglia.", + "backToHome": "Torna alla Home", + "termsAndPrivacy": "Termini e Privacy", + "seasonRecapCardTitle": "Scheda riepilogativa della stagione", + "seasonRecapCardDesc": "Genera un riepilogo PNG condivisibile della stagione di qualsiasi squadrone — curva del rating, percentuale di vittorie mobile, K/D, veicolo preferito, MVP e altro. Disponibile dal pulsante «Scheda stagione» sulla pagina del profilo di ogni squadrone.", + "playerRecapCardTitle": "Riepilogo stagionale del giocatore", + "playerRecapCardDesc": "Genera un riepilogo PNG condivisibile della stagione di qualsiasi giocatore — andamento del rating tra gli squadroni, win rate mobile, K/D, ritmo delle battaglie, miglior partita e altro. Disponibile dal pulsante «Season Recap» sulla pagina profilo di ogni giocatore.", + "tierOverview": "Panoramica piani", + "tierOverviewDesc": "L'auto-logging è disponibile su tre piani. Ogni piano limita quanti squadroni possono avere Logs e Points attivi. Gli squadroni oltre il limite restano nelle preferenze e riprendono automaticamente dopo l'upgrade.", + "tierStandardLine": "$2,99 — fino a 10 squadroni per Logs e Points, senza wildcard.", + "tierProLine": "fino a 25 squadroni per Logs e Points, più supporto wildcard (`*` / `all` / `everything`).", + "tierMaxLine": "squadroni illimitati, wildcards, accesso anticipato alle novità." + }, + "terms": { + "pageTitle": "Termini di Servizio e Informativa sulla Privacy", + "lastUpdated": "Ultimo aggiornamento: aprile 2026", + "termsOfService": "Termini di Servizio", + "byUsing": "Usando", + "youAgree": ", accetti quanto segue:", + "useResponsibly": "Uso responsabile", + "useResponsiblyDesc": "Non tentare di sovraccaricare, fare spam o danneggiare in altro modo il bot.", + "noFunnyBusiness": "Niente scorrettezze", + "noFunnyBusinessDesc": "Non tentare di fare reverse engineering, violare i Termini di Servizio di Discord o qualsiasi altra regola della gilda.", + "statsAsIs": "Le statistiche sono fornite così come sono", + "statsAsIsDesc": "Tutti i dati e le statistiche sono forniti \"così come sono\" senza garanzie di alcun tipo, esplicite o implicite. Compiamo sforzi ragionevoli per garantire l'accuratezza ma non garantiamo che le informazioni siano complete, aggiornate o prive di errori.", + "uptimeNotGuaranteed": "Uptime non garantito", + "uptimeNotGuaranteedDesc": "Il bot è fornito su base \"secondo disponibilità\". Non garantiamo un funzionamento ininterrotto o privo di errori. Il servizio potrebbe essere temporaneamente non disponibile a causa di manutenzione, aggiornamenti o problemi tecnici.", + "weCanBanYou": "Possiamo bannarti", + "weCanBanYouDesc": "Se violi le regole, possiamo revocare il tuo accesso.", + "privacyPolicy": "Informativa sulla Privacy", + "infoWeCollect": "1. Informazioni che raccogliamo", + "collectsFollowing": "raccoglie le seguenti informazioni per fornire i suoi servizi:", + "discordUserIds": "ID utente Discord:", + "discordUserIdsDesc": "Identificatori univoci per tracciare i singoli utenti", + "squadronIds": "ID squadriglia:", + "squadronIdsDesc": "Identificatori del server/guild Discord per organizzare i dati della squadriglia", + "battleData": "Dati battaglia:", + "battleDataDesc": "Registri vittorie/sconfitte, timestamp delle battaglie e statistiche correlate", + "commandUsage": "Utilizzo dei comandi:", + "commandUsageDesc": "Logging di base dei comandi bot utilizzati per migliorare il servizio", + "howWeUse": "2. Come utilizziamo le tue informazioni", + "usedExclusively": "Le informazioni raccolte sono utilizzate esclusivamente per:", + "trackingPerformance": "Monitorare le prestazioni e le statistiche delle battaglie di squadriglia", + "providingHistorical": "Fornire dati storici e analytics", + "improvingBot": "Migliorare le funzionalità del bot e l'esperienza utente", + "troubleshootingIssues": "Risolvere problemi tecnici", + "dataStorage": "3. Archiviazione e sicurezza dei dati", + "dataStoredSecurely": "I tuoi dati sono archiviati in modo sicuro con le seguenti protezioni:", + "encryptedServers": "I dati sono archiviati su server sicuri con crittografia", + "limitedAccess": "L'accesso è limitato al solo personale autorizzato", + "regularBackups": "Backup regolari garantiscono l'integrità dei dati", + "dataSharing": "4. Condivisione dei dati", + "weDoNot": "NON:", + "sellData": "Vendiamo le tue informazioni personali a terze parti", + "shareData": "Condividiamo i tuoi dati con organizzazioni esterne", + "useForAds": "Utilizziamo i tuoi dati per pubblicità o marketing", + "transferData": "Trasferiamo i tuoi dati al di fuori dei nostri sistemi sicuri", + "dataRetention": "5. Conservazione dei dati", + "dataRetentionDesc": "Conserviamo i tuoi dati per il tempo necessario a fornire i nostri servizi. Le statistiche delle battaglie e i dati della squadriglia vengono mantenuti per conservare i record storici e le analytics.", + "discordIntegration": "6. Integrazione Discord", + "discordIntegrationDesc": "Questo Bot opera all'interno dell'ecosistema di Discord ed è soggetto all'Informativa sulla Privacy di Discord. Accediamo solo alle informazioni necessarie per le funzionalità del bot tramite l'API ufficiale di Discord.", + "ageRestrictions": "7. Restrizioni di età", + "ageRestrictionsDesc": "Il Bot è destinato agli utenti che soddisfano i requisiti minimi di età di Discord (13+ o come richiesto dalla legge locale).", + "changesToPolicy": "8. Modifiche all'Informativa sulla Privacy", + "changesToPolicyDesc": "Potremmo aggiornare questa Informativa sulla Privacy di tanto in tanto.", + "premiumTitle": "Termini di Abbonamento Premium", + "premiumWhatYouGet": "1. Cosa include Premium", + "premiumWhatYouGetDesc": "Un abbonamento Premium sblocca le seguenti funzionalità per il server Discord abbonato:", + "premiumFeature1": "Pubblicazione automatica della classifica dopo ogni partita SQB", + "premiumFeature2": "Mappe dei percorsi / movimenti", + "premiumFeature3": "Log di chat e battaglia", + "premiumFeature4": "Ricerca replay", + "premiumFeature5": "Ricerche /comp illimitate (i server gratuiti sono limitati a 15 per fascia oraria)", + "premiumBilling": "2. Fatturazione e pagamento", + "premiumBillingDesc": "Premium viene fatturato a $2,99 USD al mese per server Discord. Il pagamento viene elaborato tramite il sistema di abbonamento nativo di Discord o tramite il nostro sito web via Whop. Sei responsabile di assicurarti che il tuo metodo di pagamento sia valido e disponga di fondi sufficienti. Gli abbonamenti si rinnovano automaticamente alla fine di ogni periodo di fatturazione, salvo cancellazione.", + "premiumCancellation": "3. Cancellazione", + "premiumCancellationDesc": "Puoi cancellare il tuo abbonamento in qualsiasi momento. Per gli abbonamenti Discord, vai su Impostazioni utente → Abbonamenti in Discord. Per gli abbonamenti tramite il sito web, gestisci la fatturazione su whop.com/billing. Dopo la cancellazione, le funzionalità Premium restano attive fino alla fine del periodo di fatturazione corrente. Dopodiché, il tuo server torna al livello gratuito — nessun dato viene perso.", + "premiumRefunds": "4. Rimborsi", + "premiumRefundsDesc": "Le tariffe di abbonamento non sono rimborsabili. L'utilizzo parziale del mese non viene proporzionato. Se cancelli a metà ciclo, mantieni l'accesso fino alla fine del periodo di fatturazione, ma non hai diritto a un rimborso per il tempo rimanente. I rimborsi per errori di fatturazione o addebiti duplicati possono essere emessi a nostra discrezione — contatta il supporto con l'ID del tuo server Discord e la prova di pagamento.", + "premiumPriceChanges": "5. Variazioni di prezzo", + "premiumPriceChangesDesc": "Ci riserviamo il diritto di modificare i prezzi degli abbonamenti in qualsiasi momento. Gli abbonati esistenti riceveranno un preavviso di almeno 30 giorni prima dell'entrata in vigore di qualsiasi aumento di prezzo. Se non sei d'accordo con una variazione di prezzo, puoi cancellare prima che il nuovo prezzo venga applicato.", + "premiumTermination": "6. Cessazione dell'accesso Premium", + "premiumTerminationDesc": "Ci riserviamo il diritto di revocare l'accesso Premium senza rimborso se un server viola questi Termini di Servizio, inclusi, a titolo esemplificativo, l'abuso delle funzionalità del bot, i tentativi di aggirare i limiti di utilizzo o la violazione dei Termini di Servizio di Discord.", + "serviceAvailabilityTitle": "Disponibilità del servizio e responsabilità", + "serviceNoWarranty": "1. Nessuna garanzia", + "serviceNoWarrantyDesc": "IL SERVIZIO È FORNITO \"COSÌ COM'È\" E \"SECONDO DISPONIBILITÀ\" SENZA GARANZIE DI ALCUN TIPO, ESPLICITE, IMPLICITE O PREVISTE DALLA LEGGE, INCLUSE, A TITOLO ESEMPLIFICATIVO, LE GARANZIE IMPLICITE DI COMMERCIABILITÀ, IDONEITÀ PER UN PARTICOLARE SCOPO E NON VIOLAZIONE. Non garantiamo che il servizio sarà ininterrotto, tempestivo, sicuro o privo di errori.", + "serviceLiability": "2. Limitazione di responsabilità", + "serviceLiabilityDesc": "NELLA MISURA MASSIMA CONSENTITA DALLA LEGGE APPLICABILE, IN NESSUN CASO SAREMO RESPONSABILI PER DANNI INDIRETTI, INCIDENTALI, SPECIALI, CONSEQUENZIALI O PUNITIVI, O PER QUALSIASI PERDITA DI PROFITTI O RICAVI, SOSTENUTA DIRETTAMENTE O INDIRETTAMENTE, O PER QUALSIASI PERDITA DI DATI, UTILIZZO, AVVIAMENTO O ALTRE PERDITE IMMATERIALI, DERIVANTI DALL'USO O DALL'IMPOSSIBILITÀ DI UTILIZZARE IL SERVIZIO. La nostra responsabilità totale per qualsiasi reclamo derivante da questi termini o dal servizio non supererà l'importo che ci hai pagato nei tre (3) mesi precedenti il reclamo.", + "serviceCredits": "3. Interruzioni del servizio e crediti", + "serviceCreditsDesc": "In caso di un'interruzione di servizio prolungata e non pianificata che influisca sulle funzionalità Premium per più di 72 ore consecutive, gli abbonati Premium interessati possono richiedere un credito di servizio contattando il supporto. I crediti vengono emessi a nostra esclusiva discrezione e applicati come estensione del periodo di fatturazione corrente — non come rimborsi monetari. Le finestre di manutenzione programmate, le interruzioni di terze parti (Discord, Gaijin API) e gli eventi al di fuori del nostro ragionevole controllo sono esclusi.", + "serviceForceM": "4. Forza maggiore", + "serviceForceMDesc": "Non saremo responsabili per qualsiasi inadempimento o ritardo nell'esecuzione derivante da cause al di fuori del nostro ragionevole controllo, inclusi, a titolo esemplificativo: disastri naturali, guerra, terrorismo, pandemie, interruzioni di corrente, interruzioni di internet, interruzioni della piattaforma Discord, modifiche o indisponibilità dell'API di Gaijin Entertainment, azioni governative o qualsiasi altro evento di forza maggiore.", + "disclaimer": "Disclaimer", + "warThunderDisclaimer": "è un bot Discord indipendente e non è affiliato, approvato o associato a Gaijin Entertainment o War Thunder. War Thunder è un marchio di Gaijin Entertainment.", + "acknowledgement": "Usando", + "acknowledgementEnd": ", riconosci di aver letto, compreso e di accettare di essere vincolato da questi Termini di Servizio e Informativa sulla Privacy." + }, + "premium": { + "upgradeTitle": "Potenzia la tua squadriglia", + "heroDesc": "Ogni risultato SQB pubblicato nel tuo canale nel momento in cui termina — classifiche, log battaglia, mappe di movimento e replay, tutto in automatico.", + "instantScoreboards": "Classifiche istantanee", + "viewPaths": "Visualizza percorsi", + "chatBattleLogs": "Log chat e battaglia", + "replayLookups": "Ricerca replay", + "free": "Gratuito", + "perMonth": "/mese", + "alwaysFree": "Sempre gratuito", + "noCardNeeded": "nessuna carta necessaria", + "included": "Incluso", + "manualLookups": "Ricerche manuali di partite", + "playerStats": "Statistiche e profili giocatori", + "leaderboards": "Classifiche", + "stickWithFree": "Rimani con il piano gratuito", + "premiumLabel": "Premium", + "perServer": "per server", + "cancelAnytime": "cancella in qualsiasi momento", + "everythingInFree": "Tutto del piano gratuito, più", + "autoScoreboards": "Pubblicazione automatica delle classifiche", + "pathMaps": "Mappe percorsi/movimenti", + "chatLogs": "Log chat e battaglia", + "replayLookupsFeature": "Ricerca replay", + "unlimitedComp": "Ricerche /comp illimitate", + "prioritySupport": "Supporto prioritario", + "subscribeNow": "Abbonati ora", + "comingSoon": "In arrivo", + "serverIdInfo": "Avrai bisogno del tuo", + "discordServerId": "ID server Discord", + "duringCheckout": "durante il pagamento.", + "developerMode": "Modalità sviluppatore", + "rightClickServer": "Clic destro sul server", + "copyId": "Copia ID", + "successTitle": "Premium attivato", + "successDesc": "Il tuo abbonamento è in fase di configurazione. Il bot avrà accesso premium per il tuo server entro pochi minuti.", + "whatHappensNext": "Cosa succede dopo", + "autoLogging": "Il logging automatico si attiva per il tuo server", + "setLogChannel": "Imposta il tuo canale log con", + "everyResult": "Ogni risultato SQB viene pubblicato automaticamente", + "readSetupGuide": "Leggi la guida alla configurazione", + "tierStandardName": "Standard", + "tierProName": "Pro", + "tierMaxName": "Max", + "squadCap": "Registra fino a {cap} squadroni", + "squadCapUnlimited": "Squadroni illimitati", + "everythingInStandard": "Tutto in Standard", + "everythingInPro": "Tutto in Pro", + "wildcardSupport": "Wildcard (*, all, everything)", + "noSquadCap": "Nessun limite di squadroni", + "earlyAccessFeatures": "Accesso anticipato alle novità" + }, + "player": { + "totalBattles": "Battaglie totali", + "totalWins": "Vittorie totali", + "vehicleStatistics": "Statistiche veicoli", + "cumulative": "Cumulativo", + "individual": "Individuale", + "filterBy": "Filtra per:", + "allTime": "Tutto il periodo", + "dateRange": "Intervallo di date", + "season": "Stagione", + "week": "Settimana", + "session": "Sessione", + "dateType": "Tipo di data:", + "last7Days": "Ultimi 7 giorni", + "last30Days": "Ultimi 30 giorni", + "last90Days": "Ultimi 90 giorni", + "customRange": "Intervallo personalizzato", + "specificDate": "Data specifica", + "filterType": "Tipo di filtro:", + "fullSeason": "Stagione completa", + "specificWeek": "Settimana specifica", + "from": "Da:", + "to": "A:", + "timeslot": "Fascia", + "fullDay": "Giornata intera", + "selectSeason": "Stagione:", + "selectWeek": "Settimana:", + "selectSeasonFirst": "Seleziona prima la stagione", + "pleaseSelect": "Seleziona un'opzione", + "searchVehicles": "Cerca veicoli...", + "resetFilters": "Reimposta filtri", + "vehiclesShown": "veicoli mostrati", + "gamesShown": "partite mostrate", + "noVehicleData": "Nessun dato sui veicoli disponibile", + "noVehiclesForRange": "Nessun veicolo trovato per l'intervallo di date selezionato, oppure questo giocatore non ha ancora dati.", + "switchToCards": "Passa alla vista schede", + "switchToTable": "Passa alla vista tabella", + "loadingTimeline": "Caricamento timeline...", + "noTimelineData": "Nessun dato sulla timeline ancora.", + "timelineUnavailable": "Timeline non disponibile.", + "loadingGameRecords": "Caricamento record partite...", + "unableToLoadRecords": "Impossibile caricare i record delle partite", + "failedToFetch": "Impossibile recuperare i dati della partita. Riprova più tardi.", + "noGameRecords": "Nessun record di partita trovato", + "noGamesYet": "Questo giocatore non ha ancora giocato partite registrate.", + "collapseChart": "Comprimi grafico", + "loadingChartData": "Caricamento dati grafico...", + "noHistoricalData": "Nessun dato storico ancora.", + "chartUnavailable": "Grafico non disponibile.", + "relative": "Relativo", + "uidLabel": "UID giocatore" + }, + "squadrons": { + "title": "Hub squadriglia", + "subtitle": "Scopri le squadriglie, visualizza le statistiche e monitora le prestazioni", + "findSquadron": "Trova una squadriglia", + "searchPlaceholder": "Cerca squadriglie per nome...", + "totalSquadrons": "Squadriglie totali", + "totalPlayers": "Giocatori totali", + "totalBattles": "Battaglie totali", + "avgWinRate": "Percentuale vittorie media", + "topSquadrons": "Migliori squadriglie", + "viewFullLeaderboard": "Visualizza classifica completa", + "loadingSquadrons": "Caricamento dati squadriglia...", + "noSquadronData": "Nessun dato squadriglia disponibile ancora.", + "failedToLoad": "Impossibile caricare i dati della squadriglia. Riprova più tardi.", + "backToSquadronHub": "Torna all'hub squadriglia", + "squadronPoints": "Punti squadriglia", + "squadronMembers": "Membri squadriglia", + "performance": "Prestazioni", + "performanceNoData": "Nessun dato sulle prestazioni disponibile per l'intervallo selezionato.", + "quickDetails": "Dettagli rapidi", + "noMembersFound": "Nessun membro trovato", + "noRecordedMembers": "Questa squadriglia non ha ancora membri registrati.", + "squadronGames": "Partite dello squadrone", + "loadingSquadronGames": "Caricamento delle partite dello squadrone...", + "noSquadronGames": "Nessuna partita trovata per questo squadrone.", + "retryLoadGames": "Riprova", + "searchMapPlaceholder": "Cerca per mappa..." + }, + "leaderboard": { + "playersTitle": "Classifica giocatori", + "playersSubtitle": "Migliori giocatori di War Thunder classificati per prestazioni", + "vehiclesTitle": "Classifica eliminazioni veicoli", + "vehiclesSubtitle": "Migliori veicoli di War Thunder classificati per eliminazioni totali", + "squadronsTitle": "Classifica squadriglie", + "squadronsSubtitle": "Migliori squadriglie di War Thunder classificate per prestazioni", + "statsTitle": "Statistiche globali", + "statsSubtitle": "Statistiche generali delle battaglie di squadriglia e informazioni meta", + "comparisonTitle": "Strumento di confronto", + "comparisonSubtitle": "Confronta giocatori e veicoli fianco a fianco", + "comparisonHint": "Confronta le statistiche per trovare i migliori giocatori e veicoli", + "compareSquadrons": "Confronta squadriglie", + "comparePlayers": "Confronta giocatori", + "compareVehicles": "Confronta veicoli", + "playersAndVehicles": "Giocatori + Veicoli", + "failedToLoadLeaderboard": "Impossibile caricare i dati della classifica. Riprova più tardi.", + "failedToLoadVehicles": "Impossibile caricare la classifica dei veicoli", + "failedToLoadSquadrons": "Impossibile caricare la classifica delle squadriglie", + "noResultsYet": "Nessun risultato ancora. Aggiungi squadriglie/giocatori per iniziare.", + "searchSquadron": "Cerca squadriglia", + "searchBySquadronName": "Cerca per nome squadriglia...", + "minPlayers": "Giocatori minimi", + "minPlayersPlaceholder": "Giocatori minimi", + "resetFilters": "Reimposta filtri", + "squadronsShown": "squadriglie mostrate", + "playersShown": "giocatori mostrati", + "page": "pagina", + "of": "di", + "loadingSquadronLeaderboard": "Caricamento classifica squadriglie...", + "loadingPlayerLeaderboard": "Caricamento classifica giocatori...", + "loadingComparisonData": "Caricamento dati di confronto...", + "unableToFetch": "Impossibile recuperare i dati della classifica. Riprova.", + "noSquadronsInLeaderboard": "Nessuna squadriglia trovata in classifica.", + "noPlayersInLeaderboard": "Nessun giocatore trovato in classifica.", + "loadingGlobalStats": "Caricamento statistiche globali...", + "failedToLoadStats": "Impossibile caricare le statistiche", + "unableToFetchStats": "Impossibile recuperare i dati delle statistiche. Riprova.", + "mostPopularVehicles": "Veicoli più popolari", + "vehicleKillsLeaderboardTitle": "Classifica eliminazioni veicoli", + "avgWinRate": "Percentuale vittorie media", + "avgKillsPerPlayer": "Media eliminazioni/giocatore", + "loadingVehicleKills": "Caricamento dati eliminazioni veicoli...", + "apiNotLoaded": "Client API non caricato correttamente. Aggiorna la pagina.", + "failedToInitApi": "Impossibile inizializzare il client API", + "noStatsData": "Nessun dato statistico disponibile", + "totalPlayersCard": "Giocatori totali", + "activePlayers": "Giocatori attivi", + "vehiclesUsed": "Veicoli utilizzati", + "differentVehicles": "Veicoli diversi", + "squadronBattlesLabel": "Battaglie di squadriglia", + "noVehicleData": "Nessun dato sui veicoli disponibile", + "mostPopular": "Più popolare", + "timesUsed": "Volte utilizzato", + "failedToLoadVehicleKills": "Impossibile caricare i dati delle eliminazioni dei veicoli", + "lastUpdated": "Ultimo aggiornamento", + "searchPlayer": "Cerca giocatore", + "searchByPlayerName": "Cerca per nome del giocatore...", + "minimumBattles": "Battaglie minime", + "minBattlesPlaceholder": "Battaglie minime", + "searchSquadronsPlaceholder": "Cerca squadriglie...", + "sortBy": "Ordina per", + "kdRatio": "Rapporto KD", + "killsPerSpawn": "Eliminazioni per spawn", + "caps": "Catture", + "timePeriod": "Periodo di tempo", + "allTime": "Tutto il periodo", + "dateRange": "Intervallo di date", + "season": "Stagione", + "week": "Settimana", + "dateType": "Tipo di data", + "last7Days": "Ultimi 7 giorni", + "last30Days": "Ultimi 30 giorni", + "last90Days": "Ultimi 90 giorni", + "customRange": "Intervallo personalizzato", + "from": "Da", + "to": "A", + "timeslot": "Fascia", + "fullDay": "Giornata intera", + "selectSeason": "Seleziona stagione...", + "selectWeek": "Seleziona settimana...", + "failedToLoadComparison": "Impossibile caricare i dati di confronto", + "pleaseRefresh": "Prova ad aggiornare la pagina", + "playerComparison": "Confronto giocatori", + "squadronComparison": "Confronto squadriglie", + "vehicleComparison": "Confronto veicoli", + "playersVehiclesComparison": "Confronto giocatori + veicoli", + "addPlayersToCompare": "Aggiungi giocatori da confrontare:", + "addSquadronsToCompare": "Aggiungi squadriglie da confrontare:", + "addVehiclesToCompare": "Aggiungi veicoli da confrontare:", + "addPlayerVehicleCombos": "Aggiungi combinazioni giocatore + veicolo:", + "searchSelectPlayers": "Cerca e seleziona giocatori...", + "typeSquadronName": "Inserisci il nome della squadriglia...", + "searchSelectVehicles": "Cerca e seleziona veicoli...", + "searchForPlayers": "Cerca giocatori...", + "selectPlayersToCompare": "Cerca e seleziona giocatori qui sopra per confrontare le loro statistiche", + "selectSquadronsToCompare": "Cerca e seleziona squadriglie qui sopra per confrontare le loro statistiche", + "selectVehiclesToCompare": "Cerca e seleziona veicoli qui sopra per confrontare le loro statistiche", + "selectPlayersVehiclesToCompare": "Cerca e seleziona giocatori qui sopra, poi scegli i loro veicoli per confrontare diverse combinazioni giocatore-veicolo", + "selectVehicleFor": "Seleziona veicolo per", + "selectAVehicle": "-- Seleziona un veicolo --", + "noVehiclesForPlayer": "Nessun veicolo trovato per questo giocatore", + "noPlayerVehicleSelected": "Nessuna combinazione giocatore-veicolo selezionata", + "noPlayersSelected": "Nessun giocatore selezionato", + "noVehiclesSelected": "Nessun veicolo selezionato", + "noSquadronsSelected": "Nessuna squadriglia selezionata", + "statistic": "Statistica", + "totalDeaths": "Morti totali", + "totalAssists": "Assistenze totali", + "totalCaptures": "Catture totali", + "killsPerSpawnShort": "Elim/Spawn", + "avgWinRateShort": "% vitt. media", + "avgKillsPlayerShort": "Media elim/gioc.", + "avgKillsMember": "Media elim/membro", + "avgBattlesMember": "Media batt/membro", + "serverError500": "Errore del server (500) - L'API è temporaneamente non disponibile. Riprova tra qualche istante.", + "apiEndpoint404": "Endpoint API non trovato (404) - Controlla la configurazione del server.", + "networkError": "Errore di rete - Impossibile connettersi al server. Controlla la tua connessione.", + "viewFullLeaderboard": "Visualizza classifica completa", + "vehicleName": "Nome veicolo", + "searchVehiclePlaceholder": "Cerca un veicolo specifico...", + "minKills": "Elim. min.", + "any": "Qualsiasi", + "perPage": "Per pagina", + "clear": "Cancella", + "vehicleAndPlayer": "Veicolo e giocatore", + "loadingVehicleLeaderboard": "Caricamento classifica veicoli...", + "vehiclesShown": "veicoli mostrati", + "minBattlesPerVehicle": "Minimo 3 battaglie per veicolo richieste", + "minimumBattlesRequired": "Minimo 5 battaglie richieste", + "allSeasons": "Tutte le stagioni", + "allWeeks": "Tutte le settimane", + "allBR": "Tutti i BR" + }, + "games": { + "title": "Storico partite", + "subtitle": "Cerca e sfoglia le partite delle battaglie di squadriglia", + "searchPlaceholder": "Cerca per nome giocatore o UID...", + "filterByMap": "Filtra per mappa", + "allMaps": "Tutte le mappe", + "search": "Cerca", + "noResults": "Nessuna partita trovata", + "matchDetail": "Dettaglio partita", + "chatLog": "Log chat", + "battleLog": "Log battaglia", + "duration": "Durata", + "mode": "Modalità", + "winningTeam": "Squadra vincente", + "losingTeam": "Squadra perdente", + "viewMatch": "Visualizza partita", + "loadingMatch": "Caricamento dati partita...", + "matchNotFound": "Partita non trovata", + "searchingGames": "Ricerca partite...", + "recentMatches": "Partite recenti", + "noChatLog": "Nessun log chat disponibile", + "noBattleLog": "Nessun log battaglia disponibile", + "replayVideo": "Video replay", + "generatingVideo": "Generazione video replay…", + "videoFirstLoad": "Il primo caricamento può richiedere fino a un minuto", + "videoUnavailable": "Video replay non disponibile per questa partita", + "modeGround": "Terrestre", + "modeAir": "Aereo", + "squadronPlaceholder": "Nome squadriglia...", + "loadingReplay": "Caricamento replay..." + }, + "errors": { + "pageNotFound": "Pagina non trovata", + "error": "Errore", + "oopsNotFound": "Oops! La pagina che stai cercando non esiste. Potrebbe essere stata spostata, eliminata, oppure hai inserito un URL errato.", + "searchError": "Errore di ricerca. Riprova." + }, + "js": { + "openingDiscordInvite": "Apertura invito Discord!", + "errorOpeningInvite": "Errore nell'apertura del link di invito. Riprova più tardi.", + "gettingSupportLink": "Recupero link server di supporto...", + "openingSupportServer": "Apertura server di supporto!", + "errorGettingSupport": "Errore nel recupero del link di supporto. Riprova più tardi.", + "failedToUpdateStats": "Impossibile aggiornare le statistiche", + "konamiActivated": "Obiettivo sbloccato: Codice segreto!", + "noPlayersFound": "Nessun giocatore trovato", + "searchError": "Errore di ricerca. Riprova.", + "killsSuffix": "eliminazioni", + "winRateSuffix": "percentuale vittorie", + "noSquadronsFound": "Nessuna squadriglia trovata" + }, + "index": { + "subtitle1": "Il miglior strumento per le battaglie di squadriglia", + "subtitle2": "Boris Stats ma migliore", + "subtitle3": "Il più grande dataset SQB", + "subtitle4": "Informazioni pubbliche e gratuite per tutti" + }, + "seasonCard": { + "buttonLabel": "Scheda stagione", + "buttonDisabledTitle": "Ricerca squadrone incompleta — scheda non disponibile", + "modalTitle": "Scheda stagione", + "seasonLabel": "Stagione", + "themeLabel": "Tema", + "themeDark": "Scuro", + "themeLight": "Chiaro", + "generate": "Genera", + "loadingSeasons": "Caricamento stagioni…", + "generating": "Generazione…", + "failedSeasons": "Impossibile caricare le stagioni.", + "failedGenerate": "Impossibile generare la scheda riepilogativa.", + "inProgressSuffix": "(in corso)", + "imgRecapSuffix": "RESOCONTO", + "imgHeroFinalRating": "Rating finale", + "imgHeroMatches": "Partite", + "imgHeroWinRate": "Tasso vittorie", + "imgHeroKD": "K/D", + "imgAxisRating": "Punteggio", + "imgAxisWinRate": "Tasso vittorie", + "imgStatPeakRating": "Rating massimo", + "imgStatRatingChange": "Variazione rating", + "imgStatTotalKills": "Uccisioni totali", + "imgStatTotalDeaths": "Morti totali", + "imgStatAssistsCaptures": "Assist / catture", + "imgStatMostPlayedVehicle": "Veicolo più usato", + "imgStatMVP": "MVP", + "imgStatMostActive": "Più attivo", + "imgStatLongestWinStreak": "Striscia più lunga", + "imgStatMostCommonOpponent": "Avversario più frequente", + "imgUnitKills": "uccisioni", + "imgUnitAssists": "assist", + "imgUnitCaptures": "catture", + "imgUnitGames": "partite", + "imgUnitMatches": "partite", + "imgUnitWins": "V", + "imgUnitLosses": "S", + "imgGroundShort": "T", + "imgAirShort": "A", + "imgFooterGenerated": "generato", + "imgPlaceholderNoData": "Nessun dato per {short} in {season}", + "buttonLabelPlayer": "Riepilogo stagione", + "buttonDisabledTitlePlayer": "Player lookup incomplete — recap unavailable", + "modalTitlePlayer": "Player Season Recap", + "imgHeroBattles": "Battaglie", + "imgHeroTotalKills": "Uccisioni totali", + "imgAxisBattles": "Battaglie (7 g)", + "imgAxisKD": "K/D", + "imgStatBestMatch": "Miglior partita", + "imgStatSquadronsRepresented": "Squadriglie rappresentate", + "imgStatFrequentTeammate": "Compagno più frequente", + "imgStatLongestSession": "Sessione più lunga", + "imgStatMostActiveDay": "Giorno più attivo (UTC)", + "imgStatMostCommonOppSquadron": "Squadriglia avversaria più frequente", + "imgStatPeakSquadronRating": "Punteggio max squadriglia", + "imgUnitSlotNA": "NA", + "imgUnitSlotEU": "EU", + "imgUnitNoSquadron": "senza squadriglia", + "imgUnitVs": "vs", + "imgUnitTogether": "partite insieme", + "imgStatKDAC": "U / M / A / C", + "imgPlaceholderNoDataPlayer": "Nessun dato per {nick} in {season}", + "imgUIDLabel": "UID", + "imgBestMatchLine": "{vehicle} · UT {gk} / UA {ak} / A {assists} / C {cap} / M {deaths} · {date}" + }, + "live": { + "air": "AER", + "gnd": "TER", + "ast": "ASS", + "dth": "MOR", + "cap": "CAT", + "squadronBattle": "Battaglia di squadriglia", + "randomBattle": "Battaglia casuale" + }, + "analytics": { + "pageTitle": "Analisi SQB", + "pageSubtitle": "Analisi approfondite per qualsiasi squadrone, giocatore o veicolo.", + "modeSquadron": "Squadrone", + "modePlayer": "Giocatore", + "modeVehicle": "Veicolo", + "tabMaps": "Tassi di vittoria mappa", + "tabSquadmates": "Compagni abituali", + "tabComps": "Composizioni squadra", + "tabConsistency": "Costanza del giocatore", + "tabTime": "Ora del giorno", + "tabMatchups": "Storico scontri", + "pickSquadron": "Cerca uno squadrone per vedere la sua analisi", + "pickPlayer": "Cerca un giocatore per vedere la sua analisi", + "pickVehicle": "Cerca un veicolo per vedere la sua analisi", + "noData": "Nessun dato in questo intervallo.", + "loading": "Caricamento…", + "loadError": "Impossibile caricare le analisi.", + "compComingSoon": "Analisi composizione squadra in arrivo.", + "compTopVehiclesTitle": "Veicoli più usati", + "compCompositionsTitle": "Composizioni di partita ricorrenti", + "compCompositionsMeta": "Formazioni con almeno {min} veicoli, ordinate per partite", + "compMatchesAnalyzed": "partite analizzate", + "compNoRepeats": "Nessuna formazione di questa dimensione è stata schierata in questo intervallo.", + "compColVehicle": "Veicolo", + "compColSpawns": "Comparse", + "compColMatches": "Partite", + "compColShare": "% Partite", + "compColLineup": "Formazione", + "compColTypes": "Comp", + "compTypeFighters": "Caccia", + "compTypeBombers": "Bombardieri", + "compTypeHelicopters": "Elicotteri", + "compTypeLight": "Carro leggero", + "compTypeTanks": "Carri", + "compTypeSPAA": "SPAA", + "compTypeSPAATooltip": "Antiaerea/SPAA", + "compTypeUnknown": "Sconosciuto", + "compSearchPresetLabel": "Comp preimpostata", + "compSearchPresetAll": "Tutte le comp", + "compSearchPresetHint": "Costruito dalla cronologia di comp di questa squadra", + "compSearchTypesLabel": "Tipi", + "compTypeCapsHint": "Max 8 totali · max 4 aerei (F + B + H)", + "compSearchRefineLabel": "Affina", + "compRefineHint": "Imposta un numero sopra per scegliere veicoli specifici di quel tipo.", + "compRefineAny": "Qualsiasi {type}", + "compSearchCustomLabel": "Personalizzata", + "compSearchAddVehicle": "Veicolo specifico", + "compSearchVehiclesLabel": "Veicoli", + "compSearchApply": "Applica", + "compSearchReset": "Reimposta", + "compSearchMatches": "Mostrate {shown} di {total} comp", + "compSearchNoMatches": "Nessuna comp corrisponde al filtro.", + "compSearchGamesShort": "partite", + "colMap": "Mappa", + "colWins": "V", + "colLosses": "S", + "colWinRate": "%V", + "colBar": "", + "colShared": "In comune", + "colUid": "UID", + "colGames": "Partite", + "colPlayer": "Giocatore", + "colAvgKills": "Kill medi", + "colAvgDeaths": "Morti medie", + "colScore": "Punteggio", + "colHour": "Ora (UTC)", + "colSquadron": "Squadrone", + "colTotal": "Totale", + "matchupsWonHeader": "Più vittorie contro", + "matchupsLostHeader": "Più sconfitte contro", + "uniqueOpponents": "avversari unici", + "euTimeslot": "Fascia EU", + "naTimeslot": "Fascia NA", + "offPeak": "Fuori picco", + "radarMetaMaps": "top {shown} di {total} mappe · min {min} partite", + "radarMetaSquadmates": "top {shown} di {total} compagni · min {min} partite condivise", + "radarTooFewMaps": "Dati insufficienti per mostrare il grafico: servono almeno 3 mappe con {min}+ partite in questo filtro.", + "radarTooFewSquadmates": "Dati insufficienti per mostrare il grafico: servono almeno 3 compagni con {min}+ partite condivise in questo filtro.", + "radarFootnoteMaps": "{count} mappe con meno partite nascoste — vedi la tabella completa", + "radarFootnoteSquadmates": "{count} compagni con meno partite nascosti — vedi la tabella completa", + "tabTimeline": "Timeline K/D", + "tabTopPlayers": "Migliori giocatori", + "tabTopSquadrons": "Migliori squadroni" + }, + "playerModal": { + "viewFullProfile": "Vedi profilo completo →", + "close": "Chiudi", + "overview": "Panoramica", + "vehicles": "Veicoli", + "sessions": "Sessioni", + "loadingPlayerData": "Caricamento dati giocatore...", + "kdr": "K/D", + "kps": "K/S", + "winRate": "Tasso vittorie", + "battles": "Battaglie", + "wins": "Vittorie", + "totalBattles": "Battaglie totali", + "totalKills": "Uccisioni totali", + "airKills": "Uccisioni aeree", + "groundKills": "Uccisioni terrestri", + "assists": "Assist", + "deaths": "Morti", + "captures": "Catture", + "clickToSwitchMetric": "Clicca per cambiare metrica", + "clickToCycle": "clicca per scorrere", + "noChartData": "Nessun dato grafico", + "noVehicleData": "Nessun dato veicolo", + "noSessionData": "Nessun dato sessione", + "date": "Data", + "vehicle": "Veicolo", + "ground": "Terra", + "air": "Aria", + "result": "Risultato", + "unknown": "Sconosciuto", + "failedToLoadPlayerData": "Impossibile caricare i dati giocatore" + }, + "replay": { + "playPause": "Riproduci/Pausa", + "crashed": "si è schiantato", + "destroyed": "ha distrutto", + "hit": "ha colpito" + }, + "dateFilter": { + "allTime": "Sempre", + "currentSeason": "Stagione attuale", + "bySeason": "Per stagione", + "cumulative": "Cumulativo", + "customRange": "Intervallo personalizzato", + "selectSeason": "Seleziona stagione", + "selectSeasonDots": "Seleziona una stagione...", + "selectWeek": "Seleziona settimana", + "selectWeekDots": "Seleziona una settimana...", + "entireSeason": "Intera stagione", + "applyFilter": "Applica filtro", + "cumulativeHelp": "Vedi statistiche accumulate fino a un punto specifico", + "season": "Stagione", + "upToWeek": "Fino alla settimana", + "applyCumulativeFilter": "Applica filtro cumulativo", + "startDate": "Data inizio", + "endDate": "Data fine", + "applyCustomRange": "Applica intervallo personalizzato", + "activeFilter": "Filtro attivo:", + "clear": "Cancella", + "allTimeStatistics": "Statistiche totali", + "currentSeasonValue": "Stagione attuale: {season}", + "alertSelectSeason": "Seleziona una stagione", + "seasonValue": "Stagione {season}", + "alertSelectSeasonWeek": "Seleziona stagione e settimana", + "cumulativeValue": "Cumulativo fino a {season} - {week}", + "alertSelectDate": "Seleziona almeno una data", + "alertStartBeforeEnd": "La data iniziale deve precedere quella finale", + "customRangePrefix": "Intervallo personalizzato:", + "fromDate": "Da {date}", + "upToDate": "Fino a {date}" + } +} diff --git a/web/locales/pl.json b/web/locales/pl.json new file mode 100644 index 0000000..90b78c0 --- /dev/null +++ b/web/locales/pl.json @@ -0,0 +1,906 @@ +{ + "nav": { + "home": "Strona główna", + "live": "Na żywo", + "leaderboards": "Rankingi", + "docs": "Dokumentacja", + "terms": "Regulamin", + "premium": "Premium", + "support": "Wsparcie", + "addToDiscord": "Dodaj do Discord", + "games": "Mecze", + "squadrons": "Dywizjony", + "donate": "Wesprzyj", + "analytics": "Analityka" + }, + "footer": { + "services": "Usługi", + "matchFeed": "Wyniki meczów", + "vehicleStats": "Statystyki pojazdów", + "analytics": "Analityka", + "squadronHub": "Centrum Dywizjonów", + "comparison": "Porównanie", + "resources": "Zasoby", + "documentation": "Dokumentacja", + "inviteBot": "Zaproś bota", + "legal": "Prawne", + "termsOfService": "Warunki usługi", + "privacyPolicy": "Polityka prywatności", + "termsAndPrivacy": "Regulamin i prywatność", + "meowing": "Miauczenie", + "websiteBy": "Strona stworzona przez", + "andToothless": "i Toothless" + }, + "common": { + "loading": "Ładowanie...", + "retry": "Ponów", + "backToHome": "Powrót do strony głównej", + "battles": "Bitwy", + "wins": "Zwycięstwa", + "winRate": "Wskaźnik zwycięstw", + "kills": "Zabójstwa", + "totalKills": "Łączne zabójstwa", + "groundKills": "Zniszczenia naziemne", + "airKills": "Zniszczenia powietrzne", + "assists": "Asysty", + "deaths": "Śmierci", + "captures": "Przejęcia", + "kdr": "KDR", + "kps": "KPS", + "rank": "Ranking", + "player": "Gracz", + "players": "Gracze", + "playersCount": "graczy", + "vehicle": "Pojazd", + "vehicles": "Pojazdy", + "squadron": "Dywizjon", + "squadrons": "Dywizjony", + "statistics": "Statystyki", + "comparison": "Porównanie", + "date": "Data", + "result": "Wynik", + "totalBattles": "Łączne bitwy", + "totalWins": "Łączne zwycięstwa", + "points": "Punkty", + "members": "Członkowie", + "membersCount": "członków", + "rating": "Ocena", + "searchPlayerByName": "Szukaj gracza po nazwie...", + "noPlayersFound": "Nie znaleziono graczy", + "noSquadronsFound": "Nie znaleziono dywizjonów", + "noVehiclesFound": "Nie znaleziono pojazdów", + "failedToLoad": "Nie udało się załadować danych. Spróbuj ponownie później.", + "recordingSince": "Zbieranie danych od 01.01.2026", + "vs": "VS", + "map": "Mapa" + }, + "home": { + "squadronBattles": "Bitwy Dywizjonów", + "madeSimple": "Prosto i przejrzyście", + "addToDiscord": "Dodaj do Discord", + "learnMore": "Dowiedz się więcej", + "searchBySquadron": "SZUKAJ PO DYWIZJONIE", + "typeSquadronName": "Wpisz nazwę dywizjonu...", + "orByPlayer": "LUB PO GRACZU", + "typePlayerName": "Wpisz nazwę gracza...", + "liveFeed": "Wyniki meczów", + "realTimeMatches": "Znajdź swoje mecze", + "topPlayers": "Najlepsi gracze", + "vehicleStatsCard": "Statystyki pojazdów", + "performanceMetrics": "Wskaźniki wydajności", + "analyticsCard": "Analityka", + "globalStatistics": "Globalne statystyki", + "squadronHubCard": "Centrum Dywizjonów", + "squadronStats": "Statystyki dywizjonu", + "comparisonCard": "Porównanie", + "compareStats": "Porównaj statystyki", + "joinServers": "Dołącz do 500+ serwerów korzystających z naszego bota do śledzenia wyników", + "noSquadronsFound": "Nie znaleziono dywizjonów", + "noPlayersFound": "Nie znaleziono graczy", + "searchPlayersIn": "Szukaj graczy w", + "ctaElev8": "Gotowy, aby ELEV8 swój dywizjon?", + "ctaReign": "Gotowy, aby znów R3IGN?", + "ctaMeow": "Miauuuu", + "ctaPurr": "Mrrr", + "ctaRawr": "Rawr" + }, + "docs": { + "title": "Dokumentacja", + "subtitle": "Wszystko, co musisz wiedzieć o", + "quickNavigation": "Szybka nawigacja", + "gettingStarted": "Pierwsze kroki", + "commands": "Komendy", + "serverSetup": "Konfiguracja serwera", + "features": "Funkcje", + "examples": "Przykłady", + "troubleshooting": "Rozwiązywanie problemów", + "stackManager": "Menedżer Stack", + "welcomeMessage": "Wykonaj poniższe kroki, aby rozpocząć.", + "inviteTheBot": "Zaproś bota", + "inviteBotDesc": "Kliknij przycisk \"Dodaj do serwera\" i wybierz serwer Discord swojego dywizjonu. Bot wyśle wiadomość powitalną z podpowiedzią, aby uruchomić /setup.", + "runSetupWizard": "Uruchom kreator konfiguracji", + "setupWizardDesc": "Kreator konfiguracji przeprowadzi Cię przez konfigurację dywizjonu, kanału logów i kanału punktów w jednym przepływie:", + "setupEasiest": "To najłatwiejszy sposób na start. Krok po kroku przeprowadzi Cię przez ustawienie dywizjonu i wybór kanałów.", + "youreDone": "Gotowe!", + "doneDesc": "Bot automatycznie zacznie publikować punkty i aktualizacje rankingów. Użyj /autolog-management, aby później dostosować ustawienia powiadomień.", + "premiumNote": "Automatyczne logi gier (pełne tablice wyników po każdym meczu) wymagają subskrypcji Premium. Uruchom /unlock, aby subskrybować — $2,99/miesiąc za serwer, rozliczane przez Discord.", + "manualSetup": "Ręczna konfiguracja (alternatywa)", + "manualSetupDesc": "Jeśli wolisz konfigurować elementy osobno, możesz użyć tych komend:", + "allCommandsSlash": "Wszystkie komendy korzystają z systemu komend slash Discord. Wpisz /, aby zobaczyć dostępne komendy.", + "serverSetupAdmin": "Konfiguracja serwera i administracja", + "importantNote": "Ważna uwaga", + "verifyFirst": "Zawsze najpierw weryfikuj! Komenda /sq-info potwierdza, że bot może znaleźć Twój dywizjon w bazie danych War Thunder, nawet jeśli masz bardzo niski ranking.", + "cantFindSquadron": "Jeśli bot nie może znaleźć Twojego dywizjonu za pomocą /sq-info, komendy konfiguracyjne nie będą działać poprawnie.", + "botNotResponding": "Bot nie odpowiada", + "checkOnline": "Sprawdź, czy bot jest online (zielony status)", + "verifyPermissions": "Sprawdź, czy bot ma niezbędne uprawnienia", + "tryDifferentChannel": "Spróbuj użyć komend na innym kanale", + "commandsNotWorking": "Komendy nie działają", + "ensureSlash": "Upewnij się, że używasz komend slash (zaczynające się od /)", + "checkRolePerms": "Sprawdź, czy Twoja rola ma uprawnienia do używania komend bota", + "tryRefreshing": "Spróbuj odświeżyć Discord lub uruchomić aplikację ponownie", + "dataNotSaving": "Dane nie są zapisywane", + "verifySendMessages": "Sprawdź, czy bot ma uprawnienie \"Wysyłaj wiadomości\"", + "checkOutages": "Sprawdź, czy nie ma awarii Discord", + "contactSupport": "Skontaktuj się z pomocą techniczną, jeśli problem nadal występuje", + "needMoreHelp": "Potrzebujesz więcej pomocy?", + "needMoreHelpDesc": "Jeśli potrzebujesz dodatkowej pomocy, skontaktuj się przez nasze kanały wsparcia.", + "example": "Przykład", + "supportedLanguages": "Obsługiwane języki", + "setupDesc": "Kreator krok po kroku do konfiguracji bota dla Twojego serwera. Ustawia dywizjon, kanał logów i kanał punktów w jednym przepływie.", + "recommendedForNew": "Zalecane dla nowych serwerów.", + "setSquadronDesc": "Ustaw domyślny dywizjon dla swojego serwera Discord. Używany do logowania i jako domyślny dla innych komend.", + "quickLogDesc": "Ustaw alarm dla dywizjonu na bieżącym kanale. Typ może być Logs, Points, Leaderboard lub Both — Both ustawia Logs i Points razem w jednej komendzie. Domyślnie Logs.", + "quickLogPremiumNote": "Logs (automatyczne tablice wyników gier) wymagają subskrypcji Premium. Alarmy punktów i rankingów są bezpłatne.", + "autologDesc": "Zarządzaj powiadomieniami autologu i diagnozuj uprawnienia kanału. Użyj tego, aby zmienić ustawienia po wstępnej konfiguracji.", + "autologPremiumNote": "Automatyczne logi gier wymagają subskrypcji Premium.", + "diagnosePermsDesc": "Natychmiastowo sprawdza, czy bot ma wymagane uprawnienia na bieżącym kanale, pokazuje skonfigurowane kanały autologu i wyświetla status subskrypcji Premium tego serwera. Użyj tego, jeśli tablice wyników lub punkty nie są publikowane.", + "squadronInformation": "Informacje o dywizjonie", + "sqInfoDesc": "Wyświetl szczegółowe informacje o dowolnym dywizjonie. Używa domyślnego dywizjonu serwera, jeśli nie podano żadnego.", + "sqInfoGraphDesc": "Wizualizuj aktualny skład dywizjonu jako wykres słupkowy podzielony na grupy trzon, aktywni i słabi według aktywności i współczynnika wygranych (bieżący sezon).", + "compDesc": "Znajdź ostatnio znane składy dla bitew danego dywizjonu. Darmowe serwery otrzymują 25 wyszukiwań na timeslot; Premium ma nieograniczone.", + "trackDesc": "Śledź dywizjon i porównuj statystyki z ostatnim razem, gdy sprawdzałeś.", + "topDesc": "Wyświetl top 20 dywizjonów i ich aktualne statystyki.", + "sqStatsDesc": "Wyświetl punkty dywizjonu w czasie jako interaktywny wykres.", + "lossCalculatorDesc": "Oblicz, ile punktów dywizjon straci, jeśli wybrani gracze odejdą.", + "recentDesc": "Pokaż ostatnie 5 bitew dywizjonu.", + "vsDesc": "Wyświetl rekord bezpośrednich starć z innym dywizjonem.", + "leaderboardLinkDesc": "Uzyskaj link do globalnego rankingu graczy SRE Bot.", + "playerStats": "Statystyki gracza", + "playerStatsDesc": "Wyświetl szczegółowe statystyki pojazdów gracza z interaktywnym menu wyboru pojazdów. Obsługuje autouzupełnianie.", + "viewPlayerGamesDesc": "Wyświetl ostatnie 20 gier gracza. Pokazuje ogólną liczbę wygranych/przegranych i skuteczność, podsumowanie każdej gry (wynik, dywizjon przeciwnika, mapa i skład) oraz deduplikowaną listę każdego unikalnego składu, z którym gracz grał. Obsługuje autouzupełnianie.", + "viewMatchDesc": "Wyświetl pełną tablicę wyników dla konkretnego meczu. Podaj ID meczu bezpośrednio lub wyszukaj gracza, aby przeglądać jego ostatnie 100 gier i wybrać jedną. Zawiera przyciski Obejrzyj Replay, Pokaż Trasy, Log Czatu i Log Bitwy.", + "examples2": "Przykłady", + "compareDesc": "Porównaj łączne statystyki SQB między dwoma lub więcej graczami (do 7). Pokazuje porównanie obok siebie z wyróżnionymi najlepszymi statystykami. Zawiera przycisk wykresu do wyświetlenia historii punktów z ostatnich 90 dni.", + "metaData": "Dane meta", + "metaManagementDesc": "Skonfiguruj ustawienia dostępu do danych meta dla swojego serwera.", + "metaDesc": "Przeszukaj listę meta dywizjonu według nazwy pojazdu.", + "settingsUtilities": "Ustawienia i narzędzia", + "languageDesc": "Zmień domyślny język bota. Wpływa również na język pojazdów wyświetlanych w logach bitew.", + "scheduleDesc": "Wyświetl aktualny harmonogram sezonu BR. Pokazuje maksymalny rating bitew dla każdego tygodnia z zakresem dat, przekreśleniem minionych tygodni i wyróżnieniem bieżącego aktywnego okresu.", + "websiteDesc": "Uzyskaj link do strony SRE Bot do wyszukiwania graczy, rankingów i nie tylko.", + "creditsDesc": "Wyświetl zespół zasłużony za stworzenie tego bota.", + "unlockDesc": "Odblokuj automatyczne logi gier SQB i nieograniczone wyszukiwania /comp dla tego serwera. Subskrypcja dodaje pełne tablice wyników publikowane automatycznie na skonfigurowanym kanale po każdym meczu. $2,99 / miesiąc · za serwer · anuluj w dowolnym momencie. Rozliczenia są obsługiwane całkowicie przez Discord — nie jest wymagane zewnętrzne konto.", + "analyticsDesc": "Zaawansowana analityka SQB: współczynniki wygranych na mapach, składy drużyn, konsekwencja graczy, wydajność w zależności od pory dnia i historia starć (najwięcej wygranych i przegranych przeciwko przeciwnikom).", + "sqCardDesc": "Wygeneruj kartę PNG podsumowania sezonu dla dywizjonu — trend ratingu, skuteczność, najlepsi gracze i więcej. Sezon wybierz z listy autouzupełniania. Obsługuje motyw ciemny i jasny.", + "cardDesc": "Wygeneruj kartę PNG podsumowania sezonu dla gracza. Sezon wybierz z autouzupełniania, gracza — po nazwie użytkownika. Obsługuje motyw ciemny i jasny.", + "queryDesc": "[Tylko admin] Uruchom predefiniowane zapytania do bazy danych — statystyki dywizjonu, liczba gier, najaktywniejsi gracze, najczęstsze mapy i więcej. Wyniki są efemeryczne (widoczne tylko dla ciebie).", + "donateDesc": "Wesprzyj rozwój SRE Bot przez Ko-fi.", + "botStatusDesc": "Pokazuje, kiedy odebrano ostatnią grę i średni TTL z ostatnich gier. Sygnalizuje wolne serwery Gaijin.", + "premiumBadge": "Premium", + "newsDesc": "Wyświetl najnowsze wiadomości i ogłoszenia SRE Bot.", + "stackCreateDesc": "Utwórz stack graczy, aby skoordynować drużynę przed meczem. Na kanale zostaje opublikowany trwały embed pokazujący aktualnych członków i oczekujących kandydatów. Stack trwa do 8 godzin i jest automatycznie usuwany na końcu każdego slotu SQB.", + "stackRequestToJoin": "Prośba o dołączenie — Każdy gracz może aplikować z pojazdem, którym planuje grać. Aplikacje zajmują do 20 miejsc.", + "stackLeaveWithdraw": "Opuść / Wycofaj — Członkowie mogą opuścić stack; kandydaci mogą wycofać swoją aplikację. Lider stacka jest proszony o wcześniejsze przekazanie własności.", + "stackManagePanel": "Zarządzaj stackiem ⚙️ — Panel tylko dla lidera z czterema sekcjami:", + "stackAcceptMembers": "Akceptuj członków — Akceptuj lub odrzucaj kandydatów indywidualnie lub wszystkich na raz. Łącznie do 8 członków.", + "stackRemoveMembers": "Usuń członków — Usuń aktywnych członków lub oczekujących kandydatów. Opcje: Usuń wszystkich, Usuń aktywnych, Usuń oczekujących lub Usuń wybranych z listy.", + "stackPingMembers": "Pinguj członków — Pinguj z opcjonalną niestandardową wiadomością. Opcje: Pinguj wszystkich (członkowie + kolejka, z wyłączeniem lidera), Pinguj aktywnych (tylko członkowie), Pinguj oczekujących (tylko kandydaci) lub Pinguj wybranych z listy.", + "stackRenameStack": "Zmień nazwę stacka — Ustaw niestandardową nazwę stacka. Pojawia się jako tytuł embeda i w wiadomościach pingów zamiast domyślnego \"Stack [Lidera]\".", + "stackDisbandStack": "Rozwiąż stack — Lider może zakończyć stack wcześniej.", + "stackManageDesc": "Ponownie publikuje embed aktywnego stacka na bieżącym kanale. Użyj tego, jeśli oryginalny embed został usunięty lub zagubiony po restarcie bota. Wszyscy istniejący członkowie i dane kolejki są zachowane.", + "translation": "Tłumaczenie", + "translateContextMenu": "Kliknij prawym przyciskiem wiadomość → Aplikacje → Translate Message", + "translateDesc": "Przetłumacz dowolną wiadomość za pomocą menu kontekstowego Discord. Kliknij prawym przyciskiem (lub przytrzymaj na telefonie) wiadomość i wybierz Aplikacje → Translate Message.", + "viewAllLanguages": "Wyświetl wszystkie obsługiwane języki", + "serverSetupSubtitle": "Skonfiguruj {botName} dla optymalnej wydajności na serwerze Discord swojego dywizjonu.", + "requiredPermissions": "Wymagane uprawnienia", + "sendMessages": "Wysyłaj wiadomości", + "useSlashCommands": "Używaj komend slash", + "embedLinks": "Osadzaj linki", + "readMessageHistory": "Czytaj historię wiadomości", + "recommendedChannelSetup": "Zalecana konfiguracja kanału", + "recommendedChannelDesc": "Utwórz dedykowany kanał, np. #squadron-battles, do śledzenia statystyk. Dzięki temu dane bitew będą uporządkowane i łatwo dostępne.", + "roleConfiguration": "Konfiguracja ról", + "roleConfigurationDesc": "Przypisz odpowiednie role członkom dywizjonu, którzy mogą rejestrować wyniki bitew. Zalecamy ograniczenie tego do oficerów i liderów dywizjonu.", + "premiumSectionSubtitle": "Automatyczne logi gier SQB są funkcją Premium, odblokowywaną per serwer przez natywny system subskrypcji Discord.", + "whatsIncluded": "Co jest wliczone", + "premiumInclude1": "Pełna tablica wyników publikowana automatycznie na skonfigurowanym kanale po każdym meczu SQB", + "premiumInclude2": "Nieograniczone wyszukiwania /comp (darmowe serwery otrzymują 25 na timeslot)", + "premiumInclude3": "Wszystkie istniejące bezpłatne funkcje (alarmy punktów, rankingi, komendy statystyk itp.) pozostają bezpłatne", + "pricingBilling": "Ceny i rozliczenia", + "pricingBillingDesc": "$2,99 / miesiąc · za serwer · anuluj w dowolnym momencie. Rozliczenia są zarządzane całkowicie przez Discord — nie jest wymagane zewnętrzne konto ani procesor płatności. Subskrypcje odnawiają się automatycznie i można je anulować w dowolnym momencie w ustawieniach Discord.", + "howToSubscribe": "Jak subskrybować", + "subscribe1": "Uruchom /unlock na swoim serwerze (wymagane uprawnienia administratora)", + "subscribe2": "Kliknij przycisk Subskrybuj w odpowiedzi bota", + "subscribe3": "Dokończ płatność w Discord — bot aktywuje się natychmiast", + "cancellation": "Anulowanie", + "cancellationDesc": "Gdy subskrypcja wygaśnie lub zostanie anulowana, bot automatycznie przestanie publikować logi gier dla tego serwera przy następnym cyklu autologu. Nie jest wymagana ręczna interwencja.", + "realTimeStatistics": "Statystyki w czasie rzeczywistym", + "realTimeStatisticsDesc": "Śledź zwycięstwa, porażki i wskaźniki wydajności w czasie rzeczywistym.", + "battleHistory": "Historia bitew", + "battleHistoryDesc": "Kompleksowa historia wszystkich zarejestrowanych Bitew Dywizjonów.", + "leaderboardsFeature": "Rankingi", + "leaderboardsFeatureDesc": "Porównuj wyniki swojego dywizjonu z innymi za pomocą /top.", + "playerTracking": "Śledzenie graczy", + "playerTrackingDesc": "Indywidualne statystyki graczy i integracja z ThunderSkill.", + "smartAlerts": "Inteligentne alarmy", + "smartAlertsDesc": "Automatyczne alarmy i powiadomienia o aktywności dywizjonu.", + "multiLanguageSupport": "Obsługa wielu języków", + "multiLanguageSupportDesc": "Funkcje tłumaczenia i konfigurowalne języki pojazdów.", + "timeCoordination": "Koordynacja czasu", + "timeCoordinationDesc": "Narzędzia UTC i czasu lokalnego do globalnej koordynacji dywizjonu.", + "advancedSearch": "Zaawansowane wyszukiwanie", + "advancedSearchDesc": "Znajdź składy graczy i szczegółowe informacje o dywizjonach.", + "dataSecurity": "Bezpieczeństwo danych", + "dataSecurityDesc": "Twoje dane są bezpieczne i nigdy nie są udostępniane ani sprzedawane.", + "usageExamples": "Przykłady użycia", + "quickSetupRecommended": "Szybka konfiguracja (zalecana)", + "quickSetupDesc": "Uruchom kreator konfiguracji. Przeprowadzi Cię przez ustawienie dywizjonu, wybór kanału logów i kanału punktów — wszystko w jednym przepływie.", + "comparingPlayers": "Porównywanie graczy", + "comparingPlayersDesc": "Porównaj do 7 graczy obok siebie. Najlepsze statystyki w każdej kategorii są wyróżnione, a ogólnie najlepszy gracz otrzymuje gwiazdkę. Kliknij \"Pokaż wykres\", aby zobaczyć historię punktów.", + "checkingSquadronInfo": "Sprawdzanie informacji o dywizjonie", + "verifySquadronDesc": "Zweryfikuj, że dywizjon istnieje i wyświetl jego szczegóły. Użyj tego, aby potwierdzić, że bot może znaleźć Twój dywizjon przed konfiguracją.", + "recentBattlesDesc": "Zobacz ostatnie 5 bitew rozegranych przez dywizjon.", + "headToHeadDesc": "Wyświetl rekord bezpośrednich starć z innym dywizjonem.", + "backToHome": "Powrót do strony głównej", + "termsAndPrivacy": "Regulamin i prywatność", + "seasonRecapCardTitle": "Karta podsumowania sezonu", + "seasonRecapCardDesc": "Wygeneruj udostępnialne podsumowanie PNG sezonu dowolnego dywizjonu — krzywa rankingu, kroczący wskaźnik zwycięstw, K/D, ulubiony pojazd, MVP i więcej. Dostępne pod przyciskiem „Karta sezonu” na stronie profilu każdego dywizjonu.", + "playerRecapCardTitle": "Podsumowanie sezonu gracza", + "playerRecapCardDesc": "Wygeneruj udostępnialne podsumowanie PNG sezonu dowolnego gracza — przebieg ratingu między klanami, ruchomy współczynnik wygranych, K/D, częstotliwość bitew, najlepszy mecz i więcej. Dostępne z przycisku „Season Recap” na profilu każdego gracza.", + "tierOverview": "Przegląd planów", + "tierOverviewDesc": "Auto-logging dostępny jest w trzech planach. Każdy plan ogranicza liczbę klanów, które mogą mieć Logs i Points. Klany powyżej limitu pozostają w preferencjach i automatycznie wznawiają się po zmianie planu.", + "tierStandardLine": "$2.99 — do 10 klanów na Logs i Points, bez wildcard.", + "tierProLine": "do 25 klanów na Logs i Points, plus obsługa wildcard (`*` / `all` / `everything`).", + "tierMaxLine": "bez limitu klanów, wildcards, wczesny dostęp do nowych funkcji." + }, + "terms": { + "pageTitle": "Warunki usługi i polityka prywatności", + "lastUpdated": "Ostatnia aktualizacja: kwiecień 2026", + "termsOfService": "Warunki usługi", + "byUsing": "Korzystając z", + "youAgree": ", zgadzasz się na następujące warunki:", + "useResponsibly": "Używaj odpowiedzialnie", + "useResponsiblyDesc": "Nie próbuj przeciążać, spamować ani w inny sposób psuć bota.", + "noFunnyBusiness": "Bez podejrzanych działań", + "noFunnyBusinessDesc": "Nie próbuj przeprowadzać inżynierii wstecznej, naruszać Warunków usługi Discord ani żadnych innych Zasad serwera.", + "statsAsIs": "Statystyki bez gwarancji", + "statsAsIsDesc": "Wszystkie dane i statystyki są udostępniane \"w stanie, w jakim się znajdują\" bez jakiejkolwiek gwarancji, wyraźnej lub dorozumianej. Dokładamy rozsądnych starań, aby zapewnić dokładność, ale nie gwarantujemy, że informacje są kompletne, aktualne lub wolne od błędów.", + "uptimeNotGuaranteed": "Dostępność bez gwarancji", + "uptimeNotGuaranteedDesc": "Bot jest udostępniany na zasadzie \"według dostępności\". Nie gwarantujemy nieprzerwanego ani bezbłędnego działania. Usługa może być tymczasowo niedostępna z powodu konserwacji, aktualizacji lub problemów technicznych.", + "weCanBanYou": "Możemy Cię zablokować", + "weCanBanYouDesc": "Jeśli łamiesz zasady, możemy usunąć Twój dostęp.", + "privacyPolicy": "Polityka prywatności", + "infoWeCollect": "1. Informacje, które zbieramy", + "collectsFollowing": "zbiera następujące informacje w celu świadczenia swoich usług:", + "discordUserIds": "Identyfikatory użytkowników Discord:", + "discordUserIdsDesc": "Unikalne identyfikatory do śledzenia poszczególnych użytkowników", + "squadronIds": "Identyfikatory dywizjonów:", + "squadronIdsDesc": "Identyfikatory serwerów/gildii Discord do organizowania danych dywizjonów", + "battleData": "Dane bitew:", + "battleDataDesc": "Zapisy wygranych/przegranych, znaczniki czasu bitew i powiązane statystyki", + "commandUsage": "Użycie komend:", + "commandUsageDesc": "Podstawowe logowanie używanych komend bota w celu ulepszania usługi", + "howWeUse": "2. Jak używamy Twoich informacji", + "usedExclusively": "Zebrane informacje są używane wyłącznie do:", + "trackingPerformance": "Śledzenia wyników i statystyk Bitew Dywizjonów", + "providingHistorical": "Zapewniania danych historycznych i analityki", + "improvingBot": "Ulepszania funkcjonalności bota i doświadczenia użytkownika", + "troubleshootingIssues": "Rozwiązywania problemów technicznych", + "dataStorage": "3. Przechowywanie i bezpieczeństwo danych", + "dataStoredSecurely": "Twoje dane są przechowywane bezpiecznie z następującymi zabezpieczeniami:", + "encryptedServers": "Dane są przechowywane na bezpiecznych serwerach z szyfrowaniem", + "limitedAccess": "Dostęp jest ograniczony wyłącznie do upoważnionych pracowników", + "regularBackups": "Regularne kopie zapasowe zapewniają integralność danych", + "dataSharing": "4. Udostępnianie danych", + "weDoNot": "NIE:", + "sellData": "Sprzedawać Twoje dane osobowe osobom trzecim", + "shareData": "Udostępniać Twoje dane zewnętrznym organizacjom", + "useForAds": "Używać Twoich danych do celów reklamowych lub marketingowych", + "transferData": "Przenosić Twoje dane poza nasze bezpieczne systemy", + "dataRetention": "5. Przechowywanie danych", + "dataRetentionDesc": "Przechowujemy Twoje dane tak długo, jak jest to konieczne do świadczenia naszych usług. Statystyki bitew i dane dywizjonów są przechowywane w celu utrzymania historycznych zapisów i analityki.", + "discordIntegration": "6. Integracja z Discord", + "discordIntegrationDesc": "Ten bot działa w ekosystemie Discord i podlega Polityce prywatności Discord. Uzyskujemy dostęp tylko do informacji niezbędnych do funkcjonowania bota za pośrednictwem oficjalnego API Discord.", + "ageRestrictions": "7. Ograniczenia wiekowe", + "ageRestrictionsDesc": "Bot jest przeznaczony dla użytkowników spełniających minimalne wymagania wiekowe Discord (13+ lub zgodnie z wymogami prawa lokalnego).", + "changesToPolicy": "8. Zmiany w polityce prywatności", + "changesToPolicyDesc": "Możemy od czasu do czasu aktualizować tę Politykę prywatności.", + "premiumTitle": "Warunki subskrypcji Premium", + "premiumWhatYouGet": "1. Co obejmuje Premium", + "premiumWhatYouGetDesc": "Subskrypcja Premium odblokowuje następujące funkcje dla subskrybowanego serwera Discord:", + "premiumFeature1": "Automatyczne posty z wynikami po każdym meczu SQB", + "premiumFeature2": "Mapy tras / ruchu", + "premiumFeature3": "Logi czatu i bitwy", + "premiumFeature4": "Wyszukiwanie powtórek", + "premiumFeature5": "Nieograniczone wyszukiwania /comp (darmowe serwery są ograniczone do 15 na przedział czasowy)", + "premiumBilling": "2. Fakturowanie i płatności", + "premiumBillingDesc": "Premium jest rozliczane w wysokości $2,99 USD miesięcznie za serwer Discord. Płatność jest przetwarzana przez natywny system subskrypcji Discord lub przez naszą stronę internetową za pośrednictwem Whop. Jesteś odpowiedzialny za upewnienie się, że Twoja metoda płatności jest ważna i posiada wystarczające środki. Subskrypcje odnawiają się automatycznie na koniec każdego okresu rozliczeniowego, chyba że zostaną anulowane.", + "premiumCancellation": "3. Anulowanie", + "premiumCancellationDesc": "Możesz anulować subskrypcję w dowolnym momencie. W przypadku subskrypcji Discord przejdź do Ustawienia użytkownika → Subskrypcje w Discord. W przypadku subskrypcji przez stronę internetową zarządzaj rozliczeniami na whop.com/billing. Po anulowaniu funkcje Premium pozostają aktywne do końca bieżącego okresu rozliczeniowego. Po tym czasie serwer wraca do poziomu darmowego — żadne dane nie są tracone.", + "premiumRefunds": "4. Zwroty", + "premiumRefundsDesc": "Opłaty subskrypcyjne nie podlegają zwrotowi. Częściowe wykorzystanie miesiąca nie jest proporcjonalnie rozliczane. Jeśli anulujesz w trakcie cyklu, zachowujesz dostęp do końca tego okresu rozliczeniowego, ale nie masz prawa do zwrotu za pozostały czas. Zwroty za błędy w fakturowaniu lub podwójne obciążenia mogą być wydane według naszego uznania — skontaktuj się z pomocą techniczną z ID serwera Discord i dowodem płatności.", + "premiumPriceChanges": "5. Zmiany cen", + "premiumPriceChangesDesc": "Zastrzegamy sobie prawo do zmiany cen subskrypcji w dowolnym momencie. Obecni subskrybenci zostaną powiadomieni z co najmniej 30-dniowym wyprzedzeniem przed wejściem w życie podwyżki cen. Jeśli nie zgadzasz się ze zmianą ceny, możesz anulować przed zastosowaniem nowej ceny.", + "premiumTermination": "6. Zakończenie dostępu Premium", + "premiumTerminationDesc": "Zastrzegamy sobie prawo do cofnięcia dostępu Premium bez zwrotu kosztów, jeśli serwer narusza niniejsze Warunki usługi, w tym między innymi nadużywanie funkcji bota, próby obejścia limitów użytkowania lub naruszenie Warunków usługi Discord.", + "serviceAvailabilityTitle": "Dostępność usługi i odpowiedzialność", + "serviceNoWarranty": "1. Brak gwarancji", + "serviceNoWarrantyDesc": "USŁUGA JEST UDOSTĘPNIANA \"W STANIE, W JAKIM SIĘ ZNAJDUJE\" I \"WEDŁUG DOSTĘPNOŚCI\" BEZ JAKICHKOLWIEK GWARANCJI, WYRAŹNYCH, DOROZUMIANYCH LUB USTAWOWYCH, W TYM MIĘDZY INNYMI DOROZUMIANYCH GWARANCJI PRZYDATNOŚCI HANDLOWEJ, PRZYDATNOŚCI DO OKREŚLONEGO CELU I NIENARUSZANIA PRAW. Nie gwarantujemy, że usługa będzie nieprzerwana, terminowa, bezpieczna lub bezbłędna.", + "serviceLiability": "2. Ograniczenie odpowiedzialności", + "serviceLiabilityDesc": "W MAKSYMALNYM ZAKRESIE DOZWOLONYM PRZEZ OBOWIĄZUJĄCE PRAWO W ŻADNYM WYPADKU NIE PONOSIMY ODPOWIEDZIALNOŚCI ZA JAKIEKOLWIEK POŚREDNIE, PRZYPADKOWE, SPECJALNE, WTÓRNE LUB KARNE SZKODY, ANI ZA UTRATĘ ZYSKÓW LUB PRZYCHODÓW, PONIESIONĄ BEZPOŚREDNIO LUB POŚREDNIO, ANI ZA UTRATĘ DANYCH, MOŻLIWOŚCI UŻYTKOWANIA, WARTOŚCI FIRMY LUB INNYCH STRAT NIEMATERIALNYCH WYNIKAJĄCYCH Z KORZYSTANIA LUB NIEMOŻNOŚCI KORZYSTANIA Z USŁUGI. Nasza całkowita odpowiedzialność za wszelkie roszczenia wynikające z niniejszych warunków lub usługi nie przekroczy kwoty, którą zapłaciłeś nam w ciągu trzech (3) miesięcy poprzedzających roszczenie.", + "serviceCredits": "3. Przerwy w usłudze i kredyty", + "serviceCreditsDesc": "W przypadku przedłużającej się, nieplanowanej przerwy w usłudze wpływającej na funkcje Premium przez ponad 72 kolejne godziny, dotknięci subskrybenci Premium mogą poprosić o kredyt usługowy, kontaktując się z pomocą techniczną. Kredyty są wydawane według naszego wyłącznego uznania i stosowane jako przedłużenie bieżącego okresu rozliczeniowego — nie jako zwroty pieniężne. Planowane okna konserwacyjne, awarie stron trzecich (Discord, Gaijin API) oraz zdarzenia poza naszą rozsądną kontrolą są wyłączone.", + "serviceForceM": "4. Siła wyższa", + "serviceForceMDesc": "Nie ponosimy odpowiedzialności za jakiekolwiek niepowodzenia lub opóźnienia w realizacji wynikające z przyczyn pozostających poza naszą rozsądną kontrolą, w tym między innymi: klęski żywiołowe, wojny, terroryzm, pandemie, przerwy w dostawie prądu, zakłócenia internetu, awarie platformy Discord, zmiany lub niedostępność API Gaijin Entertainment, działania rządowe lub inne zdarzenia siły wyższej.", + "disclaimer": "Zastrzeżenie", + "warThunderDisclaimer": "jest niezależnym botem Discord i nie jest powiązany z, zatwierdzony przez ani stowarzyszony z Gaijin Entertainment lub War Thunder. War Thunder jest znakiem towarowym Gaijin Entertainment.", + "acknowledgement": "Korzystając z", + "acknowledgementEnd": ", potwierdzasz, że przeczytałeś, zrozumiałeś i zgadzasz się być związany tymi Warunkami usługi i Polityką prywatności." + }, + "premium": { + "upgradeTitle": "Ulepsz swój dywizjon", + "heroDesc": "Każdy wynik SQB publikowany na Twoim kanale w chwili zakończenia — tablice wyników, logi bitew, mapy ruchu i powtórki, wszystko automatycznie.", + "instantScoreboards": "Natychmiastowe tablice wyników", + "viewPaths": "Pokaż trasy", + "chatBattleLogs": "Logi czatu i bitwy", + "replayLookups": "Wyszukiwanie replayów", + "free": "Bezpłatnie", + "perMonth": "/mies.", + "alwaysFree": "Zawsze bezpłatnie", + "noCardNeeded": "karta nie jest potrzebna", + "included": "Wliczone", + "manualLookups": "Ręczne wyszukiwanie gier", + "playerStats": "Statystyki i profile graczy", + "leaderboards": "Rankingi", + "stickWithFree": "Zostań przy bezpłatnym", + "premiumLabel": "Premium", + "perServer": "za serwer", + "cancelAnytime": "anuluj w dowolnym momencie", + "everythingInFree": "Wszystko z bezpłatnego, plus", + "autoScoreboards": "Automatyczne publikowanie tablic wyników", + "pathMaps": "Mapy tras i ruchu", + "chatLogs": "Logi czatu i bitwy", + "replayLookupsFeature": "Wyszukiwanie replayów", + "unlimitedComp": "Nieograniczone wyszukiwania /comp", + "prioritySupport": "Priorytetowe wsparcie", + "subscribeNow": "Subskrybuj teraz", + "comingSoon": "Wkrótce", + "serverIdInfo": "Będziesz potrzebować swojego", + "discordServerId": "ID serwera Discord", + "duringCheckout": "podczas finalizacji zakupu.", + "developerMode": "Tryb dewelopera", + "rightClickServer": "Kliknij prawym przyciskiem serwer", + "copyId": "Kopiuj ID", + "successTitle": "Premium aktywowane", + "successDesc": "Twoja subskrypcja jest konfigurowana. Bot będzie miał dostęp Premium dla Twojego serwera w ciągu kilku minut.", + "whatHappensNext": "Co będzie dalej", + "autoLogging": "Automatyczne logowanie aktywuje się dla Twojego serwera", + "setLogChannel": "Ustaw swój kanał logów za pomocą", + "everyResult": "Każdy wynik SQB jest publikowany automatycznie", + "readSetupGuide": "Przeczytaj przewodnik konfiguracji", + "tierStandardName": "Standard", + "tierProName": "Pro", + "tierMaxName": "Max", + "squadCap": "Loguj do {cap} klanów", + "squadCapUnlimited": "Bez limitu klanów", + "everythingInStandard": "Wszystko ze Standard", + "everythingInPro": "Wszystko z Pro", + "wildcardSupport": "Wildcard (*, all, everything)", + "noSquadCap": "Brak limitu klanów", + "earlyAccessFeatures": "Wczesny dostęp do nowych funkcji" + }, + "player": { + "totalBattles": "Łączne bitwy", + "totalWins": "Łączne zwycięstwa", + "vehicleStatistics": "Statystyki pojazdów", + "cumulative": "Skumulowane", + "individual": "Indywidualne", + "filterBy": "Filtruj według:", + "allTime": "Cały czas", + "dateRange": "Zakres dat", + "season": "Sezon", + "week": "Tydzień", + "session": "Sesja", + "dateType": "Typ daty:", + "last7Days": "Ostatnie 7 dni", + "last30Days": "Ostatnie 30 dni", + "last90Days": "Ostatnie 90 dni", + "customRange": "Własny zakres", + "specificDate": "Konkretna data", + "filterType": "Typ filtra:", + "fullSeason": "Pełny sezon", + "specificWeek": "Konkretny tydzień", + "from": "Od:", + "to": "Do:", + "timeslot": "Pora", + "fullDay": "Cały dzień", + "selectSeason": "Sezon:", + "selectWeek": "Tydzień:", + "selectSeasonFirst": "Najpierw wybierz sezon", + "pleaseSelect": "Wybierz opcję", + "searchVehicles": "Szukaj pojazdów...", + "resetFilters": "Zresetuj filtry", + "vehiclesShown": "pojazdów wyświetlonych", + "gamesShown": "gier wyświetlonych", + "noVehicleData": "Brak danych pojazdu", + "noVehiclesForRange": "Nie znaleziono pojazdów dla wybranego zakresu dat lub ten gracz nie ma jeszcze danych.", + "switchToCards": "Przełącz na widok kart", + "switchToTable": "Przełącz na widok tabeli", + "loadingTimeline": "Ładowanie osi czasu...", + "noTimelineData": "Brak danych osi czasu.", + "timelineUnavailable": "Oś czasu niedostępna.", + "loadingGameRecords": "Ładowanie rekordów gier...", + "unableToLoadRecords": "Nie można załadować rekordów gier", + "failedToFetch": "Nie udało się pobrać danych gry. Spróbuj ponownie później.", + "noGameRecords": "Nie znaleziono rekordów gier", + "noGamesYet": "Ten gracz nie rozegrał jeszcze żadnych zarejestrowanych gier.", + "collapseChart": "Zwiń wykres", + "loadingChartData": "Ładowanie danych wykresu...", + "noHistoricalData": "Brak danych historycznych.", + "chartUnavailable": "Wykres niedostępny.", + "relative": "Względne", + "uidLabel": "UID gracza" + }, + "squadrons": { + "title": "Centrum Dywizjonów", + "subtitle": "Odkrywaj dywizjony, przeglądaj statystyki i śledź wyniki", + "findSquadron": "Znajdź dywizjon", + "searchPlaceholder": "Szukaj dywizjonów po nazwie...", + "totalSquadrons": "Łączna liczba dywizjonów", + "totalPlayers": "Łączna liczba graczy", + "totalBattles": "Łączne bitwy", + "avgWinRate": "Śr. skuteczność", + "topSquadrons": "Najlepsze dywizjony", + "viewFullLeaderboard": "Wyświetl pełny ranking", + "loadingSquadrons": "Ładowanie danych dywizjonów...", + "noSquadronData": "Brak danych dywizjonów.", + "failedToLoad": "Nie udało się załadować danych dywizjonów. Spróbuj ponownie później.", + "backToSquadronHub": "Powrót do Centrum Dywizjonów", + "squadronPoints": "Punkty dywizjonu", + "squadronMembers": "Członkowie dywizjonu", + "performance": "Wydajność", + "performanceNoData": "Brak danych o wydajności dla wybranego zakresu.", + "quickDetails": "Szybkie szczegóły", + "noMembersFound": "Nie znaleziono członków", + "noRecordedMembers": "Ten dywizjon nie ma jeszcze zarejestrowanych członków.", + "squadronGames": "Gry dywizjonu", + "loadingSquadronGames": "Ładowanie rekordów gier dywizjonu...", + "noSquadronGames": "Nie znaleziono rekordów gier dla tego dywizjonu.", + "retryLoadGames": "Ponów", + "searchMapPlaceholder": "Szukaj po mapie..." + }, + "leaderboard": { + "playersTitle": "Ranking graczy", + "playersSubtitle": "Najlepsi gracze War Thunder według wyników", + "vehiclesTitle": "Ranking zabójstw pojazdów", + "vehiclesSubtitle": "Najlepsze pojazdy War Thunder według łącznych zabójstw", + "squadronsTitle": "Ranking dywizjonów", + "squadronsSubtitle": "Najlepsze dywizjony War Thunder według wyników", + "statsTitle": "Globalne statystyki", + "statsSubtitle": "Ogólne statystyki bitew dywizjonów i informacje meta", + "comparisonTitle": "Narzędzie porównania", + "comparisonSubtitle": "Porównaj graczy i pojazdy obok siebie", + "comparisonHint": "Porównaj statystyki, aby znaleźć najlepszych graczy i pojazdy", + "compareSquadrons": "Porównaj dywizjony", + "comparePlayers": "Porównaj graczy", + "compareVehicles": "Porównaj pojazdy", + "playersAndVehicles": "Gracze + Pojazdy", + "failedToLoadLeaderboard": "Nie udało się załadować danych rankingu. Spróbuj ponownie później.", + "failedToLoadVehicles": "Nie udało się załadować rankingu pojazdów", + "failedToLoadSquadrons": "Nie udało się załadować rankingu dywizjonów", + "noResultsYet": "Brak wyników. Dodaj dywizjony/graczy, aby rozpocząć.", + "searchSquadron": "Szukaj dywizjonu", + "searchBySquadronName": "Szukaj po nazwie dywizjonu...", + "minPlayers": "Min. graczy", + "minPlayersPlaceholder": "Min. graczy", + "resetFilters": "Zresetuj filtry", + "squadronsShown": "dywizjonów wyświetlonych", + "playersShown": "graczy wyświetlonych", + "page": "strona", + "of": "z", + "loadingSquadronLeaderboard": "Ładowanie rankingu dywizjonów...", + "loadingPlayerLeaderboard": "Ładowanie rankingu graczy...", + "loadingComparisonData": "Ładowanie danych porównania...", + "unableToFetch": "Nie można pobrać danych rankingu. Spróbuj ponownie.", + "noSquadronsInLeaderboard": "Nie znaleziono dywizjonów w rankingu.", + "noPlayersInLeaderboard": "Nie znaleziono graczy w rankingu.", + "loadingGlobalStats": "Ładowanie globalnych statystyk...", + "failedToLoadStats": "Nie udało się załadować statystyk", + "unableToFetchStats": "Nie można pobrać danych statystyk. Spróbuj ponownie.", + "mostPopularVehicles": "Najpopularniejsze pojazdy", + "vehicleKillsLeaderboardTitle": "Ranking zabójstw pojazdów", + "avgWinRate": "Średnia skuteczność", + "avgKillsPerPlayer": "Średnie zabójstwa/gracz", + "loadingVehicleKills": "Ładowanie danych zabójstw pojazdów...", + "apiNotLoaded": "Klient API nie załadował się poprawnie. Odśwież stronę.", + "failedToInitApi": "Nie udało się zainicjować klienta API", + "noStatsData": "Brak dostępnych danych statystyk", + "totalPlayersCard": "Łącznie graczy", + "activePlayers": "Aktywni gracze", + "vehiclesUsed": "Użyte pojazdy", + "differentVehicles": "Różne pojazdy", + "squadronBattlesLabel": "Bitwy Dywizjonów", + "noVehicleData": "Brak danych pojazdu", + "mostPopular": "Najpopularniejszy", + "timesUsed": "Razy użyty", + "failedToLoadVehicleKills": "Nie udało się załadować danych zabójstw pojazdów", + "lastUpdated": "Ostatnia aktualizacja", + "searchPlayer": "Szukaj gracza", + "searchByPlayerName": "Szukaj po nazwie gracza...", + "minimumBattles": "Minimalne bitwy", + "minBattlesPlaceholder": "Min. bitwy", + "searchSquadronsPlaceholder": "Szukaj dywizjonów...", + "sortBy": "Sortuj według", + "kdRatio": "Stosunek K/D", + "killsPerSpawn": "Zabójstwa na spawn", + "caps": "Przejęcia", + "timePeriod": "Okres czasu", + "allTime": "Cały czas", + "dateRange": "Zakres dat", + "season": "Sezon", + "week": "Tydzień", + "dateType": "Typ daty", + "last7Days": "Ostatnie 7 dni", + "last30Days": "Ostatnie 30 dni", + "last90Days": "Ostatnie 90 dni", + "customRange": "Własny zakres", + "from": "Od", + "to": "Do", + "timeslot": "Pora", + "fullDay": "Cały dzień", + "selectSeason": "Wybierz sezon...", + "selectWeek": "Wybierz tydzień...", + "failedToLoadComparison": "Nie udało się załadować danych porównania", + "pleaseRefresh": "Spróbuj odświeżyć stronę", + "playerComparison": "Porównanie graczy", + "squadronComparison": "Porównanie dywizjonów", + "vehicleComparison": "Porównanie pojazdów", + "playersVehiclesComparison": "Porównanie graczy + pojazdów", + "addPlayersToCompare": "Dodaj graczy do porównania:", + "addSquadronsToCompare": "Dodaj dywizjony do porównania:", + "addVehiclesToCompare": "Dodaj pojazdy do porównania:", + "addPlayerVehicleCombos": "Dodaj kombinacje gracz + pojazd:", + "searchSelectPlayers": "Szukaj i wybierz graczy...", + "typeSquadronName": "Wpisz nazwę dywizjonu...", + "searchSelectVehicles": "Szukaj i wybierz pojazdy...", + "searchForPlayers": "Szukaj graczy...", + "selectPlayersToCompare": "Wyszukaj i wybierz graczy powyżej, aby porównać ich statystyki", + "selectSquadronsToCompare": "Wyszukaj i wybierz dywizjony powyżej, aby porównać ich statystyki", + "selectVehiclesToCompare": "Wyszukaj i wybierz pojazdy powyżej, aby porównać ich statystyki", + "selectPlayersVehiclesToCompare": "Wyszukaj i wybierz graczy powyżej, a następnie wybierz ich pojazdy, aby porównać różne kombinacje gracz-pojazd", + "selectVehicleFor": "Wybierz pojazd dla", + "selectAVehicle": "-- Wybierz pojazd --", + "noVehiclesForPlayer": "Nie znaleziono pojazdów dla tego gracza", + "noPlayerVehicleSelected": "Nie wybrano kombinacji gracz-pojazd", + "noPlayersSelected": "Nie wybrano graczy", + "noVehiclesSelected": "Nie wybrano pojazdów", + "noSquadronsSelected": "Nie wybrano dywizjonów", + "statistic": "Statystyka", + "totalDeaths": "Łączne śmierci", + "totalAssists": "Łączne asysty", + "totalCaptures": "Łączne przejęcia", + "killsPerSpawnShort": "Zab./Spawn", + "avgWinRateShort": "Śr. skuteczność", + "avgKillsPlayerShort": "Śr. zab./gracz", + "avgKillsMember": "Śr. zab./członek", + "avgBattlesMember": "Śr. bitwy/członek", + "serverError500": "Błąd serwera (500) — API jest tymczasowo niedostępne. Spróbuj ponownie za chwilę.", + "apiEndpoint404": "Nie znaleziono punktu końcowego API (404) — sprawdź konfigurację serwera.", + "networkError": "Błąd sieci — nie można połączyć się z serwerem. Sprawdź swoje połączenie.", + "viewFullLeaderboard": "Wyświetl pełny ranking", + "vehicleName": "Nazwa pojazdu", + "searchVehiclePlaceholder": "Szukaj konkretnego pojazdu...", + "minKills": "Min. zabójstw", + "any": "Dowolne", + "perPage": "Na stronę", + "clear": "Wyczyść", + "vehicleAndPlayer": "Pojazd i gracz", + "loadingVehicleLeaderboard": "Ładowanie rankingu pojazdów...", + "vehiclesShown": "pojazdów wyświetlonych", + "minBattlesPerVehicle": "Wymagane minimum 3 bitwy na pojazd", + "minimumBattlesRequired": "Minimum 5 bitew wymagane", + "allSeasons": "Wszystkie sezony", + "allWeeks": "Wszystkie tygodnie", + "allBR": "Wszystkie BR" + }, + "games": { + "title": "Historia meczów", + "subtitle": "Przeglądaj mecze bitew dywizjonów", + "searchPlaceholder": "Szukaj po nazwie gracza lub UID...", + "filterByMap": "Filtruj według mapy", + "allMaps": "Wszystkie mapy", + "search": "Szukaj", + "noResults": "Nie znaleziono meczów", + "matchDetail": "Szczegóły meczu", + "chatLog": "Log czatu", + "battleLog": "Log bitwy", + "duration": "Czas trwania", + "mode": "Tryb", + "winningTeam": "Zwycięska drużyna", + "losingTeam": "Przegrana drużyna", + "viewMatch": "Wyświetl mecz", + "loadingMatch": "Ładowanie danych meczu...", + "matchNotFound": "Nie znaleziono meczu", + "searchingGames": "Wyszukiwanie gier...", + "recentMatches": "Ostatnie mecze", + "noChatLog": "Brak dostępnego logu czatu", + "noBattleLog": "Brak dostępnego logu bitwy", + "replayVideo": "Wideo replay", + "generatingVideo": "Generowanie wideo replay…", + "videoFirstLoad": "Pierwsze ładowanie może potrwać do minuty", + "videoUnavailable": "Wideo replay niedostępne dla tego meczu", + "modeGround": "Naziemny", + "modeAir": "Powietrzny", + "squadronPlaceholder": "Nazwa dywizjonu...", + "loadingReplay": "Ładowanie powtórki..." + }, + "errors": { + "pageNotFound": "Nie znaleziono strony", + "error": "Błąd", + "oopsNotFound": "Ups! Strona, której szukasz, nie istnieje. Mogła zostać przeniesiona, usunięta lub wpisałeś nieprawidłowy adres URL.", + "searchError": "Błąd wyszukiwania. Spróbuj ponownie." + }, + "js": { + "openingDiscordInvite": "Otwieranie zaproszenia Discord!", + "errorOpeningInvite": "Błąd podczas otwierania linku zaproszenia. Spróbuj ponownie później.", + "gettingSupportLink": "Pobieranie linku do serwera wsparcia...", + "openingSupportServer": "Otwieranie serwera wsparcia!", + "errorGettingSupport": "Błąd podczas pobierania linku wsparcia. Spróbuj ponownie później.", + "failedToUpdateStats": "Nie udało się zaktualizować statystyk", + "konamiActivated": "Odblokowano osiągnięcie: Tajny kod!", + "noPlayersFound": "Nie znaleziono graczy", + "searchError": "Błąd wyszukiwania. Spróbuj ponownie.", + "killsSuffix": "zabójstw", + "winRateSuffix": "skuteczność", + "noSquadronsFound": "Nie znaleziono dywizjonów" + }, + "index": { + "subtitle1": "Najlepsze narzędzie do bitew dywizjonów", + "subtitle2": "Boris Stats, ale lepszy", + "subtitle3": "Największy zbiór danych SQB", + "subtitle4": "Publiczne i darmowe informacje dla wszystkich" + }, + "seasonCard": { + "buttonLabel": "Karta sezonu", + "buttonDisabledTitle": "Wyszukiwanie dywizjonu niekompletne — karta niedostępna", + "modalTitle": "Karta sezonu", + "seasonLabel": "Sezon", + "themeLabel": "Motyw", + "themeDark": "Ciemny", + "themeLight": "Jasny", + "generate": "Generuj", + "loadingSeasons": "Ładowanie sezonów…", + "generating": "Generowanie…", + "failedSeasons": "Nie udało się załadować sezonów.", + "failedGenerate": "Nie udało się wygenerować karty podsumowania.", + "inProgressSuffix": "(w trakcie)", + "imgRecapSuffix": "PODSUMOWANIE", + "imgHeroFinalRating": "Końcowy ranking", + "imgHeroMatches": "Mecze", + "imgHeroWinRate": "Wygrywalność", + "imgHeroKD": "K/D", + "imgAxisRating": "Ranking", + "imgAxisWinRate": "Wygrywalność", + "imgStatPeakRating": "Szczytowy ranking", + "imgStatRatingChange": "Zmiana rankingu", + "imgStatTotalKills": "Zabicia łącznie", + "imgStatTotalDeaths": "Śmierci łącznie", + "imgStatAssistsCaptures": "Asysty / przejęcia", + "imgStatMostPlayedVehicle": "Ulubiony pojazd", + "imgStatMVP": "MVP", + "imgStatMostActive": "Najaktywniejszy", + "imgStatLongestWinStreak": "Najdłuższa seria", + "imgStatMostCommonOpponent": "Najczęstszy przeciwnik", + "imgUnitKills": "zabić", + "imgUnitAssists": "asyst", + "imgUnitCaptures": "przejęć", + "imgUnitGames": "gier", + "imgUnitMatches": "meczów", + "imgUnitWins": "Z", + "imgUnitLosses": "P", + "imgGroundShort": "L", + "imgAirShort": "P", + "imgFooterGenerated": "utworzono", + "imgPlaceholderNoData": "Brak danych dla {short} w {season}", + "buttonLabelPlayer": "Podsumowanie sezonu", + "buttonDisabledTitlePlayer": "Player lookup incomplete — recap unavailable", + "modalTitlePlayer": "Player Season Recap", + "imgHeroBattles": "Bitwy", + "imgHeroTotalKills": "Łącznie zabójstw", + "imgAxisBattles": "Bitwy (7 d)", + "imgAxisKD": "K/D", + "imgStatBestMatch": "Najlepszy mecz", + "imgStatSquadronsRepresented": "Reprezentowane klany", + "imgStatFrequentTeammate": "Najczęstszy sojusznik", + "imgStatLongestSession": "Najdłuższa sesja", + "imgStatMostActiveDay": "Najaktywniejszy dzień (UTC)", + "imgStatMostCommonOppSquadron": "Najczęstszy klan przeciwny", + "imgStatPeakSquadronRating": "Szczytowy rating klanu", + "imgUnitSlotNA": "NA", + "imgUnitSlotEU": "EU", + "imgUnitNoSquadron": "bez klanu", + "imgUnitVs": "vs", + "imgUnitTogether": "meczów razem", + "imgStatKDAC": "Z / Ś / A / P", + "imgPlaceholderNoDataPlayer": "Brak danych dla {nick} w {season}", + "imgUIDLabel": "UID", + "imgBestMatchLine": "{vehicle} · ZN {gk} / ZP {ak} / A {assists} / P {cap} / Ś {deaths} · {date}" + }, + "live": { + "air": "POW", + "gnd": "ZIE", + "ast": "ASY", + "dth": "ŚMI", + "cap": "PRZ", + "squadronBattle": "Bitwa klanów", + "randomBattle": "Bitwa losowa" + }, + "analytics": { + "pageTitle": "Analityka SQB", + "pageSubtitle": "Szczegółowa analityka dla każdego klanu, gracza lub pojazdu.", + "modeSquadron": "Klan", + "modePlayer": "Gracz", + "modeVehicle": "Pojazd", + "tabMaps": "Wygrane na mapach", + "tabSquadmates": "Częstsi współgracze", + "tabComps": "Składy drużyn", + "tabConsistency": "Konsekwencja gracza", + "tabTime": "Pora dnia", + "tabMatchups": "Historia starć", + "pickSquadron": "Wyszukaj dowolny klan, aby zobaczyć analizę", + "pickPlayer": "Wyszukaj dowolnego gracza, aby zobaczyć analizę", + "pickVehicle": "Wyszukaj pojazd, aby zobaczyć jego analizę", + "noData": "Brak danych w tym zakresie.", + "loading": "Ładowanie…", + "loadError": "Nie udało się załadować analityki.", + "compComingSoon": "Analiza składu drużyny wkrótce.", + "compTopVehiclesTitle": "Najczęściej używane pojazdy", + "compCompositionsTitle": "Powtarzające się składy meczów", + "compCompositionsMeta": "Składy z co najmniej {min} pojazdami, posortowane wg meczów", + "compMatchesAnalyzed": "przeanalizowanych meczów", + "compNoRepeats": "W tym zakresie nie wystawiono składu tej wielkości.", + "compColVehicle": "Pojazd", + "compColSpawns": "Wystawienia", + "compColMatches": "Mecze", + "compColShare": "% Meczów", + "compColLineup": "Skład", + "compColTypes": "Komp", + "compTypeFighters": "Myśliwce", + "compTypeBombers": "Bombowce", + "compTypeHelicopters": "Śmigłowce", + "compTypeLight": "Lekki czołg", + "compTypeTanks": "Czołgi", + "compTypeSPAA": "SPAA", + "compTypeSPAATooltip": "Przeciwlotniczy/SPAA", + "compTypeUnknown": "Nieznane", + "compSearchPresetLabel": "Predefiniowany skład", + "compSearchPresetAll": "Wszystkie składy", + "compSearchPresetHint": "Zbudowane z historii składów tego klanu", + "compSearchTypesLabel": "Typy", + "compTypeCapsHint": "Maks 8 łącznie · maks 4 lotnicze (F + B + H)", + "compSearchRefineLabel": "Doprecyzuj", + "compRefineHint": "Ustaw liczbę powyżej, aby wybrać konkretne pojazdy tego typu.", + "compRefineAny": "Dowolny {type}", + "compSearchCustomLabel": "Własny", + "compSearchAddVehicle": "Konkretny pojazd", + "compSearchVehiclesLabel": "Pojazdy", + "compSearchApply": "Zastosuj", + "compSearchReset": "Reset", + "compSearchMatches": "Pokazano {shown} z {total} składów", + "compSearchNoMatches": "Żaden skład nie pasuje do filtra.", + "compSearchGamesShort": "gier", + "colMap": "Mapa", + "colWins": "W", + "colLosses": "P", + "colWinRate": "%W", + "colBar": "", + "colShared": "Wspólnie", + "colUid": "UID", + "colGames": "Gry", + "colPlayer": "Gracz", + "colAvgKills": "Śr. zabicia", + "colAvgDeaths": "Śr. śmierci", + "colScore": "Wynik", + "colHour": "Godzina (UTC)", + "colSquadron": "Klan", + "colTotal": "Razem", + "matchupsWonHeader": "Najwięcej wygranych z", + "matchupsLostHeader": "Najwięcej przegranych z", + "uniqueOpponents": "unikalnych przeciwników", + "euTimeslot": "Pora EU", + "naTimeslot": "Pora NA", + "offPeak": "Poza szczytem", + "radarMetaMaps": "top {shown} z {total} map · min {min} gier", + "radarMetaSquadmates": "top {shown} z {total} współgraczy · min {min} wspólnych gier", + "radarTooFewMaps": "Za mało danych, aby wyświetlić wykres — potrzeba co najmniej 3 map z {min}+ grami w tym filtrze.", + "radarTooFewSquadmates": "Za mało danych, aby wyświetlić wykres — potrzeba co najmniej 3 współgraczy z {min}+ wspólnymi grami w tym filtrze.", + "radarFootnoteMaps": "Ukryto {count} map z mniejszą liczbą gier — zobacz pełną tabelę", + "radarFootnoteSquadmates": "Ukryto {count} współgraczy z mniejszą liczbą gier — zobacz pełną tabelę", + "tabTimeline": "Oś czasu K/D", + "tabTopPlayers": "Najlepsi gracze", + "tabTopSquadrons": "Najlepsze klany" + }, + "playerModal": { + "viewFullProfile": "Zobacz pełny profil →", + "close": "Zamknij", + "overview": "Przegląd", + "vehicles": "Pojazdy", + "sessions": "Sesje", + "loadingPlayerData": "Ładowanie danych gracza...", + "kdr": "K/D", + "kps": "K/S", + "winRate": "Procent zwycięstw", + "battles": "Bitwy", + "wins": "Zwycięstwa", + "totalBattles": "Łącznie bitew", + "totalKills": "Łącznie zniszczeń", + "airKills": "Zniszczenia powietrzne", + "groundKills": "Zniszczenia naziemne", + "assists": "Asysty", + "deaths": "Śmierci", + "captures": "Przejęcia", + "clickToSwitchMetric": "Kliknij, aby zmienić metrykę", + "clickToCycle": "kliknij, aby przełączać", + "noChartData": "Brak danych wykresu", + "noVehicleData": "Brak danych pojazdów", + "noSessionData": "Brak danych sesji", + "date": "Data", + "vehicle": "Pojazd", + "ground": "Ziemia", + "air": "Powietrze", + "result": "Wynik", + "unknown": "Nieznane", + "failedToLoadPlayerData": "Nie udało się załadować danych gracza" + }, + "replay": { + "playPause": "Odtwórz/Pauza", + "crashed": "rozbił się", + "destroyed": "zniszczył", + "hit": "trafił" + }, + "dateFilter": { + "allTime": "Całość", + "currentSeason": "Obecny sezon", + "bySeason": "Według sezonu", + "cumulative": "Narastająco", + "customRange": "Własny zakres", + "selectSeason": "Wybierz sezon", + "selectSeasonDots": "Wybierz sezon...", + "selectWeek": "Wybierz tydzień", + "selectWeekDots": "Wybierz tydzień...", + "entireSeason": "Cały sezon", + "applyFilter": "Zastosuj filtr", + "cumulativeHelp": "Pokaż statystyki skumulowane do wybranego momentu", + "season": "Sezon", + "upToWeek": "Do tygodnia", + "applyCumulativeFilter": "Zastosuj filtr narastający", + "startDate": "Data początkowa", + "endDate": "Data końcowa", + "applyCustomRange": "Zastosuj własny zakres", + "activeFilter": "Aktywny filtr:", + "clear": "Wyczyść", + "allTimeStatistics": "Statystyki całościowe", + "currentSeasonValue": "Obecny sezon: {season}", + "alertSelectSeason": "Wybierz sezon", + "seasonValue": "Sezon {season}", + "alertSelectSeasonWeek": "Wybierz sezon i tydzień", + "cumulativeValue": "Narastająco do {season} - {week}", + "alertSelectDate": "Wybierz co najmniej jedną datę", + "alertStartBeforeEnd": "Data początkowa musi być przed końcową", + "customRangePrefix": "Własny zakres:", + "fromDate": "Od {date}", + "upToDate": "Do {date}" + } +} diff --git a/web/locales/ru.json b/web/locales/ru.json new file mode 100644 index 0000000..fbdcf3e --- /dev/null +++ b/web/locales/ru.json @@ -0,0 +1,906 @@ +{ + "nav": { + "home": "Главная", + "live": "Матчи", + "leaderboards": "Рейтинг", + "docs": "Документация", + "terms": "Условия", + "premium": "Премиум", + "support": "Поддержка", + "addToDiscord": "Добавить в Discord", + "games": "Игры", + "squadrons": "Полки", + "donate": "Пожертвовать", + "analytics": "Аналитика" + }, + "footer": { + "services": "Сервисы", + "matchFeed": "Лента матчей", + "vehicleStats": "Статистика техники", + "analytics": "Аналитика", + "squadronHub": "Полки", + "comparison": "Сравнение", + "resources": "Ресурсы", + "documentation": "Документация", + "inviteBot": "Пригласить бота", + "legal": "Правовая информация", + "termsOfService": "Условия использования", + "privacyPolicy": "Политика конфиденциальности", + "termsAndPrivacy": "Условия и конфиденциальность", + "meowing": "Мяуканье", + "websiteBy": "Сайт от", + "andToothless": "и Toothless" + }, + "common": { + "loading": "Загрузка...", + "retry": "Повторить", + "backToHome": "На главную", + "battles": "Бои", + "wins": "Победы", + "winRate": "Процент побед", + "kills": "Уничтожения", + "totalKills": "Всего уничтожений", + "groundKills": "Наземные", + "airKills": "Воздушные", + "assists": "Помощь", + "deaths": "Гибели", + "captures": "Захваты", + "kdr": "У/С", + "kps": "У/И", + "rank": "Место", + "player": "Игрок", + "players": "Игроки", + "playersCount": "игроков", + "vehicle": "Техника", + "vehicles": "Техника", + "squadron": "Полк", + "squadrons": "Полки", + "statistics": "Статистика", + "comparison": "Сравнение", + "date": "Дата", + "result": "Результат", + "totalBattles": "Всего боёв", + "totalWins": "Всего побед", + "points": "Очки", + "members": "Участники", + "membersCount": "участников", + "rating": "Рейтинг", + "searchPlayerByName": "Поиск игрока по имени...", + "noPlayersFound": "Игроки не найдены", + "noSquadronsFound": "Полки не найдены", + "noVehiclesFound": "Техника не найдена", + "failedToLoad": "Не удалось загрузить данные. Попробуйте позже.", + "recordingSince": "Данные записываются с 01.01.2026", + "vs": "VS", + "map": "Карта" + }, + "home": { + "squadronBattles": "Полковые бои", + "madeSimple": "Просто и удобно", + "addToDiscord": "Добавить в Discord", + "learnMore": "Подробнее", + "searchBySquadron": "ПОИСК ПО ПОЛКУ", + "typeSquadronName": "Введите название полка...", + "orByPlayer": "ИЛИ ПО ИГРОКУ", + "typePlayerName": "Введите имя игрока...", + "liveFeed": "Лента матчей", + "realTimeMatches": "Найди свои матчи", + "topPlayers": "Лучшие игроки", + "vehicleStatsCard": "Статистика техники", + "performanceMetrics": "Показатели эффективности", + "analyticsCard": "Аналитика", + "globalStatistics": "Глобальная статистика", + "squadronHubCard": "Полки", + "squadronStats": "Статистика полков", + "comparisonCard": "Сравнение", + "compareStats": "Сравнить статистику", + "joinServers": "Присоединяйтесь к 500+ серверам, использующим нашего бота", + "noSquadronsFound": "Полки не найдены", + "noPlayersFound": "Игроки не найдены", + "searchPlayersIn": "Поиск игроков в", + "ctaElev8": "Готов ELEV8 свой полк?", + "ctaReign": "Готов снова R3IGN?", + "ctaMeow": "Мяууу", + "ctaPurr": "Мур Мур", + "ctaRawr": "Р-р-р" + }, + "docs": { + "title": "Документация", + "subtitle": "Всё, что нужно знать о", + "quickNavigation": "Быстрая навигация", + "gettingStarted": "Начало работы", + "commands": "Команды", + "serverSetup": "Настройка сервера", + "features": "Возможности", + "examples": "Примеры", + "troubleshooting": "Устранение неполадок", + "stackManager": "Менеджер стека", + "welcomeMessage": "Следуйте этим шагам, чтобы начать работу.", + "inviteTheBot": "Пригласите бота", + "inviteBotDesc": "Нажмите кнопку «Добавить на сервер» и выберите Discord-сервер вашего полка. Бот отправит приветственное сообщение с подсказкой запустить /setup.", + "runSetupWizard": "Запустите мастер настройки", + "setupWizardDesc": "Мастер настройки проведёт вас через настройку полка, канала логов и канала очков:", + "setupEasiest": "Это самый простой способ начать. Мастер поможет вам настроить полк и выбрать каналы шаг за шагом.", + "youreDone": "Готово!", + "doneDesc": "Бот начнёт автоматически публиковать очки и обновления рейтинга. Используйте /autolog-management для настройки уведомлений.", + "premiumNote": "Автоматические логи игр (полные таблицы результатов после каждого матча) требуют подписки Premium. Запустите /unlock для подписки — $2.99/мес за сервер, оплата через Discord.", + "manualSetup": "Ручная настройка (альтернатива)", + "manualSetupDesc": "Если вы предпочитаете настраивать вручную, используйте эти команды:", + "allCommandsSlash": "Все команды используют систему слэш-команд Discord. Введите / для просмотра доступных команд.", + "serverSetupAdmin": "Настройка и администрирование сервера", + "importantNote": "Важное замечание", + "verifyFirst": "Сначала проверьте! Команда /sq-info подтверждает, что бот может найти ваш полк в базе данных War Thunder, даже если ваш рейтинг очень низкий.", + "cantFindSquadron": "Если бот не может найти ваш полк через /sq-info, команды настройки не будут работать правильно.", + "botNotResponding": "Бот не отвечает", + "checkOnline": "Проверьте, что бот онлайн (зелёный статус)", + "verifyPermissions": "Убедитесь, что у бота есть необходимые разрешения", + "tryDifferentChannel": "Попробуйте использовать команды в другом канале", + "commandsNotWorking": "Команды не работают", + "ensureSlash": "Убедитесь, что используете слэш-команды (начинающиеся с /)", + "checkRolePerms": "Проверьте, есть ли у вашей роли разрешение на использование команд бота", + "tryRefreshing": "Попробуйте обновить Discord или перезапустить приложение", + "dataNotSaving": "Данные не сохраняются", + "verifySendMessages": "Убедитесь, что у бота есть разрешение «Отправлять сообщения»", + "checkOutages": "Проверьте, нет ли сбоев в работе Discord", + "contactSupport": "Свяжитесь с поддержкой, если проблема не устранена", + "needMoreHelp": "Нужна дополнительная помощь?", + "needMoreHelpDesc": "Если вам нужна дополнительная помощь, обратитесь через наши каналы поддержки.", + "example": "Пример", + "supportedLanguages": "Поддерживаемые языки", + "setupDesc": "Пошаговый мастер настройки бота для вашего сервера. Настраивает полк, канал логов и канал очков за один раз.", + "recommendedForNew": "Рекомендуется для новых серверов.", + "setSquadronDesc": "Сохранить полк по умолчанию для вашего Discord-сервера. Используется для логирования и как значение по умолчанию для других команд.", + "quickLogDesc": "Установить оповещение для полка в текущем канале. Тип может быть Logs, Points, Leaderboard или Both — Both устанавливает Logs и Points одной командой. По умолчанию — Logs.", + "quickLogPremiumNote": "Logs (автоматические таблицы результатов) требуют подписки Premium. Оповещения Points и Leaderboard бесплатны.", + "autologDesc": "Управление уведомлениями автолога и диагностика разрешений канала. Используйте для изменения настроек после первоначальной настройки.", + "autologPremiumNote": "Автоматические логи игр требуют подписки Premium.", + "diagnosePermsDesc": "Мгновенно проверяет, есть ли у бота необходимые разрешения в текущем канале, показывает настроенные каналы автолога и статус подписки Premium для этого сервера. Используйте, если таблицы результатов или очки не публикуются.", + "squadronInformation": "Информация о полке", + "sqInfoDesc": "Просмотр подробной информации о любом полке. Использует полк сервера по умолчанию, если не указан другой.", + "sqInfoGraphDesc": "Визуализирует текущий состав полка в виде столбчатой диаграммы, разбитой на группы костяк, активные и слабые по активности и винрейту (текущий сезон).", + "compDesc": "Найти последние известные составы для боёв данного полка. Бесплатные серверы получают 25 запросов за таймслот; Premium — без ограничений.", + "trackDesc": "Отслеживайте полк и сравнивайте статистику с последней проверкой.", + "topDesc": "Показать топ-20 полков и их текущую статистику.", + "sqStatsDesc": "Показать динамику очков полка в виде интерактивного графика.", + "lossCalculatorDesc": "Рассчитать, сколько очков потеряет полк, если выбранные игроки покинут его.", + "recentDesc": "Показать последние 5 полковых боёв для полка.", + "vsDesc": "Просмотр личного счёта против другого полка.", + "leaderboardLinkDesc": "Получить ссылку на глобальный рейтинг игроков SRE Bot.", + "playerStats": "Статистика игроков", + "playerStatsDesc": "Просмотр подробной статистики техники игрока с интерактивным выпадающим списком техники. Поддерживает автодополнение.", + "viewPlayerGamesDesc": "Просмотр последних 20 игр игрока. Показывает общий счёт побед/поражений и процент побед, сводку по каждой игре (результат, полк противника, карта и состав), а также список всех уникальных составов, которые использовал игрок. Поддерживает автодополнение.", + "viewMatchDesc": "Просмотр полной таблицы результатов для конкретного матча. Укажите ID матча напрямую или найдите по имени игрока, чтобы просмотреть его последние 100 игр и выбрать нужную. Включает кнопки «Просмотр реплея», «Просмотр маршрутов», «Чат-лог» и «Лог боя».", + "examples2": "Примеры", + "compareDesc": "Сравнить совокупную статистику SQB между двумя или более игроками (до 7). Показывает сравнение бок о бок с выделением лучших показателей. Включает кнопку графика для просмотра истории очков за 90 дней.", + "metaData": "Мета-данные", + "metaManagementDesc": "Настройка доступа к мета-данным для вашего сервера.", + "metaDesc": "Поиск в мета-составе вашего полка по названию техники.", + "settingsUtilities": "Настройки и утилиты", + "languageDesc": "Изменить язык бота по умолчанию. Влияет на язык техники в логах боёв.", + "scheduleDesc": "Просмотр расписания BR текущего сезона. Показывает максимальный боевой рейтинг каждой недели с диапазоном дат, зачёркнутые прошедшие недели и выделение текущего активного периода.", + "websiteDesc": "Получить ссылку на сайт SRE Bot для поиска игроков, рейтингов и другого.", + "creditsDesc": "Просмотр команды, создавшей этого бота.", + "unlockDesc": "Разблокировать автоматические логи игр SQB и неограниченные запросы /comp для этого сервера. Подписка добавляет полные таблицы результатов, автоматически публикуемые в настроенном канале после каждого матча. $2.99 / месяц · за сервер · отмена в любое время. Оплата полностью через Discord — внешний аккаунт не требуется.", + "analyticsDesc": "Продвинутая аналитика SQB: процент побед на картах, составы команд, стабильность игроков, результаты по времени суток и история противостояний (чаще всего побеждали и проигрывали полкам).", + "sqCardDesc": "Сгенерировать сезонную итоговую карточку (PNG) для полка — динамика рейтинга, процент побед, топ игроков и многое другое. Сезон выбирается из автодополнения. Поддерживает тёмную и светлую темы.", + "cardDesc": "Сгенерировать сезонную итоговую карточку (PNG) для игрока. Сезон выбирается из автодополнения, игрок — по имени пользователя. Поддерживает тёмную и светлую темы.", + "queryDesc": "[Только администратор] Запустить предопределённые запросы к базе данных — статистика полка, количество игр, самые активные игроки, топ карт и многое другое. Результаты эфемерные (видны только вам).", + "donateDesc": "Поддержите развитие SRE Bot через Ko-fi.", + "botStatusDesc": "Показывает, когда была получена последняя игра, и среднее TTL по недавним играм. Сигнализирует о медленных серверах Gaijin.", + "premiumBadge": "Премиум", + "newsDesc": "Просмотр последних новостей и объявлений SRE Bot.", + "stackCreateDesc": "Создать стек игроков для координации отряда перед матчем. В канале публикуется постоянный эмбед с текущими участниками и ожидающими заявками. Стек действует до 8 часов и автоматически удаляется в конце каждого таймслота SQB.", + "stackRequestToJoin": "Заявка на вступление — Любой игрок может подать заявку с техникой, на которой планирует играть. Очередь заявок до 20 мест.", + "stackLeaveWithdraw": "Покинуть / Отозвать — Участники могут покинуть стек; заявители могут отозвать заявку. Лидеру предлагается передать управление.", + "stackManagePanel": "Управление стеком ⚙️ — Панель только для лидера с четырьмя разделами:", + "stackAcceptMembers": "Принять участников — Принять или отклонить заявки по отдельности или все сразу. Максимум 8 участников.", + "stackRemoveMembers": "Удалить участников — Удалить активных участников или заявителей. Варианты: Удалить всех, Удалить активных, Удалить в очереди или Удалить выбранных из списка.", + "stackPingMembers": "Пинг участников — Пинг с необязательным сообщением. Варианты: Пинг всех (участники + очередь, кроме лидера), Пинг активных, Пинг в очереди или Пинг выбранных из списка.", + "stackRenameStack": "Переименовать стек — Задать своё название для стека. Отображается как заголовок эмбеда и в пинг-сообщениях вместо «Стек [Лидера]».", + "stackDisbandStack": "Распустить стек — Лидер может завершить стек досрочно.", + "stackManageDesc": "Повторно публикует эмбед вашего активного стека в текущем канале. Используйте, если оригинальный эмбед был удалён или потерян после перезапуска бота. Все участники и данные очереди сохраняются.", + "translation": "Перевод", + "translateContextMenu": "ПКМ по сообщению → Приложения → Translate Message", + "translateDesc": "Перевод любого сообщения через контекстное меню Discord. Нажмите ПКМ (или долгое нажатие на мобильных) по сообщению и выберите Приложения → Translate Message.", + "viewAllLanguages": "Все поддерживаемые языки", + "serverSetupSubtitle": "Настройте {botName} для оптимальной работы на Discord-сервере вашего полка.", + "requiredPermissions": "Необходимые разрешения", + "sendMessages": "Отправлять сообщения", + "useSlashCommands": "Использовать слэш-команды", + "embedLinks": "Встраивать ссылки", + "readMessageHistory": "Читать историю сообщений", + "recommendedChannelSetup": "Рекомендуемая настройка канала", + "recommendedChannelDesc": "Создайте отдельный канал, например #squadron-battles, для отслеживания и статистики. Это упорядочит ваши данные боёв и обеспечит удобный доступ.", + "roleConfiguration": "Настройка ролей", + "roleConfigurationDesc": "Назначьте соответствующие роли участникам полка, которые могут записывать результаты боёв. Рекомендуем ограничить это офицерами и лидерами полка.", + "premiumSectionSubtitle": "Автоматические логи игр SQB — функция Premium, разблокируемая для каждого сервера через систему подписок Discord.", + "whatsIncluded": "Что включено", + "premiumInclude1": "Полная таблица результатов автоматически публикуется в настроенном канале после каждого матча SQB", + "premiumInclude2": "Неограниченные запросы /comp (бесплатные серверы получают 25 за таймслот)", + "premiumInclude3": "Все существующие бесплатные функции (оповещения очков, рейтинг, команды статистики и т.д.) остаются бесплатными", + "pricingBilling": "Цены и оплата", + "pricingBillingDesc": "$2.99 / месяц · за сервер · отмена в любое время. Оплата полностью через Discord — без внешнего аккаунта или платёжной системы. Подписки продлеваются автоматически и могут быть отменены в любое время из настроек Discord.", + "howToSubscribe": "Как подписаться", + "subscribe1": "Запустите /unlock на вашем сервере (требуются права администратора)", + "subscribe2": "Нажмите кнопку «Подписаться» в ответе бота", + "subscribe3": "Завершите оплату внутри Discord — бот активируется мгновенно", + "cancellation": "Отмена подписки", + "cancellationDesc": "Когда подписка истекает или отменяется, бот автоматически прекращает публикацию логов игр для этого сервера в следующем цикле автолога. Никаких действий не требуется.", + "realTimeStatistics": "Статистика в реальном времени", + "realTimeStatisticsDesc": "Отслеживайте победы, поражения и показатели эффективности в реальном времени.", + "battleHistory": "История боёв", + "battleHistoryDesc": "Полная история всех записанных полковых боёв.", + "leaderboardsFeature": "Рейтинги", + "leaderboardsFeatureDesc": "Сравните результаты вашего полка с другими через /top.", + "playerTracking": "Отслеживание игроков", + "playerTrackingDesc": "Индивидуальная статистика игроков и интеграция с ThunderSkill.", + "smartAlerts": "Умные оповещения", + "smartAlertsDesc": "Автоматические оповещения и уведомления об активности полка.", + "multiLanguageSupport": "Многоязычная поддержка", + "multiLanguageSupportDesc": "Функции перевода и настраиваемые языки техники.", + "timeCoordination": "Координация времени", + "timeCoordinationDesc": "Инструменты UTC и локального времени для глобальной координации полка.", + "advancedSearch": "Расширенный поиск", + "advancedSearchDesc": "Поиск соревнований игроков и подробной информации о полках.", + "dataSecurity": "Безопасность данных", + "dataSecurityDesc": "Ваши данные защищены и никогда не передаются третьим лицам.", + "usageExamples": "Примеры использования", + "quickSetupRecommended": "Быстрая настройка (рекомендуется)", + "quickSetupDesc": "Запустите мастер настройки. Он проведёт вас через настройку полка, выбор канала логов и канала очков — всё за один раз.", + "comparingPlayers": "Сравнение игроков", + "comparingPlayersDesc": "Сравните до 7 игроков бок о бок. Лучший показатель в каждой категории выделяется, а лучший игрок получает звезду. Нажмите «Показать график» для просмотра истории очков.", + "checkingSquadronInfo": "Проверка информации о полке", + "verifySquadronDesc": "Проверьте, что полк существует и просмотрите его данные. Используйте для подтверждения, что бот может найти ваш полк перед настройкой.", + "recentBattlesDesc": "Просмотр последних 5 боёв полка.", + "headToHeadDesc": "Просмотр личного счёта против другого полка.", + "backToHome": "На главную", + "termsAndPrivacy": "Условия и конфиденциальность", + "seasonRecapCardTitle": "Итоговая карточка сезона", + "seasonRecapCardDesc": "Создайте PNG-итог сезона любого полка — график рейтинга, скользящий процент побед, K/D, любимая техника, MVP и многое другое. Доступно по кнопке «Карточка сезона» на странице профиля каждого полка.", + "playerRecapCardTitle": "Итоги сезона игрока", + "playerRecapCardDesc": "Создавайте PNG-сводку сезона любого игрока — динамика рейтинга по полкам, скользящий винрейт, K/D, частота боёв, лучший матч и многое другое. Доступно по кнопке «Season Recap» на странице профиля игрока.", + "tierOverview": "Обзор тарифов", + "tierOverviewDesc": "Автолог доступен на трёх тарифах. Каждый тариф ограничивает, сколько полков могут иметь Logs и Points. Полки сверх лимита остаются в настройках и автоматически возобновляются после апгрейда.", + "tierStandardLine": "$2.99 — до 10 полков для Logs и Points, без wildcard.", + "tierProLine": "до 25 полков для Logs и Points, плюс wildcard (`*` / `all` / `everything`).", + "tierMaxLine": "без лимита, wildcard, ранний доступ к новым функциям." + }, + "terms": { + "pageTitle": "Условия использования и политика конфиденциальности", + "lastUpdated": "Последнее обновление: апрель 2026", + "termsOfService": "Условия использования", + "byUsing": "Используя", + "youAgree": ", вы соглашаетесь со следующим:", + "useResponsibly": "Используйте ответственно", + "useResponsiblyDesc": "Не пытайтесь перегружать, спамить или ломать бота.", + "noFunnyBusiness": "Без нарушений", + "noFunnyBusinessDesc": "Не пытайтесь реверс-инжинировать, нарушать условия Discord или правила любого сервера", + "statsAsIs": "Статистика «как есть»", + "statsAsIsDesc": "Все данные и статистика предоставляются «как есть» без каких-либо гарантий, явных или подразумеваемых. Мы прилагаем разумные усилия для обеспечения точности, но не гарантируем, что информация является полной, актуальной или безошибочной.", + "uptimeNotGuaranteed": "Бесперебойность не гарантирована", + "uptimeNotGuaranteedDesc": "Бот предоставляется на условиях «по мере доступности». Мы не гарантируем бесперебойную или безошибочную работу. Сервис может быть временно недоступен из-за обслуживания, обновлений или технических проблем.", + "weCanBanYou": "Мы можем заблокировать вас", + "weCanBanYouDesc": "Если вы нарушите правила, мы можем ограничить ваш доступ.", + "privacyPolicy": "Политика конфиденциальности", + "infoWeCollect": "1. Информация, которую мы собираем", + "collectsFollowing": "собирает следующую информацию для предоставления услуг:", + "discordUserIds": "ID пользователя Discord:", + "discordUserIdsDesc": "Уникальные идентификаторы для отслеживания пользователей", + "squadronIds": "ID полков:", + "squadronIdsDesc": "Идентификаторы серверов Discord для организации данных полков", + "battleData": "Данные боёв:", + "battleDataDesc": "Записи побед/поражений, временные метки боёв и связанная статистика", + "commandUsage": "Использование команд:", + "commandUsageDesc": "Базовое логирование команд бота для улучшения сервиса", + "howWeUse": "2. Как мы используем вашу информацию", + "usedExclusively": "Собранная информация используется исключительно для:", + "trackingPerformance": "Отслеживания результатов и статистики полковых боёв", + "providingHistorical": "Предоставления исторических данных и аналитики", + "improvingBot": "Улучшения функциональности бота и пользовательского опыта", + "troubleshootingIssues": "Устранения технических неполадок", + "dataStorage": "3. Хранение и безопасность данных", + "dataStoredSecurely": "Ваши данные хранятся безопасно со следующей защитой:", + "encryptedServers": "Данные хранятся на защищённых серверах с шифрованием", + "limitedAccess": "Доступ ограничен только авторизованным персоналом", + "regularBackups": "Регулярные резервные копии обеспечивают целостность данных", + "dataSharing": "4. Передача данных", + "weDoNot": "Мы НЕ:", + "sellData": "Продаём вашу личную информацию третьим лицам", + "shareData": "Передаём ваши данные внешним организациям", + "useForAds": "Используем ваши данные для рекламы или маркетинга", + "transferData": "Переносим ваши данные за пределы наших защищённых систем", + "dataRetention": "5. Хранение данных", + "dataRetentionDesc": "Мы храним ваши данные столько, сколько необходимо для предоставления услуг. Статистика боёв и данные полков сохраняются для ведения исторических записей и аналитики.", + "discordIntegration": "6. Интеграция с Discord", + "discordIntegrationDesc": "Этот бот работает в экосистеме Discord и подчиняется Политике конфиденциальности Discord. Мы получаем только информацию, необходимую для работы бота, через официальный API Discord.", + "ageRestrictions": "7. Возрастные ограничения", + "ageRestrictionsDesc": "Бот предназначен для пользователей, соответствующих минимальным возрастным требованиям Discord (13+ или согласно местному законодательству).", + "changesToPolicy": "8. Изменения политики конфиденциальности", + "changesToPolicyDesc": "Мы можем время от времени обновлять эту Политику конфиденциальности.", + "premiumTitle": "Условия подписки Premium", + "premiumWhatYouGet": "1. Что включает Premium", + "premiumWhatYouGetDesc": "Подписка Premium открывает следующие функции для подписанного Discord-сервера:", + "premiumFeature1": "Автоматическая публикация таблиц результатов после каждого матча SQB", + "premiumFeature2": "Карты маршрутов / передвижений", + "premiumFeature3": "Логи чата и боёв", + "premiumFeature4": "Поиск реплеев", + "premiumFeature5": "Неограниченные запросы /comp (бесплатные серверы ограничены 15 за временной слот)", + "premiumBilling": "2. Выставление счетов и оплата", + "premiumBillingDesc": "Premium тарифицируется по $2,99 USD в месяц за Discord-сервер. Оплата обрабатывается через встроенную систему подписок Discord или через наш сайт с помощью Whop. Вы несёте ответственность за то, чтобы ваш способ оплаты был действительным и имел достаточно средств. Подписки автоматически продлеваются в конце каждого расчётного периода, если не были отменены.", + "premiumCancellation": "3. Отмена подписки", + "premiumCancellationDesc": "Вы можете отменить подписку в любое время. Для подписок Discord перейдите в Настройки пользователя → Подписки в Discord. Для подписок через сайт управляйте выставлением счетов на whop.com/billing. После отмены ваши функции Premium остаются активными до конца текущего расчётного периода. После этого ваш сервер возвращается на бесплатный уровень — данные не теряются.", + "premiumRefunds": "4. Возвраты", + "premiumRefundsDesc": "Абонентская плата не подлежит возврату. Частичное использование месяца не пересчитывается пропорционально. Если вы отмените подписку в середине цикла, вы сохраняете доступ до конца этого расчётного периода, но не имеете права на возврат за оставшееся время. Возвраты за ошибки в выставлении счетов или дублирующиеся списания могут быть произведены по нашему усмотрению — свяжитесь со службой поддержки, указав ID вашего Discord-сервера и подтверждение оплаты.", + "premiumPriceChanges": "5. Изменение цен", + "premiumPriceChangesDesc": "Мы оставляем за собой право изменять цены подписки в любое время. Действующие подписчики будут уведомлены не менее чем за 30 дней до вступления в силу любого повышения цен. Если вы не согласны с изменением цены, вы можете отменить подписку до вступления новой цены в силу.", + "premiumTermination": "6. Прекращение доступа Premium", + "premiumTerminationDesc": "Мы оставляем за собой право отозвать доступ Premium без возврата средств, если сервер нарушает настоящие Условия использования, включая, помимо прочего, злоупотребление функциями бота, попытки обойти ограничения использования или нарушение Условий использования Discord.", + "serviceAvailabilityTitle": "Доступность сервиса и ответственность", + "serviceNoWarranty": "1. Отсутствие гарантий", + "serviceNoWarrantyDesc": "СЕРВИС ПРЕДОСТАВЛЯЕТСЯ «КАК ЕСТЬ» И «ПО МЕРЕ ДОСТУПНОСТИ» БЕЗ КАКИХ-ЛИБО ГАРАНТИЙ, ЯВНЫХ, ПОДРАЗУМЕВАЕМЫХ ИЛИ УСТАНОВЛЕННЫХ ЗАКОНОМ, ВКЛЮЧАЯ, НО НЕ ОГРАНИЧИВАЯСЬ, ПОДРАЗУМЕВАЕМЫЕ ГАРАНТИИ ТОВАРНОЙ ПРИГОДНОСТИ, ПРИГОДНОСТИ ДЛЯ ОПРЕДЕЛЁННОЙ ЦЕЛИ И НЕНАРУШЕНИЯ ПРАВ. Мы не гарантируем, что сервис будет бесперебойным, своевременным, безопасным или безошибочным.", + "serviceLiability": "2. Ограничение ответственности", + "serviceLiabilityDesc": "В МАКСИМАЛЬНОЙ СТЕПЕНИ, ДОПУСТИМОЙ ПРИМЕНИМЫМ ЗАКОНОДАТЕЛЬСТВОМ, МЫ НИ ПРИ КАКИХ ОБСТОЯТЕЛЬСТВАХ НЕ НЕСЁМ ОТВЕТСТВЕННОСТИ ЗА КОСВЕННЫЕ, СЛУЧАЙНЫЕ, СПЕЦИАЛЬНЫЕ, ПОСЛЕДУЮЩИЕ ИЛИ ШТРАФНЫЕ УБЫТКИ, А ТАКЖЕ ЗА ПОТЕРЮ ПРИБЫЛИ ИЛИ ДОХОДА, ПОНЕСЁННУЮ ПРЯМО ИЛИ КОСВЕННО, ИЛИ ЗА ПОТЕРЮ ДАННЫХ, ВОЗМОЖНОСТИ ИСПОЛЬЗОВАНИЯ, ДЕЛОВОЙ РЕПУТАЦИИ ИЛИ ИНЫХ НЕМАТЕРИАЛЬНЫХ ПОТЕРЬ, ВОЗНИКШИХ В РЕЗУЛЬТАТЕ ИСПОЛЬЗОВАНИЯ ИЛИ НЕВОЗМОЖНОСТИ ИСПОЛЬЗОВАНИЯ СЕРВИСА. Наша совокупная ответственность по любым претензиям, вытекающим из настоящих условий или сервиса, не превышает сумму, уплаченную вами нам за три (3) месяца, предшествующих претензии.", + "serviceCredits": "3. Перебои в работе сервиса и кредиты", + "serviceCreditsDesc": "В случае продолжительного незапланированного перебоя в работе сервиса, затрагивающего функции Premium более 72 последовательных часов, затронутые подписчики Premium могут запросить сервисный кредит, связавшись со службой поддержки. Кредиты выдаются исключительно по нашему усмотрению и применяются как продление текущего расчётного периода — а не как денежный возврат. Плановые технические работы, сбои сторонних сервисов (Discord, Gaijin API) и события, находящиеся вне нашего разумного контроля, исключаются.", + "serviceForceM": "4. Форс-мажор", + "serviceForceMDesc": "Мы не несём ответственности за любые сбои или задержки в исполнении, вызванные причинами, находящимися вне нашего разумного контроля, включая, но не ограничиваясь: стихийные бедствия, войны, терроризм, пандемии, перебои в электроснабжении, нарушения работы интернета, сбои платформы Discord, изменения или недоступность API Gaijin Entertainment, действия государственных органов или любые другие обстоятельства непреодолимой силы.", + "disclaimer": "Отказ от ответственности", + "warThunderDisclaimer": "является независимым Discord-ботом и не связан с Gaijin Entertainment или War Thunder, не одобрен ими и не ассоциирован с ними. War Thunder является торговой маркой Gaijin Entertainment.", + "acknowledgement": "Используя", + "acknowledgementEnd": ", вы подтверждаете, что прочитали, поняли и согласны соблюдать настоящие Условия использования и Политику конфиденциальности." + }, + "premium": { + "upgradeTitle": "Улучшите свой полк", + "heroDesc": "Каждый результат SQB публикуется в вашем канале сразу после окончания — таблицы, логи боёв, карты перемещений и реплеи, всё автоматически.", + "instantScoreboards": "Мгновенные таблицы", + "viewPaths": "Просмотр маршрутов", + "chatBattleLogs": "Чат и логи боёв", + "replayLookups": "Поиск реплеев", + "free": "Бесплатно", + "perMonth": "/мес", + "alwaysFree": "Всегда бесплатно", + "noCardNeeded": "карта не нужна", + "included": "Включено", + "manualLookups": "Ручной поиск игр", + "playerStats": "Статистика и профили игроков", + "leaderboards": "Рейтинг", + "stickWithFree": "Остаться на бесплатном", + "premiumLabel": "Премиум", + "perServer": "за сервер", + "cancelAnytime": "отмена в любое время", + "everythingInFree": "Всё из бесплатного, плюс", + "autoScoreboards": "Автопубликация таблиц", + "pathMaps": "Карты маршрутов / перемещений", + "chatLogs": "Чат и логи боёв", + "replayLookupsFeature": "Поиск реплеев", + "unlimitedComp": "Неограниченные запросы /comp", + "prioritySupport": "Приоритетная поддержка", + "subscribeNow": "Подписаться", + "comingSoon": "Скоро", + "serverIdInfo": "Вам понадобится", + "discordServerId": "ID сервера Discord", + "duringCheckout": "при оформлении.", + "developerMode": "Режим разработчика", + "rightClickServer": "ПКМ по серверу", + "copyId": "Копировать ID", + "successTitle": "Премиум активирован", + "successDesc": "Ваша подписка настраивается. Бот получит премиум-доступ для вашего сервера в течение нескольких минут.", + "whatHappensNext": "Что дальше", + "autoLogging": "Автологирование активируется для вашего сервера", + "setLogChannel": "Установите канал логов через", + "everyResult": "Каждый результат SQB публикуется автоматически", + "readSetupGuide": "Читать руководство по настройке", + "tierStandardName": "Стандарт", + "tierProName": "Про", + "tierMaxName": "Макс", + "squadCap": "Логирование до {cap} полков", + "squadCapUnlimited": "Неограниченное логирование полков", + "everythingInStandard": "Всё из Стандарта", + "everythingInPro": "Всё из Про", + "wildcardSupport": "Wildcard-логирование (*, all, everything)", + "noSquadCap": "Без лимита на полки", + "earlyAccessFeatures": "Ранний доступ к новым функциям" + }, + "player": { + "totalBattles": "Всего боёв", + "totalWins": "Всего побед", + "vehicleStatistics": "Статистика техники", + "cumulative": "Суммарно", + "individual": "По отдельности", + "filterBy": "Фильтр:", + "allTime": "Всё время", + "dateRange": "Период", + "season": "Сезон", + "week": "Неделя", + "session": "Сессия", + "dateType": "Тип даты:", + "last7Days": "Последние 7 дней", + "last30Days": "Последние 30 дней", + "last90Days": "Последние 90 дней", + "customRange": "Свой период", + "specificDate": "Конкретная дата", + "filterType": "Тип фильтра:", + "fullSeason": "Весь сезон", + "specificWeek": "Конкретная неделя", + "from": "С:", + "to": "По:", + "timeslot": "Слот", + "fullDay": "Весь день", + "selectSeason": "Сезон:", + "selectWeek": "Неделя:", + "selectSeasonFirst": "Сначала выберите сезон", + "pleaseSelect": "Пожалуйста, выберите", + "searchVehicles": "Поиск техники...", + "resetFilters": "Сбросить фильтры", + "vehiclesShown": "техники показано", + "gamesShown": "игр показано", + "noVehicleData": "Нет данных о технике", + "noVehiclesForRange": "Техника не найдена для выбранного периода, или у игрока ещё нет данных.", + "switchToCards": "Карточки", + "switchToTable": "Таблица", + "loadingTimeline": "Загрузка временной шкалы...", + "noTimelineData": "Нет данных временной шкалы.", + "timelineUnavailable": "Временная шкала недоступна.", + "loadingGameRecords": "Загрузка записей игр...", + "unableToLoadRecords": "Не удалось загрузить записи игр", + "failedToFetch": "Не удалось получить данные игр. Попробуйте позже.", + "noGameRecords": "Записи игр не найдены", + "noGamesYet": "У этого игрока ещё нет записанных игр.", + "collapseChart": "Свернуть график", + "loadingChartData": "Загрузка данных графика...", + "noHistoricalData": "Нет исторических данных.", + "chartUnavailable": "График недоступен.", + "relative": "Относительно", + "uidLabel": "UID игрока" + }, + "squadrons": { + "title": "Полки", + "subtitle": "Находите полки, просматривайте статистику и отслеживайте результаты", + "findSquadron": "Найти полк", + "searchPlaceholder": "Поиск полка по названию...", + "totalSquadrons": "Всего полков", + "totalPlayers": "Всего игроков", + "totalBattles": "Всего боёв", + "avgWinRate": "Средний % побед", + "topSquadrons": "Лучшие полки", + "viewFullLeaderboard": "Полный рейтинг", + "loadingSquadrons": "Загрузка данных полков...", + "noSquadronData": "Данные полков пока недоступны.", + "failedToLoad": "Не удалось загрузить данные полков. Попробуйте позже.", + "backToSquadronHub": "Назад к полкам", + "squadronPoints": "Очки полка", + "squadronMembers": "Участники полка", + "performance": "Эффективность", + "performanceNoData": "Для выбранного диапазона нет данных по эффективности.", + "quickDetails": "Подробнее", + "noMembersFound": "Участники не найдены", + "noRecordedMembers": "У этого полка пока нет записанных участников.", + "squadronGames": "Игры полка", + "loadingSquadronGames": "Загрузка записей игр полка...", + "noSquadronGames": "Записи игр для этого полка не найдены.", + "retryLoadGames": "Повторить", + "searchMapPlaceholder": "Поиск по карте..." + }, + "leaderboard": { + "playersTitle": "Рейтинг игроков", + "playersSubtitle": "Лучшие игроки War Thunder по результатам", + "vehiclesTitle": "Рейтинг техники по уничтожениям", + "vehiclesSubtitle": "Лучшая техника War Thunder по количеству уничтожений", + "squadronsTitle": "Рейтинг полков", + "squadronsSubtitle": "Лучшие полки War Thunder по результатам", + "statsTitle": "Глобальная статистика", + "statsSubtitle": "Общая статистика полковых боёв и мета-информация", + "comparisonTitle": "Инструмент сравнения", + "comparisonSubtitle": "Сравните игроков и технику бок о бок", + "comparisonHint": "Сравните статистику лучших игроков и техники", + "compareSquadrons": "Сравнить полки", + "comparePlayers": "Сравнить игроков", + "compareVehicles": "Сравнить технику", + "playersAndVehicles": "Игроки + Техника", + "failedToLoadLeaderboard": "Не удалось загрузить данные рейтинга. Попробуйте позже.", + "failedToLoadVehicles": "Не удалось загрузить рейтинг техники", + "failedToLoadSquadrons": "Не удалось загрузить рейтинг полков", + "noResultsYet": "Пока нет результатов. Добавьте полки/игроков для начала.", + "searchSquadron": "Поиск полка", + "searchBySquadronName": "Поиск по названию полка...", + "minPlayers": "Мин. игроков", + "minPlayersPlaceholder": "Мин. игроков", + "resetFilters": "Сбросить фильтры", + "squadronsShown": "полков показано", + "playersShown": "игроков показано", + "page": "страница", + "of": "из", + "loadingSquadronLeaderboard": "Загрузка рейтинга полков...", + "loadingPlayerLeaderboard": "Загрузка рейтинга игроков...", + "loadingComparisonData": "Загрузка данных сравнения...", + "unableToFetch": "Не удалось получить данные рейтинга. Попробуйте снова.", + "noSquadronsInLeaderboard": "Полки в рейтинге не найдены.", + "noPlayersInLeaderboard": "Игроки в рейтинге не найдены.", + "loadingGlobalStats": "Загрузка глобальной статистики...", + "failedToLoadStats": "Не удалось загрузить статистику", + "unableToFetchStats": "Не удалось получить данные статистики. Попробуйте снова.", + "mostPopularVehicles": "Самая популярная техника", + "vehicleKillsLeaderboardTitle": "Рейтинг техники по уничтожениям", + "avgWinRate": "Средний % побед", + "avgKillsPerPlayer": "Среднее уничтож./игрок", + "loadingVehicleKills": "Загрузка данных уничтожений...", + "apiNotLoaded": "API-клиент не загружен. Обновите страницу.", + "failedToInitApi": "Не удалось инициализировать API-клиент", + "noStatsData": "Нет данных статистики", + "totalPlayersCard": "Всего игроков", + "activePlayers": "Активные игроки", + "vehiclesUsed": "Используемая техника", + "differentVehicles": "Различная техника", + "squadronBattlesLabel": "Полковые бои", + "noVehicleData": "Нет данных о технике", + "mostPopular": "Самый популярный", + "timesUsed": "Раз использован", + "failedToLoadVehicleKills": "Не удалось загрузить данные уничтожений", + "lastUpdated": "Обновлено", + "searchPlayer": "Поиск игрока", + "searchByPlayerName": "Поиск по имени игрока...", + "minimumBattles": "Мин. боёв", + "minBattlesPlaceholder": "Мин. боёв", + "searchSquadronsPlaceholder": "Поиск полков...", + "sortBy": "Сортировка", + "kdRatio": "У/С", + "killsPerSpawn": "Уничтож./Вылет", + "caps": "Захв", + "timePeriod": "Период", + "allTime": "Всё время", + "dateRange": "Период", + "season": "Сезон", + "week": "Неделя", + "dateType": "Тип даты", + "last7Days": "Последние 7 дней", + "last30Days": "Последние 30 дней", + "last90Days": "Последние 90 дней", + "customRange": "Свой период", + "from": "С", + "to": "По", + "timeslot": "Слот", + "fullDay": "Весь день", + "selectSeason": "Выберите сезон...", + "selectWeek": "Выберите неделю...", + "failedToLoadComparison": "Не удалось загрузить данные сравнения", + "pleaseRefresh": "Попробуйте обновить страницу", + "playerComparison": "Сравнение игроков", + "squadronComparison": "Сравнение полков", + "vehicleComparison": "Сравнение техники", + "playersVehiclesComparison": "Сравнение игроков + техника", + "addPlayersToCompare": "Добавьте игроков для сравнения:", + "addSquadronsToCompare": "Добавьте полки для сравнения:", + "addVehiclesToCompare": "Добавьте технику для сравнения:", + "addPlayerVehicleCombos": "Добавьте комбинации игрок + техника:", + "searchSelectPlayers": "Найдите и выберите игроков...", + "typeSquadronName": "Введите название полка...", + "searchSelectVehicles": "Найдите и выберите технику...", + "searchForPlayers": "Поиск игроков...", + "selectPlayersToCompare": "Найдите и выберите игроков выше для сравнения", + "selectSquadronsToCompare": "Найдите и выберите полки выше для сравнения", + "selectVehiclesToCompare": "Найдите и выберите технику выше для сравнения", + "selectPlayersVehiclesToCompare": "Найдите и выберите игроков, затем выберите технику для сравнения", + "selectVehicleFor": "Выберите технику для", + "selectAVehicle": "-- Выберите технику --", + "noVehiclesForPlayer": "Техника для этого игрока не найдена", + "noPlayerVehicleSelected": "Комбинации игрок-техника не выбраны", + "noPlayersSelected": "Игроки не выбраны", + "noVehiclesSelected": "Техника не выбрана", + "noSquadronsSelected": "Полки не выбраны", + "statistic": "Статистика", + "totalDeaths": "Всего гибелей", + "totalAssists": "Всего ассистов", + "totalCaptures": "Всего захватов", + "killsPerSpawnShort": "Уничтож./вылет", + "avgWinRateShort": "Средний % побед", + "avgKillsPlayerShort": "Средн. уничтож./игрок", + "avgKillsMember": "Средн. уничтож./участник", + "avgBattlesMember": "Средн. боёв/участник", + "serverError500": "Ошибка сервера (500) — API временно недоступен. Попробуйте через несколько секунд.", + "apiEndpoint404": "API-эндпоинт не найден (404) — Проверьте конфигурацию сервера.", + "networkError": "Ошибка сети — Не удаётся подключиться к серверу. Проверьте соединение.", + "viewFullLeaderboard": "Полный рейтинг", + "vehicleName": "Название техники", + "searchVehiclePlaceholder": "Поиск конкретной техники...", + "minKills": "Мин. уничтожений", + "any": "Любое", + "perPage": "На странице", + "clear": "Очистить", + "vehicleAndPlayer": "Техника и игрок", + "loadingVehicleLeaderboard": "Загрузка рейтинга техники...", + "vehiclesShown": "техники показано", + "minBattlesPerVehicle": "Минимум 3 боя на единицу техники", + "minimumBattlesRequired": "Минимум 5 боёв", + "allSeasons": "Все сезоны", + "allWeeks": "Все недели", + "allBR": "Все BR" + }, + "games": { + "title": "История матчей", + "subtitle": "Поиск и просмотр матчей полков", + "searchPlaceholder": "Поиск по имени игрока или UID...", + "filterByMap": "Фильтр по карте", + "allMaps": "Все карты", + "search": "Поиск", + "noResults": "Матчи не найдены", + "matchDetail": "Детали матча", + "chatLog": "Чат", + "battleLog": "Боевой журнал", + "duration": "Длительность", + "mode": "Режим", + "winningTeam": "Победившая команда", + "losingTeam": "Проигравшая команда", + "viewMatch": "Просмотр матча", + "loadingMatch": "Загрузка данных матча...", + "matchNotFound": "Матч не найден", + "searchingGames": "Поиск матчей...", + "recentMatches": "Последние матчи", + "noChatLog": "Чат недоступен", + "noBattleLog": "Боевой журнал недоступен", + "replayVideo": "Видео реплея", + "generatingVideo": "Генерация видео реплея…", + "videoFirstLoad": "Первая загрузка может занять до минуты", + "videoUnavailable": "Видео реплея недоступно для этого матча", + "modeGround": "Наземный", + "modeAir": "Воздушный", + "squadronPlaceholder": "Название полка...", + "loadingReplay": "Загрузка реплея..." + }, + "errors": { + "pageNotFound": "Страница не найдена", + "error": "Ошибка", + "oopsNotFound": "Упс! Страница, которую вы ищете, не существует. Возможно, она была перемещена, удалена, или вы ввели неверный URL.", + "searchError": "Ошибка поиска. Попробуйте снова." + }, + "js": { + "openingDiscordInvite": "Открываем приглашение Discord!", + "errorOpeningInvite": "Ошибка открытия ссылки. Попробуйте позже.", + "gettingSupportLink": "Получаем ссылку на сервер поддержки...", + "openingSupportServer": "Открываем сервер поддержки!", + "errorGettingSupport": "Ошибка получения ссылки. Попробуйте позже.", + "failedToUpdateStats": "Не удалось обновить статистику", + "konamiActivated": "Достижение разблокировано: секретный код!", + "noPlayersFound": "Игроки не найдены", + "searchError": "Ошибка поиска. Попробуйте снова.", + "killsSuffix": "уничтожений", + "winRateSuffix": "побед", + "noSquadronsFound": "Полки не найдены" + }, + "index": { + "subtitle1": "Лучший инструмент для полковых боёв", + "subtitle2": "Boris Stats, но лучше", + "subtitle3": "Крупнейший набор данных SQB", + "subtitle4": "Публичная и бесплатная информация для всех" + }, + "seasonCard": { + "buttonLabel": "Карточка сезона", + "buttonDisabledTitle": "Поиск полка неполный — карточка недоступна", + "modalTitle": "Карточка сезона", + "seasonLabel": "Сезон", + "themeLabel": "Тема", + "themeDark": "Тёмная", + "themeLight": "Светлая", + "generate": "Создать", + "loadingSeasons": "Загрузка сезонов…", + "generating": "Генерация…", + "failedSeasons": "Не удалось загрузить сезоны.", + "failedGenerate": "Не удалось создать карточку с итогами.", + "inProgressSuffix": "(в процессе)", + "imgRecapSuffix": "ИТОГИ", + "imgHeroFinalRating": "Итоговый рейтинг", + "imgHeroMatches": "Матчей", + "imgHeroWinRate": "% побед", + "imgHeroKD": "K/D", + "imgAxisRating": "Рейтинг", + "imgAxisWinRate": "% побед", + "imgStatPeakRating": "Пик рейтинга", + "imgStatRatingChange": "Изменение рейтинга", + "imgStatTotalKills": "Всего убийств", + "imgStatTotalDeaths": "Всего смертей", + "imgStatAssistsCaptures": "Помощь / захваты", + "imgStatMostPlayedVehicle": "Чаще всего", + "imgStatMVP": "MVP", + "imgStatMostActive": "Активнейший", + "imgStatLongestWinStreak": "Серия побед", + "imgStatMostCommonOpponent": "Частый соперник", + "imgUnitKills": "убийств", + "imgUnitAssists": "помощи", + "imgUnitCaptures": "захватов", + "imgUnitGames": "игр", + "imgUnitMatches": "матчей", + "imgUnitWins": "П", + "imgUnitLosses": "Пр", + "imgGroundShort": "З", + "imgAirShort": "В", + "imgFooterGenerated": "сгенерировано", + "imgPlaceholderNoData": "Нет данных для {short} в {season}", + "buttonLabelPlayer": "Итоги сезона", + "buttonDisabledTitlePlayer": "Player lookup incomplete — recap unavailable", + "modalTitlePlayer": "Player Season Recap", + "imgHeroBattles": "Боёв", + "imgHeroTotalKills": "Всего убийств", + "imgAxisBattles": "Боёв (7 дн)", + "imgAxisKD": "K/D", + "imgStatBestMatch": "Лучший матч", + "imgStatSquadronsRepresented": "Полки", + "imgStatFrequentTeammate": "Частый союзник", + "imgStatLongestSession": "Длиннейшая сессия", + "imgStatMostActiveDay": "Активнейший день (UTC)", + "imgStatMostCommonOppSquadron": "Частый вражеский полк", + "imgStatPeakSquadronRating": "Пик рейтинга полка", + "imgUnitSlotNA": "NA", + "imgUnitSlotEU": "EU", + "imgUnitNoSquadron": "без полка", + "imgUnitVs": "vs", + "imgUnitTogether": "совместных матчей", + "imgStatKDAC": "Уб / См / Пом / Зах", + "imgPlaceholderNoDataPlayer": "Нет данных для {nick} в {season}", + "imgUIDLabel": "UID", + "imgBestMatchLine": "{vehicle} · Нз {gk} / Вз {ak} / Пом {assists} / Зх {cap} / См {deaths} · {date}" + }, + "live": { + "air": "ВЗ", + "gnd": "НЗ", + "ast": "ПОМ", + "dth": "СМР", + "cap": "ЗХ", + "squadronBattle": "Бой полков", + "randomBattle": "Случайный бой" + }, + "analytics": { + "pageTitle": "Аналитика SQB", + "pageSubtitle": "Подробная аналитика по любому полку, игроку или технике.", + "modeSquadron": "Полк", + "modePlayer": "Игрок", + "modeVehicle": "Техника", + "tabMaps": "Винрейт по картам", + "tabSquadmates": "Частые напарники", + "tabComps": "Составы команд", + "tabConsistency": "Стабильность игрока", + "tabTime": "Время суток", + "tabMatchups": "История встреч", + "pickSquadron": "Найдите полк, чтобы увидеть его аналитику", + "pickPlayer": "Найдите игрока, чтобы увидеть его аналитику", + "pickVehicle": "Найдите технику, чтобы увидеть её аналитику", + "noData": "Нет данных за этот период.", + "loading": "Загрузка…", + "loadError": "Не удалось загрузить аналитику.", + "compComingSoon": "Анализ составов команд скоро.", + "compTopVehiclesTitle": "Часто используемые техники", + "compCompositionsTitle": "Повторяющиеся составы боёв", + "compCompositionsMeta": "Составы минимум из {min} единиц техники, сортировка по числу боёв", + "compMatchesAnalyzed": "проанализировано боёв", + "compNoRepeats": "В этом диапазоне состав такого размера не выводился.", + "compColVehicle": "Техника", + "compColSpawns": "Выезды", + "compColMatches": "Бои", + "compColShare": "% боёв", + "compColLineup": "Состав", + "compColTypes": "Комп", + "compTypeFighters": "Истребители", + "compTypeBombers": "Бомбардировщики", + "compTypeHelicopters": "Вертолёты", + "compTypeLight": "Лёгкий танк", + "compTypeTanks": "Танки", + "compTypeSPAA": "ЗСУ", + "compTypeSPAATooltip": "Зенитка/ЗСУ", + "compTypeUnknown": "Неизвестно", + "compSearchPresetLabel": "Готовый состав", + "compSearchPresetAll": "Все составы", + "compSearchPresetHint": "Построено по истории составов этого полка", + "compSearchTypesLabel": "Типы", + "compTypeCapsHint": "Макс 8 всего · макс 4 авиации (F + B + H)", + "compSearchRefineLabel": "Уточнить", + "compRefineHint": "Укажите число выше, чтобы выбрать конкретную технику этого типа.", + "compRefineAny": "Любая {type}", + "compSearchCustomLabel": "Свой", + "compSearchAddVehicle": "Конкретная техника", + "compSearchVehiclesLabel": "Техника", + "compSearchApply": "Применить", + "compSearchReset": "Сброс", + "compSearchMatches": "Показано {shown} из {total} составов", + "compSearchNoMatches": "Ни один состав не подходит под фильтр.", + "compSearchGamesShort": "боёв", + "colMap": "Карта", + "colWins": "П", + "colLosses": "П", + "colWinRate": "%П", + "colBar": "", + "colShared": "Совместно", + "colUid": "UID", + "colGames": "Игры", + "colPlayer": "Игрок", + "colAvgKills": "Ср. убийства", + "colAvgDeaths": "Ср. смерти", + "colScore": "Очки", + "colHour": "Час (UTC)", + "colSquadron": "Полк", + "colTotal": "Всего", + "matchupsWonHeader": "Чаще всего побеждали", + "matchupsLostHeader": "Чаще всего проигрывали", + "uniqueOpponents": "уникальных противников", + "euTimeslot": "EU слот", + "naTimeslot": "NA слот", + "offPeak": "Не пик", + "radarMetaMaps": "топ {shown} из {total} карт · мин. {min} боёв", + "radarMetaSquadmates": "топ {shown} из {total} напарников · мин. {min} совместных боёв", + "radarTooFewMaps": "Недостаточно данных для графика — в этом фильтре нужно минимум 3 карты с {min}+ боями.", + "radarTooFewSquadmates": "Недостаточно данных для графика — в этом фильтре нужно минимум 3 напарника с {min}+ совместными боями.", + "radarFootnoteMaps": "Скрыто {count} карт с меньшим числом боёв — см. полную таблицу", + "radarFootnoteSquadmates": "Скрыто {count} напарников с меньшим числом боёв — см. полную таблицу", + "tabTimeline": "Хронология K/D", + "tabTopPlayers": "Лучшие игроки", + "tabTopSquadrons": "Лучшие полки" + }, + "playerModal": { + "viewFullProfile": "Открыть полный профиль →", + "close": "Закрыть", + "overview": "Обзор", + "vehicles": "Техника", + "sessions": "Сессии", + "loadingPlayerData": "Загрузка данных игрока...", + "kdr": "K/D", + "kps": "K/S", + "winRate": "Процент побед", + "battles": "Бои", + "wins": "Победы", + "totalBattles": "Всего боёв", + "totalKills": "Всего уничтожений", + "airKills": "Воздушные фраги", + "groundKills": "Наземные фраги", + "assists": "Помощь", + "deaths": "Смерти", + "captures": "Захваты", + "clickToSwitchMetric": "Нажмите, чтобы сменить метрику", + "clickToCycle": "нажмите для переключения", + "noChartData": "Нет данных графика", + "noVehicleData": "Нет данных по технике", + "noSessionData": "Нет данных сессий", + "date": "Дата", + "vehicle": "Техника", + "ground": "Земля", + "air": "Воздух", + "result": "Результат", + "unknown": "Неизвестно", + "failedToLoadPlayerData": "Не удалось загрузить данные игрока" + }, + "replay": { + "playPause": "Пуск/Пауза", + "crashed": "разбился", + "destroyed": "уничтожил", + "hit": "попал" + }, + "dateFilter": { + "allTime": "Всё время", + "currentSeason": "Текущий сезон", + "bySeason": "По сезону", + "cumulative": "Накопительно", + "customRange": "Свой диапазон", + "selectSeason": "Выбрать сезон", + "selectSeasonDots": "Выберите сезон...", + "selectWeek": "Выбрать неделю", + "selectWeekDots": "Выберите неделю...", + "entireSeason": "Весь сезон", + "applyFilter": "Применить фильтр", + "cumulativeHelp": "Показать статистику, накопленную к выбранному моменту", + "season": "Сезон", + "upToWeek": "До недели", + "applyCumulativeFilter": "Применить накопительный фильтр", + "startDate": "Дата начала", + "endDate": "Дата окончания", + "applyCustomRange": "Применить свой диапазон", + "activeFilter": "Активный фильтр:", + "clear": "Очистить", + "allTimeStatistics": "Статистика за всё время", + "currentSeasonValue": "Текущий сезон: {season}", + "alertSelectSeason": "Выберите сезон", + "seasonValue": "Сезон {season}", + "alertSelectSeasonWeek": "Выберите сезон и неделю", + "cumulativeValue": "Накопительно до {season} - {week}", + "alertSelectDate": "Выберите хотя бы одну дату", + "alertStartBeforeEnd": "Дата начала должна быть раньше даты окончания", + "customRangePrefix": "Свой диапазон:", + "fromDate": "С {date}", + "upToDate": "До {date}" + } +} diff --git a/web/locales/uk.json b/web/locales/uk.json new file mode 100644 index 0000000..ccebbbd --- /dev/null +++ b/web/locales/uk.json @@ -0,0 +1,906 @@ +{ + "nav": { + "home": "Головна", + "live": "Наживо", + "leaderboards": "Рейтинг", + "docs": "Документація", + "terms": "Умови", + "premium": "Преміум", + "support": "Підтримка", + "addToDiscord": "Додати до Discord", + "games": "Матчі", + "squadrons": "Ескадрильї", + "donate": "Підтримати", + "analytics": "Аналітика" + }, + "footer": { + "services": "Послуги", + "matchFeed": "Стрічка матчів", + "vehicleStats": "Статистика техніки", + "analytics": "Аналітика", + "squadronHub": "Центр ескадрилій", + "comparison": "Порівняння", + "resources": "Ресурси", + "documentation": "Документація", + "inviteBot": "Запросити бота", + "legal": "Правова інформація", + "termsOfService": "Умови використання", + "privacyPolicy": "Політика конфіденційності", + "termsAndPrivacy": "Умови та конфіденційність", + "meowing": "Нявкання", + "websiteBy": "Сайт від", + "andToothless": "та Toothless" + }, + "common": { + "loading": "Завантаження...", + "retry": "Повторити", + "backToHome": "На головну", + "battles": "Бої", + "wins": "Перемоги", + "winRate": "Відсоток перемог", + "kills": "Знищення", + "totalKills": "Всього знищень", + "groundKills": "Знищення наземних", + "airKills": "Знищення повітряних", + "assists": "Допомога", + "deaths": "Загибелі", + "captures": "Захоплення", + "kdr": "KDR", + "kps": "KPS", + "rank": "Місце", + "player": "Гравець", + "players": "Гравці", + "playersCount": "гравців", + "vehicle": "Техніка", + "vehicles": "Техніка", + "squadron": "Ескадрилья", + "squadrons": "Ескадрильї", + "statistics": "Статистика", + "comparison": "Порівняння", + "date": "Дата", + "result": "Результат", + "totalBattles": "Всього боїв", + "totalWins": "Всього перемог", + "points": "Очки", + "members": "Учасники", + "membersCount": "учасників", + "rating": "Рейтинг", + "searchPlayerByName": "Пошук гравця за іменем...", + "noPlayersFound": "Гравців не знайдено", + "noSquadronsFound": "Ескадрилей не знайдено", + "noVehiclesFound": "Техніки не знайдено", + "failedToLoad": "Не вдалося завантажити дані. Спробуйте пізніше.", + "recordingSince": "Запис даних з 01/01/2026", + "vs": "ПРО", + "map": "Карта" + }, + "home": { + "squadronBattles": "Бої ескадрилій", + "madeSimple": "Просто та зручно", + "addToDiscord": "Додати до Discord", + "learnMore": "Дізнатися більше", + "searchBySquadron": "ПОШУК ЗА ЕСКАДРИЛЬЄЮ", + "typeSquadronName": "Введіть назву ескадрильї...", + "orByPlayer": "АБО ЗА ГРАВЦЕМ", + "typePlayerName": "Введіть ім'я гравця...", + "liveFeed": "Стрічка матчів", + "realTimeMatches": "Знайдіть свої матчі", + "topPlayers": "Топ гравців", + "vehicleStatsCard": "Статистика техніки", + "performanceMetrics": "Показники ефективності", + "analyticsCard": "Аналітика", + "globalStatistics": "Глобальна статистика", + "squadronHubCard": "Центр ескадрилій", + "squadronStats": "Статистика ескадрильї", + "comparisonCard": "Порівняння", + "compareStats": "Порівняти статистику", + "joinServers": "Приєднайтесь до 500+ серверів, що використовують нашого бота для відстеження результатів", + "noSquadronsFound": "Ескадрилей не знайдено", + "noPlayersFound": "Гравців не знайдено", + "searchPlayersIn": "Пошук гравців у", + "ctaElev8": "Готові підняти свою ескадрилью на новий рівень?", + "ctaReign": "Готові знову панувати?", + "ctaMeow": "Мяу", + "ctaPurr": "Муррр", + "ctaRawr": "Гарррр" + }, + "docs": { + "title": "Документація", + "subtitle": "Все, що вам потрібно знати про", + "quickNavigation": "Швидка навігація", + "gettingStarted": "Початок роботи", + "commands": "Команди", + "serverSetup": "Налаштування сервера", + "features": "Можливості", + "examples": "Приклади", + "troubleshooting": "Вирішення проблем", + "stackManager": "Менеджер стеку", + "welcomeMessage": "Виконайте ці кроки, щоб розпочати.", + "inviteTheBot": "Запросіть бота", + "inviteBotDesc": "Натисніть кнопку «Додати на сервер» та виберіть Discord-сервер вашої ескадрильї. Бот надішле привітальне повідомлення з підказкою запустити /setup.", + "runSetupWizard": "Запустіть майстер налаштування", + "setupWizardDesc": "Майстер налаштування проведе вас через конфігурацію ескадрильї, каналу журналів та каналу очок за один процес:", + "setupEasiest": "Це найпростіший спосіб розпочати. Він крок за кроком проведе вас через налаштування ескадрильї та вибір каналів.", + "youreDone": "Готово!", + "doneDesc": "Бот почне автоматично публікувати очки та оновлення рейтингу. Використовуйте /autolog-management для зміни налаштувань сповіщень пізніше.", + "premiumNote": "Автоматичні журнали ігор (повні таблиці результатів після кожного матчу) вимагають підписки Premium. Запустіть /unlock для оформлення підписки — $2.99/місяць за сервер, оплата через Discord.", + "manualSetup": "Ручне налаштування (альтернатива)", + "manualSetupDesc": "Якщо ви надаєте перевагу індивідуальному налаштуванню, ви можете використовувати ці команди:", + "allCommandsSlash": "Усі команди використовують систему слеш-команд Discord. Введіть / для перегляду доступних команд.", + "serverSetupAdmin": "Налаштування сервера та адміністрування", + "importantNote": "Важлива примітка", + "verifyFirst": "Завжди перевіряйте спочатку! Команда /sq-info підтверджує, що бот може знайти вашу ескадрилью в базі даних War Thunder, навіть якщо ваш рейтинг дуже низький.", + "cantFindSquadron": "Якщо бот не може знайти вашу ескадрилью за допомогою /sq-info, команди налаштування не працюватимуть належним чином.", + "botNotResponding": "Бот не відповідає", + "checkOnline": "Перевірте, чи бот онлайн (зелений статус)", + "verifyPermissions": "Перевірте, чи бот має необхідні дозволи", + "tryDifferentChannel": "Спробуйте використати команди в іншому каналі", + "commandsNotWorking": "Команди не працюють", + "ensureSlash": "Переконайтеся, що ви використовуєте слеш-команди (починаються з /)", + "checkRolePerms": "Перевірте, чи має ваша роль дозвіл на використання команд бота", + "tryRefreshing": "Спробуйте оновити Discord або перезапустити застосунок", + "dataNotSaving": "Дані не зберігаються", + "verifySendMessages": "Перевірте, чи є у бота дозвіл «Надсилати повідомлення»", + "checkOutages": "Перевірте, чи немає збоїв Discord", + "contactSupport": "Зверніться до підтримки, якщо проблема не зникає", + "needMoreHelp": "Потрібна додаткова допомога?", + "needMoreHelpDesc": "Якщо вам потрібна додаткова підтримка, звертайтесь через наші канали підтримки.", + "example": "Приклад", + "supportedLanguages": "Підтримувані мови", + "setupDesc": "Покроковий майстер для налаштування бота на вашому сервері. Встановлює ескадрилью, канал журналів та канал очок за один процес.", + "recommendedForNew": "Рекомендовано для нових серверів.", + "setSquadronDesc": "Зберегти ескадрилью за замовчуванням для вашого Discord-сервера. Використовується для журналювання та як значення за замовчуванням для інших команд.", + "quickLogDesc": "Встановити сигналізацію для ескадрильї в поточному каналі. Тип може бути Logs, Points, Leaderboard або Both — Both встановлює Logs і Points разом однією командою. За замовчуванням — Logs.", + "quickLogPremiumNote": "Logs (автоматичні таблиці результатів ігор) вимагають підписки Premium. Сповіщення про очки та рейтинг безкоштовні.", + "autologDesc": "Керуйте сповіщеннями autolog та діагностуйте дозволи каналу. Використовуйте для зміни налаштувань після початкового налаштування.", + "autologPremiumNote": "Автоматичні журнали ігор вимагають підписки Premium.", + "diagnosePermsDesc": "Миттєво перевіряє, чи є у бота необхідні дозволи в поточному каналі, показує налаштовані канали autolog та статус підписки Premium для цього сервера. Використовуйте, якщо таблиці результатів або очки не публікуються.", + "squadronInformation": "Інформація про ескадрилью", + "sqInfoDesc": "Переглянути детальну інформацію про будь-яку ескадрилью. Використовує ескадрилью сервера за замовчуванням, якщо інша не вказана.", + "sqInfoGraphDesc": "Візуалізує поточний склад ескадрильї у вигляді стовпчастої діаграми, розділеної на групи кістяк, активні та слабкі за активністю та відсотком перемог (поточний сезон).", + "compDesc": "Знайти останні відомі склади для боїв заданої ескадрильї. Безкоштовні сервери отримують 25 запитів за таймслот; Premium — без обмежень.", + "trackDesc": "Відстежити ескадрилью та порівняти статистику з останньою перевіркою.", + "topDesc": "Відобразити топ-20 ескадрилій та їх поточну статистику.", + "sqStatsDesc": "Відобразити очки ескадрильї у динаміці у вигляді інтерактивного графіка.", + "lossCalculatorDesc": "Розрахувати, скільки очок втратить ескадрилья, якщо вибрані гравці покинуть її.", + "recentDesc": "Показати останні 5 боїв ескадрильї.", + "vsDesc": "Переглянути статистику протистояння з іншою ескадрильєю.", + "leaderboardLinkDesc": "Отримати посилання на глобальний рейтинг гравців SRE Bot.", + "playerStats": "Статистика гравця", + "playerStatsDesc": "Переглянути детальну статистику техніки для гравця з інтерактивним випадаючим меню техніки. Підтримує автодоповнення.", + "viewPlayerGamesDesc": "Переглянути останні 20 ігор гравця. Показує загальний рахунок перемог/поразок та відсоток перемог, зведення по кожній грі (результат, ескадрилья-суперник, карта та склад), а також дедублікований список всіх унікальних складів гравця. Підтримує автодоповнення.", + "viewMatchDesc": "Переглянути повну таблицю результатів для конкретного матчу. Вкажіть ID матчу безпосередньо або здійсніть пошук за ім'ям гравця, щоб переглянути його останні 100 ігор та вибрати одну. Включає кнопки: Переглянути повтор, Переглянути маршрути, Журнал чату та Журнал бою.", + "examples2": "Приклади", + "compareDesc": "Порівняти зведену статистику SQB між двома або більше гравцями (до 7). Показує порівняння поряд із виділенням найкращих показників. Включає кнопку графіка для перегляду історії очок за 90 днів.", + "metaData": "Мета-дані", + "metaManagementDesc": "Налаштувати параметри доступу до мета-даних для вашого сервера.", + "metaDesc": "Здійснити пошук у мета-реєстрі вашої ескадрильї за назвою техніки.", + "settingsUtilities": "Налаштування та утиліти", + "languageDesc": "Змінити мову бота за замовчуванням. Також впливає на мову відображення техніки у журналах боїв.", + "scheduleDesc": "Переглянути розклад BR поточного сезону. Показує максимальний бойовий рейтинг кожного тижня з діапазоном дат, закреслення для минулих тижнів та виділення поточного активного періоду.", + "websiteDesc": "Отримати посилання на сайт SRE Bot для пошуку гравців, рейтингів тощо.", + "creditsDesc": "Переглянути команду, яка створила цього бота.", + "unlockDesc": "Розблокувати автоматичні журнали ігор SQB та необмежені запити /comp для цього сервера. Підписка додає повні таблиці результатів, що автоматично публікуються у налаштованому каналі після кожного матчу. $2.99 / місяць · за сервер · скасування будь-коли. Оплата здійснюється повністю через Discord — зовнішній обліковий запис не потрібен.", + "analyticsDesc": "Розширена аналітика SQB: відсоток перемог на картах, склади команд, стабільність гравців, ефективність у різний час доби та історія протистоянь (найбільше перемог і поразок проти суперників).", + "sqCardDesc": "Згенерувати PNG-картку сезонного підсумку для ескадрильї — динаміка рейтингу, відсоток перемог, найкращі гравці тощо. Сезон обирається з автодоповнення. Підтримує темну та світлу теми.", + "cardDesc": "Згенерувати PNG-картку сезонного підсумку для гравця. Сезон обирається з автодоповнення, гравець — за ім'ям користувача. Підтримує темну та світлу теми.", + "queryDesc": "[Лише адміністратор] Виконати попередньо визначені запити до бази даних — статистика ескадрильї, кількість ігор, найактивніші гравці, топ карт та інше. Результати ефемерні (видно лише вам).", + "donateDesc": "Підтримати розробку SRE Bot через Ko-fi.", + "botStatusDesc": "Показує, коли було отримано останню гру, і середній TTL за останніми іграми. Сигналізує про повільні сервери Gaijin.", + "premiumBadge": "Premium", + "newsDesc": "Переглянути останні новини та оголошення SRE Bot.", + "stackCreateDesc": "Створити стек гравців для координації загону перед матчем. У каналі публікується постійний embed із поточними учасниками та очікуючими заявниками. Стек діє до 8 годин і автоматично видаляється після закінчення кожного часового слоту SQB.", + "stackRequestToJoin": "Подати заявку на вступ — Будь-який гравець може подати заявку з технікою, на якій планує грати. У черзі до 20 місць.", + "stackLeaveWithdraw": "Вийти / Відкликати заявку — Учасники можуть покинути стек; заявники можуть відкликати свою заявку. Лідеру стеку буде запропоновано спочатку передати керівництво.", + "stackManagePanel": "Керувати стеком ⚙️ — Панель тільки для лідера з чотирма розділами:", + "stackAcceptMembers": "Прийняти учасників — Прийняти або відхилити заявників по одному або всіх одразу. Всього до 8 учасників.", + "stackRemoveMembers": "Видалити учасників — Видалити активних учасників або учасників у черзі. Параметри: видалити всіх, видалити активних, видалити тих, хто у черзі, або вибрати зі списку.", + "stackPingMembers": "Тегнути учасників — Тегнути з необов'язковим власним повідомленням. Параметри: тегнути всіх (учасники + черга, без лідера), тегнути активних, тегнути тих, хто у черзі, або вибрати зі списку.", + "stackRenameStack": "Перейменувати стек — Встановити власну назву для стеку. Відображається як заголовок embed та у повідомленнях сповіщень замість стандартного «Стек [Лідер]».", + "stackDisbandStack": "Розпустити стек — Лідер може достроково завершити стек.", + "stackManageDesc": "Повторно публікує активний embed стеку в поточному каналі. Використовуйте, якщо оригінальний embed було видалено або втрачено після перезапуску бота. Усі існуючі учасники та дані черги зберігаються.", + "translation": "Переклад", + "translateContextMenu": "Клацніть правою кнопкою на повідомлення → Застосунки → Перекласти повідомлення", + "translateDesc": "Перекладайте будь-яке повідомлення за допомогою контекстного меню Discord. Клацніть правою кнопкою миші (або утримуйте на мобільному) повідомлення та виберіть Застосунки → Перекласти повідомлення.", + "viewAllLanguages": "Переглянути всі підтримувані мови", + "serverSetupSubtitle": "Налаштуйте {botName} для оптимальної роботи на Discord-сервері вашої ескадрильї.", + "requiredPermissions": "Необхідні дозволи", + "sendMessages": "Надсилати повідомлення", + "useSlashCommands": "Використовувати слеш-команди", + "embedLinks": "Вбудовувати посилання", + "readMessageHistory": "Читати історію повідомлень", + "recommendedChannelSetup": "Рекомендоване налаштування каналу", + "recommendedChannelDesc": "Створіть окремий канал на зразок #squadron-battles для відстеження та статистики. Це дозволяє зберігати дані боїв організовано та у зручному доступі.", + "roleConfiguration": "Налаштування ролей", + "roleConfigurationDesc": "Призначте відповідні ролі членам ескадрильї, які можуть записувати результати боїв. Рекомендуємо обмежити це офіцерами та лідерами ескадрильї.", + "premiumSectionSubtitle": "Автоматичні журнали ігор SQB є функцією Premium, яка розблоковується для кожного сервера через нативну систему підписок Discord.", + "whatsIncluded": "Що входить", + "premiumInclude1": "Повна таблиця результатів автоматично публікується у налаштованому каналі після кожного матчу SQB", + "premiumInclude2": "Необмежені запити /comp (безкоштовні сервери отримують 25 за таймслот)", + "premiumInclude3": "Всі існуючі безкоштовні функції (сповіщення про очки, рейтинг, команди статистики тощо) залишаються безкоштовними", + "pricingBilling": "Ціни та оплата", + "pricingBillingDesc": "$2.99 / місяць · за сервер · скасування будь-коли. Оплата здійснюється повністю через Discord — зовнішній обліковий запис або платіжна система не потрібні. Підписки автоматично поновлюються та можуть бути скасовані будь-коли в налаштуваннях Discord.", + "howToSubscribe": "Як підписатися", + "subscribe1": "Запустіть /unlock на своєму сервері (потрібні права адміністратора)", + "subscribe2": "Натисніть кнопку Підписатися у відповіді бота", + "subscribe3": "Завершіть оформлення замовлення всередині Discord — бот активується негайно", + "cancellation": "Скасування", + "cancellationDesc": "Коли підписка закінчується або скасовується, бот автоматично припиняє публікацію журналів ігор для цього сервера в наступному циклі autolog. Ручних дій не потрібно.", + "realTimeStatistics": "Статистика в реальному часі", + "realTimeStatisticsDesc": "Відстежуйте перемоги, поразки та показники ефективності в реальному часі.", + "battleHistory": "Історія боїв", + "battleHistoryDesc": "Повна історія всіх записаних боїв ескадрилій.", + "leaderboardsFeature": "Рейтинги", + "leaderboardsFeatureDesc": "Порівнюйте результати своєї ескадрильї з іншими за допомогою /top.", + "playerTracking": "Відстеження гравців", + "playerTrackingDesc": "Індивідуальна статистика гравців та інтеграція з ThunderSkill.", + "smartAlerts": "Розумні сповіщення", + "smartAlertsDesc": "Автоматизовані сигналізації та сповіщення про активність ескадрильї.", + "multiLanguageSupport": "Підтримка кількох мов", + "multiLanguageSupportDesc": "Функції перекладу та налаштовувані мови техніки.", + "timeCoordination": "Координація часу", + "timeCoordinationDesc": "Інструменти UTC та місцевого часу для глобальної координації ескадрильї.", + "advancedSearch": "Розширений пошук", + "advancedSearchDesc": "Знаходьте ігрові склади та детальну інформацію про ескадрильї.", + "dataSecurity": "Безпека даних", + "dataSecurityDesc": "Ваші дані захищені та ніколи не передаються і не продаються.", + "usageExamples": "Приклади використання", + "quickSetupRecommended": "Швидке налаштування (рекомендовано)", + "quickSetupDesc": "Запустіть майстер налаштування. Він проведе вас через встановлення ескадрильї, вибір каналу журналів та каналу очок — все в одному процесі.", + "comparingPlayers": "Порівняння гравців", + "comparingPlayersDesc": "Порівнюйте до 7 гравців поряд. Найкращий показник у кожній категорії виділяється, а загально найкращий гравець отримує зірку. Натисніть «Показати графік», щоб переглянути їх історію очок.", + "checkingSquadronInfo": "Перевірка інформації про ескадрилью", + "verifySquadronDesc": "Перевірте, чи існує ескадрилья, та перегляньте її дані. Використовуйте це, щоб переконатися, що бот може знайти вашу ескадрилью перед налаштуванням.", + "recentBattlesDesc": "Переглянути 5 останніх боїв ескадрильї.", + "headToHeadDesc": "Переглянути статистику протистояння з іншою ескадрильєю.", + "backToHome": "На головну", + "termsAndPrivacy": "Умови та конфіденційність", + "seasonRecapCardTitle": "Підсумкова картка сезону", + "seasonRecapCardDesc": "Створіть PNG-підсумок сезону будь-якого ескадрону — графік рейтингу, ковзний відсоток перемог, K/D, улюблена техніка, MVP тощо. Доступно через кнопку «Картка сезону» на сторінці профілю кожного ескадрону.", + "playerRecapCardTitle": "Підсумки сезону гравця", + "playerRecapCardDesc": "Створюйте PNG-зведення сезону будь-якого гравця — динаміка рейтингу між полками, ковзний вінрейт, K/D, темп боїв, найкращий матч і багато іншого. Доступно з кнопки «Season Recap» на сторінці профілю гравця.", + "tierOverview": "Огляд тарифів", + "tierOverviewDesc": "Автолог доступний на трьох тарифах. Кожен тариф обмежує, скільки полків можуть мати Logs і Points. Полки понад ліміт залишаються в налаштуваннях і автоматично відновлюються після апгрейду.", + "tierStandardLine": "$2.99 — до 10 полків для Logs і Points, без wildcard.", + "tierProLine": "до 25 полків для Logs і Points, плюс підтримка wildcard (`*` / `all` / `everything`).", + "tierMaxLine": "без ліміту, wildcards, ранній доступ до нових функцій." + }, + "terms": { + "pageTitle": "Умови використання та Політика конфіденційності", + "lastUpdated": "Останнє оновлення: квітень 2026", + "termsOfService": "Умови використання", + "byUsing": "Використовуючи", + "youAgree": ", ви погоджуєтесь з наступним:", + "useResponsibly": "Використовуйте відповідально", + "useResponsiblyDesc": "Не намагайтеся перевантажувати, спамити або іншим чином порушувати роботу бота.", + "noFunnyBusiness": "Ніяких порушень", + "noFunnyBusinessDesc": "Не намагайтеся здійснювати зворотній інжиніринг, порушувати Умови використання Discord або будь-які інші правила спільноти", + "statsAsIs": "Статистика як є", + "statsAsIsDesc": "Усі дані та статистика надаються «як є» без будь-яких гарантій, явних чи неявних. Ми докладаємо розумних зусиль для забезпечення точності, але не гарантуємо, що інформація є повною, актуальною або безпомилковою.", + "uptimeNotGuaranteed": "Безперервна робота не гарантується", + "uptimeNotGuaranteedDesc": "Бот надається на умовах «за наявності». Ми не гарантуємо безперебійну або безпомилкову роботу. Сервіс може бути тимчасово недоступний через обслуговування, оновлення або технічні проблеми.", + "weCanBanYou": "Ми можемо заблокувати вас", + "weCanBanYouDesc": "Якщо ви порушуєте правила, ми можемо позбавити вас доступу.", + "privacyPolicy": "Політика конфіденційності", + "infoWeCollect": "1. Інформація, яку ми збираємо", + "collectsFollowing": "збирає наступну інформацію для надання своїх послуг:", + "discordUserIds": "Ідентифікатори користувачів Discord:", + "discordUserIdsDesc": "Унікальні ідентифікатори для відстеження окремих користувачів", + "squadronIds": "Ідентифікатори ескадрилій:", + "squadronIdsDesc": "Ідентифікатори Discord-серверів/гільдій для організації даних ескадрилій", + "battleData": "Дані боїв:", + "battleDataDesc": "Записи перемог/поразок, мітки часу боїв та пов'язана статистика", + "commandUsage": "Використання команд:", + "commandUsageDesc": "Базове журналювання використаних команд бота для вдосконалення сервісу", + "howWeUse": "2. Як ми використовуємо вашу інформацію", + "usedExclusively": "Зібрана інформація використовується виключно для:", + "trackingPerformance": "Відстеження ефективності та статистики боїв ескадрилій", + "providingHistorical": "Надання історичних даних та аналітики", + "improvingBot": "Покращення функціональності бота та користувацького досвіду", + "troubleshootingIssues": "Усунення технічних проблем", + "dataStorage": "3. Зберігання та захист даних", + "dataStoredSecurely": "Ваші дані зберігаються надійно з такими засобами захисту:", + "encryptedServers": "Дані зберігаються на захищених серверах із шифруванням", + "limitedAccess": "Доступ обмежений лише уповноваженим персоналом", + "regularBackups": "Регулярні резервні копії забезпечують цілісність даних", + "dataSharing": "4. Передача даних", + "weDoNot": "Ми НЕ:", + "sellData": "Продаємо вашу особисту інформацію третім особам", + "shareData": "Передаємо ваші дані зовнішнім організаціям", + "useForAds": "Використовуємо ваші дані для реклами або маркетингу", + "transferData": "Переміщуємо ваші дані за межі наших захищених систем", + "dataRetention": "5. Зберігання даних", + "dataRetentionDesc": "Ми зберігаємо ваші дані стільки, скільки необхідно для надання наших послуг. Статистика боїв та дані ескадрилій зберігаються для підтримки історичних записів та аналітики.", + "discordIntegration": "6. Інтеграція з Discord", + "discordIntegrationDesc": "Цей бот працює в екосистемі Discord та підпадає під дію Політики конфіденційності Discord. Ми отримуємо доступ лише до інформації, необхідної для функціонування бота, через офіційний API Discord.", + "ageRestrictions": "7. Вікові обмеження", + "ageRestrictionsDesc": "Бот призначений для користувачів, які відповідають мінімальним віковим вимогам Discord (13+ або відповідно до місцевого законодавства).", + "changesToPolicy": "8. Зміни до Політики конфіденційності", + "changesToPolicyDesc": "Ми можемо час від часу оновлювати цю Політику конфіденційності.", + "premiumTitle": "Умови підписки Premium", + "premiumWhatYouGet": "1. Що включає Premium", + "premiumWhatYouGetDesc": "Підписка Premium розблоковує наступні функції для підписаного Discord-сервера:", + "premiumFeature1": "Автоматична публікація таблиць результатів після кожного матчу SQB", + "premiumFeature2": "Карти маршрутів / переміщень", + "premiumFeature3": "Логи чату та боїв", + "premiumFeature4": "Пошук реплеїв", + "premiumFeature5": "Необмежені запити /comp (безкоштовні сервери обмежені до 15 за часовий слот)", + "premiumBilling": "2. Виставлення рахунків та оплата", + "premiumBillingDesc": "Premium тарифікується за $2,99 USD на місяць за Discord-сервер. Оплата обробляється через вбудовану систему підписок Discord або через наш вебсайт за допомогою Whop. Ви несете відповідальність за те, щоб ваш спосіб оплати був дійсним та мав достатньо коштів. Підписки автоматично поновлюються в кінці кожного розрахункового періоду, якщо не були скасовані.", + "premiumCancellation": "3. Скасування", + "premiumCancellationDesc": "Ви можете скасувати підписку в будь-який час. Для підписок Discord перейдіть до Налаштування користувача → Підписки в Discord. Для підписок через вебсайт керуйте виставленням рахунків на whop.com/billing. Після скасування ваші функції Premium залишаються активними до кінця поточного розрахункового періоду. Після цього ваш сервер повертається на безкоштовний рівень — дані не втрачаються.", + "premiumRefunds": "4. Повернення коштів", + "premiumRefundsDesc": "Абонентська плата не підлягає поверненню. Часткове використання місяця не перераховується пропорційно. Якщо ви скасуєте підписку посеред циклу, ви зберігаєте доступ до кінця цього розрахункового періоду, але не маєте права на повернення коштів за час, що залишився. Повернення за помилки у виставленні рахунків або дублюючі списання можуть бути здійснені на наш розсуд — зверніться до служби підтримки з ID вашого Discord-сервера та підтвердженням оплати.", + "premiumPriceChanges": "5. Зміна цін", + "premiumPriceChangesDesc": "Ми залишаємо за собою право змінювати ціни підписки в будь-який час. Діючі підписники будуть повідомлені не менш ніж за 30 днів до набрання чинності будь-якого підвищення цін. Якщо ви не згодні зі зміною ціни, ви можете скасувати підписку до набрання чинності нової ціни.", + "premiumTermination": "6. Припинення доступу Premium", + "premiumTerminationDesc": "Ми залишаємо за собою право відкликати доступ Premium без повернення коштів, якщо сервер порушує ці Умови використання, включаючи, але не обмежуючись, зловживання функціями бота, спроби обійти обмеження використання або порушення Умов використання Discord.", + "serviceAvailabilityTitle": "Доступність сервісу та відповідальність", + "serviceNoWarranty": "1. Відсутність гарантій", + "serviceNoWarrantyDesc": "СЕРВІС НАДАЄТЬСЯ «ЯК Є» ТА «ЗА НАЯВНОСТІ» БЕЗ БУДЬ-ЯКИХ ГАРАНТІЙ, ЯВНИХ, НЕЯВНИХ АБО ПЕРЕДБАЧЕНИХ ЗАКОНОМ, ВКЛЮЧАЮЧИ, АЛЕ НЕ ОБМЕЖУЮЧИСЬ, НЕЯВНІ ГАРАНТІЇ ПРИДАТНОСТІ ДЛЯ ПРОДАЖУ, ПРИДАТНОСТІ ДЛЯ ПЕВНОЇ МЕТИ ТА НЕПОРУШЕННЯ ПРАВ. Ми не гарантуємо, що сервіс буде безперебійним, своєчасним, безпечним або безпомилковим.", + "serviceLiability": "2. Обмеження відповідальності", + "serviceLiabilityDesc": "В МАКСИМАЛЬНОМУ ОБСЯЗІ, ДОЗВОЛЕНОМУ ЧИННИМ ЗАКОНОДАВСТВОМ, МИ ЗА ЖОДНИХ ОБСТАВИН НЕ НЕСЕМО ВІДПОВІДАЛЬНОСТІ ЗА БУДЬ-ЯКІ НЕПРЯМІ, ВИПАДКОВІ, СПЕЦІАЛЬНІ, НАСЛІДКОВІ АБО ШТРАФНІ ЗБИТКИ, АБО ЗА БУДЬ-ЯКУ ВТРАТУ ПРИБУТКУ ЧИ ДОХОДУ, ПОНЕСЕНУ ПРЯМО ЧИ НЕПРЯМО, АБО ЗА БУДЬ-ЯКУ ВТРАТУ ДАНИХ, МОЖЛИВОСТІ ВИКОРИСТАННЯ, ДІЛОВОЇ РЕПУТАЦІЇ ЧИ ІНШИХ НЕМАТЕРІАЛЬНИХ ВТРАТ, ЩО ВИНИКЛИ ВНАСЛІДОК ВИКОРИСТАННЯ АБО НЕМОЖЛИВОСТІ ВИКОРИСТАННЯ СЕРВІСУ. Наша загальна відповідальність за будь-яку претензію, що випливає з цих умов або сервісу, не перевищує суму, яку ви сплатили нам за три (3) місяці, що передують претензії.", + "serviceCredits": "3. Перебої в роботі сервісу та кредити", + "serviceCreditsDesc": "У разі тривалого незапланованого перебою в роботі сервісу, що впливає на функції Premium протягом більш ніж 72 послідовних годин, постраждалі підписники Premium можуть запросити сервісний кредит, звернувшись до служби підтримки. Кредити видаються виключно на наш розсуд і застосовуються як подовження поточного розрахункового періоду — а не як грошове повернення. Планові технічні роботи, збої сторонніх сервісів (Discord, Gaijin API) та події, що знаходяться поза нашим розумним контролем, виключаються.", + "serviceForceM": "4. Форс-мажор", + "serviceForceMDesc": "Ми не несемо відповідальності за будь-які збої або затримки у виконанні, спричинені причинами, що знаходяться поза нашим розумним контролем, включаючи, але не обмежуючись: стихійні лиха, війни, тероризм, пандемії, перебої в електропостачанні, порушення роботи інтернету, збої платформи Discord, зміни або недоступність API Gaijin Entertainment, дії державних органів або будь-які інші обставини непереборної сили.", + "disclaimer": "Відмова від відповідальності", + "warThunderDisclaimer": "є незалежним Discord-ботом і не пов'язаний, не схвалений та не асоційований із Gaijin Entertainment або War Thunder. War Thunder є торговою маркою Gaijin Entertainment.", + "acknowledgement": "Використовуючи", + "acknowledgementEnd": ", ви підтверджуєте, що ознайомились, зрозуміли та погоджуєтесь дотримуватися цих Умов використання та Політики конфіденційності." + }, + "premium": { + "upgradeTitle": "Покращте свою ескадрилью", + "heroDesc": "Кожен результат SQB публікується у вашому каналі в момент завершення — таблиці результатів, журнали боїв, карти переміщень та повтори, повністю автоматично.", + "instantScoreboards": "Миттєві таблиці результатів", + "viewPaths": "Перегляд маршрутів", + "chatBattleLogs": "Журнали чату та бою", + "replayLookups": "Пошук повторів", + "free": "Безкоштовно", + "perMonth": "/міс", + "alwaysFree": "Завжди безкоштовно", + "noCardNeeded": "картка не потрібна", + "included": "Включено", + "manualLookups": "Ручний пошук ігор", + "playerStats": "Статистика та профілі гравців", + "leaderboards": "Рейтинги", + "stickWithFree": "Залишитися на безкоштовному", + "premiumLabel": "Premium", + "perServer": "за сервер", + "cancelAnytime": "скасування будь-коли", + "everythingInFree": "Все з безкоштовного, плюс", + "autoScoreboards": "Автоматична публікація таблиць", + "pathMaps": "Карти маршрутів / переміщень", + "chatLogs": "Журнали чату та бою", + "replayLookupsFeature": "Пошук повторів", + "unlimitedComp": "Необмежені запити /comp", + "prioritySupport": "Пріоритетна підтримка", + "subscribeNow": "Підписатися зараз", + "comingSoon": "Незабаром", + "serverIdInfo": "Вам знадобиться ваш", + "discordServerId": "ID сервера Discord", + "duringCheckout": "під час оформлення замовлення.", + "developerMode": "Режим розробника", + "rightClickServer": "Клацніть правою кнопкою на сервер", + "copyId": "Копіювати ID", + "successTitle": "Premium активовано", + "successDesc": "Ваша підписка налаштовується. Бот матиме доступ до Premium для вашого сервера протягом кількох хвилин.", + "whatHappensNext": "Що відбудеться далі", + "autoLogging": "Автоматичне журналювання активується для вашого сервера", + "setLogChannel": "Встановіть канал журналів за допомогою", + "everyResult": "Кожен результат SQB публікується автоматично", + "readSetupGuide": "Прочитати посібник з налаштування", + "tierStandardName": "Стандарт", + "tierProName": "Про", + "tierMaxName": "Макс", + "squadCap": "Логування до {cap} полків", + "squadCapUnlimited": "Без ліміту на полки", + "everythingInStandard": "Все з тарифу Стандарт", + "everythingInPro": "Все з тарифу Про", + "wildcardSupport": "Wildcard (*, all, everything)", + "noSquadCap": "Без ліміту на полки", + "earlyAccessFeatures": "Ранній доступ до нових функцій" + }, + "player": { + "totalBattles": "Всього боїв", + "totalWins": "Всього перемог", + "vehicleStatistics": "Статистика техніки", + "cumulative": "Сукупно", + "individual": "Окремо", + "filterBy": "Фільтрувати за:", + "allTime": "За весь час", + "dateRange": "Діапазон дат", + "season": "Сезон", + "week": "Тиждень", + "session": "Сесія", + "dateType": "Тип дати:", + "last7Days": "Останні 7 днів", + "last30Days": "Останні 30 днів", + "last90Days": "Останні 90 днів", + "customRange": "Власний діапазон", + "specificDate": "Конкретна дата", + "filterType": "Тип фільтра:", + "fullSeason": "Повний сезон", + "specificWeek": "Конкретний тиждень", + "from": "З:", + "to": "По:", + "timeslot": "Слот", + "fullDay": "Весь день", + "selectSeason": "Сезон:", + "selectWeek": "Тиждень:", + "selectSeasonFirst": "Спочатку виберіть сезон", + "pleaseSelect": "Будь ласка, виберіть варіант", + "searchVehicles": "Пошук техніки...", + "resetFilters": "Скинути фільтри", + "vehiclesShown": "техніки показано", + "gamesShown": "ігор показано", + "noVehicleData": "Даних про техніку немає", + "noVehiclesForRange": "Техніки для вибраного діапазону дат не знайдено, або у цього гравця ще немає даних.", + "switchToCards": "Переключитись на перегляд картками", + "switchToTable": "Переключитись на перегляд таблицею", + "loadingTimeline": "Завантаження хронології...", + "noTimelineData": "Даних хронології ще немає.", + "timelineUnavailable": "Хронологія недоступна.", + "loadingGameRecords": "Завантаження записів ігор...", + "unableToLoadRecords": "Не вдалося завантажити записи ігор", + "failedToFetch": "Не вдалося отримати дані ігор. Спробуйте пізніше.", + "noGameRecords": "Записів ігор не знайдено", + "noGamesYet": "Цей гравець ще не зіграв жодної записаної гри.", + "collapseChart": "Згорнути графік", + "loadingChartData": "Завантаження даних графіка...", + "noHistoricalData": "Історичних даних ще немає.", + "chartUnavailable": "Графік недоступний.", + "relative": "Відносно", + "uidLabel": "UID гравця" + }, + "squadrons": { + "title": "Центр ескадрилій", + "subtitle": "Відкривайте ескадрильї, переглядайте статистику та відстежуйте результати", + "findSquadron": "Знайти ескадрилью", + "searchPlaceholder": "Пошук ескадрилій за назвою...", + "totalSquadrons": "Всього ескадрилій", + "totalPlayers": "Всього гравців", + "totalBattles": "Всього боїв", + "avgWinRate": "Середній відсоток перемог", + "topSquadrons": "Топ ескадрилій", + "viewFullLeaderboard": "Переглянути повний рейтинг", + "loadingSquadrons": "Завантаження даних ескадрилій...", + "noSquadronData": "Даних про ескадрильї ще немає.", + "failedToLoad": "Не вдалося завантажити дані ескадрилій. Спробуйте пізніше.", + "backToSquadronHub": "Назад до центру ескадрилій", + "squadronPoints": "Очки ескадрильї", + "squadronMembers": "Учасники ескадрильї", + "performance": "Ефективність", + "performanceNoData": "Для вибраного діапазону немає даних про ефективність.", + "quickDetails": "Короткий огляд", + "noMembersFound": "Учасників не знайдено", + "noRecordedMembers": "У цієї ескадрильї ще немає записаних учасників.", + "squadronGames": "Ігри ескадрильї", + "loadingSquadronGames": "Завантаження записів ігор ескадрильї...", + "noSquadronGames": "Записи ігор для цієї ескадрильї не знайдені.", + "retryLoadGames": "Повторити", + "searchMapPlaceholder": "Пошук за картою..." + }, + "leaderboard": { + "playersTitle": "Рейтинг гравців", + "playersSubtitle": "Найкращі гравці War Thunder, ранжовані за результатами", + "vehiclesTitle": "Рейтинг знищень техніки", + "vehiclesSubtitle": "Найкраща техніка War Thunder, ранжована за загальною кількістю знищень", + "squadronsTitle": "Рейтинг ескадрилій", + "squadronsSubtitle": "Найкращі ескадрильї War Thunder, ранжовані за результатами", + "statsTitle": "Глобальна статистика", + "statsSubtitle": "Загальна статистика боїв ескадрилій та мета-інформація", + "comparisonTitle": "Інструмент порівняння", + "comparisonSubtitle": "Порівнюйте гравців та техніку поряд", + "comparisonHint": "Порівнюйте статистику для пошуку найкращих гравців та техніки", + "compareSquadrons": "Порівняти ескадрильї", + "comparePlayers": "Порівняти гравців", + "compareVehicles": "Порівняти техніку", + "playersAndVehicles": "Гравці + Техніка", + "failedToLoadLeaderboard": "Не вдалося завантажити дані рейтингу. Спробуйте пізніше.", + "failedToLoadVehicles": "Не вдалося завантажити рейтинг техніки", + "failedToLoadSquadrons": "Не вдалося завантажити рейтинг ескадрилій", + "noResultsYet": "Результатів ще немає. Додайте ескадрильї/гравців для початку.", + "searchSquadron": "Пошук ескадрильї", + "searchBySquadronName": "Пошук за назвою ескадрильї...", + "minPlayers": "Мін. гравців", + "minPlayersPlaceholder": "Мін. гравців", + "resetFilters": "Скинути фільтри", + "squadronsShown": "ескадрилій показано", + "playersShown": "гравців показано", + "page": "сторінка", + "of": "з", + "loadingSquadronLeaderboard": "Завантаження рейтингу ескадрилій...", + "loadingPlayerLeaderboard": "Завантаження рейтингу гравців...", + "loadingComparisonData": "Завантаження даних для порівняння...", + "unableToFetch": "Не вдалося отримати дані рейтингу. Спробуйте ще раз.", + "noSquadronsInLeaderboard": "Ескадрилей у рейтингу не знайдено.", + "noPlayersInLeaderboard": "Гравців у рейтингу не знайдено.", + "loadingGlobalStats": "Завантаження глобальної статистики...", + "failedToLoadStats": "Не вдалося завантажити статистику", + "unableToFetchStats": "Не вдалося отримати дані статистики. Спробуйте ще раз.", + "mostPopularVehicles": "Найпопулярніша техніка", + "vehicleKillsLeaderboardTitle": "Рейтинг знищень техніки", + "avgWinRate": "Середній відсоток перемог", + "avgKillsPerPlayer": "Середнє знищень/гравець", + "loadingVehicleKills": "Завантаження даних знищень техніки...", + "apiNotLoaded": "API-клієнт не завантажено належним чином. Оновіть сторінку.", + "failedToInitApi": "Не вдалося ініціалізувати API-клієнт", + "noStatsData": "Даних статистики немає", + "totalPlayersCard": "Всього гравців", + "activePlayers": "Активні гравці", + "vehiclesUsed": "Використано техніки", + "differentVehicles": "Різних видів техніки", + "squadronBattlesLabel": "Бої ескадрилій", + "noVehicleData": "Даних про техніку немає", + "mostPopular": "Найпопулярніше", + "timesUsed": "разів використано", + "failedToLoadVehicleKills": "Не вдалося завантажити дані знищень техніки", + "lastUpdated": "Останнє оновлення", + "searchPlayer": "Пошук гравця", + "searchByPlayerName": "Пошук за іменем гравця...", + "minimumBattles": "Мінімум боїв", + "minBattlesPlaceholder": "Мін. боїв", + "searchSquadronsPlaceholder": "Пошук ескадрилій...", + "sortBy": "Сортувати за", + "kdRatio": "Співвідношення K/D", + "killsPerSpawn": "Знищень на спавн", + "caps": "Захоплення", + "timePeriod": "Часовий період", + "allTime": "За весь час", + "dateRange": "Діапазон дат", + "season": "Сезон", + "week": "Тиждень", + "dateType": "Тип дати", + "last7Days": "Останні 7 днів", + "last30Days": "Останні 30 днів", + "last90Days": "Останні 90 днів", + "customRange": "Власний діапазон", + "from": "З", + "to": "По", + "timeslot": "Слот", + "fullDay": "Весь день", + "selectSeason": "Виберіть сезон...", + "selectWeek": "Виберіть тиждень...", + "failedToLoadComparison": "Не вдалося завантажити дані для порівняння", + "pleaseRefresh": "Спробуйте оновити сторінку", + "playerComparison": "Порівняння гравців", + "squadronComparison": "Порівняння ескадрилій", + "vehicleComparison": "Порівняння техніки", + "playersVehiclesComparison": "Порівняння гравців + Техніки", + "addPlayersToCompare": "Додати гравців для порівняння:", + "addSquadronsToCompare": "Додати ескадрильї для порівняння:", + "addVehiclesToCompare": "Додати техніку для порівняння:", + "addPlayerVehicleCombos": "Додати комбінації гравець + техніка:", + "searchSelectPlayers": "Пошук та вибір гравців...", + "typeSquadronName": "Введіть назву ескадрильї...", + "searchSelectVehicles": "Пошук та вибір техніки...", + "searchForPlayers": "Пошук гравців...", + "selectPlayersToCompare": "Знайдіть та виберіть гравців вище, щоб порівняти їх статистику", + "selectSquadronsToCompare": "Знайдіть та виберіть ескадрильї вище, щоб порівняти їх статистику", + "selectVehiclesToCompare": "Знайдіть та виберіть техніку вище, щоб порівняти їх статистику", + "selectPlayersVehiclesToCompare": "Знайдіть та виберіть гравців вище, потім виберіть їх техніку для порівняння різних комбінацій гравець-техніка", + "selectVehicleFor": "Виберіть техніку для", + "selectAVehicle": "-- Виберіть техніку --", + "noVehiclesForPlayer": "Техніки для цього гравця не знайдено", + "noPlayerVehicleSelected": "Комбінацій гравець-техніка не вибрано", + "noPlayersSelected": "Гравців не вибрано", + "noVehiclesSelected": "Техніки не вибрано", + "noSquadronsSelected": "Ескадрилей не вибрано", + "statistic": "Показник", + "totalDeaths": "Всього загибелей", + "totalAssists": "Всього допомог", + "totalCaptures": "Всього захоплень", + "killsPerSpawnShort": "Знищень/спавн", + "avgWinRateShort": "Сер. відсоток перемог", + "avgKillsPlayerShort": "Сер. знищень/гравець", + "avgKillsMember": "Сер. знищень/учасник", + "avgBattlesMember": "Сер. боїв/учасник", + "serverError500": "Помилка сервера (500) — API тимчасово недоступний. Спробуйте через кілька хвилин.", + "apiEndpoint404": "Кінцеву точку API не знайдено (404) — Перевірте конфігурацію сервера.", + "networkError": "Мережева помилка — Не вдалося підключитися до сервера. Перевірте з'єднання.", + "viewFullLeaderboard": "Переглянути повний рейтинг", + "vehicleName": "Назва техніки", + "searchVehiclePlaceholder": "Пошук конкретної техніки...", + "minKills": "Мін. знищень", + "any": "Будь-яке", + "perPage": "На сторінці", + "clear": "Очистити", + "vehicleAndPlayer": "Техніка та гравець", + "loadingVehicleLeaderboard": "Завантаження рейтингу техніки...", + "vehiclesShown": "техніки показано", + "minBattlesPerVehicle": "Мінімум 3 бої на одиницю техніки", + "minimumBattlesRequired": "Мінімум 5 боїв", + "allSeasons": "Усі сезони", + "allWeeks": "Усі тижні", + "allBR": "Усі BR" + }, + "games": { + "title": "Історія матчів", + "subtitle": "Пошук та перегляд матчів боїв ескадрилій", + "searchPlaceholder": "Пошук за іменем гравця або UID...", + "filterByMap": "Фільтр за картою", + "allMaps": "Всі карти", + "search": "Пошук", + "noResults": "Матчів не знайдено", + "matchDetail": "Деталі матчу", + "chatLog": "Журнал чату", + "battleLog": "Журнал бою", + "duration": "Тривалість", + "mode": "Режим", + "winningTeam": "Команда-переможець", + "losingTeam": "Команда, що програла", + "viewMatch": "Переглянути матч", + "loadingMatch": "Завантаження даних матчу...", + "matchNotFound": "Матч не знайдено", + "searchingGames": "Пошук ігор...", + "recentMatches": "Нещодавні матчі", + "noChatLog": "Журнал чату недоступний", + "noBattleLog": "Журнал бою недоступний", + "replayVideo": "Відео повтору", + "generatingVideo": "Генерація відео повтору…", + "videoFirstLoad": "Перше завантаження може тривати до хвилини", + "videoUnavailable": "Відео повтору недоступне для цього матчу", + "modeGround": "Наземний", + "modeAir": "Повітряний", + "squadronPlaceholder": "Назва ескадрильї...", + "loadingReplay": "Завантаження реплею..." + }, + "errors": { + "pageNotFound": "Сторінку не знайдено", + "error": "Помилка", + "oopsNotFound": "Ой! Сторінка, яку ви шукаєте, не існує. Можливо, її було переміщено, видалено, або ви ввели неправильний URL.", + "searchError": "Помилка пошуку. Спробуйте ще раз." + }, + "js": { + "openingDiscordInvite": "Відкриття запрошення Discord!", + "errorOpeningInvite": "Помилка відкриття посилання запрошення. Спробуйте пізніше.", + "gettingSupportLink": "Отримання посилання на сервер підтримки...", + "openingSupportServer": "Відкриття сервера підтримки!", + "errorGettingSupport": "Помилка отримання посилання підтримки. Спробуйте пізніше.", + "failedToUpdateStats": "Не вдалося оновити статистику", + "konamiActivated": "Досягнення розблоковано: Секретний код!", + "noPlayersFound": "Гравців не знайдено", + "searchError": "Помилка пошуку. Спробуйте ще раз.", + "killsSuffix": "знищень", + "winRateSuffix": "відсоток перемог", + "noSquadronsFound": "Ескадрилей не знайдено" + }, + "index": { + "subtitle1": "Найкращий інструмент для ескадренних боїв", + "subtitle2": "Boris Stats, але краще", + "subtitle3": "Найбільший набір даних SQB", + "subtitle4": "Публічна та безкоштовна інформація для всіх" + }, + "seasonCard": { + "buttonLabel": "Картка сезону", + "buttonDisabledTitle": "Пошук ескадрону неповний — картка недоступна", + "modalTitle": "Картка сезону", + "seasonLabel": "Сезон", + "themeLabel": "Тема", + "themeDark": "Темна", + "themeLight": "Світла", + "generate": "Створити", + "loadingSeasons": "Завантаження сезонів…", + "generating": "Генерація…", + "failedSeasons": "Не вдалося завантажити сезони.", + "failedGenerate": "Не вдалося створити картку з підсумками.", + "inProgressSuffix": "(у процесі)", + "imgRecapSuffix": "ПІДСУМКИ", + "imgHeroFinalRating": "Підсумковий рейтинг", + "imgHeroMatches": "Матчів", + "imgHeroWinRate": "% перемог", + "imgHeroKD": "K/D", + "imgAxisRating": "Рейтинг", + "imgAxisWinRate": "% перемог", + "imgStatPeakRating": "Пік рейтингу", + "imgStatRatingChange": "Зміна рейтингу", + "imgStatTotalKills": "Усього вбивств", + "imgStatTotalDeaths": "Усього смертей", + "imgStatAssistsCaptures": "Асисти / захоплення", + "imgStatMostPlayedVehicle": "Часто вживана", + "imgStatMVP": "MVP", + "imgStatMostActive": "Активніший", + "imgStatLongestWinStreak": "Серія перемог", + "imgStatMostCommonOpponent": "Частий суперник", + "imgUnitKills": "вбивств", + "imgUnitAssists": "асист", + "imgUnitCaptures": "захоплень", + "imgUnitGames": "ігор", + "imgUnitMatches": "матчів", + "imgUnitWins": "П", + "imgUnitLosses": "Пр", + "imgGroundShort": "З", + "imgAirShort": "А", + "imgFooterGenerated": "створено", + "imgPlaceholderNoData": "Немає даних для {short} у {season}", + "buttonLabelPlayer": "Підсумки сезону", + "buttonDisabledTitlePlayer": "Player lookup incomplete — recap unavailable", + "modalTitlePlayer": "Player Season Recap", + "imgHeroBattles": "Боїв", + "imgHeroTotalKills": "Всього вбивств", + "imgAxisBattles": "Боїв (7 дн)", + "imgAxisKD": "K/D", + "imgStatBestMatch": "Найкращий матч", + "imgStatSquadronsRepresented": "Полки", + "imgStatFrequentTeammate": "Частий союзник", + "imgStatLongestSession": "Найдовша сесія", + "imgStatMostActiveDay": "Найактивніший день (UTC)", + "imgStatMostCommonOppSquadron": "Частий ворожий полк", + "imgStatPeakSquadronRating": "Пік рейтингу полка", + "imgUnitSlotNA": "NA", + "imgUnitSlotEU": "EU", + "imgUnitNoSquadron": "без полка", + "imgUnitVs": "vs", + "imgUnitTogether": "спільних матчів", + "imgStatKDAC": "Вб / См / Доп / Зх", + "imgPlaceholderNoDataPlayer": "Немає даних для {nick} в {season}", + "imgUIDLabel": "UID", + "imgBestMatchLine": "{vehicle} · Нз {gk} / Вз {ak} / Доп {assists} / Зх {cap} / См {deaths} · {date}" + }, + "live": { + "air": "ВЗ", + "gnd": "НЗ", + "ast": "ДОП", + "dth": "СМЕ", + "cap": "ЗАХ", + "squadronBattle": "Бій полків", + "randomBattle": "Випадковий бій" + }, + "analytics": { + "pageTitle": "Аналітика SQB", + "pageSubtitle": "Детальна аналітика для будь-якого полку, гравця чи техніки.", + "modeSquadron": "Полк", + "modePlayer": "Гравець", + "modeVehicle": "Техніка", + "tabMaps": "Вінрейт по картах", + "tabSquadmates": "Часті напарники", + "tabComps": "Склади команд", + "tabConsistency": "Стабільність гравця", + "tabTime": "Час доби", + "tabMatchups": "Історія зустрічей", + "pickSquadron": "Знайдіть полк, щоб побачити його аналітику", + "pickPlayer": "Знайдіть гравця, щоб побачити його аналітику", + "pickVehicle": "Знайдіть техніку, щоб побачити її аналітику", + "noData": "Немає даних за цей період.", + "loading": "Завантаження…", + "loadError": "Не вдалося завантажити аналітику.", + "compComingSoon": "Аналіз складів команд незабаром.", + "compTopVehiclesTitle": "Найчастіша техніка", + "compCompositionsTitle": "Повторювані склади матчів", + "compCompositionsMeta": "Склади мінімум з {min} одиниць техніки, відсортовані за матчами", + "compMatchesAnalyzed": "проаналізованих матчів", + "compNoRepeats": "У цьому діапазоні склад такого розміру не виставлявся.", + "compColVehicle": "Техніка", + "compColSpawns": "Виїзди", + "compColMatches": "Матчі", + "compColShare": "% Матчів", + "compColLineup": "Склад", + "compColTypes": "Комп", + "compTypeFighters": "Винищувачі", + "compTypeBombers": "Бомбардувальники", + "compTypeHelicopters": "Вертольоти", + "compTypeLight": "Легкий танк", + "compTypeTanks": "Танки", + "compTypeSPAA": "ЗСУ", + "compTypeSPAATooltip": "Зенітка/ЗСУ", + "compTypeUnknown": "Невідомо", + "compSearchPresetLabel": "Готовий склад", + "compSearchPresetAll": "Усі склади", + "compSearchPresetHint": "Побудовано з історії складів цього полку", + "compSearchTypesLabel": "Типи", + "compTypeCapsHint": "Макс 8 всього · макс 4 авіації (F + B + H)", + "compSearchRefineLabel": "Уточнити", + "compRefineHint": "Вкажіть число вище, щоб вибрати конкретну техніку цього типу.", + "compRefineAny": "Будь-яка {type}", + "compSearchCustomLabel": "Власний", + "compSearchAddVehicle": "Конкретна техніка", + "compSearchVehiclesLabel": "Техніка", + "compSearchApply": "Застосувати", + "compSearchReset": "Скинути", + "compSearchMatches": "Показано {shown} з {total} складів", + "compSearchNoMatches": "Жоден склад не відповідає фільтру.", + "compSearchGamesShort": "матчів", + "colMap": "Карта", + "colWins": "П", + "colLosses": "П", + "colWinRate": "%П", + "colBar": "", + "colShared": "Спільно", + "colUid": "UID", + "colGames": "Ігри", + "colPlayer": "Гравець", + "colAvgKills": "Сер. вбивств", + "colAvgDeaths": "Сер. смертей", + "colScore": "Очки", + "colHour": "Година (UTC)", + "colSquadron": "Полк", + "colTotal": "Всього", + "matchupsWonHeader": "Найбільше перемог проти", + "matchupsLostHeader": "Найбільше поразок від", + "uniqueOpponents": "унікальних суперників", + "euTimeslot": "EU слот", + "naTimeslot": "NA слот", + "offPeak": "Не пік", + "radarMetaMaps": "топ {shown} з {total} карт · мін. {min} ігор", + "radarMetaSquadmates": "топ {shown} з {total} напарників · мін. {min} спільних ігор", + "radarTooFewMaps": "Недостатньо даних для графіка — у цьому фільтрі потрібно щонайменше 3 карти з {min}+ іграми.", + "radarTooFewSquadmates": "Недостатньо даних для графіка — у цьому фільтрі потрібно щонайменше 3 напарників з {min}+ спільними іграми.", + "radarFootnoteMaps": "Приховано {count} карт з меншою кількістю ігор — див. повну таблицю", + "radarFootnoteSquadmates": "Приховано {count} напарників з меншою кількістю ігор — див. повну таблицю", + "tabTimeline": "Хронологія K/D", + "tabTopPlayers": "Найкращі гравці", + "tabTopSquadrons": "Найкращі полки" + }, + "playerModal": { + "viewFullProfile": "Відкрити повний профіль →", + "close": "Закрити", + "overview": "Огляд", + "vehicles": "Техніка", + "sessions": "Сесії", + "loadingPlayerData": "Завантаження даних гравця...", + "kdr": "K/D", + "kps": "K/S", + "winRate": "Відсоток перемог", + "battles": "Бої", + "wins": "Перемоги", + "totalBattles": "Усього боїв", + "totalKills": "Усього знищень", + "airKills": "Повітряні фраги", + "groundKills": "Наземні фраги", + "assists": "Допомога", + "deaths": "Смерті", + "captures": "Захоплення", + "clickToSwitchMetric": "Натисніть, щоб змінити метрику", + "clickToCycle": "натисніть для перемикання", + "noChartData": "Немає даних графіка", + "noVehicleData": "Немає даних техніки", + "noSessionData": "Немає даних сесій", + "date": "Дата", + "vehicle": "Техніка", + "ground": "Земля", + "air": "Повітря", + "result": "Результат", + "unknown": "Невідомо", + "failedToLoadPlayerData": "Не вдалося завантажити дані гравця" + }, + "replay": { + "playPause": "Пуск/Пауза", + "crashed": "розбився", + "destroyed": "знищив", + "hit": "влучив" + }, + "dateFilter": { + "allTime": "Увесь час", + "currentSeason": "Поточний сезон", + "bySeason": "За сезоном", + "cumulative": "Накопичувально", + "customRange": "Власний діапазон", + "selectSeason": "Вибрати сезон", + "selectSeasonDots": "Виберіть сезон...", + "selectWeek": "Вибрати тиждень", + "selectWeekDots": "Виберіть тиждень...", + "entireSeason": "Увесь сезон", + "applyFilter": "Застосувати фільтр", + "cumulativeHelp": "Показати статистику, накопичену до вибраного моменту", + "season": "Сезон", + "upToWeek": "До тижня", + "applyCumulativeFilter": "Застосувати накопичувальний фільтр", + "startDate": "Дата початку", + "endDate": "Дата завершення", + "applyCustomRange": "Застосувати власний діапазон", + "activeFilter": "Активний фільтр:", + "clear": "Очистити", + "allTimeStatistics": "Статистика за увесь час", + "currentSeasonValue": "Поточний сезон: {season}", + "alertSelectSeason": "Виберіть сезон", + "seasonValue": "Сезон {season}", + "alertSelectSeasonWeek": "Виберіть сезон і тиждень", + "cumulativeValue": "Накопичувально до {season} - {week}", + "alertSelectDate": "Виберіть хоча б одну дату", + "alertStartBeforeEnd": "Дата початку має бути раніше дати завершення", + "customRangePrefix": "Власний діапазон:", + "fromDate": "З {date}", + "upToDate": "До {date}" + } +} diff --git a/web/locales/zh-CN.json b/web/locales/zh-CN.json new file mode 100644 index 0000000..8cb052d --- /dev/null +++ b/web/locales/zh-CN.json @@ -0,0 +1,961 @@ +{ + "nav": { + "home": "首页", + "live": "实时", + "leaderboards": "排行榜", + "docs": "文档", + "terms": "条款", + "premium": "高级版", + "support": "支持", + "addToDiscord": "添加到 Discord", + "games": "比赛", + "squadrons": "中队", + "donate": "捐赠", + "analytics": "分析" + }, + "footer": { + "services": "服务", + "matchFeed": "比赛动态", + "vehicleStats": "载具统计", + "analytics": "分析", + "squadronHub": "中队中心", + "comparison": "对比", + "resources": "资源", + "documentation": "文档", + "inviteBot": "邀请机器人", + "legal": "法律", + "termsOfService": "服务条款", + "privacyPolicy": "隐私政策", + "termsAndPrivacy": "条款与隐私", + "websiteBy": "网站制作", + "andToothless": "和 Toothless", + "meowing": "喵喵中" + }, + "common": { + "loading": "正在加载...", + "retry": "重试", + "backToHome": "返回首页", + "battles": "战斗", + "wins": "胜场", + "winRate": "胜率", + "kills": "击杀", + "totalKills": "总击杀", + "groundKills": "地面击杀", + "airKills": "空中击杀", + "assists": "助攻", + "deaths": "死亡", + "captures": "占点", + "kdr": "K/D", + "kps": "每场击杀", + "rank": "排名", + "player": "玩家", + "players": "玩家", + "playersCount": "名玩家", + "vehicle": "载具", + "vehicles": "载具", + "squadron": "中队", + "squadrons": "中队", + "statistics": "统计", + "comparison": "对比", + "date": "日期", + "result": "结果", + "totalBattles": "总战斗", + "totalWins": "总胜场", + "points": "点数", + "members": "成员", + "membersCount": "名成员", + "rating": "评分", + "searchPlayerByName": "按玩家名称搜索...", + "noPlayersFound": "未找到玩家", + "noSquadronsFound": "未找到中队", + "noVehiclesFound": "未找到载具", + "failedToLoad": "数据加载失败,请稍后再试。", + "recordingSince": "自 2026/01/01 起记录数据", + "vs": "对阵", + "map": "地图" + }, + "home": { + "squadronBattles": "中队战斗", + "madeSimple": "简单管理", + "addToDiscord": "添加到 Discord", + "learnMore": "了解更多", + "searchBySquadron": "按中队搜索", + "typeSquadronName": "输入中队名称...", + "orByPlayer": "或按玩家搜索", + "typePlayerName": "输入玩家名称...", + "liveFeed": "比赛动态", + "realTimeMatches": "查找你的比赛", + "topPlayers": "顶尖玩家", + "vehicleStatsCard": "载具统计", + "performanceMetrics": "表现指标", + "analyticsCard": "分析", + "globalStatistics": "全局统计", + "squadronHubCard": "中队中心", + "squadronStats": "中队统计", + "comparisonCard": "对比", + "compareStats": "对比统计", + "joinServers": "已有 500+ 个服务器使用我们的机器人追踪表现", + "noSquadronsFound": "未找到中队", + "noPlayersFound": "未找到玩家", + "searchPlayersIn": "搜索玩家:", + "ctaElev8": "准备好让你的中队 ELEV8 了吗?", + "ctaReign": "准备好让 R3IGN 再次统治了吗?", + "ctaMeow": "喵喵", + "ctaPurr": "咕噜咕噜", + "ctaRawr": "嗷呜" + }, + "docs": { + "title": "文档", + "subtitle": "关于以下内容的完整说明", + "quickNavigation": "快速导航", + "gettingStarted": "入门", + "commands": "命令", + "serverSetup": "服务器设置", + "features": "功能", + "examples": "示例", + "troubleshooting": "故障排查", + "stackManager": "车队管理器", + "welcomeMessage": "按照这些步骤开始使用。", + "inviteTheBot": "邀请机器人", + "inviteBotDesc": "该项目的详细说明。", + "configureServer": "配置服务器", + "configureServerDesc": "该项目的详细说明。", + "startTracking": "开始追踪", + "startTrackingDesc": "配置完成后,机器人会自动发布中队战斗结果、战斗日志、聊天记录和回放链接。", + "runSetupWizard": "运行设置向导", + "setupWizardDesc": "设置向导会一次性引导你配置中队、日志频道和点数频道:", + "setupEasiest": "这是最简单的入门方式。它会逐步引导你设置中队并选择频道。", + "youreDone": "完成!", + "doneDesc": "机器人会开始自动发布点数和排行榜更新。之后可使用 /autolog-management 调整通知设置。", + "premiumNote": "Automatic game logs (full scoreboards after each match) require a 高级版 订阅. Run /unlock to 订阅 — $2.99/mo per 服务器, billed through Discord.", + "manualSetup": "手动设置(替代方式)", + "manualSetupDesc": "该项目的详细说明。", + "allCommandsSlash": "All 命令s 使用 Discord's slash 命令 system. Type / to see available 命令s.", + "serverSetupAdmin": "服务器 Setup & Administration", + "importantNote": "重要提示", + "verifyFirst": "Always verify first! The /sq-info 命令 confirms the bot can find your squadron in War Thunder's database, even if you're ranked very low.", + "cantFindSquadron": "If the bot can't find your squadron with /sq-info, the setup 命令s won't work properly.", + "botNotResponding": "机器人无响应", + "checkOnline": "检查机器人是否在线(绿色状态)", + "verifyPermissions": "确认机器人拥有必要权限", + "tryDifferentChannel": "Try using 命令s in a different channel", + "commandsNotWorking": "命令s Not Working", + "ensureSlash": "Ensure you're using slash 命令s (start with /)", + "checkRolePerms": "Check if your role has permission to 使用 bot 命令s", + "tryRefreshing": "尝试刷新 Discord 或重启应用", + "dataNotSaving": "数据 Not Saving", + "verifySendMessages": "确认机器人拥有“发送消息”权限", + "checkOutages": "检查 Discord 是否出现故障", + "contactSupport": "Contact 支持 if the issue persists", + "needMoreHelp": "需要更多帮助?", + "needMoreHelpDesc": "该项目的详细说明。", + "example": "示例", + "supportedLanguages": "支持的语言", + "setupDesc": "该项目的详细说明。", + "recommendedForNew": "Recommended for new 服务器s.", + "setSquadronDesc": "该项目的详细说明。", + "quickLogDesc": "该项目的详细说明。", + "quickLogPremiumNote": "Logs (automatic game scoreboards) require a 高级版 订阅. 点数 and 排行榜 alerts are free.", + "autologDesc": "该项目的详细说明。", + "autologPremiumNote": "Automatic game logs require a 高级版 订阅.", + "diagnosePermsDesc": "该项目的详细说明。", + "squadronInformation": "中队 Information", + "sqInfoDesc": "该项目的详细说明。", + "sqInfoGraphDesc": "将当前中队阵容可视化为柱状图,按活跃度与胜率分为核心、活跃和边缘三组(当前赛季)。", + "compDesc": "该项目的详细说明。", + "trackDesc": "追踪一个中队,并与上次检查时的统计进行对比。", + "topDesc": "显示前 20 名中队及其当前统计。", + "sqStatsDesc": "以交互式图表显示中队点数随时间变化。", + "lossCalculatorDesc": "计算指定玩家离队后中队会损失多少点数。", + "recentDesc": "显示某中队最近 5 场中队战斗。", + "vsDesc": "该项目的详细说明。", + "leaderboardLinkDesc": "获取 SRE Bot 全球玩家排行榜链接。", + "playerStats": "玩家 统计", + "playerStatsDesc": "该项目的详细说明。", + "viewPlayerGamesDesc": "该项目的详细说明。", + "viewMatchDesc": "该项目的详细说明。", + "examples2": "示例", + "compareDesc": "该项目的详细说明。", + "metaData": "Meta 数据", + "metaManagementDesc": "该项目的详细说明。", + "metaDesc": "该项目的详细说明。", + "settingsUtilities": "设置 & Utilities", + "languageDesc": "更改机器人的默认语言,也会影响战斗日志中载具名称的语言。", + "scheduleDesc": "该项目的详细说明。", + "websiteDesc": "该项目的详细说明。", + "creditsDesc": "该项目的详细说明。", + "unlockDesc": "该项目的详细说明。", + "analyticsDesc": "高级 SQB 分析:地图胜率、队伍阵容、玩家稳定性、分时段表现和对战历史(最常赢/最常输的对手)。", + "sqCardDesc": "该项目的详细说明。", + "cardDesc": "该项目的详细说明。", + "queryDesc": "该项目的详细说明。", + "donateDesc": "通过 Ko-fi 支持 SRE Bot 的开发。", + "botStatusDesc": "显示最近接收对局的时间以及最近对局的平均 TTL。标记 Gaijin 服务器缓慢的情况。", + "premiumBadge": "高级版", + "newsDesc": "该项目的详细说明。", + "stackCreateDesc": "该项目的详细说明。", + "stackRequestToJoin": "申请加入 — 任何玩家都可以带着计划使用的载具申请加入。申请队列最多 20 个名额。", + "stackLeaveWithdraw": "Leave / Withdraw — 成员 can leave the stack; applicants can withdraw their application. The stack 队长 is prompted to transfer ownership first.", + "stackManagePanel": "管理车队 ⚙️ — Leader-only panel with four sections:", + "stackAcceptMembers": "Accept 成员 — Accept or decline applicants individually or all at once. Up to 8 成员s 总计.", + "stackRemoveMembers": "移除成员 — 移除active 成员s or queued applicants. Options: 移除All, 移除已启用, 移除Queued, or 移除Selected from a dropdown.", + "stackPingMembers": "Ping 成员 — Ping with an optional custom message. Options: Ping All (成员s + queue, excluding 队长), Ping 已启用 (成员s only), Ping Queued (applicants only), or Ping Selected from a dropdown.", + "stackRenameStack": "重命名车队 — 为车队设置自定义名称。该名称会显示为嵌入消息标题,并替代队长名称出现在提醒消息中。", + "stackDisbandStack": "解散车队 — 队长可以提前结束车队。", + "stackManageDesc": "重新在当前频道发布你的活跃车队嵌入消息。如果原消息被删除,或机器人重启后找不到原消息,可以使用此命令。现有成员和队列会保留。", + "translation": "翻译", + "translateContextMenu": "右键消息 → 应用 → 翻译消息", + "translateDesc": "通过 Discord 右键菜单翻译任意消息。右键(移动端长按)消息并选择“应用 → 翻译消息”。", + "viewAllLanguages": "查看All 支持的语言", + "serverSetupSubtitle": "为你的中队 Discord 服务器配置 {botName},以获得最佳使用效果。", + "requiredPermissions": "所需权限", + "sendMessages": "发送消息", + "useSlashCommands": "Use Slash 命令s", + "embedLinks": "嵌入链接", + "readMessageHistory": "读取消息历史", + "recommendedChannelSetup": "Recommended 频道 Setup", + "recommendedChannelDesc": "建议创建专用频道,例如 #squadron-battles,用于追踪和统计。", + "roleConfiguration": "身份组配置", + "roleConfigurationDesc": "为可以记录战斗结果的中队成员分配合适身份组。建议仅限中队军官和队长使用。", + "premiumSectionSubtitle": "标题", + "whatsIncluded": "包含内容", + "premiumInclude1": "每场 SQB 比赛结束后,完整计分板会自动发布到你配置的频道。", + "premiumInclude2": "Unlimited /comp lookups (free 服务器s get 25 per timeslot)", + "premiumInclude3": "All existing free features (points alarms, 队长board, stats 命令s, etc.) remain free", + "pricingBilling": "价格与计费", + "pricingBillingDesc": "该项目的详细说明。", + "howToSubscribe": "如何订阅", + "subscribe1": "Run /unlock in your 服务器 (服务器 admin 必需)", + "subscribe2": "Click the 订阅 button in the bot's reply", + "subscribe3": "在 Discord 内完成结账,机器人会立即激活", + "cancellation": "取消订阅", + "cancellationDesc": "该项目的详细说明。", + "realTimeStatistics": "Real-time 统计", + "realTimeStatisticsDesc": "实时追踪胜负和表现指标。", + "battleHistory": "战斗 历史", + "battleHistoryDesc": "该项目的详细说明。", + "leaderboardsFeature": "排行榜", + "leaderboardsFeatureDesc": "该项目的详细说明。", + "playerTracking": "玩家 Tracking", + "playerTrackingDesc": "单个玩家统计和 ThunderSkill 集成。", + "smartAlerts": "智能提醒", + "smartAlertsDesc": "针对中队活动的自动警报和通知。", + "multiLanguageSupport": "多语言支持", + "multiLanguageSupportDesc": "翻译功能和可自定义的载具语言。", + "timeCoordination": "时间协调", + "timeCoordinationDesc": "用于全球中队协调的 UTC 和本地时间工具。", + "advancedSearch": "高级搜索", + "advancedSearchDesc": "查找玩家阵容和详细中队信息。", + "dataSecurity": "数据 Security", + "dataSecurityDesc": "你的数据会被安全保存,绝不会共享或出售。", + "usageExamples": "使用示例", + "quickSetupRecommended": "快速设置(推荐)", + "quickSetupDesc": "运行设置向导。它会在一个流程中引导你设置中队、选择日志频道和点数频道。", + "comparingPlayers": "Comparing 玩家s", + "comparingPlayersDesc": "该项目的详细说明。", + "checkingSquadronInfo": "Checking 中队 Info", + "verifySquadronDesc": "确认中队存在并查看详细信息。设置前可用它确认机器人能找到你的中队。", + "recentBattlesDesc": "查看某中队最近 5 场战斗。", + "headToHeadDesc": "该项目的详细说明。", + "backToHome": "返回首页", + "termsAndPrivacy": "条款与隐私", + "seasonRecapCardTitle": "赛季回顾卡", + "seasonRecapCardDesc": "该项目的详细说明。", + "playerRecapCardTitle": "标题", + "playerRecapCardDesc": "该项目的详细说明。", + "tierOverview": "档位概览", + "tierOverviewDesc": "该项目的详细说明。", + "tierStandardLine": "$2.99 — up to 10 squadrons each for Logs and 点数, no wildcard 支持.", + "tierProLine": "up to 25 squadrons each for Logs and 点数, plus wildcard (`*` / `all` / `everything`) 支持.", + "tierMaxLine": "无限中队、通配符,以及新功能抢先体验。" + }, + "leaderboards": { + "title": "排行榜", + "subtitle": "浏览玩家、中队和载具表现", + "players": "玩家", + "squadrons": "中队", + "vehicles": "载具", + "stats": "统计", + "comparison": "对比", + "search": "搜索", + "filter": "筛选", + "sortBy": "排序方式" + }, + "games": { + "title": "比赛", + "subtitle": "最近记录的中队战斗", + "searchPlaceholder": "搜索玩家、中队或地图...", + "winner": "获胜方", + "loser": "失败方", + "duration": "时长", + "viewDetails": "查看详情", + "noGamesFound": "未找到比赛", + "filterByMap": "按地图筛选", + "allMaps": "全部地图", + "search": "搜索", + "noResults": "没有比赛 found", + "matchDetail": "比赛详情", + "chatLog": "聊天记录", + "battleLog": "战斗日志", + "mode": "模式", + "winningTeam": "获胜队伍", + "losingTeam": "失败队伍", + "viewMatch": "查看比赛", + "loadingMatch": "正在加载match data...", + "matchNotFound": "未找到比赛", + "searchingGames": "正在搜索比赛...", + "recentMatches": "近期比赛", + "noChatLog": "没有chat log available", + "noBattleLog": "没有battle log available", + "replayVideo": "回放视频", + "generatingVideo": "正在生成回放视频…", + "videoFirstLoad": "首次加载可能需要最多一分钟", + "videoUnavailable": "Replay video 不可用 for this match", + "modeGround": "陆战", + "modeAir": "空战", + "squadronPlaceholder": "请输入或选择…", + "loadingReplay": "正在加载Replay..." + }, + "squadrons": { + "title": "中队", + "subtitle": "搜索和查看中队统计", + "searchPlaceholder": "搜索中队...", + "viewProfile": "查看资料", + "members": "成员", + "activity": "活跃度", + "findSquadron": "Find a 中队", + "totalSquadrons": "总中队s", + "totalPlayers": "总玩家s", + "totalBattles": "总战斗s", + "avgWinRate": "平均 胜率", + "topSquadrons": "Top 中队s", + "viewFullLeaderboard": "查看Full 排行榜", + "loadingSquadrons": "正在加载squadron data...", + "noSquadronData": "没有squadron data available yet.", + "failedToLoad": "无法load squadron data. Please try again later.", + "backToSquadronHub": "Back to 中队 Hub", + "squadronPoints": "中队 点数", + "squadronMembers": "中队 成员", + "performance": "表现", + "performanceNoData": "没有performance data available for the 选择ed range.", + "quickDetails": "快速详情", + "noMembersFound": "没有成员s found", + "noRecordedMembers": "此中队还没有记录成员。", + "squadronGames": "中队 比赛s", + "loadingSquadronGames": "正在加载squadron game records...", + "noSquadronGames": "没有game records found for this squadron.", + "retryLoadGames": "重试", + "searchMapPlaceholder": "搜索by map..." + }, + "analytics": { + "title": "分析", + "subtitle": "深入查看中队表现、地图和阵容趋势", + "mapWinRates": "地图胜率", + "teamCompositions": "队伍阵容", + "playerConsistency": "玩家稳定性", + "timeOfDay": "时段表现", + "matchupHistory": "对战历史", + "pageTitle": "SQB 分析", + "pageSubtitle": "对任意中队、玩家或载具进行深度分析。", + "modeSquadron": "中队", + "modePlayer": "玩家", + "modeVehicle": "载具", + "tabMaps": "地图 胜率s", + "tabSquadmates": "常见队友", + "tabComps": "队伍阵容", + "tabConsistency": "玩家 Consistency", + "tabTime": "时段", + "tabMatchups": "比赛up 历史", + "pickSquadron": "搜索any squadron to see their analysis", + "pickPlayer": "搜索any player to view their analysis", + "pickVehicle": "搜索a vehicle to view its analysis", + "noData": "没有data in this range.", + "loading": "正在加载…", + "loadError": "无法load analytics.", + "compComingSoon": "队伍阵容分析即将推出。", + "compTopVehiclesTitle": "Top 载具s", + "compCompositionsTitle": "标题", + "compCompositionsMeta": "至少包含 {min} 个载具的阵容,按比赛场次排序", + "compMatchesAnalyzed": "已分析比赛", + "compNoRepeats": "没有lineup of that size was fielded in this range.", + "compColVehicle": "载具", + "compColSpawns": "出场", + "compColMatches": "比赛es", + "compColShare": "比赛 %", + "compColLineup": "阵容", + "compColTypes": "阵容", + "compTypeFighters": "战斗机", + "compTypeBombers": "轰炸机", + "compTypeHelicopters": "直升机", + "compTypeLight": "轻型坦克", + "compTypeTanks": "坦克", + "compTypeSPAA": "防空车", + "compTypeSPAATooltip": "防空/SPAA", + "compTypeUnknown": "未知", + "compSearchPresetLabel": "预设阵容", + "compSearchPresetAll": "全部阵容", + "compSearchPresetHint": "根据此中队自身阵容历史生成", + "compSearchTypesLabel": "类型", + "compTypeCapsHint": "总数最多 8 · 航空最多 4(F + B + H)", + "compSearchRefineLabel": "细化条件", + "compRefineHint": "先在上方设置数量,再为该类型选择具体载具。", + "compRefineAny": "任意 {type}", + "compSearchCustomLabel": "自定义", + "compSearchAddVehicle": "指定载具", + "compSearchVehiclesLabel": "载具s", + "compSearchApply": "应用", + "compSearchReset": "重置", + "compSearchMatches": "显示 {shown}/{total} 个阵容", + "compSearchNoMatches": "没有comps match the 选择ed filter.", + "compSearchGamesShort": "场", + "colMap": "地图", + "colWins": "W", + "colLosses": "L", + "colWinRate": "WR", + "colBar": "", + "colShared": "共同", + "colUid": "UID", + "colGames": "比赛s", + "colPlayer": "玩家", + "colAvgKills": "平均击杀", + "colAvgDeaths": "平均死亡", + "colScore": "分数", + "colHour": "小时(UTC)", + "colSquadron": "中队", + "colTotal": "总计", + "matchupsWonHeader": "胜场最多的对手", + "matchupsLostHeader": "负场最多的对手", + "uniqueOpponents": "个不同对手", + "euTimeslot": "EU 时段", + "naTimeslot": "NA 时段", + "offPeak": "非高峰时段", + "radarMetaMaps": "显示 {total} 张地图中的前 {shown} 张 · 至少 {min} 场", + "radarMetaSquadmates": "显示 {total} 名队友中的前 {shown} 名 · 至少共同 {min} 场", + "radarTooFewMaps": "数据不足,无法绘制图表 — 此筛选条件下至少需要 3 张有 {min}+ 场记录的地图。", + "radarTooFewSquadmates": "数据不足,无法绘制图表 — 此筛选条件下至少需要 3 名有 {min}+ 场共同比赛的队友。", + "radarFootnoteMaps": "隐藏了 {count} 张较少游玩的地图 — 查看完整表格", + "radarFootnoteSquadmates": "隐藏了 {count} 名较少共同出战的队友 — 查看完整表格", + "tabTimeline": "K/D 时间线", + "tabTopPlayers": "Top 玩家s", + "tabTopSquadrons": "Top 中队s" + }, + "premium": { + "title": "高级版", + "subtitle": "解锁更高的日志上限、通配符和无限查询", + "standard": "标准版", + "pro": "专业版", + "max": "旗舰版", + "subscribe": "订阅", + "currentPlan": "当前套餐", + "monthly": "每月", + "features": "功能", + "upgradeTitle": "标题", + "heroDesc": "每场 SQB 结束后立即发布到你的频道:计分板、战斗日志、移动地图和回放,全程自动完成。", + "instantScoreboards": "即时计分板", + "viewPaths": "查看路径", + "chatBattleLogs": "聊天和战斗日志", + "replayLookups": "回放查询", + "free": "免费", + "perMonth": "/mo", + "alwaysFree": "永久免费", + "noCardNeeded": "无需银行卡", + "included": "包含", + "manualLookups": "手动比赛查询", + "playerStats": "玩家统计和资料", + "leaderboards": "排行榜", + "stickWithFree": "Stick with 免费", + "premiumLabel": "高级版", + "perServer": "每个服务器", + "cancelAnytime": "随时取消", + "everythingInFree": "包含免费版全部功能,另加", + "autoScoreboards": "自动发布计分板", + "pathMaps": "路径 / 移动地图", + "chatLogs": "聊天和战斗日志", + "replayLookupsFeature": "回放查询", + "unlimitedComp": "无限 /comp 查询", + "prioritySupport": "优先支持", + "subscribeNow": "立即订阅", + "comingSoon": "即将推出", + "serverIdInfo": "你需要你的", + "discordServerId": "Discord 服务器 ID", + "duringCheckout": "用于结账。", + "developerMode": "开发者模式", + "rightClickServer": "Right-click 服务器", + "copyId": "复制 ID", + "successTitle": "高级版已激活", + "successDesc": "该项目的详细说明。", + "whatHappensNext": "接下来会发生什么", + "autoLogging": "Auto-logging activates for your 服务器", + "setLogChannel": "使用以下命令设置日志频道:", + "everyResult": "每场 SQB 结果都会自动发布", + "readSetupGuide": "阅读设置指南", + "tierStandardName": "标准版", + "tierProName": "专业版", + "tierMaxName": "旗舰版", + "squadCap": "最多记录 {cap} 个中队", + "squadCapUnlimited": "记录无限中队", + "everythingInStandard": "包含标准版全部功能", + "everythingInPro": "包含专业版全部功能", + "wildcardSupport": "通配符日志(*, all, everything)", + "noSquadCap": "没有squadron cap", + "earlyAccessFeatures": "抢先体验新功能" + }, + "terms": { + "title": "服务条款与隐私政策", + "subtitle": "标题", + "termsOfService": "服务条款", + "privacyPolicy": "隐私政策", + "dataWeCollect": "我们收集的数据", + "howWeUseData": "我们如何使用数据", + "contact": "联系我们", + "pageTitle": "服务条款与隐私政策", + "lastUpdated": "最后更新:2026 年 4 月", + "byUsing": "使用", + "youAgree": " 即表示你同意以下内容:", + "useResponsibly": "负责任地使用", + "useResponsiblyDesc": "请勿尝试过载、刷屏或以其他方式破坏机器人。", + "noFunnyBusiness": "禁止违规行为", + "noFunnyBusinessDesc": "该项目的详细说明。", + "statsAsIs": "统计按现状提供", + "statsAsIsDesc": "所有数据和统计均按“现状”提供,不作任何明示或暗示保证。我们会尽合理努力保持数据准确,但不保证完全无误。", + "uptimeNotGuaranteed": "不保证在线时间", + "uptimeNotGuaranteedDesc": "机器人按“可用状态”提供。我们不保证服务不中断或无错误,服务可能因维护、故障或第三方问题而不可用。", + "weCanBanYou": "我们可以移除访问权限", + "weCanBanYouDesc": "如果你违反规则,我们可以移除你的访问权限。", + "infoWeCollect": "1. 我们收集的信息", + "collectsFollowing": "会收集以下信息以提供服务:", + "discordUserIds": "Discord 用户 ID:", + "discordUserIdsDesc": "用于追踪单个用户的唯一标识符", + "squadronIds": "中队 ID:", + "squadronIdsDesc": "该项目的详细说明。", + "battleData": "战斗数据:", + "battleDataDesc": "胜负记录、战斗时间戳及相关统计", + "commandUsage": "命令使用情况:", + "commandUsageDesc": "为改进服务而记录的基本机器人命令使用情况", + "howWeUse": "2. 我们如何使用你的信息", + "usedExclusively": "收集的信息仅用于:", + "trackingPerformance": "追踪中队战斗表现和统计", + "providingHistorical": "提供历史数据和分析", + "improvingBot": "改进机器人功能和用户体验", + "troubleshootingIssues": "排查技术问题", + "dataStorage": "3. 数据存储与安全", + "dataStoredSecurely": "你的数据会通过以下保护措施安全存储:", + "encryptedServers": "数据存储在安全服务器并进行加密", + "limitedAccess": "仅授权人员可以访问", + "regularBackups": "定期备份以确保数据完整性", + "dataSharing": "4. 数据共享", + "weDoNot": "我们不会:", + "sellData": "向第三方出售你的个人信息", + "shareData": "与外部组织共享你的数据", + "useForAds": "将你的数据用于广告或营销", + "transferData": "将你的数据转移到安全系统之外", + "dataRetention": "5. 数据保留", + "dataRetentionDesc": "我们会在提供服务所需的时间内保留你的数据。战斗统计和中队数据会保留用于维护历史记录和分析。", + "discordIntegration": "6. Discord 集成", + "discordIntegrationDesc": "该项目的详细说明。", + "ageRestrictions": "7. 年龄限制", + "ageRestrictionsDesc": "该项目的详细说明。", + "changesToPolicy": "8. 隐私政策变更", + "changesToPolicyDesc": "我们可能会不时更新本隐私政策。", + "premiumTitle": "高级订阅条款", + "premiumWhatYouGet": "1. 高级版包含内容", + "premiumWhatYouGetDesc": "该项目的详细说明。", + "premiumFeature1": "每场 SQB 比赛结束后自动发布计分板", + "premiumFeature2": "路径 / 移动地图", + "premiumFeature3": "聊天和战斗日志", + "premiumFeature4": "回放查询", + "premiumFeature5": "无限 /comp 查询(免费服务器每个时段有限制)", + "premiumBilling": "2. 计费与付款", + "premiumBillingDesc": "该项目的详细说明。", + "premiumCancellation": "3. 取消", + "premiumCancellationDesc": "该项目的详细说明。", + "premiumRefunds": "4. 退款", + "premiumRefundsDesc": "订阅费用不可退款。取消后,当期周期结束前仍可继续使用。", + "premiumPriceChanges": "5. 价格变更", + "premiumPriceChangesDesc": "我们保留随时调整订阅价格的权利。现有订阅者会至少提前 30 天收到通知。", + "premiumTermination": "6. 终止高级访问", + "premiumTerminationDesc": "如果服务器违反服务条款,包括滥用自动日志、刷请求或规避限制,我们保留在不退款的情况下撤销高级访问的权利。", + "serviceAvailabilityTitle": "服务可用性与责任", + "serviceNoWarranty": "1. 无保证", + "serviceNoWarrantyDesc": "本服务按“现状”和“可用状态”提供,不作任何明示、暗示或法定保证。", + "serviceLiability": "2. 责任限制", + "serviceLiabilityDesc": "在适用法律允许的最大范围内,我们不对任何间接、附带、特殊、后果性或惩罚性损害负责。", + "serviceCredits": "3. 服务中断与补偿", + "serviceCreditsDesc": "该项目的详细说明。", + "serviceForceM": "4. 不可抗力", + "serviceForceMDesc": "该项目的详细说明。", + "disclaimer": "免责声明", + "warThunderDisclaimer": "是一个独立 Discord 机器人,与 Gaijin Entertainment 或 War Thunder 没有关联、认可或官方合作关系。War Thunder 是 Gaijin Entertainment 的商标。", + "acknowledgement": "使用", + "acknowledgementEnd": "即表示你确认已阅读、理解并同意受本服务条款和隐私政策约束。" + }, + "seasonCard": { + "season": "赛季", + "battles": "战斗", + "wins": "胜场", + "losses": "负场", + "winRate": "胜率", + "rating": "评分", + "rank": "排名", + "points": "点数", + "bestMap": "最佳地图", + "topVehicle": "常用载具", + "noData": "没有数据", + "buttonLabel": "赛季卡片", + "buttonDisabledTitle": "标题", + "modalTitle": "赛季卡片", + "seasonLabel": "赛季", + "themeLabel": "主题", + "themeDark": "深色", + "themeLight": "浅色", + "generate": "生成", + "loadingSeasons": "正在加载seasons…", + "generating": "正在生成…", + "failedSeasons": "无法load seasons.", + "failedGenerate": "无法generate recap card.", + "inProgressSuffix": "(进行中)", + "imgRecapSuffix": "回顾", + "imgHeroFinalRating": "Final 评分", + "imgHeroMatches": "比赛es", + "imgHeroWinRate": "胜率", + "imgHeroKD": "K/D", + "imgAxisRating": "评分", + "imgAxisWinRate": "胜率 (%)", + "imgStatPeakRating": "最高评分", + "imgStatRatingChange": "评分 change", + "imgStatTotalKills": "总kills", + "imgStatTotalDeaths": "总deaths", + "imgStatAssistsCaptures": "助攻 / captures", + "imgStatMostPlayedVehicle": "最常用载具", + "imgStatMVP": "MVP", + "imgStatMostActive": "最活跃", + "imgStatLongestWinStreak": "最长连胜", + "imgStatMostCommonOpponent": "最常见对手", + "imgUnitKills": "击杀", + "imgUnitAssists": "助攻", + "imgUnitCaptures": "占点", + "imgUnitGames": "场", + "imgUnitMatches": "比赛", + "imgUnitWins": "胜场", + "imgUnitLosses": "负场", + "imgGroundShort": "G", + "imgAirShort": "A", + "imgFooterGenerated": "已生成", + "imgPlaceholderNoData": "{short} 在 {season} 没有数据", + "buttonLabelPlayer": "赛季回顾", + "buttonDisabledTitlePlayer": "标题", + "modalTitlePlayer": "标题", + "imgHeroBattles": "战斗", + "imgHeroTotalKills": "总击杀", + "imgAxisBattles": "战斗s (7d)", + "imgAxisKD": "K/D", + "imgStatBestMatch": "最佳比赛", + "imgStatSquadronsRepresented": "中队s represented", + "imgStatFrequentTeammate": "最常见队友", + "imgStatLongestSession": "最长场次", + "imgStatMostActiveDay": "最活跃日期(UTC)", + "imgStatMostCommonOppSquadron": "最常见对方中队", + "imgStatPeakSquadronRating": "最高中队评分", + "imgUnitSlotNA": "NA", + "imgUnitSlotEU": "EU", + "imgUnitNoSquadron": "no sq", + "imgUnitVs": "vs", + "imgUnitTogether": "共同比赛", + "imgStatKDAC": "K / D / A / C", + "imgPlaceholderNoDataPlayer": "{nick} 在 {season} 没有数据", + "imgUIDLabel": "UID", + "imgBestMatchLine": "{vehicle} · 地杀 {gk} / 空杀 {ak} / 助攻 {assists} / 占点 {cap} / 死亡 {deaths} · {date}" + }, + "player": { + "totalBattles": "总战斗s", + "totalWins": "总胜场", + "vehicleStatistics": "载具 统计", + "cumulative": "累计", + "individual": "单项", + "filterBy": "筛选:", + "allTime": "全部时间", + "dateRange": "日期范围", + "season": "赛季", + "week": "周", + "session": "场次", + "dateType": "日期类型:", + "last7Days": "最近 7 天", + "last30Days": "最近 30 天", + "last90Days": "最近 90 天", + "customRange": "自定义范围", + "specificDate": "指定日期", + "filterType": "筛选类型:", + "fullSeason": "整个赛季", + "specificWeek": "指定周", + "from": "从:", + "to": "到:", + "timeslot": "时段", + "fullDay": "全天", + "selectSeason": "赛季:", + "selectWeek": "周:", + "selectSeasonFirst": "选择season first", + "pleaseSelect": "请选择一个选项", + "searchVehicles": "搜索vehicles...", + "resetFilters": "重置筛选", + "vehiclesShown": "个载具已显示", + "gamesShown": "场比赛已显示", + "noVehicleData": "没有载具数据 available", + "noVehiclesForRange": "没有vehicles found for the 选择ed date range, or this player has no data yet.", + "switchToCards": "切换到卡片视图", + "switchToTable": "切换到表格视图", + "loadingTimeline": "正在加载timeline...", + "noTimelineData": "没有timeline data yet.", + "timelineUnavailable": "时间线 不可用.", + "loadingGameRecords": "正在加载game records...", + "unableToLoadRecords": "无法load game records", + "failedToFetch": "无法fetch game data. Please try again later.", + "noGameRecords": "没有game records found", + "noGamesYet": "此玩家还没有任何已记录比赛。", + "collapseChart": "折叠图表", + "loadingChartData": "正在加载chart data...", + "noHistoricalData": "没有historical data yet.", + "chartUnavailable": "图表 不可用.", + "relative": "相对值", + "uidLabel": "玩家 UID" + }, + "leaderboard": { + "playersTitle": "玩家s 排行榜", + "playersSubtitle": "按表现排名的 War Thunder 顶尖玩家", + "vehiclesTitle": "载具 击杀 排行榜", + "vehiclesSubtitle": "按总击杀排名的 War Thunder 顶尖载具", + "squadronsTitle": "中队 排行榜", + "squadronsSubtitle": "按表现排名的 War Thunder 顶尖中队", + "statsTitle": "标题", + "statsSubtitle": "整体中队战斗统计和 Meta 信息", + "comparisonTitle": "标题", + "comparisonSubtitle": "标题", + "comparisonHint": "对比stats to find the best performers and vehicles", + "compareSquadrons": "对比中队s", + "comparePlayers": "对比玩家s", + "compareVehicles": "对比载具s", + "playersAndVehicles": "玩家s + 载具s", + "failedToLoadLeaderboard": "无法load 队长board data. Please try again later.", + "failedToLoadVehicles": "无法load vehicle 队长board", + "failedToLoadSquadrons": "无法load squadron 队长board", + "noResultsYet": "没有results yet. 添加squadrons/players to begin.", + "searchSquadron": "搜索中队", + "searchBySquadronName": "搜索by squadron name...", + "minPlayers": "Min 玩家s", + "minPlayersPlaceholder": "请输入或选择…", + "resetFilters": "重置筛选", + "squadronsShown": "个中队已显示", + "playersShown": "名玩家已显示", + "page": "页", + "of": "of", + "loadingSquadronLeaderboard": "正在加载squadron 队长board...", + "loadingPlayerLeaderboard": "正在加载player 队长board...", + "loadingComparisonData": "正在加载comparison data...", + "unableToFetch": "无法fetch 队长board data. Please try again.", + "noSquadronsInLeaderboard": "没有squadrons found in 队长board.", + "noPlayersInLeaderboard": "没有players found in 队长board.", + "loadingGlobalStats": "正在加载global statistics...", + "failedToLoadStats": "无法load statistics", + "unableToFetchStats": "无法fetch statistics data. Please try again.", + "mostPopularVehicles": "最受欢迎 载具s", + "vehicleKillsLeaderboardTitle": "载具 击杀 排行榜", + "avgWinRate": "平均胜率", + "avgKillsPerPlayer": "平均击杀/玩家", + "loadingVehicleKills": "正在加载vehicle kills data...", + "apiNotLoaded": "API 客户端未正确加载。请刷新页面。", + "failedToInitApi": "无法initialize API client", + "noStatsData": "没有statistics data available", + "totalPlayersCard": "总玩家s", + "activePlayers": "已启用 玩家s", + "vehiclesUsed": "载具s Used", + "differentVehicles": "Different 载具s", + "squadronBattlesLabel": "中队 战斗s", + "noVehicleData": "没有载具数据 available", + "mostPopular": "最受欢迎", + "timesUsed": "使用次数", + "failedToLoadVehicleKills": "无法load vehicle kills data", + "lastUpdated": "最后更新", + "searchPlayer": "搜索玩家", + "searchByPlayerName": "搜索by player name...", + "minimumBattles": "最低战斗s", + "minBattlesPlaceholder": "请输入或选择…", + "searchSquadronsPlaceholder": "请输入或选择…", + "sortBy": "排序方式", + "kdRatio": "K/D 比", + "killsPerSpawn": "击杀 Per Spawn", + "caps": "占点", + "timePeriod": "时间范围", + "allTime": "全部时间", + "dateRange": "日期范围", + "season": "赛季", + "week": "周", + "dateType": "日期类型", + "last7Days": "最近 7 天", + "last30Days": "最近 30 天", + "last90Days": "最近 90 天", + "customRange": "自定义范围", + "from": "从", + "to": "To", + "timeslot": "时段", + "fullDay": "全天", + "selectSeason": "选择season...", + "selectWeek": "选择week...", + "failedToLoadComparison": "无法load comparison data", + "pleaseRefresh": "请尝试刷新页面", + "playerComparison": "玩家 对比", + "squadronComparison": "中队 对比", + "vehicleComparison": "载具 对比", + "playersVehiclesComparison": "玩家s + 载具s 对比", + "addPlayersToCompare": "添加玩家s to Compare:", + "addSquadronsToCompare": "添加中队s to Compare:", + "addVehiclesToCompare": "添加载具s to Compare:", + "addPlayerVehicleCombos": "添加玩家 + 载具 Combinations:", + "searchSelectPlayers": "搜索and 选择 players...", + "typeSquadronName": "输入中队名称...", + "searchSelectVehicles": "搜索and 选择 vehicles...", + "searchForPlayers": "搜索for players...", + "selectPlayersToCompare": "搜索and 选择 players above to compare their stats", + "selectSquadronsToCompare": "搜索and 选择 squadrons above to compare their stats", + "selectVehiclesToCompare": "搜索and 选择 vehicles above to compare their stats", + "selectPlayersVehiclesToCompare": "搜索and 选择 players above, then choose their vehicles to compare different player-vehicle combinations", + "selectVehicleFor": "选择vehicle for", + "selectAVehicle": "-- 选择a vehicle --", + "noVehiclesForPlayer": "没有vehicles found for this player", + "noPlayerVehicleSelected": "没有player-vehicle combinations 选择ed", + "noPlayersSelected": "没有players 选择ed", + "noVehiclesSelected": "没有vehicles 选择ed", + "noSquadronsSelected": "没有squadrons 选择ed", + "statistic": "统计项", + "totalDeaths": "总死亡", + "totalAssists": "总助攻", + "totalCaptures": "总占点", + "killsPerSpawnShort": "击杀/Spawn", + "avgWinRateShort": "平均 胜率", + "avgKillsPlayerShort": "平均 击杀/玩家", + "avgKillsMember": "平均 击杀/Member", + "avgBattlesMember": "平均 战斗s/Member", + "serverError500": "服务器 error (500) - The API is temporarily 不可用. Please try again in a few moments.", + "apiEndpoint404": "API endpoint not found (404) - Please check 服务器 configuration.", + "networkError": "Network error - 无法connect to 服务器. Please check your connection.", + "viewFullLeaderboard": "查看Full 排行榜", + "vehicleName": "载具名称", + "searchVehiclePlaceholder": "请输入或选择…", + "minKills": "Min 击杀", + "any": "任意", + "perPage": "每页", + "clear": "清除", + "vehicleAndPlayer": "载具和玩家", + "loadingVehicleLeaderboard": "正在加载vehicle 队长board...", + "vehiclesShown": "个载具已显示", + "minBattlesPerVehicle": "最低3 battles per vehicle 必需", + "minimumBattlesRequired": "最低5 battles 必需", + "allSeasons": "全部赛季", + "allWeeks": "全部周", + "allBR": "全部 BR" + }, + "errors": { + "pageNotFound": "Page Not 已找到", + "error": "错误", + "oopsNotFound": "哎呀!你要找的页面不存在。它可能已移动、删除,或 URL 输入错误。", + "searchError": "搜索error. Please try again." + }, + "js": { + "openingDiscordInvite": "正在打开 Discord 邀请!", + "errorOpeningInvite": "错误 opening invite link. Please try again later.", + "gettingSupportLink": "Getting 支持 服务器 link...", + "openingSupportServer": "Opening 支持 服务器!", + "errorGettingSupport": "错误 getting 支持 link. Please try again later.", + "failedToUpdateStats": "无法update stats", + "konamiActivated": "成就解锁:隐藏代码!", + "noPlayersFound": "没有players found", + "searchError": "搜索error. Please try again.", + "killsSuffix": "击杀", + "winRateSuffix": "胜率", + "noSquadronsFound": "没有squadrons found" + }, + "index": { + "subtitle1": "标题", + "subtitle2": "标题", + "subtitle3": "标题", + "subtitle4": "标题" + }, + "live": { + "air": "空杀", + "gnd": "地杀", + "ast": "助攻", + "dth": "死亡", + "cap": "占点", + "squadronBattle": "中队 战斗", + "randomBattle": "Random 战斗" + }, + "playerModal": { + "viewFullProfile": "查看完整资料 →", + "close": "关闭", + "overview": "概览", + "vehicles": "载具", + "sessions": "场次", + "loadingPlayerData": "正在加载玩家数据...", + "kdr": "K/D", + "kps": "K/S", + "winRate": "胜率", + "battles": "战斗", + "wins": "胜场", + "totalBattles": "总战斗", + "totalKills": "总击杀", + "airKills": "空中击杀", + "groundKills": "地面击杀", + "assists": "助攻", + "deaths": "死亡", + "captures": "占点", + "clickToSwitchMetric": "点击切换指标", + "clickToCycle": "点击循环切换", + "noChartData": "无图表数据", + "noVehicleData": "无载具数据", + "noSessionData": "无场次数据", + "date": "日期", + "vehicle": "载具", + "ground": "地面", + "air": "空中", + "result": "结果", + "unknown": "未知", + "failedToLoadPlayerData": "加载玩家数据失败" + }, + "replay": { + "playPause": "播放/暂停", + "crashed": "坠毁", + "destroyed": "摧毁了", + "hit": "击中了" + }, + "dateFilter": { + "allTime": "全部时间", + "currentSeason": "当前赛季", + "bySeason": "按赛季", + "cumulative": "累计", + "customRange": "自定义范围", + "selectSeason": "选择赛季", + "selectSeasonDots": "选择一个赛季...", + "selectWeek": "选择周", + "selectWeekDots": "选择一周...", + "entireSeason": "整个赛季", + "applyFilter": "应用筛选", + "cumulativeHelp": "查看截至指定时间点的累计统计", + "season": "赛季", + "upToWeek": "截至周", + "applyCumulativeFilter": "应用累计筛选", + "startDate": "开始日期", + "endDate": "结束日期", + "applyCustomRange": "应用自定义范围", + "activeFilter": "当前筛选:", + "clear": "清除", + "allTimeStatistics": "全部时间统计", + "currentSeasonValue": "当前赛季:{season}", + "alertSelectSeason": "请选择一个赛季", + "seasonValue": "赛季 {season}", + "alertSelectSeasonWeek": "请选择赛季和周", + "cumulativeValue": "累计至 {season} - {week}", + "alertSelectDate": "请至少选择一个日期", + "alertStartBeforeEnd": "开始日期必须早于结束日期", + "customRangePrefix": "自定义范围:", + "fromDate": "从 {date}", + "upToDate": "截至 {date}" + } +} diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..6eeed36 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,5912 @@ +{ + "name": "toothless-sqb-bot-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "toothless-sqb-bot-web", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "compression": "^1.8.1", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "ejs": "^3.1.9", + "express": "^4.18.2", + "node-fetch": "^2.7.0", + "sqlite3": "^5.1.7" + }, + "devDependencies": { + "autoprefixer": "^10.4.22", + "concurrently": "^9.2.1", + "cross-env": "^10.1.0", + "cssnano": "^7.1.2", + "javascript-obfuscator": "^4.1.0", + "nodemon": "^3.0.1", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.18" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "license": "MIT", + "optional": true + }, + "node_modules/@javascript-obfuscator/escodegen": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@javascript-obfuscator/escodegen/-/escodegen-2.3.0.tgz", + "integrity": "sha512-QVXwMIKqYMl3KwtTirYIA6gOCiJ0ZDtptXqAv/8KWLG9uQU2fZqTVy7a/A5RvcoZhbDoFfveTxuGxJ5ibzQtkw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@javascript-obfuscator/estraverse": "^5.3.0", + "esprima": "^4.0.1", + "esutils": "^2.0.2", + "optionator": "^0.8.1" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/@javascript-obfuscator/estraverse": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@javascript-obfuscator/estraverse/-/estraverse-5.4.0.tgz", + "integrity": "sha512-CZFX7UZVN9VopGbjTx4UXaXsi9ewoM1buL0kY7j1ftYdSs7p2spv9opxFjHlQ/QGTgh4UqufYqJJ0WKLml7b6w==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@npmcli/fs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", + "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "@gar/promisify": "^1.0.1", + "semver": "^7.3.5" + } + }, + "node_modules/@npmcli/move-file": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", + "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "license": "MIT", + "optional": true, + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/move-file/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "optional": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@types/minimatch": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", + "integrity": "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/validator": { + "version": "13.15.3", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.3.tgz", + "integrity": "sha512-7bcUmDyS6PN3EuD9SlGGOxM77F8WLVsrwkxyWxKnxzmXoequ6c7741QBrANq6htVRGOITJ7z72mTP6Z4XyuG+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC", + "optional": true + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.8.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", + "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "license": "MIT", + "optional": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC", + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-differ": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-3.0.0.tgz", + "integrity": "sha512-THtfYS6KtME/yIAhKjZ2ul7XI96lQGHRputJQHO80LAWQnuGP4iCIN8vdMRboGbIEYBwU33q8Tch1os2+X0kMg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/assert": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.0.0.tgz", + "integrity": "sha512-se5Cd+js9dXJnu6Ag2JFc00t+HmHOen+8Q+L7O9zI0PqQXr20uk2J0XQqMxZEeo5U50o8Nvmmx7dZrl+Ufr35A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es6-object-assign": "^1.1.0", + "is-nan": "^1.2.1", + "object-is": "^1.0.1", + "util": "^0.12.0" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", + "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.27.0", + "caniuse-lite": "^1.0.30001754", + "fraction.js": "^5.3.4", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.31", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz", + "integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", + "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.25", + "caniuse-lite": "^1.0.30001754", + "electron-to-chromium": "^1.5.249", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacache": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", + "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "@npmcli/fs": "^1.0.0", + "@npmcli/move-file": "^1.0.1", + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "glob": "^7.1.4", + "infer-owner": "^1.0.4", + "lru-cache": "^6.0.0", + "minipass": "^3.1.1", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.2", + "mkdirp": "^1.0.3", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^8.0.1", + "tar": "^6.0.2", + "unique-filename": "^1.1.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cacache/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "optional": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-api": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", + "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.0.0", + "caniuse-lite": "^1.0.0", + "lodash.memoize": "^4.1.2", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001757", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", + "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chance": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/chance/-/chance-1.1.9.tgz", + "integrity": "sha512-TfxnA/DcZXRTA4OekA2zL9GH8qscbbl6X0ZqU4tXhGveVY/mXWvEQLt5GwZcYXTEyEFflVtj+pG8nc8EwSm1RQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/class-validator": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.1.tgz", + "integrity": "sha512-2VEG9JICxIqTpoK1eMzZqaV+u/EiwEJkMGzTrZf6sU/fwsnOITVgYJ8yojSy6CaXtO9V0Cc6ZQZ8h8m4UBuLwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/validator": "^13.11.8", + "libphonenumber-js": "^1.10.53", + "validator": "^13.9.0" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "optional": true, + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/colord": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.0.tgz", + "integrity": "sha512-zS5PnTI22FIRM6ylNW8G4Ap0IEOyk62fhLSD0+uHRT9McRCLGpkVNvao4bjimpK/GShynyQkFFxHhwMcETmduA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC", + "optional": true + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/css-declaration-sorter": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.3.0.tgz", + "integrity": "sha512-LQF6N/3vkAMYF4xoHLJfG718HRJh34Z8BnNhd6bosOMIVjMlhuZK5++oZa3uYAgrI5+7x2o27gUqTR2U/KjUOQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.0.9" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssnano": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.2.tgz", + "integrity": "sha512-HYOPBsNvoiFeR1eghKD5C3ASm64v9YVyJB4Ivnl2gqKoQYvjjN/G0rztvKQq8OxocUtC6sjqY8jwYngIB4AByA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssnano-preset-default": "^7.0.10", + "lilconfig": "^3.1.3" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/cssnano" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/cssnano-preset-default": { + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-7.0.10.tgz", + "integrity": "sha512-6ZBjW0Lf1K1Z+0OKUAUpEN62tSXmYChXWi2NAA0afxEVsj9a+MbcB1l5qel6BHJHmULai2fCGRthCeKSFbScpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.27.0", + "css-declaration-sorter": "^7.2.0", + "cssnano-utils": "^5.0.1", + "postcss-calc": "^10.1.1", + "postcss-colormin": "^7.0.5", + "postcss-convert-values": "^7.0.8", + "postcss-discard-comments": "^7.0.5", + "postcss-discard-duplicates": "^7.0.2", + "postcss-discard-empty": "^7.0.1", + "postcss-discard-overridden": "^7.0.1", + "postcss-merge-longhand": "^7.0.5", + "postcss-merge-rules": "^7.0.7", + "postcss-minify-font-values": "^7.0.1", + "postcss-minify-gradients": "^7.0.1", + "postcss-minify-params": "^7.0.5", + "postcss-minify-selectors": "^7.0.5", + "postcss-normalize-charset": "^7.0.1", + "postcss-normalize-display-values": "^7.0.1", + "postcss-normalize-positions": "^7.0.1", + "postcss-normalize-repeat-style": "^7.0.1", + "postcss-normalize-string": "^7.0.1", + "postcss-normalize-timing-functions": "^7.0.1", + "postcss-normalize-unicode": "^7.0.5", + "postcss-normalize-url": "^7.0.1", + "postcss-normalize-whitespace": "^7.0.1", + "postcss-ordered-values": "^7.0.2", + "postcss-reduce-initial": "^7.0.5", + "postcss-reduce-transforms": "^7.0.1", + "postcss-svgo": "^7.1.0", + "postcss-unique-selectors": "^7.0.4" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/cssnano-utils": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-5.0.1.tgz", + "integrity": "sha512-ZIP71eQgG9JwjVZsTPSqhc6GHgEr53uJ7tK5///VfyWj6Xp2DBmixWHqJgPno+PqATzn48pL42ww9x5SSGmhZg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT", + "optional": true + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.260", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.260.tgz", + "integrity": "sha512-ov8rBoOBhVawpzdre+Cmz4FB+y66Eqrk6Gwqd8NGxuhv99GQ8XqMAr351KEkOt7gukXWDg6gJWEMKgL2RLMPtA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "license": "MIT", + "optional": true + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-object-assign": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es6-object-assign/-/es6-object-assign-1.1.0.tgz", + "integrity": "sha512-MEl9uirslVwqQU369iHNWZXsI8yaZYGg/D65aOgZkeyFJwHYSxilf7rQzXKI7DdDuBPrBXbfk3sl9hJhmd5AUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/eslint-scope": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", + "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", + "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC", + "optional": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC", + "optional": true + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC", + "optional": true + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "license": "ISC", + "optional": true + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "optional": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/inversify": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/inversify/-/inversify-6.0.1.tgz", + "integrity": "sha512-B3ex30927698TJENHR++8FfEaJGqoWOgI6ZY5Ht/nLUsFCwHn6akbwtnUAPCgUepAnTpe2qHxhDNjoKLyz6rgQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "license": "MIT", + "optional": true + }, + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/jake": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", + "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.3", + "chalk": "^4.0.2", + "filelist": "^1.0.4", + "minimatch": "^3.1.2" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/javascript-obfuscator": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/javascript-obfuscator/-/javascript-obfuscator-4.1.1.tgz", + "integrity": "sha512-gt+KZpIIrrxXHEQGD8xZrL8mTRwRY0U76/xz/YX0gZdPrSqQhT/c7dYLASlLlecT3r+FxE7je/+C0oLnTDCx4A==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-2-Clause", + "dependencies": { + "@javascript-obfuscator/escodegen": "2.3.0", + "@javascript-obfuscator/estraverse": "5.4.0", + "acorn": "8.8.2", + "assert": "2.0.0", + "chalk": "4.1.2", + "chance": "1.1.9", + "class-validator": "0.14.1", + "commander": "10.0.0", + "eslint-scope": "7.1.1", + "eslint-visitor-keys": "3.3.0", + "fast-deep-equal": "3.1.3", + "inversify": "6.0.1", + "js-string-escape": "1.0.1", + "md5": "2.3.0", + "mkdirp": "2.1.3", + "multimatch": "5.0.0", + "opencollective-postinstall": "2.0.3", + "process": "0.11.10", + "reflect-metadata": "0.1.13", + "source-map-support": "0.5.21", + "string-template": "1.0.0", + "stringz": "2.1.0", + "tslib": "2.5.0" + }, + "bin": { + "javascript-obfuscator": "bin/javascript-obfuscator" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/javascript-obfuscator" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-string-escape": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz", + "integrity": "sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/libphonenumber-js": { + "version": "1.12.23", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.23.tgz", + "integrity": "sha512-RN3q3gImZ91BvRDYjWp7ICz3gRn81mW5L4SW+2afzNCC0I/nkXstBgZThQGTE3S/9q5J90FH4dP+TXx8NhdZKg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-fetch-happen": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", + "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", + "license": "ISC", + "optional": true, + "dependencies": { + "agentkeepalive": "^4.1.3", + "cacache": "^15.2.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^6.0.0", + "minipass": "^3.1.3", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^1.3.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.2", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^6.0.0", + "ssri": "^8.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-fetch": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", + "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.1.0", + "minipass-sized": "^1.0.3", + "minizlib": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "optionalDependencies": { + "encoding": "^0.1.12" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/mkdirp": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-2.1.3.tgz", + "integrity": "sha512-sjAkg21peAG9HS+Dkx7hlG9Ztx7HLeKnvB3NQRcu/mltCVmvkF0pisbiTSfDVYTT86XEfZrTUosLdZLStquZUw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multimatch": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/multimatch/-/multimatch-5.0.0.tgz", + "integrity": "sha512-ypMKuglUrZUD99Tk2bUQ+xNQj43lPEfAeX2o9cTteAmShXy2VHDJpuwu1o0xqoKCt9jLVAvwyFKdLTPXKAfJyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/minimatch": "^3.0.3", + "array-differ": "^3.0.0", + "array-union": "^2.1.0", + "arrify": "^2.0.1", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.85.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", + "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-gyp": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", + "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", + "license": "MIT", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^9.1.0", + "nopt": "^5.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemon": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", + "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/opencollective-postinstall": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", + "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", + "dev": true, + "license": "MIT", + "bin": { + "opencollective-postinstall": "index.js" + } + }, + "node_modules/optionator": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", + "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.6", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "word-wrap": "~1.2.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-calc": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-10.1.1.tgz", + "integrity": "sha512-NYEsLHh8DgG/PRH2+G9BTuUdtf9ViS+vdoQ0YA5OQdGsfN4ztiwtDWNtBl9EKeqNMFnIu8IKZ0cLxEQ5r5KVMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12 || ^20.9 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.38" + } + }, + "node_modules/postcss-calc/node_modules/postcss-selector-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", + "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-colormin": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-7.0.5.tgz", + "integrity": "sha512-ekIBP/nwzRWhEMmIxHHbXHcMdzd1HIUzBECaj5KEdLz9DVP2HzT065sEhvOx1dkLjYW7jyD0CngThx6bpFi2fA==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.27.0", + "caniuse-api": "^3.0.0", + "colord": "^2.9.3", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/postcss-convert-values": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-7.0.8.tgz", + "integrity": "sha512-+XNKuPfkHTCEo499VzLMYn94TiL3r9YqRE3Ty+jP7UX4qjewUONey1t7CG21lrlTLN07GtGM8MqFVp86D4uKJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.27.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/postcss-discard-comments": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-7.0.5.tgz", + "integrity": "sha512-IR2Eja8WfYgN5n32vEGSctVQ1+JARfu4UH8M7bgGh1bC+xI/obsPJXaBpQF7MAByvgwZinhpHpdrmXtvVVlKcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^7.1.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/postcss-discard-comments/node_modules/postcss-selector-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", + "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-discard-duplicates": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-7.0.2.tgz", + "integrity": "sha512-eTonaQvPZ/3i1ASDHOKkYwAybiM45zFIc7KXils4mQmHLqIswXD9XNOKEVxtTFnsmwYzF66u4LMgSr0abDlh5w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/postcss-discard-empty": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-7.0.1.tgz", + "integrity": "sha512-cFrJKZvcg/uxB6Ijr4l6qmn3pXQBna9zyrPC+sK0zjbkDUZew+6xDltSF7OeB7rAtzaaMVYSdbod+sZOCWnMOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/postcss-discard-overridden": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-7.0.1.tgz", + "integrity": "sha512-7c3MMjjSZ/qYrx3uc1940GSOzN1Iqjtlqe8uoSg+qdVPYyRb0TILSqqmtlSFuE4mTDECwsm397Ya7iXGzfF7lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-merge-longhand": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-7.0.5.tgz", + "integrity": "sha512-Kpu5v4Ys6QI59FxmxtNB/iHUVDn9Y9sYw66D6+SZoIk4QTz1prC4aYkhIESu+ieG1iylod1f8MILMs1Em3mmIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0", + "stylehacks": "^7.0.5" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/postcss-merge-rules": { + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-7.0.7.tgz", + "integrity": "sha512-njWJrd/Ms6XViwowaaCc+/vqhPG3SmXn725AGrnl+BgTuRPEacjiLEaGq16J6XirMJbtKkTwnt67SS+e2WGoew==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.27.0", + "caniuse-api": "^3.0.0", + "cssnano-utils": "^5.0.1", + "postcss-selector-parser": "^7.1.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/postcss-merge-rules/node_modules/postcss-selector-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", + "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-minify-font-values": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-7.0.1.tgz", + "integrity": "sha512-2m1uiuJeTplll+tq4ENOQSzB8LRnSUChBv7oSyFLsJRtUgAAJGP6LLz0/8lkinTgxrmJSPOEhgY1bMXOQ4ZXhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/postcss-minify-gradients": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-7.0.1.tgz", + "integrity": "sha512-X9JjaysZJwlqNkJbUDgOclyG3jZEpAMOfof6PUZjPnPrePnPG62pS17CjdM32uT1Uq1jFvNSff9l7kNbmMSL2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "colord": "^2.9.3", + "cssnano-utils": "^5.0.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/postcss-minify-params": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-7.0.5.tgz", + "integrity": "sha512-FGK9ky02h6Ighn3UihsyeAH5XmLEE2MSGH5Tc4tXMFtEDx7B+zTG6hD/+/cT+fbF7PbYojsmmWjyTwFwW1JKQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.27.0", + "cssnano-utils": "^5.0.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/postcss-minify-selectors": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-7.0.5.tgz", + "integrity": "sha512-x2/IvofHcdIrAm9Q+p06ZD1h6FPcQ32WtCRVodJLDR+WMn8EVHI1kvLxZuGKz/9EY5nAmI6lIQIrpo4tBy5+ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "postcss-selector-parser": "^7.1.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/postcss-minify-selectors/node_modules/postcss-selector-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", + "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-normalize-charset": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-7.0.1.tgz", + "integrity": "sha512-sn413ofhSQHlZFae//m9FTOfkmiZ+YQXsbosqOWRiVQncU2BA3daX3n0VF3cG6rGLSFVc5Di/yns0dFfh8NFgQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/postcss-normalize-display-values": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-7.0.1.tgz", + "integrity": "sha512-E5nnB26XjSYz/mGITm6JgiDpAbVuAkzXwLzRZtts19jHDUBFxZ0BkXAehy0uimrOjYJbocby4FVswA/5noOxrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/postcss-normalize-positions": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-7.0.1.tgz", + "integrity": "sha512-pB/SzrIP2l50ZIYu+yQZyMNmnAcwyYb9R1fVWPRxm4zcUFCY2ign7rcntGFuMXDdd9L2pPNUgoODDk91PzRZuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/postcss-normalize-repeat-style": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-7.0.1.tgz", + "integrity": "sha512-NsSQJ8zj8TIDiF0ig44Byo3Jk9e4gNt9x2VIlJudnQQ5DhWAHJPF4Tr1ITwyHio2BUi/I6Iv0HRO7beHYOloYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/postcss-normalize-string": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-7.0.1.tgz", + "integrity": "sha512-QByrI7hAhsoze992kpbMlJSbZ8FuCEc1OT9EFbZ6HldXNpsdpZr+YXC5di3UEv0+jeZlHbZcoCADgb7a+lPmmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/postcss-normalize-timing-functions": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-7.0.1.tgz", + "integrity": "sha512-bHifyuuSNdKKsnNJ0s8fmfLMlvsQwYVxIoUBnowIVl2ZAdrkYQNGVB4RxjfpvkMjipqvbz0u7feBZybkl/6NJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/postcss-normalize-unicode": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-7.0.5.tgz", + "integrity": "sha512-X6BBwiRxVaFHrb2WyBMddIeB5HBjJcAaUHyhLrM2FsxSq5TFqcHSsK7Zu1otag+o0ZphQGJewGH1tAyrD0zX1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.27.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/postcss-normalize-url": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-7.0.1.tgz", + "integrity": "sha512-sUcD2cWtyK1AOL/82Fwy1aIVm/wwj5SdZkgZ3QiUzSzQQofrbq15jWJ3BA7Z+yVRwamCjJgZJN0I9IS7c6tgeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/postcss-normalize-whitespace": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-7.0.1.tgz", + "integrity": "sha512-vsbgFHMFQrJBJKrUFJNZ2pgBeBkC2IvvoHjz1to0/0Xk7sII24T0qFOiJzG6Fu3zJoq/0yI4rKWi7WhApW+EFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/postcss-ordered-values": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-7.0.2.tgz", + "integrity": "sha512-AMJjt1ECBffF7CEON/Y0rekRLS6KsePU6PRP08UqYW4UGFRnTXNrByUzYK1h8AC7UWTZdQ9O3Oq9kFIhm0SFEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssnano-utils": "^5.0.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/postcss-reduce-initial": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-7.0.5.tgz", + "integrity": "sha512-RHagHLidG8hTZcnr4FpyMB2jtgd/OcyAazjMhoy5qmWJOx1uxKh4ntk0Pb46ajKM0rkf32lRH4C8c9qQiPR6IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.27.0", + "caniuse-api": "^3.0.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/postcss-reduce-transforms": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-7.0.1.tgz", + "integrity": "sha512-MhyEbfrm+Mlp/36hvZ9mT9DaO7dbncU0CvWI8V93LRkY6IYlu38OPg3FObnuKTUxJ4qA8HpurdQOo5CyqqO76g==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-svgo": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-7.1.0.tgz", + "integrity": "sha512-KnAlfmhtoLz6IuU3Sij2ycusNs4jPW+QoFE5kuuUOK8awR6tMxZQrs5Ey3BUz7nFCzT3eqyFgqkyrHiaU2xx3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0", + "svgo": "^4.0.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >= 18" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/postcss-unique-selectors": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-7.0.4.tgz", + "integrity": "sha512-pmlZjsmEAG7cHd7uK3ZiNSW6otSZ13RHuZ/4cDN/bVglS5EpF2r2oxY99SuOHa8m7AWoBCelTS3JPpzsIs8skQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^7.1.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/postcss-unique-selectors/node_modules/postcss-selector-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", + "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "license": "ISC", + "optional": true + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "license": "MIT", + "optional": true, + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/reflect-metadata": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", + "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz", + "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC", + "optional": true + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "optional": true + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", + "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/socks-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socks-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sqlite3": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", + "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1", + "tar": "^6.1.11" + }, + "optionalDependencies": { + "node-gyp": "8.x" + }, + "peerDependencies": { + "node-gyp": "8.x" + }, + "peerDependenciesMeta": { + "node-gyp": { + "optional": true + } + } + }, + "node_modules/ssri": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", + "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-template": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string-template/-/string-template-1.0.0.tgz", + "integrity": "sha512-SLqR3GBUXuoPP5MmYtD7ompvXiG87QjT6lzOszyXjTM86Uu7At7vNnt2xgyTLq5o9T4IxTYFyGxcULqpsmsfdg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/stringz": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/stringz/-/stringz-2.1.0.tgz", + "integrity": "sha512-KlywLT+MZ+v0IRepfMxRtnSvDCMc3nR1qqCs3m/qIbSOWkNZYT8XHQA31rS3TnKp0c5xjZu3M4GY/2aRKSi/6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stylehacks": { + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-7.0.7.tgz", + "integrity": "sha512-bJkD0JkEtbRrMFtwgpJyBbFIwfDDONQ1Ov3sDLZQP8HuJ73kBOyx66H4bOcAbVWmnfLdvQ0AJwXxOMkpujcO6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.27.0", + "postcss-selector-parser": "^7.1.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/stylehacks/node_modules/postcss-selector-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", + "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svgo": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.0.tgz", + "integrity": "sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^11.1.0", + "css-select": "^5.1.0", + "css-tree": "^3.0.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.1.1", + "sax": "^1.4.1" + }, + "bin": { + "svgo": "bin/svgo.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/svgo/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", + "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", + "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/unique-filename": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", + "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "unique-slug": "^2.0.0" + } + }, + "node_modules/unique-slug": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", + "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", + "license": "ISC", + "optional": true, + "dependencies": { + "imurmurhash": "^0.1.4" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/validator": { + "version": "13.15.26", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", + "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..1065dc6 --- /dev/null +++ b/web/package.json @@ -0,0 +1,53 @@ +{ + "name": "toothless-sqb-bot-web", + "version": "1.0.0", + "description": "Website for Toothless SQB Discord Bot", + "main": "server.js", + "scripts": { + "start": "cross-env NODE_ENV=production node server.js", + "dev": "npm run build:css && concurrently \"npm run watch:css\" \"cross-env NODE_ENV=development nodemon server.js\"", + "build": "npm run build:css && node build.js", + "build:css": "node build-css.js", + "watch:css": "nodemon --watch public/css/tailwind.css --watch tailwind.config.js --exec \"node build-css.js\"", + "build:prod": "npm run build:css && node build.js && cross-env NODE_ENV=production node server.js", + "pm2:start": "npm run build && pm2 start ecosystem.config.js", + "pm2:stop": "pm2 stop toothless-sqb-web", + "pm2:restart": "pm2 restart toothless-sqb-web", + "pm2:reload": "pm2 reload toothless-sqb-web", + "pm2:delete": "pm2 delete toothless-sqb-web", + "pm2:logs": "pm2 logs toothless-sqb-web", + "pm2:monit": "pm2 monit", + "test": "echo \"No tests specified yet. Please run 'npm run dev' to start the development server.\"" + }, + "keywords": [ + "discord", + "bot", + "website", + "express", + "node" + ], + "author": "Sophie :3", + "license": "MIT", + "dependencies": { + "compression": "^1.8.1", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "ejs": "^3.1.9", + "express": "^4.18.2", + "node-fetch": "^2.7.0", + "sqlite3": "^5.1.7" + }, + "devDependencies": { + "autoprefixer": "^10.4.22", + "concurrently": "^9.2.1", + "cross-env": "^10.1.0", + "cssnano": "^7.1.2", + "javascript-obfuscator": "^4.1.0", + "nodemon": "^3.0.1", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.18" + }, + "engines": { + "node": ">=16.0.0" + } +} diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 0000000..2ce518b --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,7 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} + diff --git a/web/public/css/output.css b/web/public/css/output.css new file mode 100644 index 0000000..52e6e61 --- /dev/null +++ b/web/public/css/output.css @@ -0,0 +1,2108 @@ +*, ::before, ::after { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} + +::backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +}/* +! tailwindcss v3.4.18 | MIT License | https://tailwindcss.com +*//* +1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) +2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) +*/ + +*, +::before, +::after { + box-sizing: border-box; /* 1 */ + border-width: 0; /* 2 */ + border-style: solid; /* 2 */ + border-color: #e5e7eb; /* 2 */ +} + +::before, +::after { + --tw-content: ''; +} + +/* +1. Use a consistent sensible line-height in all browsers. +2. Prevent adjustments of font size after orientation changes in iOS. +3. Use a more readable tab size. +4. Use the user's configured `sans` font-family by default. +5. Use the user's configured `sans` font-feature-settings by default. +6. Use the user's configured `sans` font-variation-settings by default. +7. Disable tap highlights on iOS +*/ + +html, +:host { + line-height: 1.5; /* 1 */ + -webkit-text-size-adjust: 100%; /* 2 */ + -moz-tab-size: 4; /* 3 */ + -o-tab-size: 4; + tab-size: 4; /* 3 */ + font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; /* 4 */ + font-feature-settings: normal; /* 5 */ + font-variation-settings: normal; /* 6 */ + -webkit-tap-highlight-color: transparent; /* 7 */ +} + +/* +1. Remove the margin in all browsers. +2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. +*/ + +body { + margin: 0; /* 1 */ + line-height: inherit; /* 2 */ +} + +/* +1. Add the correct height in Firefox. +2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) +3. Ensure horizontal rules are visible by default. +*/ + +hr { + height: 0; /* 1 */ + color: inherit; /* 2 */ + border-top-width: 1px; /* 3 */ +} + +/* +Add the correct text decoration in Chrome, Edge, and Safari. +*/ + +abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +/* +Remove the default font size and weight for headings. +*/ + +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: inherit; + font-weight: inherit; +} + +/* +Reset links to optimize for opt-in styling instead of opt-out. +*/ + +a { + color: inherit; + text-decoration: inherit; +} + +/* +Add the correct font weight in Edge and Safari. +*/ + +b, +strong { + font-weight: bolder; +} + +/* +1. Use the user's configured `mono` font-family by default. +2. Use the user's configured `mono` font-feature-settings by default. +3. Use the user's configured `mono` font-variation-settings by default. +4. Correct the odd `em` font sizing in all browsers. +*/ + +code, +kbd, +samp, +pre { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; /* 1 */ + font-feature-settings: normal; /* 2 */ + font-variation-settings: normal; /* 3 */ + font-size: 1em; /* 4 */ +} + +/* +Add the correct font size in all browsers. +*/ + +small { + font-size: 80%; +} + +/* +Prevent `sub` and `sup` elements from affecting the line height in all browsers. +*/ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* +1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) +2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) +3. Remove gaps between table borders by default. +*/ + +table { + text-indent: 0; /* 1 */ + border-color: inherit; /* 2 */ + border-collapse: collapse; /* 3 */ +} + +/* +1. Change the font styles in all browsers. +2. Remove the margin in Firefox and Safari. +3. Remove default padding in all browsers. +*/ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; /* 1 */ + font-feature-settings: inherit; /* 1 */ + font-variation-settings: inherit; /* 1 */ + font-size: 100%; /* 1 */ + font-weight: inherit; /* 1 */ + line-height: inherit; /* 1 */ + letter-spacing: inherit; /* 1 */ + color: inherit; /* 1 */ + margin: 0; /* 2 */ + padding: 0; /* 3 */ +} + +/* +Remove the inheritance of text transform in Edge and Firefox. +*/ + +button, +select { + text-transform: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Remove default button styles. +*/ + +button, +input:where([type='button']), +input:where([type='reset']), +input:where([type='submit']) { + -webkit-appearance: button; /* 1 */ + background-color: transparent; /* 2 */ + background-image: none; /* 2 */ +} + +/* +Use the modern Firefox focus style for all focusable elements. +*/ + +:-moz-focusring { + outline: auto; +} + +/* +Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) +*/ + +:-moz-ui-invalid { + box-shadow: none; +} + +/* +Add the correct vertical alignment in Chrome and Firefox. +*/ + +progress { + vertical-align: baseline; +} + +/* +Correct the cursor style of increment and decrement buttons in Safari. +*/ + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +/* +1. Correct the odd appearance in Chrome and Safari. +2. Correct the outline style in Safari. +*/ + +[type='search'] { + -webkit-appearance: textfield; /* 1 */ + outline-offset: -2px; /* 2 */ +} + +/* +Remove the inner padding in Chrome and Safari on macOS. +*/ + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Change font properties to `inherit` in Safari. +*/ + +::-webkit-file-upload-button { + -webkit-appearance: button; /* 1 */ + font: inherit; /* 2 */ +} + +/* +Add the correct display in Chrome and Safari. +*/ + +summary { + display: list-item; +} + +/* +Removes the default spacing and border for appropriate elements. +*/ + +blockquote, +dl, +dd, +h1, +h2, +h3, +h4, +h5, +h6, +hr, +figure, +p, +pre { + margin: 0; +} + +fieldset { + margin: 0; + padding: 0; +} + +legend { + padding: 0; +} + +ol, +ul, +menu { + list-style: none; + margin: 0; + padding: 0; +} + +/* +Reset default styling for dialogs. +*/ +dialog { + padding: 0; +} + +/* +Prevent resizing textareas horizontally by default. +*/ + +textarea { + resize: vertical; +} + +/* +1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) +2. Set the default placeholder color to the user's configured gray 400 color. +*/ + +input::-moz-placeholder, textarea::-moz-placeholder { + opacity: 1; /* 1 */ + color: #9ca3af; /* 2 */ +} + +input::placeholder, +textarea::placeholder { + opacity: 1; /* 1 */ + color: #9ca3af; /* 2 */ +} + +/* +Set the default cursor for buttons. +*/ + +button, +[role="button"] { + cursor: pointer; +} + +/* +Make sure disabled buttons don't get the pointer cursor. +*/ +:disabled { + cursor: default; +} + +/* +1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) +2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) + This can trigger a poorly considered lint error in some tools but is included by design. +*/ + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; /* 1 */ + vertical-align: middle; /* 2 */ +} + +/* +Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) +*/ + +img, +video { + max-width: 100%; + height: auto; +} + +/* Make elements with the HTML hidden attribute stay hidden by default */ +[hidden]:where(:not([hidden="until-found"])) { + display: none; +} + * { + margin: 0px; + box-sizing: border-box; + padding: 0px; +} + + body { + min-height: 100vh; + overflow-x: hidden; + font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + line-height: 1.625; + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity, 1)); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background: linear-gradient(rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.7)), url('/images/toothless_face.webp'); + background-size: cover; + background-position: center; + background-attachment: fixed; + background-repeat: no-repeat; + -webkit-overflow-scrolling: touch; +} + + html { + scroll-behavior: smooth; +} +.\!container { + width: 100% !important; +} +.container { + width: 100%; +} +@media (min-width: 640px) { + + .\!container { + max-width: 640px !important; + } + + .container { + max-width: 640px; + } +} +@media (min-width: 768px) { + + .\!container { + max-width: 768px !important; + } + + .container { + max-width: 768px; + } +} +@media (min-width: 1024px) { + + .\!container { + max-width: 1024px !important; + } + + .container { + max-width: 1024px; + } +} +@media (min-width: 1280px) { + + .\!container { + max-width: 1280px !important; + } + + .container { + max-width: 1280px; + } +} +@media (min-width: 1536px) { + + .\!container { + max-width: 1536px !important; + } + + .container { + max-width: 1536px; + } +} +/* ======================================== + COLOR SCHEME: + - Background: Dark earth green (#1C1E1D) to graphite (#0A0B0A) + - Accent/Primary text: Cream (#F5F5DC) + - Secondary/Muted text: Mint green (#90EE90) + ======================================== */ +/* Vehicle, Squadron, and Player names use custom font */ +.vehicle-name, + .squadron-name, + .squadron-tag, + .player-name, + .player-nick { + font-family: skyquakesymbols, Inter, sans-serif; +} +/* Primary Button - Cream gradient */ +.btn-primary { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + overflow: hidden; + border-radius: 0.5rem; + padding-left: 1.5rem; + padding-right: 1.5rem; + padding-top: 0.75rem; + padding-bottom: 0.75rem; + font-weight: 700; + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 300ms; + background: linear-gradient(135deg, #F5F5DC 0%, #E8E8D0 100%); + color: #1E1E1E; + box-shadow: 0 4px 20px rgba(245, 245, 220, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.4); +} +.btn-primary:hover { + box-shadow: 0 8px 25px rgba(245, 245, 220, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.4); + transform: translateY(-2px); + } +.btn-primary:active { + transform: translateY(0); + } +/* Navigation callouts */ +.nav-premium { + color: #f4d35e; + } +.nav-premium:hover { + color: #ffe08a; + } +.nav-rainbow { + color: #ff9b8a; + } +.nav-rainbow:hover { + color: #ffc0b4; + } +.nav-donate { + background: linear-gradient(90deg, #ff7a7a 0%, #ffd166 25%, #90ee90 50%, #8fd3ff 75%, #c79bff 100%); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + color: transparent; + } +.nav-donate:hover { + filter: brightness(1.12); + } +.nav-donate i { + -webkit-text-fill-color: initial; + color: #ffd166; + } +/* Secondary Button */ +.btn-secondary { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 0.5rem; + border-width: 2px; + border-color: rgb(57 255 20 / 0.5); + background-color: rgb(255 255 255 / 0.1); + padding-left: 1.5rem; + padding-right: 1.5rem; + padding-top: 0.75rem; + padding-bottom: 0.75rem; + font-weight: 600; + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity, 1)); + --tw-backdrop-blur: blur(4px); + backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 300ms; +} +.btn-secondary:hover { + --tw-translate-y: -0.125rem; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); + --tw-border-opacity: 1; + border-color: rgb(57 255 20 / var(--tw-border-opacity, 1)); + background-color: rgb(57 255 20 / 0.2); +} +.btn-secondary:active { + --tw-translate-y: 0px; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} +/* Feature Card - used on homepage */ +.feature-card { + background: linear-gradient(135deg, rgba(62, 78, 62, 0.2) 0%, rgba(44, 44, 44, 0.2) 100%); + border: 1px solid rgba(245, 245, 220, 0.08); + backdrop-filter: blur(12px); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + } +.card-hover:hover { + transform: translateY(-3px); + background: linear-gradient(135deg, rgba(62, 78, 62, 0.3) 0%, rgba(44, 44, 44, 0.3) 100%); + border-color: rgba(144, 238, 144, 0.3); + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3); + } +.card-hover:hover i { + color: #F5F5DC; + transform: scale(1.1); + transition: transform 0.3s ease; + } +/* Search Input - Glass effect */ +.search-input-glass { + background: rgba(30, 30, 30, 0.6); + border: 1px solid rgba(245, 245, 220, 0.1); + backdrop-filter: blur(10px); + transition: all 0.3s ease; + box-shadow: inset 0 2px 4px rgba(0,0,0,0.2); + } +.search-input-glass:focus { + background: rgba(40, 40, 40, 0.8); + border-color: rgba(144, 238, 144, 0.4); + box-shadow: 0 0 0 2px rgba(144, 238, 144, 0.1), inset 0 2px 4px rgba(0,0,0,0.2); + } +/* Card Component */ +.\!card { + border-radius: 1rem; + border-width: 1px; + border-color: rgb(57 255 20 / 0.2); + background-color: rgb(26 26 46 / 0.8); + padding: 1.5rem; + --tw-backdrop-blur: blur(12px); + backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 300ms; +} +.\!card:hover { + border-color: rgb(57 255 20 / 0.4); + --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); + --tw-shadow-color: rgb(57 255 20 / 0.1); + --tw-shadow: var(--tw-shadow-colored); +} +.card { + border-radius: 1rem; + border-width: 1px; + border-color: rgb(57 255 20 / 0.2); + background-color: rgb(26 26 46 / 0.8); + padding: 1.5rem; + --tw-backdrop-blur: blur(12px); + backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 300ms; +} +.card:hover { + border-color: rgb(57 255 20 / 0.4); + --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); + --tw-shadow-color: rgb(57 255 20 / 0.1); + --tw-shadow: var(--tw-shadow-colored); +} +/* Glass Card (more transparent) */ +/* Stat Card */ +.stat-card { + border-radius: 1rem; + border-width: 1px; + border-color: rgb(57 255 20 / 0.2); + background-color: rgb(26 26 46 / 0.8); + padding: 1.5rem; + --tw-backdrop-blur: blur(12px); + backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 300ms; +} +.stat-card:hover { + border-color: rgb(57 255 20 / 0.4); + --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); + --tw-shadow-color: rgb(57 255 20 / 0.1); + --tw-shadow: var(--tw-shadow-colored); +} +.stat-card { + position: relative; + overflow: hidden; + text-align: center; +} +.stat-card:hover { + --tw-translate-y: -0.25rem; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); + --tw-border-opacity: 1; + border-color: rgb(57 255 20 / var(--tw-border-opacity, 1)); +} +/* Input Field */ +.input-field { + width: 100%; + border-radius: 0.5rem; + border-width: 2px; + border-color: rgb(57 255 20 / 0.3); + background-color: rgb(15 15 26 / 0.8); + padding-left: 1rem; + padding-right: 1rem; + padding-top: 0.75rem; + padding-bottom: 0.75rem; + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity, 1)); +} +.input-field::-moz-placeholder { + color: rgb(255 255 255 / 0.5); +} +.input-field::placeholder { + color: rgb(255 255 255 / 0.5); +} +.input-field { + --tw-backdrop-blur: blur(4px); + backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 300ms; +} +.input-field:focus { + --tw-border-opacity: 1; + border-color: rgb(57 255 20 / var(--tw-border-opacity, 1)); + --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); + --tw-shadow-color: rgb(57 255 20 / 0.2); + --tw-shadow: var(--tw-shadow-colored); + outline: 2px solid transparent; + outline-offset: 2px; +} +/* Select Dropdown */ +.select-field { + width: 100%; + border-radius: 0.5rem; + border-width: 2px; + border-color: rgb(57 255 20 / 0.3); + background-color: rgb(15 15 26 / 0.8); + padding-left: 1rem; + padding-right: 1rem; + padding-top: 0.75rem; + padding-bottom: 0.75rem; + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity, 1)); +} +.select-field::-moz-placeholder { + color: rgb(255 255 255 / 0.5); +} +.select-field::placeholder { + color: rgb(255 255 255 / 0.5); +} +.select-field { + --tw-backdrop-blur: blur(4px); + backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 300ms; +} +.select-field:focus { + --tw-border-opacity: 1; + border-color: rgb(57 255 20 / var(--tw-border-opacity, 1)); + --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); + --tw-shadow-color: rgb(57 255 20 / 0.2); + --tw-shadow: var(--tw-shadow-colored); + outline: 2px solid transparent; + outline-offset: 2px; +} +.select-field { + cursor: pointer; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + padding-right: 2.5rem; + background-image: url('data:image/svg+xml;charset=UTF-8,%3csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%2339ff14%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3e%3cpolyline points=%226 9 12 15 18 9%22%3e%3c/polyline%3e%3c/svg%3e'); + background-position: right 0.75rem center; + background-size: 1.25rem; + background-repeat: no-repeat; +} +/* Table Styles */ +/* Loading Spinner */ +.spinner { + margin-left: auto; + margin-right: auto; + height: 2.5rem; + width: 2.5rem; +} +@keyframes spin { + + to { + transform: rotate(360deg); + } +} +.spinner { + animation: spin 1s linear infinite; + border-radius: 9999px; + border-width: 4px; + border-color: rgb(57 255 20 / 0.3); + --tw-border-opacity: 1; + border-top-color: rgb(57 255 20 / var(--tw-border-opacity, 1)); +} +/* Badge */ +/* Navbar */ +.navbar { + position: fixed; + top: 0px; + z-index: 50; + width: 100%; + border-bottom-width: 1px; + border-color: rgb(57 255 20 / 0.2); + background-color: rgb(13 13 21 / 0.95); + --tw-backdrop-blur: blur(12px); + backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 300ms; +} +/* Container */ +/* Section Title */ +.section-title { + margin-bottom: 1rem; +} +@keyframes gradientShift { + + 0%, 100% { + background-position: 0% 50%; + } + + 50% { + background-position: 100% 50%; + } +} +.section-title { + animation: gradientShift 3s ease-in-out infinite; + background-image: linear-gradient(to right, var(--tw-gradient-stops)); + --tw-gradient-from: #39ff14 var(--tw-gradient-from-position); + --tw-gradient-to: rgb(57 255 20 / 0) var(--tw-gradient-to-position); + --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); + --tw-gradient-to: rgb(0 255 107 / 0) var(--tw-gradient-to-position); + --tw-gradient-stops: var(--tw-gradient-from), #00ff6b var(--tw-gradient-via-position), var(--tw-gradient-to); + --tw-gradient-to: #39ff14 var(--tw-gradient-to-position); + background-size: 200% auto; + -webkit-background-clip: text; + background-clip: text; + text-align: center; + font-size: 2.25rem; + line-height: 2.5rem; + font-weight: 700; + color: transparent; +} +/* Link Hover Effect */ +/* Date Filter Styles */ +.date-filter-container { + border-radius: 1rem; + border-width: 1px; + border-color: rgb(57 255 20 / 0.2); + background-color: rgb(26 26 46 / 0.6); + padding: 1.5rem; + --tw-backdrop-blur: blur(24px); + backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 300ms; +} +.date-filter-container > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(1rem * var(--tw-space-y-reverse)); +} +.filter-button { + border-radius: 0.5rem; + border-width: 1px; + border-color: rgb(57 255 20 / 0.3); + background-color: rgb(26 26 46 / 0.5); + padding-left: 1rem; + padding-right: 1rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + font-weight: 500; + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity, 1)); + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 300ms; +} +.filter-button:hover { + --tw-border-opacity: 1; + border-color: rgb(57 255 20 / var(--tw-border-opacity, 1)); + background-color: rgb(57 255 20 / 0.1); +} +.filter-button:focus { + outline: 2px solid transparent; + outline-offset: 2px; + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); + --tw-ring-color: rgb(57 255 20 / 0.5); +} +.filter-button-active { + border-radius: 0.5rem; + border-width: 1px; + border-color: rgb(57 255 20 / 0.3); + background-color: rgb(26 26 46 / 0.5); + padding-left: 1rem; + padding-right: 1rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + font-weight: 500; + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity, 1)); + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 300ms; +} +.filter-button-active:hover { + --tw-border-opacity: 1; + border-color: rgb(57 255 20 / var(--tw-border-opacity, 1)); + background-color: rgb(57 255 20 / 0.1); +} +.filter-button-active:focus { + outline: 2px solid transparent; + outline-offset: 2px; + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); + --tw-ring-color: rgb(57 255 20 / 0.5); +} +.filter-button-active { + --tw-border-opacity: 1; + border-color: rgb(57 255 20 / var(--tw-border-opacity, 1)); + background-color: rgb(57 255 20 / 0.2); + --tw-text-opacity: 1; + color: rgb(57 255 20 / var(--tw-text-opacity, 1)); + --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); + --tw-shadow-color: rgb(57 255 20 / 0.2); + --tw-shadow: var(--tw-shadow-colored); +} +.pointer-events-none { + pointer-events: none; +} +.visible { + visibility: visible; +} +.collapse { + visibility: collapse; +} +.static { + position: static; +} +.fixed { + position: fixed; +} +.absolute { + position: absolute; +} +.relative { + position: relative; +} +.sticky { + position: sticky; +} +.inset-0 { + inset: 0px; +} +.left-0 { + left: 0px; +} +.right-0 { + right: 0px; +} +.top-0 { + top: 0px; +} +.top-10 { + top: 2.5rem; +} +.top-full { + top: 100%; +} +.z-10 { + z-index: 10; +} +.mx-1 { + margin-left: 0.25rem; + margin-right: 0.25rem; +} +.mx-auto { + margin-left: auto; + margin-right: auto; +} +.my-3 { + margin-top: 0.75rem; + margin-bottom: 0.75rem; +} +.\!mt-32 { + margin-top: 8rem !important; +} +.mb-1\.5 { + margin-bottom: 0.375rem; +} +.mb-10 { + margin-bottom: 2.5rem; +} +.mb-12 { + margin-bottom: 3rem; +} +.mb-2 { + margin-bottom: 0.5rem; +} +.mb-3 { + margin-bottom: 0.75rem; +} +.mb-4 { + margin-bottom: 1rem; +} +.mb-5 { + margin-bottom: 1.25rem; +} +.mb-6 { + margin-bottom: 1.5rem; +} +.mb-8 { + margin-bottom: 2rem; +} +.ml-1 { + margin-left: 0.25rem; +} +.ml-2 { + margin-left: 0.5rem; +} +.ml-4 { + margin-left: 1rem; +} +.mr-1 { + margin-right: 0.25rem; +} +.mr-1\.5 { + margin-right: 0.375rem; +} +.mr-2 { + margin-right: 0.5rem; +} +.mr-3 { + margin-right: 0.75rem; +} +.mt-0\.5 { + margin-top: 0.125rem; +} +.mt-1 { + margin-top: 0.25rem; +} +.mt-3 { + margin-top: 0.75rem; +} +.mt-5 { + margin-top: 1.25rem; +} +.mt-6 { + margin-top: 1.5rem; +} +.mt-8 { + margin-top: 2rem; +} +.block { + display: block; +} +.inline-block { + display: inline-block; +} +.inline { + display: inline; +} +.flex { + display: flex; +} +.inline-flex { + display: inline-flex; +} +.\!table { + display: table !important; +} +.table { + display: table; +} +.grid { + display: grid; +} +.contents { + display: contents; +} +.hidden { + display: none; +} +.h-10 { + height: 2.5rem; +} +.h-12 { + height: 3rem; +} +.h-16 { + height: 4rem; +} +.h-20 { + height: 5rem; +} +.h-24 { + height: 6rem; +} +.h-5 { + height: 1.25rem; +} +.h-6 { + height: 1.5rem; +} +.h-8 { + height: 2rem; +} +.h-full { + height: 100%; +} +.min-h-screen { + min-height: 100vh; +} +.w-10 { + width: 2.5rem; +} +.w-12 { + width: 3rem; +} +.w-20 { + width: 5rem; +} +.w-24 { + width: 6rem; +} +.w-5 { + width: 1.25rem; +} +.w-auto { + width: auto; +} +.w-full { + width: 100%; +} +.min-w-\[170px\] { + min-width: 170px; +} +.max-w-2xl { + max-width: 42rem; +} +.max-w-3xl { + max-width: 48rem; +} +.max-w-\[1100px\] { + max-width: 1100px; +} +.max-w-\[1200px\] { + max-width: 1200px; +} +.max-w-\[1400px\] { + max-width: 1400px; +} +.max-w-\[640px\] { + max-width: 640px; +} +.max-w-\[800px\] { + max-width: 800px; +} +.max-w-\[900px\] { + max-width: 900px; +} +.max-w-lg { + max-width: 32rem; +} +.max-w-md { + max-width: 28rem; +} +.max-w-sm { + max-width: 24rem; +} +.flex-1 { + flex: 1 1 0%; +} +.flex-shrink { + flex-shrink: 1; +} +.flex-shrink-0 { + flex-shrink: 0; +} +.border-collapse { + border-collapse: collapse; +} +.transform { + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} +.cursor-pointer { + cursor: pointer; +} +.resize { + resize: both; +} +.scroll-mt-24 { + scroll-margin-top: 6rem; +} +.list-inside { + list-style-position: inside; +} +.list-decimal { + list-style-type: decimal; +} +.list-disc { + list-style-type: disc; +} +.grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); +} +.grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} +.flex-col { + flex-direction: column; +} +.flex-wrap { + flex-wrap: wrap; +} +.items-center { + align-items: center; +} +.items-baseline { + align-items: baseline; +} +.justify-center { + justify-content: center; +} +.justify-between { + justify-content: space-between; +} +.gap-2 { + gap: 0.5rem; +} +.gap-3 { + gap: 0.75rem; +} +.gap-4 { + gap: 1rem; +} +.gap-6 { + gap: 1.5rem; +} +.gap-8 { + gap: 2rem; +} +.gap-x-4 { + -moz-column-gap: 1rem; + column-gap: 1rem; +} +.gap-y-1 { + row-gap: 0.25rem; +} +.space-x-2\.5 > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 0; + margin-right: calc(0.625rem * var(--tw-space-x-reverse)); + margin-left: calc(0.625rem * calc(1 - var(--tw-space-x-reverse))); +} +.space-x-3 > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 0; + margin-right: calc(0.75rem * var(--tw-space-x-reverse)); + margin-left: calc(0.75rem * calc(1 - var(--tw-space-x-reverse))); +} +.space-x-6 > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 0; + margin-right: calc(1.5rem * var(--tw-space-x-reverse)); + margin-left: calc(1.5rem * calc(1 - var(--tw-space-x-reverse))); +} +.space-y-1 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(0.25rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(0.25rem * var(--tw-space-y-reverse)); +} +.space-y-2 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(0.5rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(0.5rem * var(--tw-space-y-reverse)); +} +.space-y-20 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(5rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(5rem * var(--tw-space-y-reverse)); +} +.space-y-3 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(0.75rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(0.75rem * var(--tw-space-y-reverse)); +} +.space-y-4 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(1rem * var(--tw-space-y-reverse)); +} +.space-y-6 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(1.5rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(1.5rem * var(--tw-space-y-reverse)); +} +.self-stretch { + align-self: stretch; +} +.overflow-hidden { + overflow: hidden; +} +.truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.rounded { + border-radius: 0.25rem; +} +.rounded-2xl { + border-radius: 1rem; +} +.rounded-full { + border-radius: 9999px; +} +.rounded-lg { + border-radius: 0.5rem; +} +.rounded-md { + border-radius: 0.375rem; +} +.rounded-xl { + border-radius: 0.75rem; +} +.border { + border-width: 1px; +} +.border-b { + border-bottom-width: 1px; +} +.border-b-2 { + border-bottom-width: 2px; +} +.border-t { + border-top-width: 1px; +} +.border-\[\#ffd700\] { + --tw-border-opacity: 1; + border-color: rgb(255 215 0 / var(--tw-border-opacity, 1)); +} +.border-\[rgba\(144\2c 238\2c 144\2c 0\.08\)\] { + border-color: rgba(144,238,144,0.08); +} +.border-\[rgba\(144\2c 238\2c 144\2c 0\.1\)\] { + border-color: rgba(144,238,144,0.1); +} +.border-\[rgba\(144\2c 238\2c 144\2c 0\.15\)\] { + border-color: rgba(144,238,144,0.15); +} +.border-\[rgba\(168\2c 230\2c 207\2c 0\.2\)\] { + border-color: rgba(168,230,207,0.2); +} +.border-accent { + --tw-border-opacity: 1; + border-color: rgb(245 245 220 / var(--tw-border-opacity, 1)); +} +.border-primary-400\/30 { + border-color: rgb(57 255 20 / 0.3); +} +.border-white\/10 { + border-color: rgb(255 255 255 / 0.1); +} +.border-white\/5 { + border-color: rgb(255 255 255 / 0.05); +} +.border-white\/\[0\.06\] { + border-color: rgb(255 255 255 / 0.06); +} +.border-yellow-400\/20 { + border-color: rgb(250 204 21 / 0.2); +} +.border-yellow-400\/30 { + border-color: rgb(250 204 21 / 0.3); +} +.bg-\[\#2A2A2A\] { + --tw-bg-opacity: 1; + background-color: rgb(42 42 42 / var(--tw-bg-opacity, 1)); +} +.bg-\[rgba\(0\2c 0\2c 0\2c 0\.4\)\] { + background-color: rgba(0,0,0,0.4); +} +.bg-\[rgba\(144\2c 238\2c 144\2c 0\.15\)\] { + background-color: rgba(144,238,144,0.15); +} +.bg-\[rgba\(144\2c 238\2c 144\2c 0\.2\)\] { + background-color: rgba(144,238,144,0.2); +} +.bg-\[rgba\(168\2c 230\2c 207\2c 0\.06\)\] { + background-color: rgba(168,230,207,0.06); +} +.bg-\[rgba\(168\2c 230\2c 207\2c 0\.1\)\] { + background-color: rgba(168,230,207,0.1); +} +.bg-\[rgba\(44\2c 44\2c 44\2c 0\.3\)\] { + background-color: rgba(44,44,44,0.3); +} +.bg-accent { + --tw-bg-opacity: 1; + background-color: rgb(245 245 220 / var(--tw-bg-opacity, 1)); +} +.bg-primary-400\/10 { + background-color: rgb(57 255 20 / 0.1); +} +.bg-white\/5 { + background-color: rgb(255 255 255 / 0.05); +} +.bg-white\/\[0\.04\] { + background-color: rgb(255 255 255 / 0.04); +} +.bg-yellow-400\/10 { + background-color: rgb(250 204 21 / 0.1); +} +.bg-gradient-to-r { + background-image: linear-gradient(to right, var(--tw-gradient-stops)); +} +.from-accent { + --tw-gradient-from: #F5F5DC var(--tw-gradient-from-position); + --tw-gradient-to: rgb(245 245 220 / 0) var(--tw-gradient-to-position); + --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); +} +.to-muted { + --tw-gradient-to: #90EE90 var(--tw-gradient-to-position); +} +.bg-clip-text { + -webkit-background-clip: text; + background-clip: text; +} +.p-3 { + padding: 0.75rem; +} +.p-4 { + padding: 1rem; +} +.p-6 { + padding: 1.5rem; +} +.p-8 { + padding: 2rem; +} +.px-1\.5 { + padding-left: 0.375rem; + padding-right: 0.375rem; +} +.px-10 { + padding-left: 2.5rem; + padding-right: 2.5rem; +} +.px-12 { + padding-left: 3rem; + padding-right: 3rem; +} +.px-2 { + padding-left: 0.5rem; + padding-right: 0.5rem; +} +.px-3 { + padding-left: 0.75rem; + padding-right: 0.75rem; +} +.px-4 { + padding-left: 1rem; + padding-right: 1rem; +} +.px-6 { + padding-left: 1.5rem; + padding-right: 1.5rem; +} +.px-8 { + padding-left: 2rem; + padding-right: 2rem; +} +.py-0\.5 { + padding-top: 0.125rem; + padding-bottom: 0.125rem; +} +.py-1 { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} +.py-1\.5 { + padding-top: 0.375rem; + padding-bottom: 0.375rem; +} +.py-12 { + padding-top: 3rem; + padding-bottom: 3rem; +} +.py-16 { + padding-top: 4rem; + padding-bottom: 4rem; +} +.py-2 { + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} +.py-2\.5 { + padding-top: 0.625rem; + padding-bottom: 0.625rem; +} +.py-3 { + padding-top: 0.75rem; + padding-bottom: 0.75rem; +} +.py-4 { + padding-top: 1rem; + padding-bottom: 1rem; +} +.py-6 { + padding-top: 1.5rem; + padding-bottom: 1.5rem; +} +.pb-1 { + padding-bottom: 0.25rem; +} +.pb-12 { + padding-bottom: 3rem; +} +.pb-14 { + padding-bottom: 3.5rem; +} +.pb-16 { + padding-bottom: 4rem; +} +.pb-20 { + padding-bottom: 5rem; +} +.pb-40 { + padding-bottom: 10rem; +} +.pt-3 { + padding-top: 0.75rem; +} +.pt-32 { + padding-top: 8rem; +} +.text-left { + text-align: left; +} +.text-center { + text-align: center; +} +.font-\[\'skyquakesymbols\'\] { + font-family: 'skyquakesymbols'; +} +.font-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; +} +.text-2xl { + font-size: 1.5rem; + line-height: 2rem; +} +.text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; +} +.text-4xl { + font-size: 2.25rem; + line-height: 2.5rem; +} +.text-8xl { + font-size: 6rem; + line-height: 1; +} +.text-\[10px\] { + font-size: 10px; +} +.text-\[11px\] { + font-size: 11px; +} +.text-\[12px\] { + font-size: 12px; +} +.text-\[13px\] { + font-size: 13px; +} +.text-\[8px\] { + font-size: 8px; +} +.text-base { + font-size: 1rem; + line-height: 1.5rem; +} +.text-lg { + font-size: 1.125rem; + line-height: 1.75rem; +} +.text-sm { + font-size: 0.875rem; + line-height: 1.25rem; +} +.text-xl { + font-size: 1.25rem; + line-height: 1.75rem; +} +.text-xs { + font-size: 0.75rem; + line-height: 1rem; +} +.font-bold { + font-weight: 700; +} +.font-extrabold { + font-weight: 800; +} +.font-medium { + font-weight: 500; +} +.font-semibold { + font-weight: 600; +} +.uppercase { + text-transform: uppercase; +} +.lowercase { + text-transform: lowercase; +} +.italic { + font-style: italic; +} +.leading-\[1\.1\] { + line-height: 1.1; +} +.leading-loose { + line-height: 2; +} +.leading-relaxed { + line-height: 1.625; +} +.leading-tight { + line-height: 1.25; +} +.tracking-\[0\.2em\] { + letter-spacing: 0.2em; +} +.tracking-tight { + letter-spacing: -0.025em; +} +.tracking-wide { + letter-spacing: 0.025em; +} +.tracking-wider { + letter-spacing: 0.05em; +} +.text-\[\#1E1E1E\] { + --tw-text-opacity: 1; + color: rgb(30 30 30 / var(--tw-text-opacity, 1)); +} +.text-\[\#90EE90\] { + --tw-text-opacity: 1; + color: rgb(144 238 144 / var(--tw-text-opacity, 1)); +} +.text-accent { + --tw-text-opacity: 1; + color: rgb(245 245 220 / var(--tw-text-opacity, 1)); +} +.text-accent\/80 { + color: rgb(245 245 220 / 0.8); +} +.text-muted { + --tw-text-opacity: 1; + color: rgb(144 238 144 / var(--tw-text-opacity, 1)); +} +.text-primary-400 { + --tw-text-opacity: 1; + color: rgb(57 255 20 / var(--tw-text-opacity, 1)); +} +.text-red-400 { + --tw-text-opacity: 1; + color: rgb(248 113 113 / var(--tw-text-opacity, 1)); +} +.text-transparent { + color: transparent; +} +.text-white { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity, 1)); +} +.text-white\/15 { + color: rgb(255 255 255 / 0.15); +} +.text-white\/20 { + color: rgb(255 255 255 / 0.2); +} +.text-white\/25 { + color: rgb(255 255 255 / 0.25); +} +.text-white\/30 { + color: rgb(255 255 255 / 0.3); +} +.text-white\/40 { + color: rgb(255 255 255 / 0.4); +} +.text-white\/45 { + color: rgb(255 255 255 / 0.45); +} +.text-white\/50 { + color: rgb(255 255 255 / 0.5); +} +.text-white\/60 { + color: rgb(255 255 255 / 0.6); +} +.text-white\/70 { + color: rgb(255 255 255 / 0.7); +} +.text-white\/80 { + color: rgb(255 255 255 / 0.8); +} +.text-yellow-400 { + --tw-text-opacity: 1; + color: rgb(250 204 21 / var(--tw-text-opacity, 1)); +} +.underline { + text-decoration-line: underline; +} +.antialiased { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +.opacity-60 { + opacity: 0.6; +} +.opacity-70 { + opacity: 0.7; +} +.opacity-75 { + opacity: 0.75; +} +.shadow-xl { + --tw-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} +.outline { + outline-style: solid; +} +.ring { + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} +.blur { + --tw-blur: blur(8px); + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); +} +.invert { + --tw-invert: invert(100%); + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); +} +.sepia { + --tw-sepia: sepia(100%); + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); +} +.filter { + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); +} +.backdrop-blur-md { + --tw-backdrop-blur: blur(12px); + backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); +} +.backdrop-filter { + backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); +} +.transition { + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} +.transition-all { + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} +.transition-colors { + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} +.ease-in-out { + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} +.ease-out { + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); +} +/* Text Gradient */ +/* Glass Effect */ +.glass { + background-color: rgb(255 255 255 / 0.05); + --tw-backdrop-blur: blur(12px); + backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); +} +/* Glow Effect */ +.glow { + --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); + --tw-shadow-color: rgb(57 255 20 / 0.3); + --tw-shadow: var(--tw-shadow-colored); +} +/* Scrollbar Styles */ + +/* Custom Fonts */ +@font-face { + font-family: 'skyquakesymbols'; + src: url('/Fonts/symbols_skyquake.ttf'); + font-display: block; +} + +/* Additional custom animations */ +@keyframes gradientShift { + 0%, 100% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } +} + +@keyframes scrollRight { + 0% { transform: translateX(0); } + 100% { transform: translateX(-50%); } +} + +/* Row link overlay - makes entire table row clickable with native right-click support */ +tr.row-link { + position: relative; + cursor: pointer; +} +tr.row-link a.row-link-overlay::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 0; +} +tr.row-link td a:not(.row-link-overlay), +tr.row-link td button, +tr.row-link td [tabindex], +tr.row-link td input, +tr.row-link td select, +tr.row-link td textarea { + position: relative; + z-index: 1; +} +.hover\:border-white\/20:hover { + border-color: rgb(255 255 255 / 0.2); +} +.hover\:bg-accent:hover { + --tw-bg-opacity: 1; + background-color: rgb(245 245 220 / var(--tw-bg-opacity, 1)); +} +.hover\:bg-white\/5:hover { + background-color: rgb(255 255 255 / 0.05); +} +.hover\:bg-white\/\[0\.03\]:hover { + background-color: rgb(255 255 255 / 0.03); +} +.hover\:text-\[\#1E1E1E\]:hover { + --tw-text-opacity: 1; + color: rgb(30 30 30 / var(--tw-text-opacity, 1)); +} +.hover\:text-accent:hover { + --tw-text-opacity: 1; + color: rgb(245 245 220 / var(--tw-text-opacity, 1)); +} +.hover\:text-red-300:hover { + --tw-text-opacity: 1; + color: rgb(252 165 165 / var(--tw-text-opacity, 1)); +} +.hover\:text-white\/70:hover { + color: rgb(255 255 255 / 0.7); +} +.hover\:underline:hover { + text-decoration-line: underline; +} +@media (min-width: 640px) { + + .sm\:flex-row { + flex-direction: row; + } + + .sm\:text-4xl { + font-size: 2.25rem; + line-height: 2.5rem; + } +} +@media (min-width: 768px) { + + .md\:w-auto { + width: auto; + } + + .md\:grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .md\:text-4xl { + font-size: 2.25rem; + line-height: 2.5rem; + } + + .md\:text-base { + font-size: 1rem; + line-height: 1.5rem; + } +} +@media (min-width: 1024px) { + + .lg\:col-span-1 { + grid-column: span 1 / span 1; + } + + .lg\:col-span-3 { + grid-column: span 3 / span 3; + } + + .lg\:col-span-9 { + grid-column: span 9 / span 9; + } + + .lg\:flex { + display: flex; + } + + .lg\:hidden { + display: none; + } + + .lg\:grid-cols-12 { + grid-template-columns: repeat(12, minmax(0, 1fr)); + } + + .lg\:grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .lg\:grid-cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .lg\:gap-5 { + gap: 1.25rem; + } + + .lg\:p-10 { + padding: 2.5rem; + } + + .lg\:px-8 { + padding-left: 2rem; + padding-right: 2rem; + } + + .lg\:py-20 { + padding-top: 5rem; + padding-bottom: 5rem; + } + + .lg\:pb-16 { + padding-bottom: 4rem; + } + + .lg\:pb-20 { + padding-bottom: 5rem; + } + + .lg\:pb-24 { + padding-bottom: 6rem; + } + + .lg\:pt-40 { + padding-top: 10rem; + } + + .lg\:text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; + } + + .lg\:text-4xl { + font-size: 2.25rem; + line-height: 2.5rem; + } + + .lg\:text-5xl { + font-size: 3rem; + line-height: 1; + } + + .lg\:text-9xl { + font-size: 8rem; + line-height: 1; + } +} +@media (min-width: 1280px) { + + .xl\:text-6xl { + font-size: 3.75rem; + line-height: 1; + } +} + +/*# sourceMappingURL=output.css.map */ \ No newline at end of file diff --git a/web/public/css/style.css b/web/public/css/style.css new file mode 100644 index 0000000..9b4ab1b --- /dev/null +++ b/web/public/css/style.css @@ -0,0 +1,2451 @@ +/* Custom Fonts */ +@font-face { + font-family: 'skyquakesymbols'; + src: url('/Fonts/symbols_skyquake.ttf'); + font-display: block; +} + +/* Reset and Base Styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +/* iOS Safe Area Support */ +:root { + --safe-area-inset-top: env(safe-area-inset-top); + --safe-area-inset-bottom: env(safe-area-inset-bottom); + --safe-area-inset-left: env(safe-area-inset-left); + --safe-area-inset-right: env(safe-area-inset-right); +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + line-height: 1.6; + color: #ffffff; + background: #1b1b1b; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; + padding-top: var(--safe-area-inset-top); + padding-bottom: var(--safe-area-inset-bottom); + min-height: 100vh; + min-height: -webkit-fill-available; +} + +/* Container */ +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 20px; +} + +/* Navigation */ +.navbar { + position: fixed; + top: var(--safe-area-inset-top); + width: 100%; + background: rgba(13, 14, 15, 1); + backdrop-filter: blur(10px); + z-index: 1000; + padding: 1rem 0; + transition: all 0.3s ease; + border-bottom: 1px solid rgba(57, 255, 20, 0.2); + padding-left: var(--safe-area-inset-left); + padding-right: var(--safe-area-inset-right); +} + +.nav-container { + max-width: 1200px; + margin: 0 auto; + padding: 0 20px; + display: flex; + justify-content: space-between; + align-items: center; + position: relative; +} + +.nav-brand { + display: flex; + align-items: center; + font-size: 1.5rem; + font-weight: 700; + color: #ffffff; + z-index: 1001; +} + +.nav-logo { + height: 40px; + width: auto; + margin-right: 0.5rem; + border-radius: 0; + filter: none; + transition: all 0.3s ease; +} + +.nav-logo:hover { + transform: scale(1.05); + filter: brightness(1.1); +} + +.nav-menu { + display: flex; + align-items: center; + gap: 2rem; + transition: all 0.3s ease; +} + +.nav-link { + color: #ffffff; + text-decoration: none; + font-weight: 500; + transition: color 0.3s ease; + position: relative; +} + +.nav-link:hover { + color: #39ff14; +} + +.nav-link::after { + content: ''; + position: absolute; + bottom: -5px; + left: 0; + width: 0; + height: 2px; + background: linear-gradient(90deg, #39ff14, #00ff6b); + transition: width 0.3s ease; +} + +.nav-link:hover::after { + width: 100%; +} + +.hamburger { + display: none; + flex-direction: column; + cursor: pointer; + z-index: 1001; +} + +.hamburger span { + width: 25px; + height: 3px; + background: #ffffff; + margin: 3px 0; + transition: 0.3s; + transform-origin: center; +} + +.hamburger.active span:nth-child(1) { + transform: rotate(45deg) translate(6px, 6px); +} + +.hamburger.active span:nth-child(2) { + opacity: 0; +} + +.hamburger.active span:nth-child(3) { + transform: rotate(-45deg) translate(6px, -6px); +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.75rem 1.5rem; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + text-decoration: none; + cursor: pointer; + transition: all 0.3s ease; + gap: 0.5rem; + position: relative; + overflow: hidden; + min-height: 48px; /* Better touch target for mobile */ + text-align: center; + user-select: none; + -webkit-tap-highlight-color: transparent; +} + +.btn::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); + transition: left 0.5s; +} + +.btn:hover::before { + left: 100%; +} + +.btn-primary { + background: linear-gradient(45deg, #39ff14, #00ff6b); + color: #000000; + box-shadow: 0 4px 15px rgba(57, 255, 20, 0.4); +} + +.btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(57, 255, 20, 0.6); +} + +.btn-secondary { + background: rgba(255, 255, 255, 0.1); + color: #ffffff; + border: 2px solid rgba(57, 255, 20, 0.5); +} + +.btn-secondary:hover { + background: rgba(57, 255, 20, 0.2); + transform: translateY(-2px); + border-color: #39ff14; +} + +.btn-large { + padding: 1rem 2rem; + font-size: 1.1rem; +} + +.btn-kofi { + background-color: #FF5E5B; + color: white; + transition: all 0.3s ease; +} + +.btn-kofi:hover { + background-color: #FF3D3A; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(255, 94, 91, 0.2); +} + +.btn-kofi i { + margin-right: 8px; +} + +/* Hero Section */ +.hero { + min-height: 100vh; + display: flex; + align-items: center; + padding-top: 100px; + position: relative; +} + +.hero::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: radial-gradient(ellipse at center, rgba(57, 255, 20, 0.1) 0%, transparent 70%); + pointer-events: none; +} + +.hero-container { + max-width: 1200px; + margin: 0 auto; + padding: 0 20px; + display: flex; + align-items: center; + justify-content: center; + width: 100%; +} + +.hero-content { + flex: 1; + max-width: 800px; + text-align: center; +} + +.hero-title { + font-size: clamp(2.5rem, 5vw, 4rem); + font-weight: 800; + margin-bottom: 1.5rem; + line-height: 1.2; +} + +.gradient-text { + background: linear-gradient(45deg, #39ff14, #00ff6b, #66ff99); + background-size: 200% 200%; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + animation: gradientShift 3s ease-in-out infinite; +} + +@keyframes gradientShift { + 0%, 100% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } +} + +.hero-subtitle { + font-size: 1.3rem; + margin-bottom: 2.5rem; + opacity: 0.9; + line-height: 1.6; + text-align: center; + transition: opacity 0.3s ease-in-out; +} + +.hero-buttons { + display: flex; + gap: 1.5rem; + justify-content: center; + margin-bottom: 3rem; + flex-wrap: wrap; +} + +.hero-image { + display: flex; + justify-content: center; + align-items: center; +} + +.bot-avatar { + width: 300px; + height: 300px; + background: linear-gradient(135deg, rgba(57, 255, 20, 0.1), rgba(0, 255, 107, 0.1)); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 20px 40px rgba(57, 255, 20, 0.3); + border: 3px solid rgba(57, 255, 20, 0.5); + position: relative; + overflow: hidden; +} + +.bot-avatar::before { + content: ''; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: conic-gradient(from 0deg, transparent, rgba(57, 255, 20, 0.3), transparent); + animation: rotate 8s linear infinite; +} + +.hero-logo { + height: 200px; + width: auto; + max-width: 200px; + border-radius: 20px; + position: relative; + z-index: 2; + filter: drop-shadow(0 0 20px rgba(57, 255, 20, 0.5)); +} + +@keyframes rotate { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Stats Section */ +.stats { + padding: 5rem 0; + background: rgba(15, 15, 26, 0.6); + position: relative; +} + +.stats::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(90deg, transparent, rgba(57, 255, 20, 0.1), transparent); +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 2rem; + position: relative; + z-index: 1; +} + +.stat-card { + background: rgba(15, 15, 26, 0.8); + padding: 2rem; + border-radius: 16px; + text-align: center; + backdrop-filter: blur(10px); + border: 1px solid rgba(57, 255, 20, 0.3); + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.stat-card::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(57, 255, 20, 0.2), transparent); + transition: left 0.5s; +} + +.stat-card:hover::before { + left: 100%; +} + +.stat-card:hover { + transform: translateY(-5px); + border-color: #39ff14; + box-shadow: 0 10px 30px rgba(57, 255, 20, 0.3); +} + +.stat-icon { + font-size: 2.5rem; + color: #39ff14; + margin-bottom: 1rem; +} + +.stat-number { + font-size: 2.5rem; + font-weight: 700; + margin-bottom: 0.5rem; + color: #ffffff; +} + +.stat-label { + font-size: 1.1rem; + opacity: 0.8; +} + +/* Features Section */ +.features { + padding: 5rem 0; + position: relative; +} + +.section-title { + font-size: 2.5rem; + font-weight: 700; + text-align: center; + margin-bottom: 1rem; +} + +.section-subtitle { + font-size: 1.2rem; + text-align: center; + opacity: 0.9; + margin-bottom: 3rem; +} + +.features-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); + gap: 2rem; +} + +.feature-card { + background: rgba(15, 15, 26, 0.8); + padding: 2rem; + border-radius: 16px; + backdrop-filter: blur(10px); + border: 1px solid rgba(57, 255, 20, 0.3); + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.feature-card::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(57, 255, 20, 0.1), transparent); + transition: left 0.5s; +} + +.feature-card:hover::before { + left: 100%; +} + +.feature-card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 30px rgba(57, 255, 20, 0.2); + border-color: #39ff14; +} + +.feature-icon { + font-size: 2.5rem; + color: #39ff14; + margin-bottom: 1rem; +} + +.feature-card h3 { + font-size: 1.3rem; + font-weight: 600; + margin-bottom: 1rem; + color: #ffffff; +} + +.feature-card p { + opacity: 0.9; + line-height: 1.6; +} + +/* CTA Section */ +.cta { + padding: 5rem 0; + background: rgba(15, 15, 26, 0.6); + text-align: center; + position: relative; + margin-top: 2rem; +} + +.cta::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: radial-gradient(ellipse at center, rgba(57, 255, 20, 0.1) 0%, transparent 70%); +} + +.cta-content { + position: relative; + z-index: 1; + max-width: 600px; + margin: 0 auto; +} + +.cta-content h2 { + font-size: 2.5rem; + font-weight: 700; + margin-bottom: 2rem; +} + +.cta-content p { + font-size: 1.2rem; + opacity: 0.9; + margin-bottom: 2rem; +} + +/* Footer */ +.footer { + background: rgba(15, 15, 26, 0.95); + padding: 4rem 0 2rem; + border-top: 1px solid rgba(57, 255, 20, 0.2); + margin-top: 0; +} + +.footer-content { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.footer-brand { + display: flex; + align-items: center; + font-size: 1.3rem; + font-weight: 600; +} + +.footer-logo { + height: 30px; + width: auto; + margin-right: 0.5rem; + border-radius: 6px; + filter: drop-shadow(0 0 8px rgba(57, 255, 20, 0.4)); +} + +.footer-links { + display: flex; + gap: 2rem; +} + +.footer-links a { + color: #ffffff; + text-decoration: none; + opacity: 0.8; + transition: all 0.3s ease; + position: relative; +} + +.footer-links a::after { + content: ''; + position: absolute; + bottom: -5px; + left: 0; + width: 0; + height: 2px; + background: linear-gradient(90deg, #39ff14, #00ff6b); + transition: width 0.3s ease; +} + +.footer-links a:hover { + opacity: 1; + color: #39ff14; +} + +.footer-links a:hover::after { + width: 100%; +} + +.footer-bottom { + text-align: center; + padding-top: 2rem; + border-top: 1px solid rgba(57, 255, 20, 0.2); + opacity: 0.6; +} + + + + + +/* Terms Page Styles */ +.terms-content { + padding: 120px 0 60px; + min-height: 100vh; +} + +.terms-header { + text-align: center; + margin-bottom: 3rem; +} + +.terms-header h1 { + font-size: 2.5rem; + font-weight: 700; + margin-bottom: 0.5rem; +} + +.last-updated { + opacity: 0.7; + font-style: italic; +} + +.terms-section { + margin-bottom: 3rem; + background: rgba(15, 15, 26, 0.8); + padding: 2rem; + border-radius: 16px; + border: 1px solid rgba(57, 255, 20, 0.3); +} + +.terms-section h2 { + font-size: 2rem; + font-weight: 600; + margin-bottom: 1.5rem; + color: #39ff14; + border-bottom: 2px solid rgba(57, 255, 20, 0.3); + padding-bottom: 0.5rem; +} + +.terms-section h3 { + font-size: 1.3rem; + font-weight: 600; + margin: 1.5rem 0 0.75rem; + color: #ffffff; +} + +.terms-section p { + margin-bottom: 1rem; + line-height: 1.6; + opacity: 0.9; +} + +.terms-section ul { + margin: 1rem 0 1rem 2rem; + opacity: 0.9; +} + +.terms-section li { + margin-bottom: 0.5rem; + line-height: 1.5; +} + +.terms-footer { + background: rgba(57, 255, 20, 0.1); + padding: 1.5rem; + border-radius: 12px; + border: 1px solid rgba(57, 255, 20, 0.3); + margin-bottom: 2rem; + text-align: center; +} + +.back-home { + text-align: center; + margin-bottom: 2rem; +} + + + +/* Trusted By Section */ +.trusted-by { + padding: 4rem 0; + background: rgba(15, 15, 26, 0.4); + border-top: 1px solid rgba(57, 255, 20, 0.2); + border-bottom: 1px solid rgba(57, 255, 20, 0.2); + overflow: hidden; +} + +.trusted-title { + text-align: center; + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 2rem; + opacity: 0.9; + color: #ffffff; +} + +.trusted-scroll { + width: 100%; + overflow: hidden; + mask: linear-gradient(90deg, transparent, #000 20%, #000 80%, transparent); + -webkit-mask: linear-gradient(90deg, transparent, #000 20%, #000 80%, transparent); +} + +.trusted-track { + display: flex; + animation: scroll-right 60s linear infinite; + gap: 3rem; + width: fit-content; +} + +.trusted-item { + background: rgba(15, 15, 26, 0.8); + border: 2px solid rgba(57, 255, 20, 0.3); + padding: 1rem 2rem; + border-radius: 12px; + font-weight: 700; + font-size: 1.1rem; + color: #ffffff; + text-align: center; + min-width: 120px; + backdrop-filter: blur(10px); + position: relative; + transition: all 0.3s ease; + flex-shrink: 0; +} + +@keyframes scroll-right { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(-50%); + } +} + +/* Hero Trusted By - Integrated */ +.hero-trusted { + margin-top: 3rem; + width: 100%; +} + +.trusted-label { + text-align: center; + font-size: 0.9rem; + font-weight: 500; + margin-bottom: 1.5rem; + opacity: 0.7; + color: #ffffff; + text-transform: uppercase; + letter-spacing: 1px; +} + +.trusted-scroll { + width: 100%; + overflow: hidden; + mask: linear-gradient(90deg, transparent, #000 15%, #000 85%, transparent); + -webkit-mask: linear-gradient(90deg, transparent, #000 15%, #000 85%, transparent); +} + +.trusted-track { + display: flex; + animation: scroll-right 50s linear infinite; + gap: 1.5rem; + width: fit-content; +} + +.trusted-item { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(57, 255, 20, 0.2); + padding: 0.5rem 1rem; + border-radius: 6px; + font-weight: 600; + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.8); + text-align: center; + min-width: 80px; + backdrop-filter: blur(5px); + flex-shrink: 0; +} + +/* Removed hover effects to prevent color changes during scroll */ + +.trusted-track { + gap: 2rem; +} + +.hero-trusted { + margin-top: 2rem; +} + +.trusted-label { + font-size: 0.8rem; + margin-bottom: 1rem; +} + +.hero-trusted .trusted-item { + padding: 0.4rem 0.8rem; + font-size: 0.75rem; + min-width: 70px; +} + +.hero-trusted .trusted-track { + gap: 1rem; +} + +/* Documentation Page Styles */ +.docs-content { + padding: 120px 0 60px; + min-height: 100vh; +} + +.docs-header { + text-align: center; + margin-bottom: 3rem; +} + +.docs-header h1 { + font-size: 2.5rem; + font-weight: 700; + margin-bottom: 0.5rem; +} + +.docs-subtitle { + font-size: 1.2rem; + opacity: 0.8; + margin-bottom: 0; +} + +.docs-grid { + display: grid; + grid-template-columns: 250px 1fr; + gap: 3rem; + align-items: start; +} + +/* Sidebar Navigation */ +.docs-sidebar { + background: rgba(15, 15, 26, 0.8); + padding: 2rem; + border-radius: 16px; + border: 1px solid rgba(57, 255, 20, 0.3); + position: sticky; + top: 100px; +} + +.docs-sidebar h3 { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 1rem; + color: #39ff14; +} + +.docs-nav { + list-style: none; + padding: 0; + margin: 0; +} + +.docs-nav li { + margin-bottom: 0.5rem; +} + +.docs-nav a { + color: rgba(255, 255, 255, 0.8); + text-decoration: none; + font-size: 0.9rem; + padding: 0.5rem 0.75rem; + border-radius: 6px; + display: block; + transition: all 0.3s ease; +} + +.docs-nav a:hover { + color: #39ff14; + background: rgba(57, 255, 20, 0.1); +} + +/* Main Documentation Content */ +.docs-main { + max-width: 800px; +} + +.docs-section { + margin-bottom: 3rem; +} + +.docs-section h2 { + font-size: 1.8rem; + font-weight: 600; + margin-bottom: 1rem; + color: #ffffff; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.docs-section h2 i { + color: #39ff14; + font-size: 1.5rem; +} + +.docs-section h3 { + font-size: 1.3rem; + font-weight: 600; + margin: 2rem 0 1rem; + color: #39ff14; +} + +.docs-section h4 { + font-size: 1.1rem; + font-weight: 600; + margin: 1.5rem 0 0.5rem; + color: #ffffff; +} + +.docs-section p { + line-height: 1.6; + margin-bottom: 1rem; + opacity: 0.9; +} + +.docs-section ul { + margin: 1rem 0; + padding-left: 1.5rem; +} + +.docs-section li { + margin-bottom: 0.5rem; + line-height: 1.5; + opacity: 0.9; +} + +/* Step Cards */ +.step-card { + display: flex; + align-items: center; + background: rgba(15, 15, 26, 0.6); + padding: 1.5rem; + border-radius: 12px; + border: 1px solid rgba(57, 255, 20, 0.2); + margin-bottom: 1rem; +} + +.step-number { + background: linear-gradient(45deg, #39ff14, #00ff6b); + color: #000000; + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 1.2rem; + margin-right: 1rem; + flex-shrink: 0; +} + +.step-content h4 { + margin: 0 0 0.5rem 0; + color: #ffffff; +} + +.step-content p { + margin: 0; + opacity: 0.8; +} + +/* Command Cards */ +.command-group { + margin-bottom: 2rem; +} + +.command-card { + background: rgba(15, 15, 26, 0.6); + padding: 1.5rem; + border-radius: 12px; + border: 1px solid rgba(57, 255, 20, 0.2); + margin-bottom: 1rem; +} + +.command-syntax { + background: rgba(57, 255, 20, 0.1); + padding: 0.75rem 1rem; + border-radius: 8px; + border: 1px solid rgba(57, 255, 20, 0.3); + margin-bottom: 0.75rem; +} + +.command-syntax code { + color: #39ff14; + font-weight: 600; + font-size: 1rem; + font-family: 'Courier New', monospace; +} + +.command-card p { + margin: 0; + opacity: 0.9; +} + +/* Command Notes and Examples */ +.command-note { + font-size: 0.85rem; + opacity: 0.7; + margin-top: 0.5rem; + font-style: italic; +} + +.command-example { + background: rgba(0, 0, 0, 0.2); + padding: 0.5rem 0.75rem; + border-radius: 6px; + border-left: 3px solid #39ff14; + margin-top: 0.75rem; + font-size: 0.9rem; +} + +.command-example strong { + color: #39ff14; +} + +.command-example code { + background: transparent; + padding: 0; + color: #ffffff; + font-weight: 600; +} + +/* Setup Items */ +.setup-item { + background: rgba(15, 15, 26, 0.6); + padding: 1.5rem; + border-radius: 12px; + border: 1px solid rgba(57, 255, 20, 0.2); + margin-bottom: 1.5rem; +} + +.setup-item h4 { + color: #39ff14; + margin-top: 0; +} + +/* Feature Grid */ +.feature-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; + margin-top: 1.5rem; +} + +.feature-item { + background: rgba(15, 15, 26, 0.6); + padding: 1.5rem; + border-radius: 12px; + border: 1px solid rgba(57, 255, 20, 0.2); + text-align: center; +} + +.feature-item i { + color: #39ff14; + font-size: 2rem; + margin-bottom: 1rem; +} + +.feature-item h4 { + color: #ffffff; + margin: 0 0 0.75rem 0; +} + +.feature-item p { + margin: 0; + opacity: 0.8; + font-size: 0.9rem; +} + +/* Example Cards */ +.example-card { + background: rgba(15, 15, 26, 0.6); + padding: 1.5rem; + border-radius: 12px; + border: 1px solid rgba(57, 255, 20, 0.2); + margin-bottom: 1.5rem; +} + +.example-card h4 { + color: #39ff14; + margin-top: 0; +} + +.example-code { + background: rgba(0, 0, 0, 0.3); + padding: 1rem; + border-radius: 8px; + border: 1px solid rgba(57, 255, 20, 0.3); + margin: 1rem 0; +} + +.example-code code { + color: #39ff14; + font-weight: 600; + font-family: 'Courier New', monospace; +} + +/* Troubleshooting */ +.trouble-item { + background: rgba(15, 15, 26, 0.6); + padding: 1.5rem; + border-radius: 12px; + border: 1px solid rgba(57, 255, 20, 0.2); + margin-bottom: 1.5rem; +} + +.trouble-item h4 { + color: #39ff14; + margin-top: 0; +} + +/* Support Section */ +.support-section { + background: rgba(57, 255, 20, 0.1); + padding: 2rem; + border-radius: 16px; + border: 1px solid rgba(57, 255, 20, 0.3); + text-align: center; + margin-top: 3rem; +} + +.support-section h3 { + color: #39ff14; + margin-top: 0; +} + +.support-buttons { + display: flex; + gap: 1rem; + justify-content: center; + margin-top: 1.5rem; +} + +/* Active nav link */ +.nav-link.active { + color: #39ff14; +} + +/* Code styling */ +code { + background: rgba(57, 255, 20, 0.1); + padding: 0.2rem 0.4rem; + border-radius: 4px; + color: #39ff14; + font-family: 'Courier New', monospace; + font-size: 0.9em; +} + + + +/* Languages Dropdown Styles */ +.languages-dropdown { + margin-top: 1rem; +} + +.dropdown-toggle { + background: rgba(57, 255, 20, 0.1); + border: 1px solid rgba(57, 255, 20, 0.3); + color: #ffffff; + padding: 0.75rem 1rem; + border-radius: 8px; + cursor: pointer; + font-size: 0.9rem; + font-weight: 500; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + transition: all 0.3s ease; +} + +.dropdown-toggle:hover { + background: rgba(57, 255, 20, 0.2); + border-color: rgba(57, 255, 20, 0.5); +} + +.dropdown-toggle i { + color: #39ff14; +} + +.dropdown-arrow { + transition: transform 0.3s ease; +} + +.dropdown-toggle.active .dropdown-arrow { + transform: rotate(180deg); +} + +.languages-list { + display: none; + margin-top: 0.75rem; + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(57, 255, 20, 0.2); + border-radius: 8px; + padding: 1rem; + animation: slideDown 0.3s ease; +} + +.languages-list.show { + display: block; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.languages-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 0.5rem; +} + +.language-item { + background: rgba(15, 15, 26, 0.6); + border: 1px solid rgba(57, 255, 20, 0.2); + padding: 0.5rem 0.75rem; + border-radius: 6px; + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.9); + text-align: center; + transition: all 0.3s ease; +} + +.language-item:hover { + border-color: rgba(57, 255, 20, 0.4); + background: rgba(57, 255, 20, 0.1); +} + +/* Touch and Accessibility Improvements */ +@media (hover: none) and (pointer: coarse) { + /* Mobile touch device styles */ + .btn:hover, + .nav-link:hover, + .feature-card:hover, + .stat-card:hover { + transform: none; + } + + .btn:active { + transform: scale(0.98); + } + + .nav-link:active { + color: #39ff14; + } +} + +/* Focus states for accessibility */ +.btn:focus-visible, +.nav-link:focus-visible, +.hamburger:focus-visible { + outline: 2px solid #39ff14; + outline-offset: 2px; +} + +/* Prevent zoom on input focus on iOS */ +input, textarea, select { + font-size: 16px; +} + +/* Smooth scrolling for all elements */ +html { + scroll-behavior: smooth; +} + +/* Better text selection */ +::selection { + background: rgba(57, 255, 20, 0.3); + color: #ffffff; +} + +/* Loading states for better UX */ +.btn.loading { + pointer-events: none; + opacity: 0.7; +} + +.btn.loading::after { + content: ''; + position: absolute; + width: 16px; + height: 16px; + border: 2px solid transparent; + border-top: 2px solid currentColor; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Row link overlay - makes entire table row clickable with native right-click support */ +tr.row-link { + position: relative; + cursor: pointer; +} +tr.row-link a.row-link-overlay::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 0; +} +tr.row-link td a:not(.row-link-overlay), +tr.row-link td button, +tr.row-link td [tabindex], +tr.row-link td input, +tr.row-link td select, +tr.row-link td textarea { + position: relative; + z-index: 1; +} + +/* Safe area insets for newer devices */ +@supports (padding: max(0px)) { + .navbar { + padding-left: max(20px, env(safe-area-inset-left)); + padding-right: max(20px, env(safe-area-inset-right)); + } + + .container { + padding-left: max(20px, env(safe-area-inset-left)); + padding-right: max(20px, env(safe-area-inset-right)); + } +} + +/* Desktop specific improvements */ +@media (min-width: 769px) { + .hero { + padding-top: 120px; + padding-bottom: 60px; + } + + .hero-container { + min-height: calc(100vh - 180px); + } + + .hero-content { + max-width: 900px; + } + + .hero-title { + font-size: clamp(3rem, 6vw, 4.5rem); + margin-bottom: 2rem; + } + + .hero-subtitle { + font-size: 1.4rem; + margin-bottom: 3rem; + max-width: 600px; + margin-left: auto; + margin-right: auto; + } + + .hero-buttons { + gap: 2rem; + margin-bottom: 4rem; + } + + .hero-buttons .btn { + min-width: 200px; + } + + .hero-trusted { + margin-top: 4rem; + } + + .cta { + padding: 6rem 0; + margin-top: 4rem; + } + + .cta-content h2 { + font-size: 3rem; + margin-bottom: 2.5rem; + } + + .cta-content .btn { + min-width: 250px; + padding: 1.25rem 2.5rem; + font-size: 1.2rem; + } + + .footer { + padding: 5rem 0 2.5rem; + } + + .footer-content { + margin-bottom: 3rem; + } + + .footer-brand { + font-size: 1.4rem; + } + + .footer-links { + gap: 2.5rem; + } + + .footer-links a { + font-size: 1.1rem; + } +} + +/* Large desktop screens */ +@media (min-width: 1200px) { + .hero-title { + font-size: 4.5rem; + } + + .hero-subtitle { + font-size: 1.5rem; + } + + .hero-buttons .btn { + min-width: 220px; + padding: 1.25rem 2.5rem; + font-size: 1.2rem; + } +} + + +/* VEHICLE NAMES - All Contexts */ +.vehicle-name, +.vehicle-info .vehicle-name, +.leaderboard-table .vehicle-name, +.mobile-vehicle-name, +.player-vehicle, +.vehicle-display, +.vehicle-tag, +.vehicle-title, +.game-vehicle, +[class*="vehicle"]:not(.vehicle-info):not(.vehicle-rank):not(.vehicles-table):not(.vehicles-section):not(.vehicles-filters), +.stat-vehicle, +*[data-vehicle], +td.vehicle-name, +div.vehicle-name, +span.vehicle-name { + font-family: 'skyquakesymbols', 'Inter', sans-serif !important; +} + +/* SQUADRON NAMES - All Contexts */ +.squadron-name, +.squadron-tag, +.squadron-tag-search, +.squadron-tag-leaderboard, +.squadron-tag-player, +.player-squadron, +.player-squadron-tag, +.squadron-result-name, +.squadron-search-result-item .squadron-result-name, +h1.squadron-name, +td.squadron-name, +div.squadron-name, +span.squadron-name, +span.player-squadron, +span.player-squadron-tag, +[class*="squadron"]:not(.squadron-container):not(.squadron-section):not(.squadron-header):not(.squadron-stats) { + font-family: 'skyquakesymbols', 'Inter', sans-serif !important; +} + +/* PLAYER NAMES - All Contexts */ +.player-name, +.player-nick, +.player-info .player-nick, +.result-name, +.search-result .result-name, +.header-result-name, +.leaderboard-table .player-name, +td.player-name, +div.player-name, +span.player-name, +a.player-nick, +h1.player-nick, +.comparison-table th:not(:first-child), +.selected-item, +[class*="player-name"], +[class*="player-nick"] { + font-family: 'skyquakesymbols', 'Inter', sans-serif !important; +} + +/* Enhanced Styling for Better Visibility */ +.leaderboard-table .vehicle-name { + font-size: 1.1em; + color: #39ff14; + text-decoration: none; + transition: all 0.3s ease; + text-shadow: 0 0 10px rgba(57, 255, 20, 0.3); +} + +.leaderboard-table .vehicle-name:hover { + text-shadow: 0 0 12px rgba(57, 255, 20, 0.6); + transform: scale(1.02); +} + +/* Vehicle names in search results and cards */ +.search-result .vehicle-name, +.vehicle-card .vehicle-name, +.stat-card .vehicle-name { + color: #39ff14; + text-shadow: 0 0 8px rgba(57, 255, 20, 0.3); +} + +/* Vehicle Comparison - Clean Redesign */ +.comparison-page-content { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; +} + +.comparison-page-content .section-title { + text-align: center; + color: #ffffff; + font-size: 2.5rem; + margin-bottom: 0.5rem; +} + +.comparison-page-content .section-subtitle { + text-align: center; + color: rgba(255, 255, 255, 0.7); + margin-bottom: 3rem; + font-size: 1.1rem; +} + +/* Selection Header */ +.selection-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding: 0 1rem; +} + +.selection-header h3 { + color: #ffffff; + margin: 0; + font-size: 1.3rem; +} + +/* Vehicle Grid */ +.vehicle-selection-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +/* Vehicle Cards */ +.vehicle-select-card { + background: rgba(26, 26, 46, 0.8); + border: 1px solid rgba(57, 255, 20, 0.2); + border-radius: 12px; + padding: 1.5rem; + cursor: pointer; + transition: all 0.3s ease; + backdrop-filter: blur(10px); +} + +.vehicle-select-card:hover { + border-color: rgba(57, 255, 20, 0.5); + transform: translateY(-3px); + box-shadow: 0 8px 25px rgba(57, 255, 20, 0.15); +} + +.vehicle-select-card.selected { + border-color: #39ff14; + background: rgba(57, 255, 20, 0.08); + box-shadow: 0 0 30px rgba(57, 255, 20, 0.3); +} + +/* Card Header */ +.vehicle-select-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.vehicle-select-card .vehicle-name { + font-family: 'skyquakesymbols', 'Inter', sans-serif !important; + color: #39ff14; + font-size: 1.1rem; + font-weight: normal; + letter-spacing: 0.5px; + text-shadow: 0 0 10px rgba(57, 255, 20, 0.4); +} + +/* Selection Checkbox */ +.selection-checkbox { + width: 24px; + height: 24px; + border: 2px solid rgba(57, 255, 20, 0.4); + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s ease; + background: rgba(0, 0, 0, 0.3); +} + +.vehicle-select-card.selected .selection-checkbox { + background: #39ff14; + border-color: #39ff14; +} + +.selection-checkbox i { + color: transparent; + font-size: 14px; + transition: color 0.3s ease; +} + +.vehicle-select-card.selected .selection-checkbox i { + color: #000000; +} + +/* Card Stats */ +.vehicle-select-stats { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} + +.vehicle-select-stats .stat-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.vehicle-select-stats .stat-item:last-child { + border-bottom: none; +} + +.vehicle-select-stats .stat-label { + color: rgba(255, 255, 255, 0.8); + font-size: 0.9rem; + font-weight: 500; +} + +.vehicle-select-stats .stat-value { + color: #39ff14; + font-weight: 700; + font-size: 0.95rem; + text-shadow: 0 0 5px rgba(57, 255, 20, 0.3); +} + +/* Comparison Results Section - Using Leaderboard Style */ +.comparison-result { + margin-top: 2rem; +} + +.comparison-result h3 { + color: #ffffff; + font-size: 1.8rem; + text-align: center; + margin-bottom: 1.5rem; + text-shadow: 0 0 10px rgba(57, 255, 20, 0.3); +} + +.comparison-table-container { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.comparison-table { + width: 100%; + border-collapse: collapse; + background: rgba(15, 15, 26, 0.8); + border-radius: 0.75rem; + overflow: hidden; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + border: 1px solid rgba(57, 255, 20, 0.1); + table-layout: auto; + min-width: 800px; +} + +.comparison-table thead { + background: linear-gradient(135deg, rgba(57, 255, 20, 0.2), rgba(0, 255, 107, 0.2)); +} + +.comparison-table th { + padding: 1rem 0.75rem; + color: #ffffff; + font-weight: 600; + text-align: center; + border-bottom: 2px solid rgba(57, 255, 20, 0.3); + font-size: 0.95rem; + background: rgba(57, 255, 20, 0.1); +} + +.comparison-table .vehicle-header { + font-family: 'skyquakesymbols', 'Inter', sans-serif !important; + letter-spacing: 0.5px; + text-shadow: 0 0 8px rgba(57, 255, 20, 0.4); + color: #39ff14; + font-size: 1rem; +} + +.comparison-table tbody tr { + transition: all 0.3s ease; + border-bottom: 1px solid rgba(57, 255, 20, 0.1); +} + +.comparison-table tbody tr:hover { + background: linear-gradient(135deg, rgba(57, 255, 20, 0.1), rgba(0, 255, 107, 0.1)); + transform: scale(1.01); + box-shadow: 0 4px 15px rgba(57, 255, 20, 0.2); +} + +.comparison-table tbody tr:nth-child(even) { + background: rgba(255, 255, 255, 0.02); +} + +.comparison-table tbody tr:nth-child(even):hover { + background: linear-gradient(135deg, rgba(57, 255, 20, 0.1), rgba(0, 255, 107, 0.1)); +} + +.comparison-table td { + padding: 1rem 0.75rem; + text-align: center; + color: #ffffff; + font-weight: 500; + position: relative; +} + +.comparison-table .stat-label { + font-weight: 600; + color: #ffffff; + text-align: left; + padding-left: 1rem; + background: rgba(57, 255, 20, 0.05); + border-right: 2px solid rgba(57, 255, 20, 0.2); +} + +.comparison-table .stat-best { + background: linear-gradient(135deg, rgba(57, 255, 20, 0.3), rgba(0, 255, 107, 0.3)) !important; + color: #39ff14; + font-weight: 700; + text-shadow: 0 0 10px rgba(57, 255, 20, 0.8); + box-shadow: inset 0 0 20px rgba(57, 255, 20, 0.3); +} + +.comparison-table .stat-best::before { + content: '★'; + position: absolute; + left: 0.25rem; + top: 50%; + transform: translateY(-50%); + color: #39ff14; + font-size: 0.9rem; + text-shadow: 0 0 8px rgba(57, 255, 20, 0.8); +} + +/* Placeholder styling */ +.comparison-placeholder { + text-align: center; + padding: 4rem 2rem; + color: rgba(255, 255, 255, 0.6); + background: rgba(15, 15, 26, 0.4); + border-radius: 12px; + border: 2px dashed rgba(57, 255, 20, 0.2); + margin-top: 2rem; +} + +.comparison-placeholder i { + display: block; + margin-bottom: 1rem; + font-size: 3rem; + color: rgba(57, 255, 20, 0.3); +} + +.comparison-placeholder p { + font-size: 1.1rem; + margin: 0; +} + +/* Responsive Design */ +@media (max-width: 1200px) { + .comparison-page-content { + padding: 1.5rem; + } + + .vehicle-selection-grid { + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + } +} + + +/* Homepage Search Section */ +.hero-search-section { + margin-top: 2rem; + max-width: 1000px; + margin-left: auto; + margin-right: auto; + position: relative; + z-index: 997; +} + +.hero-search-section .search-box { + background: rgba(26, 26, 46, 0.6); + border-radius: 1rem; + padding: 2rem; + backdrop-filter: blur(10px); + border: 1px solid rgba(57, 255, 20, 0.2); + margin-bottom: 1rem; +} + +.hero-search-section .search-input-container { + position: relative; + margin-bottom: 1.5rem; + z-index: 999; /* Below header (1000) but above navigation buttons */ +} + +.hero-search-section .search-results { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: rgba(15, 15, 26, 0.95); + border: 1px solid rgba(57, 255, 20, 0.3); + border-top: none; + border-radius: 0 0 0.5rem 0.5rem; + max-height: 300px; + overflow-y: auto; + z-index: 998; + display: none; + backdrop-filter: blur(20px); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + margin-top: 0; +} + +.hero-search-section .search-results.show { + display: block; +} + +.hero-search-section .search-result-item { + padding: 1rem; + cursor: pointer; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + transition: all 0.3s ease; + display: flex; + justify-content: space-between; + align-items: center; + border-radius: 0.5rem; + margin: 0.25rem; +} + +.hero-search-section .search-result-item:hover { + background: rgba(57, 255, 20, 0.15); + border: 1px solid rgba(57, 255, 20, 0.4); + transform: translateX(4px); + box-shadow: 0 4px 12px rgba(57, 255, 20, 0.2); +} + +.hero-search-section .search-result-item:hover .result-name { + color: #00ff6b; + text-shadow: 0 0 15px rgba(0, 255, 107, 0.6); +} + +.hero-search-section .search-result-item:hover .result-stats { + color: rgba(255, 255, 255, 0.9); +} + +.hero-search-section .search-result-item:last-child { + border-bottom: none; +} + +.hero-search-section .result-name { + color: #39ff14; + font-weight: 600; + font-size: 1rem; + text-shadow: 0 0 10px rgba(57, 255, 20, 0.3); +} + +.hero-search-section .result-stats { + color: rgba(255, 255, 255, 0.7); + font-size: 0.85rem; +} + +.hero-search-section .search-hint { + color: rgba(255, 255, 255, 0.6); + font-size: 0.9rem; + text-align: center; + margin-top: 0.5rem; + margin-bottom: 1rem; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.hero-search-section .search-hint i { + color: #39ff14; +} + +.hero-search-section .search-input { + width: 100%; + padding: 1rem 1rem 1rem 3rem; + background: rgba(15, 15, 26, 0.8); + border: 2px solid rgba(57, 255, 20, 0.3); + border-radius: 0.5rem; + color: #ffffff; + font-size: 1.1rem; + transition: all 0.3s ease; +} + +.hero-search-section .search-input:focus { + outline: none; + border-color: #39ff14; + box-shadow: 0 0 20px rgba(57, 255, 20, 0.3); +} + +.hero-search-section .search-input::placeholder { + color: rgba(255, 255, 255, 0.5); +} + +.hero-search-section .search-icon { + position: absolute; + left: 1rem; + top: 50%; + transform: translateY(-50%); + color: #39ff14; + font-size: 1.2rem; +} + +.hero-search-section .search-button { + background: linear-gradient(45deg, #39ff14, #00ff6b); + color: #000000; + border: none; + padding: 1rem 2rem; + border-radius: 0.5rem; + font-weight: 600; + font-size: 1.1rem; + cursor: pointer; + transition: all 0.3s ease; + width: 100%; +} + +.hero-search-section .search-button:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(57, 255, 20, 0.4); +} + +.hero-search-section .search-button:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.hero-search-section .loading-spinner { + display: none; + text-align: center; + margin: 1rem 0; +} + +.hero-search-section .spinner { + border: 3px solid rgba(57, 255, 20, 0.3); + border-top: 3px solid #39ff14; + border-radius: 50%; + width: 40px; + height: 40px; + animation: spin 1s linear infinite; + margin: 0 auto; +} + +.hero-search-section .results-container { + display: none; +} + +.hero-search-section .results-header { + background: rgba(26, 26, 46, 0.6); + border-radius: 1rem; + padding: 1rem; + backdrop-filter: blur(10px); + border: 1px solid rgba(57, 255, 20, 0.2); + margin-bottom: 0.5rem; + text-align: center; +} + +.hero-search-section .results-count { + color: #39ff14; + font-weight: 600; + font-size: 1rem; +} + +.hero-search-section .results-list { + background: rgba(26, 26, 46, 0.6); + border-radius: 1rem; + padding: 1rem; + backdrop-filter: blur(10px); + border: 1px solid rgba(57, 255, 20, 0.2); +} + +.hero-search-section .result-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem; + background: rgba(15, 15, 26, 0.8); + border-radius: 0.5rem; + margin-bottom: 0.5rem; + transition: all 0.3s ease; + cursor: pointer; +} + +.hero-search-section .result-item:hover { + background: rgba(57, 255, 20, 0.1); + border: 1px solid rgba(57, 255, 20, 0.3); +} + +.hero-search-section .result-item:last-child { + margin-bottom: 0; +} + +.hero-search-section .player-nick { + color: #39ff14; + font-weight: 600; + font-size: 1rem; +} + +.hero-search-section .player-uid { + color: rgba(255, 255, 255, 0.7); + font-size: 0.85rem; +} + +.hero-search-section .error-message { + background: rgba(255, 56, 56, 0.1); + border: 1px solid rgba(255, 56, 56, 0.3); + border-radius: 0.5rem; + padding: 1rem; + color: #ff3838; + text-align: center; + margin-top: 1rem; + display: none; +} + +.hero-search-section .no-results { + text-align: center; + padding: 2rem; + color: rgba(255, 255, 255, 0.6); +} + +/* Squadron Tags - Styling Only (font applied globally above) */ +.squadron-tag-search { + color: rgba(255, 255, 255, 0.6); + font-size: 0.9rem; + margin-right: 0.5rem; +} + +.squadron-tag-leaderboard { + color: rgba(255, 255, 255, 0.7); + font-size: 0.8rem; + margin-right: 0.5rem; + background: rgba(26, 26, 46, 0.6); + padding: 0.2rem 0.4rem; + border-radius: 0.3rem; + border: 1px solid rgba(57, 255, 20, 0.3); +} + +.squadron-tag-player { + color: #39ff14; + font-size: 1rem; + margin-right: 0.75rem; + font-weight: 600; + background: rgba(57, 255, 20, 0.1); + padding: 0.3rem 0.6rem; + border-radius: 0.4rem; + border: 1px solid rgba(57, 255, 20, 0.4); +} + +.player-info { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; +} + +/* Navigation Buttons Section */ +.navigation-buttons { + margin-top: 2rem; + position: relative; + z-index: 1; +} + +.nav-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1.5rem; + max-width: 1000px; + margin: 0 auto; + margin-bottom: 1.5rem; +} + +.nav-grid-half { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1.5rem; + max-width: 1000px; + margin: 0 auto; + margin-bottom: 1.5rem; +} + +.nav-button-full { + max-width: 1000px; + margin: 0 auto; + width: 100%; +} + +.nav-button { + display: flex; + align-items: center; + gap: 1rem; + background: rgba(26, 26, 46, 0.6); + border-radius: 1rem; + padding: 1.5rem; + backdrop-filter: blur(10px); + border: 1px solid rgba(57, 255, 20, 0.2); + text-decoration: none; + color: inherit; + transition: all 0.3s ease; + min-height: 100px; +} + +.nav-button:hover { + border-color: rgba(57, 255, 20, 0.4); + transform: translateY(-2px); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3); + text-decoration: none; + color: inherit; +} + +.nav-button-icon { + display: flex; + align-items: center; + justify-content: center; + width: 60px; + height: 60px; + background: rgba(57, 255, 20, 0.1); + border-radius: 0.75rem; + border: 1px solid rgba(57, 255, 20, 0.2); + flex-shrink: 0; +} + +.nav-button-icon i { + color: #39ff14; + font-size: 1.5rem; +} + +.nav-button-content { + flex: 1; +} + +.nav-button-content h3 { + color: #ffffff; + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 0.25rem; + line-height: 1.3; +} + +.nav-button-content p { + color: rgba(255, 255, 255, 0.7); + font-size: 0.9rem; + margin: 0; + line-height: 1.4; +} + +/* Universal Button Styles */ +.btn-action, +.retry-btn, +.refresh-btn, +.filter-reset-btn, +.clear-filters-btn { + background: linear-gradient(45deg, #39ff14, #00ff6b); + color: #000000; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 0.5rem; + font-weight: 600; + font-size: 0.9rem; + cursor: pointer; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + text-decoration: none; +} + +.btn-action:hover, +.retry-btn:hover, +.refresh-btn:hover, +.filter-reset-btn:hover:not([class*="reset"]), +.clear-filters-btn:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(57, 255, 20, 0.4); +} + +.btn-action:active, +.retry-btn:active, +.refresh-btn:active, +.filter-reset-btn:active, +.clear-filters-btn:active { + transform: translateY(0); +} + +/* Reset/Clear buttons get destructive styling */ +.filter-reset-btn, +.clear-filters-btn { + background: linear-gradient(135deg, rgba(239, 68, 68, 0.15), rgba(220, 38, 38, 0.15)); + border: 1px solid rgba(239, 68, 68, 0.4); + color: #ff6b6b; +} + +.filter-reset-btn:hover, +.clear-filters-btn:hover { + background: linear-gradient(135deg, rgba(239, 68, 68, 0.25), rgba(220, 38, 38, 0.25)); + border-color: #ef4444; + box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3); +} + +/* Trusted By Section */ +.trusted-by { + padding: 2rem 0 1rem 0; + background: rgba(0, 0, 0, 0.1); + margin-top: 0; + margin-bottom: 0; +} + +.trusted-content { + text-align: center; +} + +.trusted-label { + color: rgba(255, 255, 255, 0.8); + font-size: 1rem; + font-weight: 500; + margin-bottom: 1.5rem; +} + +.trusted-scroll { + overflow: hidden; + position: relative; + height: 60px; + mask: linear-gradient(to right, transparent, white 20%, white 80%, transparent); + -webkit-mask: linear-gradient(to right, transparent, white 20%, white 80%, transparent); +} + +.trusted-track { + display: flex; + animation: scrollTrusted 30s linear infinite; + gap: 2rem; + align-items: center; + height: 100%; + width: max-content; +} + +.trusted-item { + color: rgba(255, 255, 255, 0.6); + font-weight: 600; + font-size: 1.1rem; + white-space: nowrap; + text-shadow: 0 0 10px rgba(57, 255, 20, 0.2); + transition: all 0.3s ease; + padding: 0 1rem; + flex-shrink: 0; +} + +.trusted-item:hover { + color: #39ff14; + text-shadow: 0 0 15px rgba(57, 255, 20, 0.5); +} + +@keyframes scrollTrusted { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(-50%); + } +} + +/* Inline CTA Section */ +.cta-inline { + text-align: center; + margin-top: 3rem; + padding: 2rem; + background: rgba(26, 26, 46, 0.3); + border-radius: 1rem; + border: 1px solid rgba(57, 255, 20, 0.1); + max-width: 1000px; + margin-left: auto; + margin-right: auto; +} + +.cta-inline .cta-content h2 { + color: #ffffff; + font-size: 1.8rem; + font-weight: 600; + margin-bottom: 1.5rem; +} + +/* Enhanced CTA Button */ +.cta-button { + font-size: 1.25rem !important; + padding: 1.25rem 3rem !important; + background: linear-gradient(45deg, #39ff14, #00ff6b) !important; + border: none !important; + border-radius: 0.75rem !important; + font-weight: 700 !important; + text-transform: uppercase !important; + letter-spacing: 1px !important; + transition: all 0.3s ease !important; +} + +.cta-button:hover { + transform: translateY(-3px) !important; + box-shadow: 0 10px 30px rgba(57, 255, 20, 0.4) !important; +} + + +/* Universal Header Search Styles */ +.nav-search { + position: relative; + margin: 0 1rem; +} + +.nav-search .header-search-container { + position: relative; + width: 250px; +} + +.nav-search .header-search-container input { + width: 100%; + padding: 0.5rem 2.5rem 0.5rem 1rem; + background: rgba(26, 26, 46, 0.8); + border: 1px solid rgba(57, 255, 20, 0.3); + border-radius: 1.5rem; + color: #ffffff; + font-size: 0.9rem; + transition: all 0.3s ease; +} + +.nav-search .header-search-container input:focus { + outline: none; + border-color: #39ff14; + box-shadow: 0 0 0 2px rgba(57, 255, 20, 0.2); +} + +.nav-search .header-search-container input::placeholder { + color: rgba(255, 255, 255, 0.5); +} + +.nav-search .header-search-icon { + position: absolute; + right: 1rem; + top: 50%; + transform: translateY(-50%); + color: rgba(255, 255, 255, 0.5); + pointer-events: none; +} + +.nav-search .header-search-results { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: rgba(15, 15, 26, 0.95); + border: 1px solid rgba(57, 255, 20, 0.3); + border-radius: 0.5rem; + max-height: 300px; + overflow-y: auto; + z-index: 1000; + opacity: 0; + visibility: hidden; + transform: translateY(-10px); + transition: all 0.3s ease; + backdrop-filter: blur(10px); +} + +.nav-search .header-search-results.show { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.nav-search .header-search-result-item { + padding: 0.75rem 1rem; + border-bottom: 1px solid rgba(57, 255, 20, 0.1); + cursor: pointer; + transition: background 0.2s ease; +} + +.nav-search .header-search-result-item:hover { + background: rgba(57, 255, 20, 0.1); +} + +.nav-search .header-search-result-item:last-child { + border-bottom: none; +} + +.nav-search .header-result-name { + color: #39ff14; + font-weight: 600; + margin-bottom: 0.25rem; +} + +.nav-search .header-result-stats { + color: rgba(255, 255, 255, 0.7); + font-size: 0.8rem; +} + + +/* Squadron and player font styling moved to global rules section above (line 2282+) */ \ No newline at end of file diff --git a/web/public/css/tailwind.css b/web/public/css/tailwind.css new file mode 100644 index 0000000..0d1c0fb --- /dev/null +++ b/web/public/css/tailwind.css @@ -0,0 +1,325 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Custom Fonts */ +@font-face { + font-family: 'skyquakesymbols'; + src: url('/Fonts/symbols_skyquake.ttf'); + font-display: block; +} + +@layer base { + * { + @apply m-0 p-0 box-border; + } + + body { + @apply font-sans leading-relaxed text-white overflow-x-hidden min-h-screen antialiased; + background: linear-gradient(rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.7)), url('/images/toothless_face.webp'); + background-size: cover; + background-position: center; + background-attachment: fixed; + background-repeat: no-repeat; + -webkit-overflow-scrolling: touch; + } + + html { + @apply scroll-smooth; + } +} + +@layer components { + /* ======================================== + COLOR SCHEME: + - Background: Dark earth green (#1C1E1D) to graphite (#0A0B0A) + - Accent/Primary text: Cream (#F5F5DC) + - Secondary/Muted text: Mint green (#90EE90) + ======================================== */ + + /* Vehicle, Squadron, and Player names use custom font */ + .vehicle-name, + .squadron-name, + .squadron-tag, + .player-name, + .player-nick { + @apply font-skyquake; + } + + /* Primary Button - Cream gradient */ + .btn-primary { + @apply inline-flex items-center justify-center px-6 py-3 font-bold rounded-lg + transition-all duration-300 relative overflow-hidden; + background: linear-gradient(135deg, #F5F5DC 0%, #E8E8D0 100%); + color: #1E1E1E; + box-shadow: 0 4px 20px rgba(245, 245, 220, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.4); + } + + .btn-primary:hover { + box-shadow: 0 8px 25px rgba(245, 245, 220, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.4); + transform: translateY(-2px); + } + + .btn-primary:active { + transform: translateY(0); + } + + /* Navigation callouts */ + .nav-premium { + color: #f4d35e; + } + + .nav-premium:hover { + color: #ffe08a; + } + + .nav-rainbow { + color: #ff9b8a; + } + + .nav-rainbow:hover { + color: #ffc0b4; + } + + .nav-donate { + background: linear-gradient(90deg, #ff7a7a 0%, #ffd166 25%, #90ee90 50%, #8fd3ff 75%, #c79bff 100%); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + color: transparent; + } + + .nav-donate:hover { + filter: brightness(1.12); + } + + .nav-donate i { + -webkit-text-fill-color: initial; + color: #ffd166; + } + + /* Secondary Button */ + .btn-secondary { + @apply inline-flex items-center justify-center px-6 py-3 + bg-white/10 text-white font-semibold rounded-lg border-2 border-primary-400/50 + transition-all duration-300 backdrop-blur-sm + hover:-translate-y-0.5 hover:bg-primary-400/20 hover:border-primary-400 + active:translate-y-0; + } + + /* Feature Card - used on homepage */ + .feature-card { + background: linear-gradient(135deg, rgba(62, 78, 62, 0.2) 0%, rgba(44, 44, 44, 0.2) 100%); + border: 1px solid rgba(245, 245, 220, 0.08); + backdrop-filter: blur(12px); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + } + + .card-hover:hover { + transform: translateY(-3px); + background: linear-gradient(135deg, rgba(62, 78, 62, 0.3) 0%, rgba(44, 44, 44, 0.3) 100%); + border-color: rgba(144, 238, 144, 0.3); + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3); + } + + .card-hover:hover i { + color: #F5F5DC; + transform: scale(1.1); + transition: transform 0.3s ease; + } + + /* Search Input - Glass effect */ + .search-input-glass { + background: rgba(30, 30, 30, 0.6); + border: 1px solid rgba(245, 245, 220, 0.1); + backdrop-filter: blur(10px); + transition: all 0.3s ease; + box-shadow: inset 0 2px 4px rgba(0,0,0,0.2); + } + + .search-input-glass:focus { + background: rgba(40, 40, 40, 0.8); + border-color: rgba(144, 238, 144, 0.4); + box-shadow: 0 0 0 2px rgba(144, 238, 144, 0.1), inset 0 2px 4px rgba(0,0,0,0.2); + } + + /* Card Component */ + .card { + @apply bg-dark-100/80 backdrop-blur-md rounded-2xl border border-primary-400/20 + p-6 transition-all duration-300 + hover:border-primary-400/40 hover:shadow-lg hover:shadow-primary-400/10; + } + + /* Glass Card (more transparent) */ + .glass-card { + @apply bg-dark-100/60 backdrop-blur-xl rounded-2xl border border-primary-400/20 + p-6 transition-all duration-300; + } + + /* Stat Card */ + .stat-card { + @apply card text-center relative overflow-hidden + hover:-translate-y-1 hover:border-primary-400; + } + + /* Input Field */ + .input-field { + @apply w-full px-4 py-3 bg-dark-200/80 border-2 border-primary-400/30 + rounded-lg text-white placeholder-white/50 + transition-all duration-300 backdrop-blur-sm + focus:outline-none focus:border-primary-400 focus:shadow-lg focus:shadow-primary-400/20; + } + + /* Select Dropdown */ + .select-field { + @apply input-field cursor-pointer appearance-none pr-10; + background-image: url('data:image/svg+xml;charset=UTF-8,%3csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%2339ff14%22 stroke-width=%222%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22%3e%3cpolyline points=%226 9 12 15 18 9%22%3e%3c/polyline%3e%3c/svg%3e'); + background-position: right 0.75rem center; + background-size: 1.25rem; + background-repeat: no-repeat; + } + + /* Table Styles */ + .data-table { + @apply w-full border-collapse bg-dark-200/80 rounded-xl overflow-hidden + shadow-xl border border-primary-400/10; + } + + .data-table thead { + @apply bg-gradient-to-r from-primary-400/20 to-primary-500/20; + } + + .data-table th { + @apply px-4 py-3 text-white font-semibold text-center border-b-2 border-primary-400/30; + } + + .data-table td { + @apply px-4 py-3 text-center text-white/90 border-b border-primary-400/10; + } + + .data-table tbody tr { + @apply transition-all duration-300 hover:bg-gradient-to-r hover:from-primary-400/10 + hover:to-primary-500/10 hover:scale-[1.01]; + } + + /* Loading Spinner */ + .spinner { + @apply border-4 border-primary-400/30 border-t-primary-400 rounded-full + w-10 h-10 animate-spin mx-auto; + } + + /* Badge */ + .badge { + @apply inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold + bg-primary-400/10 text-primary-400 border border-primary-400/30; + } + + /* Navbar */ + .navbar { + @apply fixed top-0 w-full bg-dark-300/95 backdrop-blur-md z-50 + border-b border-primary-400/20 transition-all duration-300; + } + + /* Container */ + .container-custom { + @apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8; + } + + /* Section Title */ + .section-title { + @apply text-4xl font-bold text-center mb-4 bg-gradient-to-r from-primary-400 via-primary-500 to-primary-400 + bg-clip-text text-transparent bg-[length:200%_auto] animate-gradient-shift; + } + + /* Link Hover Effect */ + .link-hover { + @apply relative text-white transition-colors duration-300 + hover:text-primary-400 + after:content-[''] after:absolute after:bottom-0 after:left-0 + after:w-0 after:h-0.5 after:bg-gradient-to-r after:from-primary-400 after:to-primary-500 + after:transition-all after:duration-300 + hover:after:w-full; + } + + /* Date Filter Styles */ + .date-filter-container { + @apply glass-card space-y-4; + } + + .filter-button { + @apply px-4 py-2 rounded-lg border border-primary-400/30 bg-dark-100/50 + text-white font-medium transition-all duration-300 + hover:border-primary-400 hover:bg-primary-400/10 + focus:outline-none focus:ring-2 focus:ring-primary-400/50; + } + + .filter-button-active { + @apply filter-button border-primary-400 bg-primary-400/20 text-primary-400 shadow-lg shadow-primary-400/20; + } +} + +@layer utilities { + /* Text Gradient */ + .text-gradient { + @apply bg-gradient-to-r from-primary-400 via-primary-500 to-primary-600 + bg-clip-text text-transparent; + } + + /* Glass Effect */ + .glass { + @apply bg-white/5 backdrop-blur-md; + } + + /* Glow Effect */ + .glow { + @apply shadow-lg shadow-primary-400/30; + } + + /* Scrollbar Styles */ + .custom-scrollbar::-webkit-scrollbar { + @apply w-2 h-2; + } + + .custom-scrollbar::-webkit-scrollbar-track { + @apply bg-dark-200 rounded; + } + + .custom-scrollbar::-webkit-scrollbar-thumb { + @apply bg-primary-400/30 rounded hover:bg-primary-400/50; + } +} + +/* Additional custom animations */ +@keyframes gradientShift { + 0%, 100% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } +} + +@keyframes scrollRight { + 0% { transform: translateX(0); } + 100% { transform: translateX(-50%); } +} + +/* Row link overlay - makes entire table row clickable with native right-click support */ +tr.row-link { + position: relative; + cursor: pointer; +} +tr.row-link a.row-link-overlay::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 0; +} +tr.row-link td a:not(.row-link-overlay), +tr.row-link td button, +tr.row-link td [tabindex], +tr.row-link td input, +tr.row-link td select, +tr.row-link td textarea { + position: relative; + z-index: 1; +} diff --git a/web/public/images/Untitled-1.png b/web/public/images/Untitled-1.png new file mode 100644 index 0000000..1e7f689 Binary files /dev/null and b/web/public/images/Untitled-1.png differ diff --git a/web/public/images/toothless_face.png b/web/public/images/toothless_face.png new file mode 100644 index 0000000..cc95d90 Binary files /dev/null and b/web/public/images/toothless_face.png differ diff --git a/web/public/images/toothless_face.webp b/web/public/images/toothless_face.webp new file mode 100644 index 0000000..2694874 Binary files /dev/null and b/web/public/images/toothless_face.webp differ diff --git a/web/public/images/toothless_server.gif b/web/public/images/toothless_server.gif new file mode 100644 index 0000000..61541cd Binary files /dev/null and b/web/public/images/toothless_server.gif differ diff --git a/web/public/images/transparent_toothlessssss.png b/web/public/images/transparent_toothlessssss.png new file mode 100644 index 0000000..b7ecf03 Binary files /dev/null and b/web/public/images/transparent_toothlessssss.png differ diff --git a/web/public/js/api-client.js b/web/public/js/api-client.js new file mode 100644 index 0000000..2c67110 --- /dev/null +++ b/web/public/js/api-client.js @@ -0,0 +1,362 @@ +// API Client with automatic token handling and caching +class APIClient { + constructor() { + this.apiKey = null; + this.apiKeyExpiresAt = 0; + this.apiKeyPromise = null; + this.baseURL = ''; + this.cache = new Map(); + this.pendingRequests = new Map(); + this.CACHE_DURATION = 5 * 60 * 1000; // 5 minutes client-side cache for general requests + this.SEARCH_CACHE_DURATION = 15 * 60 * 1000; // 15 minutes for search results (longer because less frequently changing) + } + + // Initialize the API client by fetching the API key + async init(forceRefresh = false) { + if (!forceRefresh && this.apiKey && this.apiKeyExpiresAt && Date.now() < this.apiKeyExpiresAt) { + return; + } + + if (this.apiKeyPromise) { + return this.apiKeyPromise; + } + + this.apiKeyPromise = (async () => { + const response = await fetch('/api-key', { + method: 'GET', + cache: 'no-cache', + headers: { + 'Accept': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`Failed to fetch API key: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + this.apiKey = data.apiKey; + const expiresAt = Date.parse(data.expires); + this.apiKeyExpiresAt = Number.isFinite(expiresAt) ? expiresAt : Date.now() + 24 * 60 * 60 * 1000; + return this.apiKey; + })(); + + try { + return await this.apiKeyPromise; + } finally { + this.apiKeyPromise = null; + } + } + + async ensureValidApiKey() { + if (!this.apiKey || !this.apiKeyExpiresAt || Date.now() >= this.apiKeyExpiresAt) { + await this.init(true); + } + } + + // Generate signature for request (matches server-side algorithm) + generateSignature(method, path, queryString, timestamp, apiSecret) { + const data = `${method}${path}${queryString}`; + // Match server-side simple hashing algorithm exactly + let hash = 0; + const combined = `${data}-${timestamp}-${apiSecret}`; + for (let i = 0; i < combined.length; i++) { + const char = combined.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32bit integer + } + return Math.abs(hash).toString(16); + } + + // Make authenticated API requests with caching and in-flight dedup + async request(endpoint, options = {}) { + const method = options.method || 'GET'; + const authEnabled = options.auth !== false; + const cacheKey = `${method}:${endpoint}`; + const useCache = options.cache !== false && method === 'GET'; // Only cache GET requests + + // Determine cache duration based on endpoint type + const isSearchRequest = endpoint.includes('/api/search/'); + const cacheDuration = isSearchRequest ? this.SEARCH_CACHE_DURATION : this.CACHE_DURATION; + + // Check cache for GET requests + if (useCache && this.cache.has(cacheKey)) { + const cached = this.cache.get(cacheKey); + const age = Date.now() - cached.timestamp; + + if (age < cacheDuration) { + console.log(`[API Client] Cache hit for ${endpoint} (${Math.floor(age / 1000)}s old)`); + return cached.data; + } else { + console.log(`[API Client] Cache expired for ${endpoint}`); + this.cache.delete(cacheKey); + } + } + + // In-flight dedup: if an identical GET is already in flight, share its promise + if (useCache && this.pendingRequests.has(cacheKey)) { + console.log(`[API Client] In-flight dedup hit for ${endpoint}`); + return this.pendingRequests.get(cacheKey); + } + + const promise = this._performRequest(endpoint, options, { method, authEnabled, cacheKey, useCache }); + + if (useCache) { + this.pendingRequests.set(cacheKey, promise); + promise.finally(() => { + if (this.pendingRequests.get(cacheKey) === promise) { + this.pendingRequests.delete(cacheKey); + } + }); + } + + return promise; + } + + async _performRequest(endpoint, options, ctx) { + const { method, authEnabled, cacheKey, useCache } = ctx; + + console.log('[API Client] request called for:', endpoint); + + // Auto-initialize if needed + if (authEnabled) { + await this.ensureValidApiKey(); + } + + if (authEnabled && !this.apiKey) { + throw new Error('Failed to get API key after initialization'); + } + + const url = endpoint.startsWith('/') ? endpoint : `/${endpoint}`; + const timestamp = Date.now().toString(); + + const headers = { + 'Content-Type': 'application/json', + ...options.headers + }; + if (authEnabled) { + headers['X-API-Key'] = this.apiKey; + headers['X-Request-Timestamp'] = timestamp; + } + + const finalOptions = { + ...options, + method, + headers + }; + + console.log('[API Client] Making request to:', url); + + let response = await fetch(url, finalOptions); + console.log('[API Client] Response status:', response.status); + + if (authEnabled && (response.status === 401 || response.status === 403)) { + console.warn(`[API Client] Auth failed for ${endpoint}; refreshing API key and retrying once`); + this.apiKey = null; + this.apiKeyExpiresAt = 0; + await this.init(true); + finalOptions.headers['X-API-Key'] = this.apiKey; + finalOptions.headers['X-Request-Timestamp'] = Date.now().toString(); + response = await fetch(url, finalOptions); + console.log('[API Client] Retry response status:', response.status); + } + + if (!response.ok) { + const errorText = await response.text(); + console.error('[API Client] Request failed:', response.status, errorText); + throw new Error(`API request failed: ${response.status} - ${errorText}`); + } + + const data = await response.json(); + + // Cache successful GET responses + if (useCache) { + this.cache.set(cacheKey, { + data: data, + timestamp: Date.now() + }); + console.log(`[API Client] Cached response for ${endpoint}`); + } + + return data; + } + + // Clear cache manually if needed + clearCache() { + this.cache.clear(); + this.pendingRequests.clear(); + console.log('[API Client] Cache cleared'); + } + + // Get cache stats + getCacheStats() { + const now = Date.now(); + const stats = { + total: this.cache.size, + fresh: 0, + stale: 0, + searchEntries: 0 + }; + + for (const [key, value] of this.cache.entries()) { + const age = now - value.timestamp; + const isSearch = key.includes('/api/search/'); + const maxAge = isSearch ? this.SEARCH_CACHE_DURATION : this.CACHE_DURATION; + + if (isSearch) stats.searchEntries++; + + if (age < maxAge) { + stats.fresh++; + } else { + stats.stale++; + } + } + + return stats; + } + + // Convenience methods + async searchPlayers(nickname) { + return this.request(`/api/search/${encodeURIComponent(nickname)}`); + } + + async getPlayer(uid) { + return this.request(`/api/player/${uid}`); + } + + async getPlayerGames(uid) { + return this.request(`/api/player/${uid}/games`); + } + + async getStats() { + return this.request('/api/stats', { auth: false }); + } + + // Leaderboard methods with stale-while-revalidate option + async getPlayerLeaderboard(useStaleWhileRevalidate = true) { + return this.requestWithSWR('/api/leaderboard/players', useStaleWhileRevalidate); + } + + async getVehicleLeaderboard(vehicle = null, useStaleWhileRevalidate = true) { + const endpoint = vehicle + ? `/api/leaderboard/vehicles?vehicle=${encodeURIComponent(vehicle)}` + : '/api/leaderboard/vehicles'; + return this.requestWithSWR(endpoint, useStaleWhileRevalidate); + } + + async getSquadronLeaderboard(useStaleWhileRevalidate = true) { + return this.requestWithSWR('/api/leaderboard/squadrons', useStaleWhileRevalidate); + } + + async getSquadronDetails(squadronName, startDate, endDate) { + let endpoint = `/api/squadrons/${encodeURIComponent(squadronName)}`; + if (startDate || endDate) { + const params = new URLSearchParams(); + if (startDate) params.append('start_date', startDate.toISOString()); + if (endDate) params.append('end_date', endDate.toISOString()); + endpoint += '?' + params.toString(); + } + return this.request(endpoint); + } + + async getSquadronGames(squadronName, startDate, endDate) { + let endpoint = `/api/squadrons/${encodeURIComponent(squadronName)}/games`; + if (startDate || endDate) { + const params = new URLSearchParams(); + if (startDate) params.append('start_date', startDate.toISOString()); + if (endDate) params.append('end_date', endDate.toISOString()); + endpoint += '?' + params.toString(); + } + return this.request(endpoint); + } + + async getLeaderboardStats(useStaleWhileRevalidate = true) { + return this.requestWithSWR('/api/leaderboard/stats', useStaleWhileRevalidate); + } + + // Stale-While-Revalidate: Return cached data immediately, fetch fresh data in background + async requestWithSWR(endpoint, useSWR = true) { + const cacheKey = `GET:${endpoint}`; + + if (useSWR && this.cache.has(cacheKey)) { + const cached = this.cache.get(cacheKey); + const age = Date.now() - cached.timestamp; + + // If data is stale (older than cache duration) but not ancient (< 10 minutes) + if (age >= this.CACHE_DURATION && age < 10 * 60 * 1000) { + console.log(`[API Client] SWR: Returning stale data and revalidating ${endpoint}`); + + // Fetch fresh data in background (don't await) + this.request(endpoint).catch(err => { + console.error('[API Client] SWR background fetch failed:', err); + }); + + // Return stale data immediately + return cached.data; + } + } + + // Normal request (will use cache if fresh) + return this.request(endpoint); + } + + async getVehicleIcons() { + return this.request('/api/vehicle-icons'); + } + + async getMatch(sessionId) { + return this.request(`/api/match/${sessionId}`); + } + + async getMatchReplay(sessionId) { + return this.request(`/api/match/${sessionId}/replay`); + } + + async getReplayCanvas(sessionId) { + return this.request(`/api/match/${sessionId}/replay-canvas`); + } + + async searchGames(params = {}) { + const queryParams = new URLSearchParams(); + if (params.player) queryParams.append('player', params.player); + if (params.map) queryParams.append('map', params.map); + if (params.squadron) queryParams.append('squadron', params.squadron); + if (params.time_from) queryParams.append('time_from', params.time_from); + if (params.time_to) queryParams.append('time_to', params.time_to); + if (params.limit) queryParams.append('limit', params.limit); + return this.request(`/api/games/search?${queryParams}`); + } + + async getMaps() { + return this.request('/api/maps'); + } + +} + +// Global API client instance +window.apiClient = new APIClient(); + +// Pre-initialize when DOM is ready (but don't block) +document.addEventListener('DOMContentLoaded', () => { + // Initialize in background without blocking + window.apiClient.init().catch(() => { + // Silently fail, will retry on first request + }); +}); + +// Quick init function that doesn't throw +window.ensureAPIClient = async () => { + try { + if (!window.apiClient) { + console.error('[API Client] window.apiClient is not defined'); + return false; + } + if (!window.apiClient.apiKey) { + await window.apiClient.init(); + } + return true; + } catch (error) { + console.error('[API Client] Error in ensureAPIClient:', error); + return false; + } +}; diff --git a/web/public/js/chart.umd.min.js b/web/public/js/chart.umd.min.js new file mode 100644 index 0000000..9a07c2f --- /dev/null +++ b/web/public/js/chart.umd.min.js @@ -0,0 +1,20 @@ +/** + * Skipped minification because the original files appears to be already minified. + * Original file: /npm/chart.js@4.4.0/dist/chart.umd.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +/*! + * Chart.js v4.4.0 + * https://www.chartjs.org + * (c) 2023 Chart.js Contributors + * Released under the MIT License + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,(function(){"use strict";var t=Object.freeze({__proto__:null,get Colors(){return Go},get Decimation(){return Qo},get Filler(){return ma},get Legend(){return ya},get SubTitle(){return ka},get Title(){return Ma},get Tooltip(){return Ba}});function e(){}const i=(()=>{let t=0;return()=>t++})();function s(t){return null==t}function n(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function o(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function a(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function r(t,e){return a(t)?t:e}function l(t,e){return void 0===t?e:t}const h=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,c=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function d(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function u(t,e,i,s){let a,r,l;if(n(t))if(r=t.length,s)for(a=r-1;a>=0;a--)e.call(i,t[a],a);else for(a=0;at,x:t=>t.x,y:t=>t.y};function v(t){const e=t.split("."),i=[];let s="";for(const t of e)s+=t,s.endsWith("\\")?s=s.slice(0,-1)+".":(i.push(s),s="");return i}function M(t,e){const i=y[e]||(y[e]=function(t){const e=v(t);return t=>{for(const i of e){if(""===i)break;t=t&&t[i]}return t}}(e));return i(t)}function w(t){return t.charAt(0).toUpperCase()+t.slice(1)}const k=t=>void 0!==t,S=t=>"function"==typeof t,P=(t,e)=>{if(t.size!==e.size)return!1;for(const i of t)if(!e.has(i))return!1;return!0};function D(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const C=Math.PI,O=2*C,A=O+C,T=Number.POSITIVE_INFINITY,L=C/180,E=C/2,R=C/4,I=2*C/3,z=Math.log10,F=Math.sign;function V(t,e,i){return Math.abs(t-e)t-e)).pop(),e}function N(t){return!isNaN(parseFloat(t))&&isFinite(t)}function H(t,e){const i=Math.round(t);return i-e<=t&&i+e>=t}function j(t,e,i){let s,n,o;for(s=0,n=t.length;sl&&h=Math.min(e,i)-s&&t<=Math.max(e,i)+s}function et(t,e,i){i=i||(i=>t[i]1;)s=o+n>>1,i(s)?o=s:n=s;return{lo:o,hi:n}}const it=(t,e,i,s)=>et(t,i,s?s=>{const n=t[s][e];return nt[s][e]et(t,i,(s=>t[s][e]>=i));function nt(t,e,i){let s=0,n=t.length;for(;ss&&t[n-1]>i;)n--;return s>0||n{const i="_onData"+w(e),s=t[e];Object.defineProperty(t,e,{configurable:!0,enumerable:!1,value(...e){const n=s.apply(this,e);return t._chartjs.listeners.forEach((t=>{"function"==typeof t[i]&&t[i](...e)})),n}})})))}function rt(t,e){const i=t._chartjs;if(!i)return;const s=i.listeners,n=s.indexOf(e);-1!==n&&s.splice(n,1),s.length>0||(ot.forEach((e=>{delete t[e]})),delete t._chartjs)}function lt(t){const e=new Set(t);return e.size===t.length?t:Array.from(e)}const ht="undefined"==typeof window?function(t){return t()}:window.requestAnimationFrame;function ct(t,e){let i=[],s=!1;return function(...n){i=n,s||(s=!0,ht.call(window,(()=>{s=!1,t.apply(e,i)})))}}function dt(t,e){let i;return function(...s){return e?(clearTimeout(i),i=setTimeout(t,e,s)):t.apply(this,s),e}}const ut=t=>"start"===t?"left":"end"===t?"right":"center",ft=(t,e,i)=>"start"===t?e:"end"===t?i:(e+i)/2,gt=(t,e,i,s)=>t===(s?"left":"right")?i:"center"===t?(e+i)/2:e;function pt(t,e,i){const s=e.length;let n=0,o=s;if(t._sorted){const{iScale:a,_parsed:r}=t,l=a.axis,{min:h,max:c,minDefined:d,maxDefined:u}=a.getUserBounds();d&&(n=J(Math.min(it(r,l,h).lo,i?s:it(e,l,a.getPixelForValue(h)).lo),0,s-1)),o=u?J(Math.max(it(r,a.axis,c,!0).hi+1,i?0:it(e,l,a.getPixelForValue(c),!0).hi+1),n,s)-n:s-n}return{start:n,count:o}}function mt(t){const{xScale:e,yScale:i,_scaleRanges:s}=t,n={xmin:e.min,xmax:e.max,ymin:i.min,ymax:i.max};if(!s)return t._scaleRanges=n,!0;const o=s.xmin!==e.min||s.xmax!==e.max||s.ymin!==i.min||s.ymax!==i.max;return Object.assign(s,n),o}class bt{constructor(){this._request=null,this._charts=new Map,this._running=!1,this._lastDate=void 0}_notify(t,e,i,s){const n=e.listeners[s],o=e.duration;n.forEach((s=>s({chart:t,initial:e.initial,numSteps:o,currentStep:Math.min(i-e.start,o)})))}_refresh(){this._request||(this._running=!0,this._request=ht.call(window,(()=>{this._update(),this._request=null,this._running&&this._refresh()})))}_update(t=Date.now()){let e=0;this._charts.forEach(((i,s)=>{if(!i.running||!i.items.length)return;const n=i.items;let o,a=n.length-1,r=!1;for(;a>=0;--a)o=n[a],o._active?(o._total>i.duration&&(i.duration=o._total),o.tick(t),r=!0):(n[a]=n[n.length-1],n.pop());r&&(s.draw(),this._notify(s,i,t,"progress")),n.length||(i.running=!1,this._notify(s,i,t,"complete"),i.initial=!1),e+=n.length})),this._lastDate=t,0===e&&(this._running=!1)}_getAnims(t){const e=this._charts;let i=e.get(t);return i||(i={running:!1,initial:!0,items:[],listeners:{complete:[],progress:[]}},e.set(t,i)),i}listen(t,e,i){this._getAnims(t).listeners[e].push(i)}add(t,e){e&&e.length&&this._getAnims(t).items.push(...e)}has(t){return this._getAnims(t).items.length>0}start(t){const e=this._charts.get(t);e&&(e.running=!0,e.start=Date.now(),e.duration=e.items.reduce(((t,e)=>Math.max(t,e._duration)),0),this._refresh())}running(t){if(!this._running)return!1;const e=this._charts.get(t);return!!(e&&e.running&&e.items.length)}stop(t){const e=this._charts.get(t);if(!e||!e.items.length)return;const i=e.items;let s=i.length-1;for(;s>=0;--s)i[s].cancel();e.items=[],this._notify(t,e,Date.now(),"complete")}remove(t){return this._charts.delete(t)}}var xt=new bt; +/*! + * @kurkle/color v0.3.2 + * https://github.com/kurkle/color#readme + * (c) 2023 Jukka Kurkela + * Released under the MIT License + */function _t(t){return t+.5|0}const yt=(t,e,i)=>Math.max(Math.min(t,i),e);function vt(t){return yt(_t(2.55*t),0,255)}function Mt(t){return yt(_t(255*t),0,255)}function wt(t){return yt(_t(t/2.55)/100,0,1)}function kt(t){return yt(_t(100*t),0,100)}const St={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},Pt=[..."0123456789ABCDEF"],Dt=t=>Pt[15&t],Ct=t=>Pt[(240&t)>>4]+Pt[15&t],Ot=t=>(240&t)>>4==(15&t);function At(t){var e=(t=>Ot(t.r)&&Ot(t.g)&&Ot(t.b)&&Ot(t.a))(t)?Dt:Ct;return t?"#"+e(t.r)+e(t.g)+e(t.b)+((t,e)=>t<255?e(t):"")(t.a,e):void 0}const Tt=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function Lt(t,e,i){const s=e*Math.min(i,1-i),n=(e,n=(e+t/30)%12)=>i-s*Math.max(Math.min(n-3,9-n,1),-1);return[n(0),n(8),n(4)]}function Et(t,e,i){const s=(s,n=(s+t/60)%6)=>i-i*e*Math.max(Math.min(n,4-n,1),0);return[s(5),s(3),s(1)]}function Rt(t,e,i){const s=Lt(t,1,.5);let n;for(e+i>1&&(n=1/(e+i),e*=n,i*=n),n=0;n<3;n++)s[n]*=1-e-i,s[n]+=e;return s}function It(t){const e=t.r/255,i=t.g/255,s=t.b/255,n=Math.max(e,i,s),o=Math.min(e,i,s),a=(n+o)/2;let r,l,h;return n!==o&&(h=n-o,l=a>.5?h/(2-n-o):h/(n+o),r=function(t,e,i,s,n){return t===n?(e-i)/s+(e>16&255,o>>8&255,255&o]}return t}(),Ht.transparent=[0,0,0,0]);const e=Ht[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:4===e.length?e[3]:255}}const $t=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;const Yt=t=>t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,Ut=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function Xt(t,e,i){if(t){let s=It(t);s[e]=Math.max(0,Math.min(s[e]+s[e]*i,0===e?360:1)),s=Ft(s),t.r=s[0],t.g=s[1],t.b=s[2]}}function qt(t,e){return t?Object.assign(e||{},t):t}function Kt(t){var e={r:0,g:0,b:0,a:255};return Array.isArray(t)?t.length>=3&&(e={r:t[0],g:t[1],b:t[2],a:255},t.length>3&&(e.a=Mt(t[3]))):(e=qt(t,{r:0,g:0,b:0,a:1})).a=Mt(e.a),e}function Gt(t){return"r"===t.charAt(0)?function(t){const e=$t.exec(t);let i,s,n,o=255;if(e){if(e[7]!==i){const t=+e[7];o=e[8]?vt(t):yt(255*t,0,255)}return i=+e[1],s=+e[3],n=+e[5],i=255&(e[2]?vt(i):yt(i,0,255)),s=255&(e[4]?vt(s):yt(s,0,255)),n=255&(e[6]?vt(n):yt(n,0,255)),{r:i,g:s,b:n,a:o}}}(t):Bt(t)}class Zt{constructor(t){if(t instanceof Zt)return t;const e=typeof t;let i;var s,n,o;"object"===e?i=Kt(t):"string"===e&&(o=(s=t).length,"#"===s[0]&&(4===o||5===o?n={r:255&17*St[s[1]],g:255&17*St[s[2]],b:255&17*St[s[3]],a:5===o?17*St[s[4]]:255}:7!==o&&9!==o||(n={r:St[s[1]]<<4|St[s[2]],g:St[s[3]]<<4|St[s[4]],b:St[s[5]]<<4|St[s[6]],a:9===o?St[s[7]]<<4|St[s[8]]:255})),i=n||jt(t)||Gt(t)),this._rgb=i,this._valid=!!i}get valid(){return this._valid}get rgb(){var t=qt(this._rgb);return t&&(t.a=wt(t.a)),t}set rgb(t){this._rgb=Kt(t)}rgbString(){return this._valid?(t=this._rgb)&&(t.a<255?`rgba(${t.r}, ${t.g}, ${t.b}, ${wt(t.a)})`:`rgb(${t.r}, ${t.g}, ${t.b})`):void 0;var t}hexString(){return this._valid?At(this._rgb):void 0}hslString(){return this._valid?function(t){if(!t)return;const e=It(t),i=e[0],s=kt(e[1]),n=kt(e[2]);return t.a<255?`hsla(${i}, ${s}%, ${n}%, ${wt(t.a)})`:`hsl(${i}, ${s}%, ${n}%)`}(this._rgb):void 0}mix(t,e){if(t){const i=this.rgb,s=t.rgb;let n;const o=e===n?.5:e,a=2*o-1,r=i.a-s.a,l=((a*r==-1?a:(a+r)/(1+a*r))+1)/2;n=1-l,i.r=255&l*i.r+n*s.r+.5,i.g=255&l*i.g+n*s.g+.5,i.b=255&l*i.b+n*s.b+.5,i.a=o*i.a+(1-o)*s.a,this.rgb=i}return this}interpolate(t,e){return t&&(this._rgb=function(t,e,i){const s=Ut(wt(t.r)),n=Ut(wt(t.g)),o=Ut(wt(t.b));return{r:Mt(Yt(s+i*(Ut(wt(e.r))-s))),g:Mt(Yt(n+i*(Ut(wt(e.g))-n))),b:Mt(Yt(o+i*(Ut(wt(e.b))-o))),a:t.a+i*(e.a-t.a)}}(this._rgb,t._rgb,e)),this}clone(){return new Zt(this.rgb)}alpha(t){return this._rgb.a=Mt(t),this}clearer(t){return this._rgb.a*=1-t,this}greyscale(){const t=this._rgb,e=_t(.3*t.r+.59*t.g+.11*t.b);return t.r=t.g=t.b=e,this}opaquer(t){return this._rgb.a*=1+t,this}negate(){const t=this._rgb;return t.r=255-t.r,t.g=255-t.g,t.b=255-t.b,this}lighten(t){return Xt(this._rgb,2,t),this}darken(t){return Xt(this._rgb,2,-t),this}saturate(t){return Xt(this._rgb,1,t),this}desaturate(t){return Xt(this._rgb,1,-t),this}rotate(t){return function(t,e){var i=It(t);i[0]=Vt(i[0]+e),i=Ft(i),t.r=i[0],t.g=i[1],t.b=i[2]}(this._rgb,t),this}}function Jt(t){if(t&&"object"==typeof t){const e=t.toString();return"[object CanvasPattern]"===e||"[object CanvasGradient]"===e}return!1}function Qt(t){return Jt(t)?t:new Zt(t)}function te(t){return Jt(t)?t:new Zt(t).saturate(.5).darken(.1).hexString()}const ee=["x","y","borderWidth","radius","tension"],ie=["color","borderColor","backgroundColor"];const se=new Map;function ne(t,e,i){return function(t,e){e=e||{};const i=t+JSON.stringify(e);let s=se.get(i);return s||(s=new Intl.NumberFormat(t,e),se.set(i,s)),s}(e,i).format(t)}const oe={values:t=>n(t)?t:""+t,numeric(t,e,i){if(0===t)return"0";const s=this.chart.options.locale;let n,o=t;if(i.length>1){const e=Math.max(Math.abs(i[0].value),Math.abs(i[i.length-1].value));(e<1e-4||e>1e15)&&(n="scientific"),o=function(t,e){let i=e.length>3?e[2].value-e[1].value:e[1].value-e[0].value;Math.abs(i)>=1&&t!==Math.floor(t)&&(i=t-Math.floor(t));return i}(t,i)}const a=z(Math.abs(o)),r=isNaN(a)?1:Math.max(Math.min(-1*Math.floor(a),20),0),l={notation:n,minimumFractionDigits:r,maximumFractionDigits:r};return Object.assign(l,this.options.ticks.format),ne(t,s,l)},logarithmic(t,e,i){if(0===t)return"0";const s=i[e].significand||t/Math.pow(10,Math.floor(z(t)));return[1,2,3,5,10,15].includes(s)||e>.8*i.length?oe.numeric.call(this,t,e,i):""}};var ae={formatters:oe};const re=Object.create(null),le=Object.create(null);function he(t,e){if(!e)return t;const i=e.split(".");for(let e=0,s=i.length;et.chart.platform.getDevicePixelRatio(),this.elements={},this.events=["mousemove","mouseout","click","touchstart","touchmove"],this.font={family:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",size:12,style:"normal",lineHeight:1.2,weight:null},this.hover={},this.hoverBackgroundColor=(t,e)=>te(e.backgroundColor),this.hoverBorderColor=(t,e)=>te(e.borderColor),this.hoverColor=(t,e)=>te(e.color),this.indexAxis="x",this.interaction={mode:"nearest",intersect:!0,includeInvisible:!1},this.maintainAspectRatio=!0,this.onHover=null,this.onClick=null,this.parsing=!0,this.plugins={},this.responsive=!0,this.scale=void 0,this.scales={},this.showLine=!0,this.drawActiveElementsOnTop=!0,this.describe(t),this.apply(e)}set(t,e){return ce(this,t,e)}get(t){return he(this,t)}describe(t,e){return ce(le,t,e)}override(t,e){return ce(re,t,e)}route(t,e,i,s){const n=he(this,t),a=he(this,i),r="_"+e;Object.defineProperties(n,{[r]:{value:n[e],writable:!0},[e]:{enumerable:!0,get(){const t=this[r],e=a[s];return o(t)?Object.assign({},e,t):l(t,e)},set(t){this[r]=t}}})}apply(t){t.forEach((t=>t(this)))}}var ue=new de({_scriptable:t=>!t.startsWith("on"),_indexable:t=>"events"!==t,hover:{_fallback:"interaction"},interaction:{_scriptable:!1,_indexable:!1}},[function(t){t.set("animation",{delay:void 0,duration:1e3,easing:"easeOutQuart",fn:void 0,from:void 0,loop:void 0,to:void 0,type:void 0}),t.describe("animation",{_fallback:!1,_indexable:!1,_scriptable:t=>"onProgress"!==t&&"onComplete"!==t&&"fn"!==t}),t.set("animations",{colors:{type:"color",properties:ie},numbers:{type:"number",properties:ee}}),t.describe("animations",{_fallback:"animation"}),t.set("transitions",{active:{animation:{duration:400}},resize:{animation:{duration:0}},show:{animations:{colors:{from:"transparent"},visible:{type:"boolean",duration:0}}},hide:{animations:{colors:{to:"transparent"},visible:{type:"boolean",easing:"linear",fn:t=>0|t}}}})},function(t){t.set("layout",{autoPadding:!0,padding:{top:0,right:0,bottom:0,left:0}})},function(t){t.set("scale",{display:!0,offset:!1,reverse:!1,beginAtZero:!1,bounds:"ticks",clip:!0,grace:0,grid:{display:!0,lineWidth:1,drawOnChartArea:!0,drawTicks:!0,tickLength:8,tickWidth:(t,e)=>e.lineWidth,tickColor:(t,e)=>e.color,offset:!1},border:{display:!0,dash:[],dashOffset:0,width:1},title:{display:!1,text:"",padding:{top:4,bottom:4}},ticks:{minRotation:0,maxRotation:50,mirror:!1,textStrokeWidth:0,textStrokeColor:"",padding:3,display:!0,autoSkip:!0,autoSkipPadding:3,labelOffset:0,callback:ae.formatters.values,minor:{},major:{},align:"center",crossAlign:"near",showLabelBackdrop:!1,backdropColor:"rgba(255, 255, 255, 0.75)",backdropPadding:2}}),t.route("scale.ticks","color","","color"),t.route("scale.grid","color","","borderColor"),t.route("scale.border","color","","borderColor"),t.route("scale.title","color","","color"),t.describe("scale",{_fallback:!1,_scriptable:t=>!t.startsWith("before")&&!t.startsWith("after")&&"callback"!==t&&"parser"!==t,_indexable:t=>"borderDash"!==t&&"tickBorderDash"!==t&&"dash"!==t}),t.describe("scales",{_fallback:"scale"}),t.describe("scale.ticks",{_scriptable:t=>"backdropPadding"!==t&&"callback"!==t,_indexable:t=>"backdropPadding"!==t})}]);function fe(){return"undefined"!=typeof window&&"undefined"!=typeof document}function ge(t){let e=t.parentNode;return e&&"[object ShadowRoot]"===e.toString()&&(e=e.host),e}function pe(t,e,i){let s;return"string"==typeof t?(s=parseInt(t,10),-1!==t.indexOf("%")&&(s=s/100*e.parentNode[i])):s=t,s}const me=t=>t.ownerDocument.defaultView.getComputedStyle(t,null);function be(t,e){return me(t).getPropertyValue(e)}const xe=["top","right","bottom","left"];function _e(t,e,i){const s={};i=i?"-"+i:"";for(let n=0;n<4;n++){const o=xe[n];s[o]=parseFloat(t[e+"-"+o+i])||0}return s.width=s.left+s.right,s.height=s.top+s.bottom,s}const ye=(t,e,i)=>(t>0||e>0)&&(!i||!i.shadowRoot);function ve(t,e){if("native"in t)return t;const{canvas:i,currentDevicePixelRatio:s}=e,n=me(i),o="border-box"===n.boxSizing,a=_e(n,"padding"),r=_e(n,"border","width"),{x:l,y:h,box:c}=function(t,e){const i=t.touches,s=i&&i.length?i[0]:t,{offsetX:n,offsetY:o}=s;let a,r,l=!1;if(ye(n,o,t.target))a=n,r=o;else{const t=e.getBoundingClientRect();a=s.clientX-t.left,r=s.clientY-t.top,l=!0}return{x:a,y:r,box:l}}(t,i),d=a.left+(c&&r.left),u=a.top+(c&&r.top);let{width:f,height:g}=e;return o&&(f-=a.width+r.width,g-=a.height+r.height),{x:Math.round((l-d)/f*i.width/s),y:Math.round((h-u)/g*i.height/s)}}const Me=t=>Math.round(10*t)/10;function we(t,e,i,s){const n=me(t),o=_e(n,"margin"),a=pe(n.maxWidth,t,"clientWidth")||T,r=pe(n.maxHeight,t,"clientHeight")||T,l=function(t,e,i){let s,n;if(void 0===e||void 0===i){const o=ge(t);if(o){const t=o.getBoundingClientRect(),a=me(o),r=_e(a,"border","width"),l=_e(a,"padding");e=t.width-l.width-r.width,i=t.height-l.height-r.height,s=pe(a.maxWidth,o,"clientWidth"),n=pe(a.maxHeight,o,"clientHeight")}else e=t.clientWidth,i=t.clientHeight}return{width:e,height:i,maxWidth:s||T,maxHeight:n||T}}(t,e,i);let{width:h,height:c}=l;if("content-box"===n.boxSizing){const t=_e(n,"border","width"),e=_e(n,"padding");h-=e.width+t.width,c-=e.height+t.height}h=Math.max(0,h-o.width),c=Math.max(0,s?h/s:c-o.height),h=Me(Math.min(h,a,l.maxWidth)),c=Me(Math.min(c,r,l.maxHeight)),h&&!c&&(c=Me(h/2));return(void 0!==e||void 0!==i)&&s&&l.height&&c>l.height&&(c=l.height,h=Me(Math.floor(c*s))),{width:h,height:c}}function ke(t,e,i){const s=e||1,n=Math.floor(t.height*s),o=Math.floor(t.width*s);t.height=Math.floor(t.height),t.width=Math.floor(t.width);const a=t.canvas;return a.style&&(i||!a.style.height&&!a.style.width)&&(a.style.height=`${t.height}px`,a.style.width=`${t.width}px`),(t.currentDevicePixelRatio!==s||a.height!==n||a.width!==o)&&(t.currentDevicePixelRatio=s,a.height=n,a.width=o,t.ctx.setTransform(s,0,0,s,0,0),!0)}const Se=function(){let t=!1;try{const e={get passive(){return t=!0,!1}};window.addEventListener("test",null,e),window.removeEventListener("test",null,e)}catch(t){}return t}();function Pe(t,e){const i=be(t,e),s=i&&i.match(/^(\d+)(\.\d+)?px$/);return s?+s[1]:void 0}function De(t){return!t||s(t.size)||s(t.family)?null:(t.style?t.style+" ":"")+(t.weight?t.weight+" ":"")+t.size+"px "+t.family}function Ce(t,e,i,s,n){let o=e[n];return o||(o=e[n]=t.measureText(n).width,i.push(n)),o>s&&(s=o),s}function Oe(t,e,i,s){let o=(s=s||{}).data=s.data||{},a=s.garbageCollect=s.garbageCollect||[];s.font!==e&&(o=s.data={},a=s.garbageCollect=[],s.font=e),t.save(),t.font=e;let r=0;const l=i.length;let h,c,d,u,f;for(h=0;hi.length){for(h=0;h0&&t.stroke()}}function Re(t,e,i){return i=i||.5,!e||t&&t.x>e.left-i&&t.xe.top-i&&t.y0&&""!==r.strokeColor;let c,d;for(t.save(),t.font=a.string,function(t,e){e.translation&&t.translate(e.translation[0],e.translation[1]),s(e.rotation)||t.rotate(e.rotation),e.color&&(t.fillStyle=e.color),e.textAlign&&(t.textAlign=e.textAlign),e.textBaseline&&(t.textBaseline=e.textBaseline)}(t,r),c=0;ct[0])){const o=i||t;void 0===s&&(s=ti("_fallback",t));const a={[Symbol.toStringTag]:"Object",_cacheable:!0,_scopes:t,_rootScopes:o,_fallback:s,_getTarget:n,override:i=>je([i,...t],e,o,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete e._keys,delete t[0][i],!0),get:(i,s)=>qe(i,s,(()=>function(t,e,i,s){let n;for(const o of e)if(n=ti(Ue(o,t),i),void 0!==n)return Xe(t,n)?Je(i,s,t,n):n}(s,e,t,i))),getOwnPropertyDescriptor:(t,e)=>Reflect.getOwnPropertyDescriptor(t._scopes[0],e),getPrototypeOf:()=>Reflect.getPrototypeOf(t[0]),has:(t,e)=>ei(t).includes(e),ownKeys:t=>ei(t),set(t,e,i){const s=t._storage||(t._storage=n());return t[e]=s[e]=i,delete t._keys,!0}})}function $e(t,e,i,s){const a={_cacheable:!1,_proxy:t,_context:e,_subProxy:i,_stack:new Set,_descriptors:Ye(t,s),setContext:e=>$e(t,e,i,s),override:n=>$e(t.override(n),e,i,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete t[i],!0),get:(t,e,i)=>qe(t,e,(()=>function(t,e,i){const{_proxy:s,_context:a,_subProxy:r,_descriptors:l}=t;let h=s[e];S(h)&&l.isScriptable(e)&&(h=function(t,e,i,s){const{_proxy:n,_context:o,_subProxy:a,_stack:r}=i;if(r.has(t))throw new Error("Recursion detected: "+Array.from(r).join("->")+"->"+t);r.add(t);let l=e(o,a||s);r.delete(t),Xe(t,l)&&(l=Je(n._scopes,n,t,l));return l}(e,h,t,i));n(h)&&h.length&&(h=function(t,e,i,s){const{_proxy:n,_context:a,_subProxy:r,_descriptors:l}=i;if(void 0!==a.index&&s(t))return e[a.index%e.length];if(o(e[0])){const i=e,s=n._scopes.filter((t=>t!==i));e=[];for(const o of i){const i=Je(s,n,t,o);e.push($e(i,a,r&&r[t],l))}}return e}(e,h,t,l.isIndexable));Xe(e,h)&&(h=$e(h,a,r&&r[e],l));return h}(t,e,i))),getOwnPropertyDescriptor:(e,i)=>e._descriptors.allKeys?Reflect.has(t,i)?{enumerable:!0,configurable:!0}:void 0:Reflect.getOwnPropertyDescriptor(t,i),getPrototypeOf:()=>Reflect.getPrototypeOf(t),has:(e,i)=>Reflect.has(t,i),ownKeys:()=>Reflect.ownKeys(t),set:(e,i,s)=>(t[i]=s,delete e[i],!0)})}function Ye(t,e={scriptable:!0,indexable:!0}){const{_scriptable:i=e.scriptable,_indexable:s=e.indexable,_allKeys:n=e.allKeys}=t;return{allKeys:n,scriptable:i,indexable:s,isScriptable:S(i)?i:()=>i,isIndexable:S(s)?s:()=>s}}const Ue=(t,e)=>t?t+w(e):e,Xe=(t,e)=>o(e)&&"adapters"!==t&&(null===Object.getPrototypeOf(e)||e.constructor===Object);function qe(t,e,i){if(Object.prototype.hasOwnProperty.call(t,e))return t[e];const s=i();return t[e]=s,s}function Ke(t,e,i){return S(t)?t(e,i):t}const Ge=(t,e)=>!0===t?e:"string"==typeof t?M(e,t):void 0;function Ze(t,e,i,s,n){for(const o of e){const e=Ge(i,o);if(e){t.add(e);const o=Ke(e._fallback,i,n);if(void 0!==o&&o!==i&&o!==s)return o}else if(!1===e&&void 0!==s&&i!==s)return null}return!1}function Je(t,e,i,s){const a=e._rootScopes,r=Ke(e._fallback,i,s),l=[...t,...a],h=new Set;h.add(s);let c=Qe(h,l,i,r||i,s);return null!==c&&((void 0===r||r===i||(c=Qe(h,l,r,c,s),null!==c))&&je(Array.from(h),[""],a,r,(()=>function(t,e,i){const s=t._getTarget();e in s||(s[e]={});const a=s[e];if(n(a)&&o(i))return i;return a||{}}(e,i,s))))}function Qe(t,e,i,s,n){for(;i;)i=Ze(t,e,i,s,n);return i}function ti(t,e){for(const i of e){if(!i)continue;const e=i[t];if(void 0!==e)return e}}function ei(t){let e=t._keys;return e||(e=t._keys=function(t){const e=new Set;for(const i of t)for(const t of Object.keys(i).filter((t=>!t.startsWith("_"))))e.add(t);return Array.from(e)}(t._scopes)),e}function ii(t,e,i,s){const{iScale:n}=t,{key:o="r"}=this._parsing,a=new Array(s);let r,l,h,c;for(r=0,l=s;re"x"===t?"y":"x";function ai(t,e,i,s){const n=t.skip?e:t,o=e,a=i.skip?e:i,r=q(o,n),l=q(a,o);let h=r/(r+l),c=l/(r+l);h=isNaN(h)?0:h,c=isNaN(c)?0:c;const d=s*h,u=s*c;return{previous:{x:o.x-d*(a.x-n.x),y:o.y-d*(a.y-n.y)},next:{x:o.x+u*(a.x-n.x),y:o.y+u*(a.y-n.y)}}}function ri(t,e="x"){const i=oi(e),s=t.length,n=Array(s).fill(0),o=Array(s);let a,r,l,h=ni(t,0);for(a=0;a!t.skip))),"monotone"===e.cubicInterpolationMode)ri(t,n);else{let i=s?t[t.length-1]:t[0];for(o=0,a=t.length;o0===t||1===t,di=(t,e,i)=>-Math.pow(2,10*(t-=1))*Math.sin((t-e)*O/i),ui=(t,e,i)=>Math.pow(2,-10*t)*Math.sin((t-e)*O/i)+1,fi={linear:t=>t,easeInQuad:t=>t*t,easeOutQuad:t=>-t*(t-2),easeInOutQuad:t=>(t/=.5)<1?.5*t*t:-.5*(--t*(t-2)-1),easeInCubic:t=>t*t*t,easeOutCubic:t=>(t-=1)*t*t+1,easeInOutCubic:t=>(t/=.5)<1?.5*t*t*t:.5*((t-=2)*t*t+2),easeInQuart:t=>t*t*t*t,easeOutQuart:t=>-((t-=1)*t*t*t-1),easeInOutQuart:t=>(t/=.5)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2),easeInQuint:t=>t*t*t*t*t,easeOutQuint:t=>(t-=1)*t*t*t*t+1,easeInOutQuint:t=>(t/=.5)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2),easeInSine:t=>1-Math.cos(t*E),easeOutSine:t=>Math.sin(t*E),easeInOutSine:t=>-.5*(Math.cos(C*t)-1),easeInExpo:t=>0===t?0:Math.pow(2,10*(t-1)),easeOutExpo:t=>1===t?1:1-Math.pow(2,-10*t),easeInOutExpo:t=>ci(t)?t:t<.5?.5*Math.pow(2,10*(2*t-1)):.5*(2-Math.pow(2,-10*(2*t-1))),easeInCirc:t=>t>=1?t:-(Math.sqrt(1-t*t)-1),easeOutCirc:t=>Math.sqrt(1-(t-=1)*t),easeInOutCirc:t=>(t/=.5)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1),easeInElastic:t=>ci(t)?t:di(t,.075,.3),easeOutElastic:t=>ci(t)?t:ui(t,.075,.3),easeInOutElastic(t){const e=.1125;return ci(t)?t:t<.5?.5*di(2*t,e,.45):.5+.5*ui(2*t-1,e,.45)},easeInBack(t){const e=1.70158;return t*t*((e+1)*t-e)},easeOutBack(t){const e=1.70158;return(t-=1)*t*((e+1)*t+e)+1},easeInOutBack(t){let e=1.70158;return(t/=.5)<1?t*t*((1+(e*=1.525))*t-e)*.5:.5*((t-=2)*t*((1+(e*=1.525))*t+e)+2)},easeInBounce:t=>1-fi.easeOutBounce(1-t),easeOutBounce(t){const e=7.5625,i=2.75;return t<1/i?e*t*t:t<2/i?e*(t-=1.5/i)*t+.75:t<2.5/i?e*(t-=2.25/i)*t+.9375:e*(t-=2.625/i)*t+.984375},easeInOutBounce:t=>t<.5?.5*fi.easeInBounce(2*t):.5*fi.easeOutBounce(2*t-1)+.5};function gi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:t.y+i*(e.y-t.y)}}function pi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:"middle"===s?i<.5?t.y:e.y:"after"===s?i<1?t.y:e.y:i>0?e.y:t.y}}function mi(t,e,i,s){const n={x:t.cp2x,y:t.cp2y},o={x:e.cp1x,y:e.cp1y},a=gi(t,n,i),r=gi(n,o,i),l=gi(o,e,i),h=gi(a,r,i),c=gi(r,l,i);return gi(h,c,i)}const bi=/^(normal|(\d+(?:\.\d+)?)(px|em|%)?)$/,xi=/^(normal|italic|initial|inherit|unset|(oblique( -?[0-9]?[0-9]deg)?))$/;function _i(t,e){const i=(""+t).match(bi);if(!i||"normal"===i[1])return 1.2*e;switch(t=+i[2],i[3]){case"px":return t;case"%":t/=100}return e*t}const yi=t=>+t||0;function vi(t,e){const i={},s=o(e),n=s?Object.keys(e):e,a=o(t)?s?i=>l(t[i],t[e[i]]):e=>t[e]:()=>t;for(const t of n)i[t]=yi(a(t));return i}function Mi(t){return vi(t,{top:"y",right:"x",bottom:"y",left:"x"})}function wi(t){return vi(t,["topLeft","topRight","bottomLeft","bottomRight"])}function ki(t){const e=Mi(t);return e.width=e.left+e.right,e.height=e.top+e.bottom,e}function Si(t,e){t=t||{},e=e||ue.font;let i=l(t.size,e.size);"string"==typeof i&&(i=parseInt(i,10));let s=l(t.style,e.style);s&&!(""+s).match(xi)&&(console.warn('Invalid font style specified: "'+s+'"'),s=void 0);const n={family:l(t.family,e.family),lineHeight:_i(l(t.lineHeight,e.lineHeight),i),size:i,style:s,weight:l(t.weight,e.weight),string:""};return n.string=De(n),n}function Pi(t,e,i,s){let o,a,r,l=!0;for(o=0,a=t.length;oi&&0===t?0:t+e;return{min:a(s,-Math.abs(o)),max:a(n,o)}}function Ci(t,e){return Object.assign(Object.create(t),e)}function Oi(t,e,i){return t?function(t,e){return{x:i=>t+t+e-i,setWidth(t){e=t},textAlign:t=>"center"===t?t:"right"===t?"left":"right",xPlus:(t,e)=>t-e,leftForLtr:(t,e)=>t-e}}(e,i):{x:t=>t,setWidth(t){},textAlign:t=>t,xPlus:(t,e)=>t+e,leftForLtr:(t,e)=>t}}function Ai(t,e){let i,s;"ltr"!==e&&"rtl"!==e||(i=t.canvas.style,s=[i.getPropertyValue("direction"),i.getPropertyPriority("direction")],i.setProperty("direction",e,"important"),t.prevTextDirection=s)}function Ti(t,e){void 0!==e&&(delete t.prevTextDirection,t.canvas.style.setProperty("direction",e[0],e[1]))}function Li(t){return"angle"===t?{between:Z,compare:K,normalize:G}:{between:tt,compare:(t,e)=>t-e,normalize:t=>t}}function Ei({start:t,end:e,count:i,loop:s,style:n}){return{start:t%i,end:e%i,loop:s&&(e-t+1)%i==0,style:n}}function Ri(t,e,i){if(!i)return[t];const{property:s,start:n,end:o}=i,a=e.length,{compare:r,between:l,normalize:h}=Li(s),{start:c,end:d,loop:u,style:f}=function(t,e,i){const{property:s,start:n,end:o}=i,{between:a,normalize:r}=Li(s),l=e.length;let h,c,{start:d,end:u,loop:f}=t;if(f){for(d+=l,u+=l,h=0,c=l;hx||l(n,b,p)&&0!==r(n,b),v=()=>!x||0===r(o,p)||l(o,b,p);for(let t=c,i=c;t<=d;++t)m=e[t%a],m.skip||(p=h(m[s]),p!==b&&(x=l(p,n,o),null===_&&y()&&(_=0===r(p,n)?t:i),null!==_&&v()&&(g.push(Ei({start:_,end:t,loop:u,count:a,style:f})),_=null),i=t,b=p));return null!==_&&g.push(Ei({start:_,end:d,loop:u,count:a,style:f})),g}function Ii(t,e){const i=[],s=t.segments;for(let n=0;nn&&t[o%e].skip;)o--;return o%=e,{start:n,end:o}}(i,n,o,s);if(!0===s)return Fi(t,[{start:a,end:r,loop:o}],i,e);return Fi(t,function(t,e,i,s){const n=t.length,o=[];let a,r=e,l=t[e];for(a=e+1;a<=i;++a){const i=t[a%n];i.skip||i.stop?l.skip||(s=!1,o.push({start:e%n,end:(a-1)%n,loop:s}),e=r=i.stop?a:null):(r=a,l.skip&&(e=a)),l=i}return null!==r&&o.push({start:e%n,end:r%n,loop:s}),o}(i,a,r{t[a](e[i],n)&&(o.push({element:t,datasetIndex:s,index:l}),r=r||t.inRange(e.x,e.y,n))})),s&&!r?[]:o}var Xi={evaluateInteractionItems:Hi,modes:{index(t,e,i,s){const n=ve(e,t),o=i.axis||"x",a=i.includeInvisible||!1,r=i.intersect?ji(t,n,o,s,a):Yi(t,n,o,!1,s,a),l=[];return r.length?(t.getSortedVisibleDatasetMetas().forEach((t=>{const e=r[0].index,i=t.data[e];i&&!i.skip&&l.push({element:i,datasetIndex:t.index,index:e})})),l):[]},dataset(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;let r=i.intersect?ji(t,n,o,s,a):Yi(t,n,o,!1,s,a);if(r.length>0){const e=r[0].datasetIndex,i=t.getDatasetMeta(e).data;r=[];for(let t=0;tji(t,ve(e,t),i.axis||"xy",s,i.includeInvisible||!1),nearest(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;return Yi(t,n,o,i.intersect,s,a)},x:(t,e,i,s)=>Ui(t,ve(e,t),"x",i.intersect,s),y:(t,e,i,s)=>Ui(t,ve(e,t),"y",i.intersect,s)}};const qi=["left","top","right","bottom"];function Ki(t,e){return t.filter((t=>t.pos===e))}function Gi(t,e){return t.filter((t=>-1===qi.indexOf(t.pos)&&t.box.axis===e))}function Zi(t,e){return t.sort(((t,i)=>{const s=e?i:t,n=e?t:i;return s.weight===n.weight?s.index-n.index:s.weight-n.weight}))}function Ji(t,e){const i=function(t){const e={};for(const i of t){const{stack:t,pos:s,stackWeight:n}=i;if(!t||!qi.includes(s))continue;const o=e[t]||(e[t]={count:0,placed:0,weight:0,size:0});o.count++,o.weight+=n}return e}(t),{vBoxMaxWidth:s,hBoxMaxHeight:n}=e;let o,a,r;for(o=0,a=t.length;o{s[t]=Math.max(e[t],i[t])})),s}return s(t?["left","right"]:["top","bottom"])}function ss(t,e,i,s){const n=[];let o,a,r,l,h,c;for(o=0,a=t.length,h=0;ot.box.fullSize)),!0),s=Zi(Ki(e,"left"),!0),n=Zi(Ki(e,"right")),o=Zi(Ki(e,"top"),!0),a=Zi(Ki(e,"bottom")),r=Gi(e,"x"),l=Gi(e,"y");return{fullSize:i,leftAndTop:s.concat(o),rightAndBottom:n.concat(l).concat(a).concat(r),chartArea:Ki(e,"chartArea"),vertical:s.concat(n).concat(l),horizontal:o.concat(a).concat(r)}}(t.boxes),l=r.vertical,h=r.horizontal;u(t.boxes,(t=>{"function"==typeof t.beforeLayout&&t.beforeLayout()}));const c=l.reduce(((t,e)=>e.box.options&&!1===e.box.options.display?t:t+1),0)||1,d=Object.freeze({outerWidth:e,outerHeight:i,padding:n,availableWidth:o,availableHeight:a,vBoxMaxWidth:o/2/c,hBoxMaxHeight:a/2}),f=Object.assign({},n);ts(f,ki(s));const g=Object.assign({maxPadding:f,w:o,h:a,x:n.left,y:n.top},n),p=Ji(l.concat(h),d);ss(r.fullSize,g,d,p),ss(l,g,d,p),ss(h,g,d,p)&&ss(l,g,d,p),function(t){const e=t.maxPadding;function i(i){const s=Math.max(e[i]-t[i],0);return t[i]+=s,s}t.y+=i("top"),t.x+=i("left"),i("right"),i("bottom")}(g),os(r.leftAndTop,g,d,p),g.x+=g.w,g.y+=g.h,os(r.rightAndBottom,g,d,p),t.chartArea={left:g.left,top:g.top,right:g.left+g.w,bottom:g.top+g.h,height:g.h,width:g.w},u(r.chartArea,(e=>{const i=e.box;Object.assign(i,t.chartArea),i.update(g.w,g.h,{left:0,top:0,right:0,bottom:0})}))}};class rs{acquireContext(t,e){}releaseContext(t){return!1}addEventListener(t,e,i){}removeEventListener(t,e,i){}getDevicePixelRatio(){return 1}getMaximumSize(t,e,i,s){return e=Math.max(0,e||t.width),i=i||t.height,{width:e,height:Math.max(0,s?Math.floor(e/s):i)}}isAttached(t){return!0}updateConfig(t){}}class ls extends rs{acquireContext(t){return t&&t.getContext&&t.getContext("2d")||null}updateConfig(t){t.options.animation=!1}}const hs="$chartjs",cs={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"},ds=t=>null===t||""===t;const us=!!Se&&{passive:!0};function fs(t,e,i){t.canvas.removeEventListener(e,i,us)}function gs(t,e){for(const i of t)if(i===e||i.contains(e))return!0}function ps(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||gs(i.addedNodes,s),e=e&&!gs(i.removedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}function ms(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||gs(i.removedNodes,s),e=e&&!gs(i.addedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}const bs=new Map;let xs=0;function _s(){const t=window.devicePixelRatio;t!==xs&&(xs=t,bs.forEach(((e,i)=>{i.currentDevicePixelRatio!==t&&e()})))}function ys(t,e,i){const s=t.canvas,n=s&&ge(s);if(!n)return;const o=ct(((t,e)=>{const s=n.clientWidth;i(t,e),s{const e=t[0],i=e.contentRect.width,s=e.contentRect.height;0===i&&0===s||o(i,s)}));return a.observe(n),function(t,e){bs.size||window.addEventListener("resize",_s),bs.set(t,e)}(t,o),a}function vs(t,e,i){i&&i.disconnect(),"resize"===e&&function(t){bs.delete(t),bs.size||window.removeEventListener("resize",_s)}(t)}function Ms(t,e,i){const s=t.canvas,n=ct((e=>{null!==t.ctx&&i(function(t,e){const i=cs[t.type]||t.type,{x:s,y:n}=ve(t,e);return{type:i,chart:e,native:t,x:void 0!==s?s:null,y:void 0!==n?n:null}}(e,t))}),t);return function(t,e,i){t.addEventListener(e,i,us)}(s,e,n),n}class ws extends rs{acquireContext(t,e){const i=t&&t.getContext&&t.getContext("2d");return i&&i.canvas===t?(function(t,e){const i=t.style,s=t.getAttribute("height"),n=t.getAttribute("width");if(t[hs]={initial:{height:s,width:n,style:{display:i.display,height:i.height,width:i.width}}},i.display=i.display||"block",i.boxSizing=i.boxSizing||"border-box",ds(n)){const e=Pe(t,"width");void 0!==e&&(t.width=e)}if(ds(s))if(""===t.style.height)t.height=t.width/(e||2);else{const e=Pe(t,"height");void 0!==e&&(t.height=e)}}(t,e),i):null}releaseContext(t){const e=t.canvas;if(!e[hs])return!1;const i=e[hs].initial;["height","width"].forEach((t=>{const n=i[t];s(n)?e.removeAttribute(t):e.setAttribute(t,n)}));const n=i.style||{};return Object.keys(n).forEach((t=>{e.style[t]=n[t]})),e.width=e.width,delete e[hs],!0}addEventListener(t,e,i){this.removeEventListener(t,e);const s=t.$proxies||(t.$proxies={}),n={attach:ps,detach:ms,resize:ys}[e]||Ms;s[e]=n(t,e,i)}removeEventListener(t,e){const i=t.$proxies||(t.$proxies={}),s=i[e];if(!s)return;({attach:vs,detach:vs,resize:vs}[e]||fs)(t,e,s),i[e]=void 0}getDevicePixelRatio(){return window.devicePixelRatio}getMaximumSize(t,e,i,s){return we(t,e,i,s)}isAttached(t){const e=ge(t);return!(!e||!e.isConnected)}}function ks(t){return!fe()||"undefined"!=typeof OffscreenCanvas&&t instanceof OffscreenCanvas?ls:ws}var Ss=Object.freeze({__proto__:null,BasePlatform:rs,BasicPlatform:ls,DomPlatform:ws,_detectPlatform:ks});const Ps="transparent",Ds={boolean:(t,e,i)=>i>.5?e:t,color(t,e,i){const s=Qt(t||Ps),n=s.valid&&Qt(e||Ps);return n&&n.valid?n.mix(s,i).hexString():e},number:(t,e,i)=>t+(e-t)*i};class Cs{constructor(t,e,i,s){const n=e[i];s=Pi([t.to,s,n,t.from]);const o=Pi([t.from,n,s]);this._active=!0,this._fn=t.fn||Ds[t.type||typeof o],this._easing=fi[t.easing]||fi.linear,this._start=Math.floor(Date.now()+(t.delay||0)),this._duration=this._total=Math.floor(t.duration),this._loop=!!t.loop,this._target=e,this._prop=i,this._from=o,this._to=s,this._promises=void 0}active(){return this._active}update(t,e,i){if(this._active){this._notify(!1);const s=this._target[this._prop],n=i-this._start,o=this._duration-n;this._start=i,this._duration=Math.floor(Math.max(o,t.duration)),this._total+=n,this._loop=!!t.loop,this._to=Pi([t.to,e,s,t.from]),this._from=Pi([t.from,s,e])}}cancel(){this._active&&(this.tick(Date.now()),this._active=!1,this._notify(!1))}tick(t){const e=t-this._start,i=this._duration,s=this._prop,n=this._from,o=this._loop,a=this._to;let r;if(this._active=n!==a&&(o||e1?2-r:r,r=this._easing(Math.min(1,Math.max(0,r))),this._target[s]=this._fn(n,a,r))}wait(){const t=this._promises||(this._promises=[]);return new Promise(((e,i)=>{t.push({res:e,rej:i})}))}_notify(t){const e=t?"res":"rej",i=this._promises||[];for(let t=0;t{const a=t[s];if(!o(a))return;const r={};for(const t of e)r[t]=a[t];(n(a.properties)&&a.properties||[s]).forEach((t=>{t!==s&&i.has(t)||i.set(t,r)}))}))}_animateOptions(t,e){const i=e.options,s=function(t,e){if(!e)return;let i=t.options;if(!i)return void(t.options=e);i.$shared&&(t.options=i=Object.assign({},i,{$shared:!1,$animations:{}}));return i}(t,i);if(!s)return[];const n=this._createAnimations(s,i);return i.$shared&&function(t,e){const i=[],s=Object.keys(e);for(let e=0;e{t.options=i}),(()=>{})),n}_createAnimations(t,e){const i=this._properties,s=[],n=t.$animations||(t.$animations={}),o=Object.keys(e),a=Date.now();let r;for(r=o.length-1;r>=0;--r){const l=o[r];if("$"===l.charAt(0))continue;if("options"===l){s.push(...this._animateOptions(t,e));continue}const h=e[l];let c=n[l];const d=i.get(l);if(c){if(d&&c.active()){c.update(d,h,a);continue}c.cancel()}d&&d.duration?(n[l]=c=new Cs(d,t,l,h),s.push(c)):t[l]=h}return s}update(t,e){if(0===this._properties.size)return void Object.assign(t,e);const i=this._createAnimations(t,e);return i.length?(xt.add(this._chart,i),!0):void 0}}function As(t,e){const i=t&&t.options||{},s=i.reverse,n=void 0===i.min?e:0,o=void 0===i.max?e:0;return{start:s?o:n,end:s?n:o}}function Ts(t,e){const i=[],s=t._getSortedDatasetMetas(e);let n,o;for(n=0,o=s.length;n0||!i&&e<0)return n.index}return null}function zs(t,e){const{chart:i,_cachedMeta:s}=t,n=i._stacks||(i._stacks={}),{iScale:o,vScale:a,index:r}=s,l=o.axis,h=a.axis,c=function(t,e,i){return`${t.id}.${e.id}.${i.stack||i.type}`}(o,a,s),d=e.length;let u;for(let t=0;ti[t].axis===e)).shift()}function Vs(t,e){const i=t.controller.index,s=t.vScale&&t.vScale.axis;if(s){e=e||t._parsed;for(const t of e){const e=t._stacks;if(!e||void 0===e[s]||void 0===e[s][i])return;delete e[s][i],void 0!==e[s]._visualValues&&void 0!==e[s]._visualValues[i]&&delete e[s]._visualValues[i]}}}const Bs=t=>"reset"===t||"none"===t,Ws=(t,e)=>e?t:Object.assign({},t);class Ns{static defaults={};static datasetElementType=null;static dataElementType=null;constructor(t,e){this.chart=t,this._ctx=t.ctx,this.index=e,this._cachedDataOpts={},this._cachedMeta=this.getMeta(),this._type=this._cachedMeta.type,this.options=void 0,this._parsing=!1,this._data=void 0,this._objectData=void 0,this._sharedOptions=void 0,this._drawStart=void 0,this._drawCount=void 0,this.enableOptionSharing=!1,this.supportsDecimation=!1,this.$context=void 0,this._syncList=[],this.datasetElementType=new.target.datasetElementType,this.dataElementType=new.target.dataElementType,this.initialize()}initialize(){const t=this._cachedMeta;this.configure(),this.linkScales(),t._stacked=Es(t.vScale,t),this.addElements(),this.options.fill&&!this.chart.isPluginEnabled("filler")&&console.warn("Tried to use the 'fill' option without the 'Filler' plugin enabled. Please import and register the 'Filler' plugin and make sure it is not disabled in the options")}updateIndex(t){this.index!==t&&Vs(this._cachedMeta),this.index=t}linkScales(){const t=this.chart,e=this._cachedMeta,i=this.getDataset(),s=(t,e,i,s)=>"x"===t?e:"r"===t?s:i,n=e.xAxisID=l(i.xAxisID,Fs(t,"x")),o=e.yAxisID=l(i.yAxisID,Fs(t,"y")),a=e.rAxisID=l(i.rAxisID,Fs(t,"r")),r=e.indexAxis,h=e.iAxisID=s(r,n,o,a),c=e.vAxisID=s(r,o,n,a);e.xScale=this.getScaleForId(n),e.yScale=this.getScaleForId(o),e.rScale=this.getScaleForId(a),e.iScale=this.getScaleForId(h),e.vScale=this.getScaleForId(c)}getDataset(){return this.chart.data.datasets[this.index]}getMeta(){return this.chart.getDatasetMeta(this.index)}getScaleForId(t){return this.chart.scales[t]}_getOtherScale(t){const e=this._cachedMeta;return t===e.iScale?e.vScale:e.iScale}reset(){this._update("reset")}_destroy(){const t=this._cachedMeta;this._data&&rt(this._data,this),t._stacked&&Vs(t)}_dataCheck(){const t=this.getDataset(),e=t.data||(t.data=[]),i=this._data;if(o(e))this._data=function(t){const e=Object.keys(t),i=new Array(e.length);let s,n,o;for(s=0,n=e.length;s0&&i._parsed[t-1];if(!1===this._parsing)i._parsed=s,i._sorted=!0,d=s;else{d=n(s[t])?this.parseArrayData(i,s,t,e):o(s[t])?this.parseObjectData(i,s,t,e):this.parsePrimitiveData(i,s,t,e);const a=()=>null===c[l]||f&&c[l]t&&!e.hidden&&e._stacked&&{keys:Ts(i,!0),values:null})(e,i,this.chart),h={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY},{min:c,max:d}=function(t){const{min:e,max:i,minDefined:s,maxDefined:n}=t.getUserBounds();return{min:s?e:Number.NEGATIVE_INFINITY,max:n?i:Number.POSITIVE_INFINITY}}(r);let u,f;function g(){f=s[u];const e=f[r.axis];return!a(f[t.axis])||c>e||d=0;--u)if(!g()){this.updateRangeFromParsed(h,t,f,l);break}return h}getAllParsedValues(t){const e=this._cachedMeta._parsed,i=[];let s,n,o;for(s=0,n=e.length;s=0&&tthis.getContext(i,s,e)),c);return f.$shared&&(f.$shared=r,n[o]=Object.freeze(Ws(f,r))),f}_resolveAnimations(t,e,i){const s=this.chart,n=this._cachedDataOpts,o=`animation-${e}`,a=n[o];if(a)return a;let r;if(!1!==s.options.animation){const s=this.chart.config,n=s.datasetAnimationScopeKeys(this._type,e),o=s.getOptionScopes(this.getDataset(),n);r=s.createResolver(o,this.getContext(t,i,e))}const l=new Os(s,r&&r.animations);return r&&r._cacheable&&(n[o]=Object.freeze(l)),l}getSharedOptions(t){if(t.$shared)return this._sharedOptions||(this._sharedOptions=Object.assign({},t))}includeOptions(t,e){return!e||Bs(t)||this.chart._animationsDisabled}_getSharedOptions(t,e){const i=this.resolveDataElementOptions(t,e),s=this._sharedOptions,n=this.getSharedOptions(i),o=this.includeOptions(e,n)||n!==s;return this.updateSharedOptions(n,e,i),{sharedOptions:n,includeOptions:o}}updateElement(t,e,i,s){Bs(s)?Object.assign(t,i):this._resolveAnimations(e,s).update(t,i)}updateSharedOptions(t,e,i){t&&!Bs(e)&&this._resolveAnimations(void 0,e).update(t,i)}_setStyle(t,e,i,s){t.active=s;const n=this.getStyle(e,s);this._resolveAnimations(e,i,s).update(t,{options:!s&&this.getSharedOptions(n)||n})}removeHoverStyle(t,e,i){this._setStyle(t,i,"active",!1)}setHoverStyle(t,e,i){this._setStyle(t,i,"active",!0)}_removeDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!1)}_setDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!0)}_resyncElements(t){const e=this._data,i=this._cachedMeta.data;for(const[t,e,i]of this._syncList)this[t](e,i);this._syncList=[];const s=i.length,n=e.length,o=Math.min(n,s);o&&this.parse(0,o),n>s?this._insertElements(s,n-s,t):n{for(t.length+=e,a=t.length-1;a>=o;a--)t[a]=t[a-e]};for(r(n),a=t;a{s[t]=i[t]&&i[t].active()?i[t]._to:this[t]})),s}}function js(t,e){const i=t.options.ticks,n=function(t){const e=t.options.offset,i=t._tickSize(),s=t._length/i+(e?0:1),n=t._maxLength/i;return Math.floor(Math.min(s,n))}(t),o=Math.min(i.maxTicksLimit||n,n),a=i.major.enabled?function(t){const e=[];let i,s;for(i=0,s=t.length;io)return function(t,e,i,s){let n,o=0,a=i[0];for(s=Math.ceil(s),n=0;nn)return e}return Math.max(n,1)}(a,e,o);if(r>0){let t,i;const n=r>1?Math.round((h-l)/(r-1)):null;for($s(e,c,d,s(n)?0:l-n,l),t=0,i=r-1;t"top"===e||"left"===e?t[e]+i:t[e]-i,Us=(t,e)=>Math.min(e||t,t);function Xs(t,e){const i=[],s=t.length/e,n=t.length;let o=0;for(;oa+r)))return h}function Ks(t){return t.drawTicks?t.tickLength:0}function Gs(t,e){if(!t.display)return 0;const i=Si(t.font,e),s=ki(t.padding);return(n(t.text)?t.text.length:1)*i.lineHeight+s.height}function Zs(t,e,i){let s=ut(t);return(i&&"right"!==e||!i&&"right"===e)&&(s=(t=>"left"===t?"right":"right"===t?"left":t)(s)),s}class Js extends Hs{constructor(t){super(),this.id=t.id,this.type=t.type,this.options=void 0,this.ctx=t.ctx,this.chart=t.chart,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this._margins={left:0,right:0,top:0,bottom:0},this.maxWidth=void 0,this.maxHeight=void 0,this.paddingTop=void 0,this.paddingBottom=void 0,this.paddingLeft=void 0,this.paddingRight=void 0,this.axis=void 0,this.labelRotation=void 0,this.min=void 0,this.max=void 0,this._range=void 0,this.ticks=[],this._gridLineItems=null,this._labelItems=null,this._labelSizes=null,this._length=0,this._maxLength=0,this._longestTextCache={},this._startPixel=void 0,this._endPixel=void 0,this._reversePixels=!1,this._userMax=void 0,this._userMin=void 0,this._suggestedMax=void 0,this._suggestedMin=void 0,this._ticksLength=0,this._borderValue=0,this._cache={},this._dataLimitsCached=!1,this.$context=void 0}init(t){this.options=t.setContext(this.getContext()),this.axis=t.axis,this._userMin=this.parse(t.min),this._userMax=this.parse(t.max),this._suggestedMin=this.parse(t.suggestedMin),this._suggestedMax=this.parse(t.suggestedMax)}parse(t,e){return t}getUserBounds(){let{_userMin:t,_userMax:e,_suggestedMin:i,_suggestedMax:s}=this;return t=r(t,Number.POSITIVE_INFINITY),e=r(e,Number.NEGATIVE_INFINITY),i=r(i,Number.POSITIVE_INFINITY),s=r(s,Number.NEGATIVE_INFINITY),{min:r(t,i),max:r(e,s),minDefined:a(t),maxDefined:a(e)}}getMinMax(t){let e,{min:i,max:s,minDefined:n,maxDefined:o}=this.getUserBounds();if(n&&o)return{min:i,max:s};const a=this.getMatchingVisibleMetas();for(let r=0,l=a.length;rs?s:i,s=n&&i>s?i:s,{min:r(i,r(s,i)),max:r(s,r(i,s))}}getPadding(){return{left:this.paddingLeft||0,top:this.paddingTop||0,right:this.paddingRight||0,bottom:this.paddingBottom||0}}getTicks(){return this.ticks}getLabels(){const t=this.chart.data;return this.options.labels||(this.isHorizontal()?t.xLabels:t.yLabels)||t.labels||[]}getLabelItems(t=this.chart.chartArea){return this._labelItems||(this._labelItems=this._computeLabelItems(t))}beforeLayout(){this._cache={},this._dataLimitsCached=!1}beforeUpdate(){d(this.options.beforeUpdate,[this])}update(t,e,i){const{beginAtZero:s,grace:n,ticks:o}=this.options,a=o.sampleSize;this.beforeUpdate(),this.maxWidth=t,this.maxHeight=e,this._margins=i=Object.assign({left:0,right:0,top:0,bottom:0},i),this.ticks=null,this._labelSizes=null,this._gridLineItems=null,this._labelItems=null,this.beforeSetDimensions(),this.setDimensions(),this.afterSetDimensions(),this._maxLength=this.isHorizontal()?this.width+i.left+i.right:this.height+i.top+i.bottom,this._dataLimitsCached||(this.beforeDataLimits(),this.determineDataLimits(),this.afterDataLimits(),this._range=Di(this,n,s),this._dataLimitsCached=!0),this.beforeBuildTicks(),this.ticks=this.buildTicks()||[],this.afterBuildTicks();const r=a=n||i<=1||!this.isHorizontal())return void(this.labelRotation=s);const h=this._getLabelSizes(),c=h.widest.width,d=h.highest.height,u=J(this.chart.width-c,0,this.maxWidth);o=t.offset?this.maxWidth/i:u/(i-1),c+6>o&&(o=u/(i-(t.offset?.5:1)),a=this.maxHeight-Ks(t.grid)-e.padding-Gs(t.title,this.chart.options.font),r=Math.sqrt(c*c+d*d),l=Y(Math.min(Math.asin(J((h.highest.height+6)/o,-1,1)),Math.asin(J(a/r,-1,1))-Math.asin(J(d/r,-1,1)))),l=Math.max(s,Math.min(n,l))),this.labelRotation=l}afterCalculateLabelRotation(){d(this.options.afterCalculateLabelRotation,[this])}afterAutoSkip(){}beforeFit(){d(this.options.beforeFit,[this])}fit(){const t={width:0,height:0},{chart:e,options:{ticks:i,title:s,grid:n}}=this,o=this._isVisible(),a=this.isHorizontal();if(o){const o=Gs(s,e.options.font);if(a?(t.width=this.maxWidth,t.height=Ks(n)+o):(t.height=this.maxHeight,t.width=Ks(n)+o),i.display&&this.ticks.length){const{first:e,last:s,widest:n,highest:o}=this._getLabelSizes(),r=2*i.padding,l=$(this.labelRotation),h=Math.cos(l),c=Math.sin(l);if(a){const e=i.mirror?0:c*n.width+h*o.height;t.height=Math.min(this.maxHeight,t.height+e+r)}else{const e=i.mirror?0:h*n.width+c*o.height;t.width=Math.min(this.maxWidth,t.width+e+r)}this._calculatePadding(e,s,c,h)}}this._handleMargins(),a?(this.width=this._length=e.width-this._margins.left-this._margins.right,this.height=t.height):(this.width=t.width,this.height=this._length=e.height-this._margins.top-this._margins.bottom)}_calculatePadding(t,e,i,s){const{ticks:{align:n,padding:o},position:a}=this.options,r=0!==this.labelRotation,l="top"!==a&&"x"===this.axis;if(this.isHorizontal()){const a=this.getPixelForTick(0)-this.left,h=this.right-this.getPixelForTick(this.ticks.length-1);let c=0,d=0;r?l?(c=s*t.width,d=i*e.height):(c=i*t.height,d=s*e.width):"start"===n?d=e.width:"end"===n?c=t.width:"inner"!==n&&(c=t.width/2,d=e.width/2),this.paddingLeft=Math.max((c-a+o)*this.width/(this.width-a),0),this.paddingRight=Math.max((d-h+o)*this.width/(this.width-h),0)}else{let i=e.height/2,s=t.height/2;"start"===n?(i=0,s=t.height):"end"===n&&(i=e.height,s=0),this.paddingTop=i+o,this.paddingBottom=s+o}}_handleMargins(){this._margins&&(this._margins.left=Math.max(this.paddingLeft,this._margins.left),this._margins.top=Math.max(this.paddingTop,this._margins.top),this._margins.right=Math.max(this.paddingRight,this._margins.right),this._margins.bottom=Math.max(this.paddingBottom,this._margins.bottom))}afterFit(){d(this.options.afterFit,[this])}isHorizontal(){const{axis:t,position:e}=this.options;return"top"===e||"bottom"===e||"x"===t}isFullSize(){return this.options.fullSize}_convertTicksToLabels(t){let e,i;for(this.beforeTickToLabelConversion(),this.generateTickLabels(t),e=0,i=t.length;e{const i=t.gc,s=i.length/2;let n;if(s>e){for(n=0;n({width:r[t]||0,height:l[t]||0});return{first:P(0),last:P(e-1),widest:P(k),highest:P(S),widths:r,heights:l}}getLabelForValue(t){return t}getPixelForValue(t,e){return NaN}getValueForPixel(t){}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getPixelForDecimal(t){this._reversePixels&&(t=1-t);const e=this._startPixel+t*this._length;return Q(this._alignToPixels?Ae(this.chart,e,0):e)}getDecimalForPixel(t){const e=(t-this._startPixel)/this._length;return this._reversePixels?1-e:e}getBasePixel(){return this.getPixelForValue(this.getBaseValue())}getBaseValue(){const{min:t,max:e}=this;return t<0&&e<0?e:t>0&&e>0?t:0}getContext(t){const e=this.ticks||[];if(t>=0&&ta*s?a/i:r/s:r*s0}_computeGridLineItems(t){const e=this.axis,i=this.chart,s=this.options,{grid:n,position:a,border:r}=s,h=n.offset,c=this.isHorizontal(),d=this.ticks.length+(h?1:0),u=Ks(n),f=[],g=r.setContext(this.getContext()),p=g.display?g.width:0,m=p/2,b=function(t){return Ae(i,t,p)};let x,_,y,v,M,w,k,S,P,D,C,O;if("top"===a)x=b(this.bottom),w=this.bottom-u,S=x-m,D=b(t.top)+m,O=t.bottom;else if("bottom"===a)x=b(this.top),D=t.top,O=b(t.bottom)-m,w=x+m,S=this.top+u;else if("left"===a)x=b(this.right),M=this.right-u,k=x-m,P=b(t.left)+m,C=t.right;else if("right"===a)x=b(this.left),P=t.left,C=b(t.right)-m,M=x+m,k=this.left+u;else if("x"===e){if("center"===a)x=b((t.top+t.bottom)/2+.5);else if(o(a)){const t=Object.keys(a)[0],e=a[t];x=b(this.chart.scales[t].getPixelForValue(e))}D=t.top,O=t.bottom,w=x+m,S=w+u}else if("y"===e){if("center"===a)x=b((t.left+t.right)/2);else if(o(a)){const t=Object.keys(a)[0],e=a[t];x=b(this.chart.scales[t].getPixelForValue(e))}M=x-m,k=M-u,P=t.left,C=t.right}const A=l(s.ticks.maxTicksLimit,d),T=Math.max(1,Math.ceil(d/A));for(_=0;_e.value===t));if(i>=0){return e.setContext(this.getContext(i)).lineWidth}return 0}drawGrid(t){const e=this.options.grid,i=this.ctx,s=this._gridLineItems||(this._gridLineItems=this._computeGridLineItems(t));let n,o;const a=(t,e,s)=>{s.width&&s.color&&(i.save(),i.lineWidth=s.width,i.strokeStyle=s.color,i.setLineDash(s.borderDash||[]),i.lineDashOffset=s.borderDashOffset,i.beginPath(),i.moveTo(t.x,t.y),i.lineTo(e.x,e.y),i.stroke(),i.restore())};if(e.display)for(n=0,o=s.length;n{this.drawBackground(),this.drawGrid(t),this.drawTitle()}},{z:s,draw:()=>{this.drawBorder()}},{z:e,draw:t=>{this.drawLabels(t)}}]:[{z:e,draw:t=>{this.draw(t)}}]}getMatchingVisibleMetas(t){const e=this.chart.getSortedVisibleDatasetMetas(),i=this.axis+"AxisID",s=[];let n,o;for(n=0,o=e.length;n{const s=i.split("."),n=s.pop(),o=[t].concat(s).join("."),a=e[i].split("."),r=a.pop(),l=a.join(".");ue.route(o,n,l,r)}))}(e,t.defaultRoutes);t.descriptors&&ue.describe(e,t.descriptors)}(t,o,i),this.override&&ue.override(t.id,t.overrides)),o}get(t){return this.items[t]}unregister(t){const e=this.items,i=t.id,s=this.scope;i in e&&delete e[i],s&&i in ue[s]&&(delete ue[s][i],this.override&&delete re[i])}}class tn{constructor(){this.controllers=new Qs(Ns,"datasets",!0),this.elements=new Qs(Hs,"elements"),this.plugins=new Qs(Object,"plugins"),this.scales=new Qs(Js,"scales"),this._typedRegistries=[this.controllers,this.scales,this.elements]}add(...t){this._each("register",t)}remove(...t){this._each("unregister",t)}addControllers(...t){this._each("register",t,this.controllers)}addElements(...t){this._each("register",t,this.elements)}addPlugins(...t){this._each("register",t,this.plugins)}addScales(...t){this._each("register",t,this.scales)}getController(t){return this._get(t,this.controllers,"controller")}getElement(t){return this._get(t,this.elements,"element")}getPlugin(t){return this._get(t,this.plugins,"plugin")}getScale(t){return this._get(t,this.scales,"scale")}removeControllers(...t){this._each("unregister",t,this.controllers)}removeElements(...t){this._each("unregister",t,this.elements)}removePlugins(...t){this._each("unregister",t,this.plugins)}removeScales(...t){this._each("unregister",t,this.scales)}_each(t,e,i){[...e].forEach((e=>{const s=i||this._getRegistryForType(e);i||s.isForType(e)||s===this.plugins&&e.id?this._exec(t,s,e):u(e,(e=>{const s=i||this._getRegistryForType(e);this._exec(t,s,e)}))}))}_exec(t,e,i){const s=w(t);d(i["before"+s],[],i),e[t](i),d(i["after"+s],[],i)}_getRegistryForType(t){for(let e=0;et.filter((t=>!e.some((e=>t.plugin.id===e.plugin.id))));this._notify(s(e,i),t,"stop"),this._notify(s(i,e),t,"start")}}function nn(t,e){return e||!1!==t?!0===t?{}:t:null}function on(t,{plugin:e,local:i},s,n){const o=t.pluginScopeKeys(e),a=t.getOptionScopes(s,o);return i&&e.defaults&&a.push(e.defaults),t.createResolver(a,n,[""],{scriptable:!1,indexable:!1,allKeys:!0})}function an(t,e){const i=ue.datasets[t]||{};return((e.datasets||{})[t]||{}).indexAxis||e.indexAxis||i.indexAxis||"x"}function rn(t){if("x"===t||"y"===t||"r"===t)return t}function ln(t,...e){if(rn(t))return t;for(const s of e){const e=s.axis||("top"===(i=s.position)||"bottom"===i?"x":"left"===i||"right"===i?"y":void 0)||t.length>1&&rn(t[0].toLowerCase());if(e)return e}var i;throw new Error(`Cannot determine type of '${t}' axis. Please provide 'axis' or 'position' option.`)}function hn(t,e,i){if(i[e+"AxisID"]===t)return{axis:e}}function cn(t,e){const i=re[t.type]||{scales:{}},s=e.scales||{},n=an(t.type,e),a=Object.create(null);return Object.keys(s).forEach((e=>{const r=s[e];if(!o(r))return console.error(`Invalid scale configuration for scale: ${e}`);if(r._proxy)return console.warn(`Ignoring resolver passed as options for scale: ${e}`);const l=ln(e,r,function(t,e){if(e.data&&e.data.datasets){const i=e.data.datasets.filter((e=>e.xAxisID===t||e.yAxisID===t));if(i.length)return hn(t,"x",i[0])||hn(t,"y",i[0])}return{}}(e,t),ue.scales[r.type]),h=function(t,e){return t===e?"_index_":"_value_"}(l,n),c=i.scales||{};a[e]=x(Object.create(null),[{axis:l},r,c[l],c[h]])})),t.data.datasets.forEach((i=>{const n=i.type||t.type,o=i.indexAxis||an(n,e),r=(re[n]||{}).scales||{};Object.keys(r).forEach((t=>{const e=function(t,e){let i=t;return"_index_"===t?i=e:"_value_"===t&&(i="x"===e?"y":"x"),i}(t,o),n=i[e+"AxisID"]||e;a[n]=a[n]||Object.create(null),x(a[n],[{axis:e},s[n],r[t]])}))})),Object.keys(a).forEach((t=>{const e=a[t];x(e,[ue.scales[e.type],ue.scale])})),a}function dn(t){const e=t.options||(t.options={});e.plugins=l(e.plugins,{}),e.scales=cn(t,e)}function un(t){return(t=t||{}).datasets=t.datasets||[],t.labels=t.labels||[],t}const fn=new Map,gn=new Set;function pn(t,e){let i=fn.get(t);return i||(i=e(),fn.set(t,i),gn.add(i)),i}const mn=(t,e,i)=>{const s=M(e,i);void 0!==s&&t.add(s)};class bn{constructor(t){this._config=function(t){return(t=t||{}).data=un(t.data),dn(t),t}(t),this._scopeCache=new Map,this._resolverCache=new Map}get platform(){return this._config.platform}get type(){return this._config.type}set type(t){this._config.type=t}get data(){return this._config.data}set data(t){this._config.data=un(t)}get options(){return this._config.options}set options(t){this._config.options=t}get plugins(){return this._config.plugins}update(){const t=this._config;this.clearCache(),dn(t)}clearCache(){this._scopeCache.clear(),this._resolverCache.clear()}datasetScopeKeys(t){return pn(t,(()=>[[`datasets.${t}`,""]]))}datasetAnimationScopeKeys(t,e){return pn(`${t}.transition.${e}`,(()=>[[`datasets.${t}.transitions.${e}`,`transitions.${e}`],[`datasets.${t}`,""]]))}datasetElementScopeKeys(t,e){return pn(`${t}-${e}`,(()=>[[`datasets.${t}.elements.${e}`,`datasets.${t}`,`elements.${e}`,""]]))}pluginScopeKeys(t){const e=t.id;return pn(`${this.type}-plugin-${e}`,(()=>[[`plugins.${e}`,...t.additionalOptionScopes||[]]]))}_cachedScopes(t,e){const i=this._scopeCache;let s=i.get(t);return s&&!e||(s=new Map,i.set(t,s)),s}getOptionScopes(t,e,i){const{options:s,type:n}=this,o=this._cachedScopes(t,i),a=o.get(e);if(a)return a;const r=new Set;e.forEach((e=>{t&&(r.add(t),e.forEach((e=>mn(r,t,e)))),e.forEach((t=>mn(r,s,t))),e.forEach((t=>mn(r,re[n]||{},t))),e.forEach((t=>mn(r,ue,t))),e.forEach((t=>mn(r,le,t)))}));const l=Array.from(r);return 0===l.length&&l.push(Object.create(null)),gn.has(e)&&o.set(e,l),l}chartOptionScopes(){const{options:t,type:e}=this;return[t,re[e]||{},ue.datasets[e]||{},{type:e},ue,le]}resolveNamedOptions(t,e,i,s=[""]){const o={$shared:!0},{resolver:a,subPrefixes:r}=xn(this._resolverCache,t,s);let l=a;if(function(t,e){const{isScriptable:i,isIndexable:s}=Ye(t);for(const o of e){const e=i(o),a=s(o),r=(a||e)&&t[o];if(e&&(S(r)||_n(r))||a&&n(r))return!0}return!1}(a,e)){o.$shared=!1;l=$e(a,i=S(i)?i():i,this.createResolver(t,i,r))}for(const t of e)o[t]=l[t];return o}createResolver(t,e,i=[""],s){const{resolver:n}=xn(this._resolverCache,t,i);return o(e)?$e(n,e,void 0,s):n}}function xn(t,e,i){let s=t.get(e);s||(s=new Map,t.set(e,s));const n=i.join();let o=s.get(n);if(!o){o={resolver:je(e,i),subPrefixes:i.filter((t=>!t.toLowerCase().includes("hover")))},s.set(n,o)}return o}const _n=t=>o(t)&&Object.getOwnPropertyNames(t).reduce(((e,i)=>e||S(t[i])),!1);const yn=["top","bottom","left","right","chartArea"];function vn(t,e){return"top"===t||"bottom"===t||-1===yn.indexOf(t)&&"x"===e}function Mn(t,e){return function(i,s){return i[t]===s[t]?i[e]-s[e]:i[t]-s[t]}}function wn(t){const e=t.chart,i=e.options.animation;e.notifyPlugins("afterRender"),d(i&&i.onComplete,[t],e)}function kn(t){const e=t.chart,i=e.options.animation;d(i&&i.onProgress,[t],e)}function Sn(t){return fe()&&"string"==typeof t?t=document.getElementById(t):t&&t.length&&(t=t[0]),t&&t.canvas&&(t=t.canvas),t}const Pn={},Dn=t=>{const e=Sn(t);return Object.values(Pn).filter((t=>t.canvas===e)).pop()};function Cn(t,e,i){const s=Object.keys(t);for(const n of s){const s=+n;if(s>=e){const o=t[n];delete t[n],(i>0||s>e)&&(t[s+i]=o)}}}function On(t,e,i){return t.options.clip?t[i]:e[i]}class An{static defaults=ue;static instances=Pn;static overrides=re;static registry=en;static version="4.4.0";static getChart=Dn;static register(...t){en.add(...t),Tn()}static unregister(...t){en.remove(...t),Tn()}constructor(t,e){const s=this.config=new bn(e),n=Sn(t),o=Dn(n);if(o)throw new Error("Canvas is already in use. Chart with ID '"+o.id+"' must be destroyed before the canvas with ID '"+o.canvas.id+"' can be reused.");const a=s.createResolver(s.chartOptionScopes(),this.getContext());this.platform=new(s.platform||ks(n)),this.platform.updateConfig(s);const r=this.platform.acquireContext(n,a.aspectRatio),l=r&&r.canvas,h=l&&l.height,c=l&&l.width;this.id=i(),this.ctx=r,this.canvas=l,this.width=c,this.height=h,this._options=a,this._aspectRatio=this.aspectRatio,this._layers=[],this._metasets=[],this._stacks=void 0,this.boxes=[],this.currentDevicePixelRatio=void 0,this.chartArea=void 0,this._active=[],this._lastEvent=void 0,this._listeners={},this._responsiveListeners=void 0,this._sortedMetasets=[],this.scales={},this._plugins=new sn,this.$proxies={},this._hiddenIndices={},this.attached=!1,this._animationsDisabled=void 0,this.$context=void 0,this._doResize=dt((t=>this.update(t)),a.resizeDelay||0),this._dataChanges=[],Pn[this.id]=this,r&&l?(xt.listen(this,"complete",wn),xt.listen(this,"progress",kn),this._initialize(),this.attached&&this.update()):console.error("Failed to create chart: can't acquire context from the given item")}get aspectRatio(){const{options:{aspectRatio:t,maintainAspectRatio:e},width:i,height:n,_aspectRatio:o}=this;return s(t)?e&&o?o:n?i/n:null:t}get data(){return this.config.data}set data(t){this.config.data=t}get options(){return this._options}set options(t){this.config.options=t}get registry(){return en}_initialize(){return this.notifyPlugins("beforeInit"),this.options.responsive?this.resize():ke(this,this.options.devicePixelRatio),this.bindEvents(),this.notifyPlugins("afterInit"),this}clear(){return Te(this.canvas,this.ctx),this}stop(){return xt.stop(this),this}resize(t,e){xt.running(this)?this._resizeBeforeDraw={width:t,height:e}:this._resize(t,e)}_resize(t,e){const i=this.options,s=this.canvas,n=i.maintainAspectRatio&&this.aspectRatio,o=this.platform.getMaximumSize(s,t,e,n),a=i.devicePixelRatio||this.platform.getDevicePixelRatio(),r=this.width?"resize":"attach";this.width=o.width,this.height=o.height,this._aspectRatio=this.aspectRatio,ke(this,a,!0)&&(this.notifyPlugins("resize",{size:o}),d(i.onResize,[this,o],this),this.attached&&this._doResize(r)&&this.render())}ensureScalesHaveIDs(){u(this.options.scales||{},((t,e)=>{t.id=e}))}buildOrUpdateScales(){const t=this.options,e=t.scales,i=this.scales,s=Object.keys(i).reduce(((t,e)=>(t[e]=!1,t)),{});let n=[];e&&(n=n.concat(Object.keys(e).map((t=>{const i=e[t],s=ln(t,i),n="r"===s,o="x"===s;return{options:i,dposition:n?"chartArea":o?"bottom":"left",dtype:n?"radialLinear":o?"category":"linear"}})))),u(n,(e=>{const n=e.options,o=n.id,a=ln(o,n),r=l(n.type,e.dtype);void 0!==n.position&&vn(n.position,a)===vn(e.dposition)||(n.position=e.dposition),s[o]=!0;let h=null;if(o in i&&i[o].type===r)h=i[o];else{h=new(en.getScale(r))({id:o,type:r,ctx:this.ctx,chart:this}),i[h.id]=h}h.init(n,t)})),u(s,((t,e)=>{t||delete i[e]})),u(i,(t=>{as.configure(this,t,t.options),as.addBox(this,t)}))}_updateMetasets(){const t=this._metasets,e=this.data.datasets.length,i=t.length;if(t.sort(((t,e)=>t.index-e.index)),i>e){for(let t=e;te.length&&delete this._stacks,t.forEach(((t,i)=>{0===e.filter((e=>e===t._dataset)).length&&this._destroyDatasetMeta(i)}))}buildOrUpdateControllers(){const t=[],e=this.data.datasets;let i,s;for(this._removeUnreferencedMetasets(),i=0,s=e.length;i{this.getDatasetMeta(e).controller.reset()}),this)}reset(){this._resetElements(),this.notifyPlugins("reset")}update(t){const e=this.config;e.update();const i=this._options=e.createResolver(e.chartOptionScopes(),this.getContext()),s=this._animationsDisabled=!i.animation;if(this._updateScales(),this._checkEventBindings(),this._updateHiddenIndices(),this._plugins.invalidate(),!1===this.notifyPlugins("beforeUpdate",{mode:t,cancelable:!0}))return;const n=this.buildOrUpdateControllers();this.notifyPlugins("beforeElementsUpdate");let o=0;for(let t=0,e=this.data.datasets.length;t{t.reset()})),this._updateDatasets(t),this.notifyPlugins("afterUpdate",{mode:t}),this._layers.sort(Mn("z","_idx"));const{_active:a,_lastEvent:r}=this;r?this._eventHandler(r,!0):a.length&&this._updateHoverStyles(a,a,!0),this.render()}_updateScales(){u(this.scales,(t=>{as.removeBox(this,t)})),this.ensureScalesHaveIDs(),this.buildOrUpdateScales()}_checkEventBindings(){const t=this.options,e=new Set(Object.keys(this._listeners)),i=new Set(t.events);P(e,i)&&!!this._responsiveListeners===t.responsive||(this.unbindEvents(),this.bindEvents())}_updateHiddenIndices(){const{_hiddenIndices:t}=this,e=this._getUniformDataChanges()||[];for(const{method:i,start:s,count:n}of e){Cn(t,s,"_removeElements"===i?-n:n)}}_getUniformDataChanges(){const t=this._dataChanges;if(!t||!t.length)return;this._dataChanges=[];const e=this.data.datasets.length,i=e=>new Set(t.filter((t=>t[0]===e)).map(((t,e)=>e+","+t.splice(1).join(",")))),s=i(0);for(let t=1;tt.split(","))).map((t=>({method:t[1],start:+t[2],count:+t[3]})))}_updateLayout(t){if(!1===this.notifyPlugins("beforeLayout",{cancelable:!0}))return;as.update(this,this.width,this.height,t);const e=this.chartArea,i=e.width<=0||e.height<=0;this._layers=[],u(this.boxes,(t=>{i&&"chartArea"===t.position||(t.configure&&t.configure(),this._layers.push(...t._layers()))}),this),this._layers.forEach(((t,e)=>{t._idx=e})),this.notifyPlugins("afterLayout")}_updateDatasets(t){if(!1!==this.notifyPlugins("beforeDatasetsUpdate",{mode:t,cancelable:!0})){for(let t=0,e=this.data.datasets.length;t=0;--e)this._drawDataset(t[e]);this.notifyPlugins("afterDatasetsDraw")}_drawDataset(t){const e=this.ctx,i=t._clip,s=!i.disabled,n=function(t,e){const{xScale:i,yScale:s}=t;return i&&s?{left:On(i,e,"left"),right:On(i,e,"right"),top:On(s,e,"top"),bottom:On(s,e,"bottom")}:e}(t,this.chartArea),o={meta:t,index:t.index,cancelable:!0};!1!==this.notifyPlugins("beforeDatasetDraw",o)&&(s&&Ie(e,{left:!1===i.left?0:n.left-i.left,right:!1===i.right?this.width:n.right+i.right,top:!1===i.top?0:n.top-i.top,bottom:!1===i.bottom?this.height:n.bottom+i.bottom}),t.controller.draw(),s&&ze(e),o.cancelable=!1,this.notifyPlugins("afterDatasetDraw",o))}isPointInArea(t){return Re(t,this.chartArea,this._minPadding)}getElementsAtEventForMode(t,e,i,s){const n=Xi.modes[e];return"function"==typeof n?n(this,t,i,s):[]}getDatasetMeta(t){const e=this.data.datasets[t],i=this._metasets;let s=i.filter((t=>t&&t._dataset===e)).pop();return s||(s={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:e&&e.order||0,index:t,_dataset:e,_parsed:[],_sorted:!1},i.push(s)),s}getContext(){return this.$context||(this.$context=Ci(null,{chart:this,type:"chart"}))}getVisibleDatasetCount(){return this.getSortedVisibleDatasetMetas().length}isDatasetVisible(t){const e=this.data.datasets[t];if(!e)return!1;const i=this.getDatasetMeta(t);return"boolean"==typeof i.hidden?!i.hidden:!e.hidden}setDatasetVisibility(t,e){this.getDatasetMeta(t).hidden=!e}toggleDataVisibility(t){this._hiddenIndices[t]=!this._hiddenIndices[t]}getDataVisibility(t){return!this._hiddenIndices[t]}_updateVisibility(t,e,i){const s=i?"show":"hide",n=this.getDatasetMeta(t),o=n.controller._resolveAnimations(void 0,s);k(e)?(n.data[e].hidden=!i,this.update()):(this.setDatasetVisibility(t,i),o.update(n,{visible:i}),this.update((e=>e.datasetIndex===t?s:void 0)))}hide(t,e){this._updateVisibility(t,e,!1)}show(t,e){this._updateVisibility(t,e,!0)}_destroyDatasetMeta(t){const e=this._metasets[t];e&&e.controller&&e.controller._destroy(),delete this._metasets[t]}_stop(){let t,e;for(this.stop(),xt.remove(this),t=0,e=this.data.datasets.length;t{e.addEventListener(this,i,s),t[i]=s},s=(t,e,i)=>{t.offsetX=e,t.offsetY=i,this._eventHandler(t)};u(this.options.events,(t=>i(t,s)))}bindResponsiveEvents(){this._responsiveListeners||(this._responsiveListeners={});const t=this._responsiveListeners,e=this.platform,i=(i,s)=>{e.addEventListener(this,i,s),t[i]=s},s=(i,s)=>{t[i]&&(e.removeEventListener(this,i,s),delete t[i])},n=(t,e)=>{this.canvas&&this.resize(t,e)};let o;const a=()=>{s("attach",a),this.attached=!0,this.resize(),i("resize",n),i("detach",o)};o=()=>{this.attached=!1,s("resize",n),this._stop(),this._resize(0,0),i("attach",a)},e.isAttached(this.canvas)?a():o()}unbindEvents(){u(this._listeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._listeners={},u(this._responsiveListeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._responsiveListeners=void 0}updateHoverStyle(t,e,i){const s=i?"set":"remove";let n,o,a,r;for("dataset"===e&&(n=this.getDatasetMeta(t[0].datasetIndex),n.controller["_"+s+"DatasetHoverStyle"]()),a=0,r=t.length;a{const i=this.getDatasetMeta(t);if(!i)throw new Error("No dataset found at index "+t);return{datasetIndex:t,element:i.data[e],index:e}}));!f(i,e)&&(this._active=i,this._lastEvent=null,this._updateHoverStyles(i,e))}notifyPlugins(t,e,i){return this._plugins.notify(this,t,e,i)}isPluginEnabled(t){return 1===this._plugins._cache.filter((e=>e.plugin.id===t)).length}_updateHoverStyles(t,e,i){const s=this.options.hover,n=(t,e)=>t.filter((t=>!e.some((e=>t.datasetIndex===e.datasetIndex&&t.index===e.index)))),o=n(e,t),a=i?t:n(t,e);o.length&&this.updateHoverStyle(o,s.mode,!1),a.length&&s.mode&&this.updateHoverStyle(a,s.mode,!0)}_eventHandler(t,e){const i={event:t,replay:e,cancelable:!0,inChartArea:this.isPointInArea(t)},s=e=>(e.options.events||this.options.events).includes(t.native.type);if(!1===this.notifyPlugins("beforeEvent",i,s))return;const n=this._handleEvent(t,e,i.inChartArea);return i.cancelable=!1,this.notifyPlugins("afterEvent",i,s),(n||i.changed)&&this.render(),this}_handleEvent(t,e,i){const{_active:s=[],options:n}=this,o=e,a=this._getActiveElements(t,s,i,o),r=D(t),l=function(t,e,i,s){return i&&"mouseout"!==t.type?s?e:t:null}(t,this._lastEvent,i,r);i&&(this._lastEvent=null,d(n.onHover,[t,a,this],this),r&&d(n.onClick,[t,a,this],this));const h=!f(a,s);return(h||e)&&(this._active=a,this._updateHoverStyles(a,s,e)),this._lastEvent=l,h}_getActiveElements(t,e,i,s){if("mouseout"===t.type)return[];if(!i)return e;const n=this.options.hover;return this.getElementsAtEventForMode(t,n.mode,n,s)}}function Tn(){return u(An.instances,(t=>t._plugins.invalidate()))}function Ln(){throw new Error("This method is not implemented: Check that a complete date adapter is provided.")}class En{static override(t){Object.assign(En.prototype,t)}options;constructor(t){this.options=t||{}}init(){}formats(){return Ln()}parse(){return Ln()}format(){return Ln()}add(){return Ln()}diff(){return Ln()}startOf(){return Ln()}endOf(){return Ln()}}var Rn={_date:En};function In(t){const e=t.iScale,i=function(t,e){if(!t._cache.$bar){const i=t.getMatchingVisibleMetas(e);let s=[];for(let e=0,n=i.length;et-e)))}return t._cache.$bar}(e,t.type);let s,n,o,a,r=e._length;const l=()=>{32767!==o&&-32768!==o&&(k(a)&&(r=Math.min(r,Math.abs(o-a)||r)),a=o)};for(s=0,n=i.length;sMath.abs(r)&&(l=r,h=a),e[i.axis]=h,e._custom={barStart:l,barEnd:h,start:n,end:o,min:a,max:r}}(t,e,i,s):e[i.axis]=i.parse(t,s),e}function Fn(t,e,i,s){const n=t.iScale,o=t.vScale,a=n.getLabels(),r=n===o,l=[];let h,c,d,u;for(h=i,c=i+s;ht.x,i="left",s="right"):(e=t.base"spacing"!==t,_indexable:t=>"spacing"!==t&&!t.startsWith("borderDash")&&!t.startsWith("hoverBorderDash")};static overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(t){const e=t.data;if(e.labels.length&&e.datasets.length){const{labels:{pointStyle:i,color:s}}=t.legend.options;return e.labels.map(((e,n)=>{const o=t.getDatasetMeta(0).controller.getStyle(n);return{text:e,fillStyle:o.backgroundColor,strokeStyle:o.borderColor,fontColor:s,lineWidth:o.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(n),index:n}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}}};constructor(t,e){super(t,e),this.enableOptionSharing=!0,this.innerRadius=void 0,this.outerRadius=void 0,this.offsetX=void 0,this.offsetY=void 0}linkScales(){}parse(t,e){const i=this.getDataset().data,s=this._cachedMeta;if(!1===this._parsing)s._parsed=i;else{let n,a,r=t=>+i[t];if(o(i[t])){const{key:t="value"}=this._parsing;r=e=>+M(i[e],t)}for(n=t,a=t+e;nZ(t,r,l,!0)?1:Math.max(e,e*i,s,s*i),g=(t,e,s)=>Z(t,r,l,!0)?-1:Math.min(e,e*i,s,s*i),p=f(0,h,d),m=f(E,c,u),b=g(C,h,d),x=g(C+E,c,u);s=(p-b)/2,n=(m-x)/2,o=-(p+b)/2,a=-(m+x)/2}return{ratioX:s,ratioY:n,offsetX:o,offsetY:a}}(u,d,r),b=(i.width-o)/f,x=(i.height-o)/g,_=Math.max(Math.min(b,x)/2,0),y=c(this.options.radius,_),v=(y-Math.max(y*r,0))/this._getVisibleDatasetWeightTotal();this.offsetX=p*y,this.offsetY=m*y,s.total=this.calculateTotal(),this.outerRadius=y-v*this._getRingWeightOffset(this.index),this.innerRadius=Math.max(this.outerRadius-v*l,0),this.updateElements(n,0,n.length,t)}_circumference(t,e){const i=this.options,s=this._cachedMeta,n=this._getCircumference();return e&&i.animation.animateRotate||!this.chart.getDataVisibility(t)||null===s._parsed[t]||s.data[t].hidden?0:this.calculateCircumference(s._parsed[t]*n/O)}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.chartArea,r=o.options.animation,l=(a.left+a.right)/2,h=(a.top+a.bottom)/2,c=n&&r.animateScale,d=c?0:this.innerRadius,u=c?0:this.outerRadius,{sharedOptions:f,includeOptions:g}=this._getSharedOptions(e,s);let p,m=this._getRotation();for(p=0;p0&&!isNaN(t)?O*(Math.abs(t)/e):0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t],i.options.locale);return{label:s[t]||"",value:n}}getMaxBorderWidth(t){let e=0;const i=this.chart;let s,n,o,a,r;if(!t)for(s=0,n=i.data.datasets.length;s{const o=t.getDatasetMeta(0).controller.getStyle(n);return{text:e,fillStyle:o.backgroundColor,strokeStyle:o.borderColor,fontColor:s,lineWidth:o.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(n),index:n}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}},scales:{r:{type:"radialLinear",angleLines:{display:!1},beginAtZero:!0,grid:{circular:!0},pointLabels:{display:!1},startAngle:0}}};constructor(t,e){super(t,e),this.innerRadius=void 0,this.outerRadius=void 0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t].r,i.options.locale);return{label:s[t]||"",value:n}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta.data;this._updateRadius(),this.updateElements(e,0,e.length,t)}getMinMax(){const t=this._cachedMeta,e={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY};return t.data.forEach(((t,i)=>{const s=this.getParsed(i).r;!isNaN(s)&&this.chart.getDataVisibility(i)&&(se.max&&(e.max=s))})),e}_updateRadius(){const t=this.chart,e=t.chartArea,i=t.options,s=Math.min(e.right-e.left,e.bottom-e.top),n=Math.max(s/2,0),o=(n-Math.max(i.cutoutPercentage?n/100*i.cutoutPercentage:1,0))/t.getVisibleDatasetCount();this.outerRadius=n-o*this.index,this.innerRadius=this.outerRadius-o}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.options.animation,r=this._cachedMeta.rScale,l=r.xCenter,h=r.yCenter,c=r.getIndexAngle(0)-.5*C;let d,u=c;const f=360/this.countVisibleElements();for(d=0;d{!isNaN(this.getParsed(i).r)&&this.chart.getDataVisibility(i)&&e++})),e}_computeAngle(t,e,i){return this.chart.getDataVisibility(t)?$(this.resolveDataElementOptions(t,e).angle||i):0}}var Yn=Object.freeze({__proto__:null,BarController:class extends Ns{static id="bar";static defaults={datasetElementType:!1,dataElementType:"bar",categoryPercentage:.8,barPercentage:.9,grouped:!0,animations:{numbers:{type:"number",properties:["x","y","base","width","height"]}}};static overrides={scales:{_index_:{type:"category",offset:!0,grid:{offset:!0}},_value_:{type:"linear",beginAtZero:!0}}};parsePrimitiveData(t,e,i,s){return Fn(t,e,i,s)}parseArrayData(t,e,i,s){return Fn(t,e,i,s)}parseObjectData(t,e,i,s){const{iScale:n,vScale:o}=t,{xAxisKey:a="x",yAxisKey:r="y"}=this._parsing,l="x"===n.axis?a:r,h="x"===o.axis?a:r,c=[];let d,u,f,g;for(d=i,u=i+s;dt.controller.options.grouped)),o=i.options.stacked,a=[],r=t=>{const i=t.controller.getParsed(e),n=i&&i[t.vScale.axis];if(s(n)||isNaN(n))return!0};for(const i of n)if((void 0===e||!r(i))&&((!1===o||-1===a.indexOf(i.stack)||void 0===o&&void 0===i.stack)&&a.push(i.stack),i.index===t))break;return a.length||a.push(void 0),a}_getStackCount(t){return this._getStacks(void 0,t).length}_getStackIndex(t,e,i){const s=this._getStacks(t,i),n=void 0!==e?s.indexOf(e):-1;return-1===n?s.length-1:n}_getRuler(){const t=this.options,e=this._cachedMeta,i=e.iScale,s=[];let n,o;for(n=0,o=e.data.length;n=i?1:-1)}(u,e,r)*a,f===r&&(b-=u/2);const t=e.getPixelForDecimal(0),s=e.getPixelForDecimal(1),o=Math.min(t,s),h=Math.max(t,s);b=Math.max(Math.min(b,h),o),d=b+u,i&&!c&&(l._stacks[e.axis]._visualValues[n]=e.getValueForPixel(d)-e.getValueForPixel(b))}if(b===e.getPixelForValue(r)){const t=F(u)*e.getLineWidthForValue(r)/2;b+=t,u-=t}return{size:u,base:b,head:d,center:d+u/2}}_calculateBarIndexPixels(t,e){const i=e.scale,n=this.options,o=n.skipNull,a=l(n.maxBarThickness,1/0);let r,h;if(e.grouped){const i=o?this._getStackCount(t):e.stackCount,l="flex"===n.barThickness?function(t,e,i,s){const n=e.pixels,o=n[t];let a=t>0?n[t-1]:null,r=t=0;--i)e=Math.max(e,t[i].size(this.resolveDataElementOptions(i))/2);return e>0&&e}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart.data.labels||[],{xScale:s,yScale:n}=e,o=this.getParsed(t),a=s.getLabelForValue(o.x),r=n.getLabelForValue(o.y),l=o._custom;return{label:i[t]||"",value:"("+a+", "+r+(l?", "+l:"")+")"}}update(t){const e=this._cachedMeta.data;this.updateElements(e,0,e.length,t)}updateElements(t,e,i,s){const n="reset"===s,{iScale:o,vScale:a}=this._cachedMeta,{sharedOptions:r,includeOptions:l}=this._getSharedOptions(e,s),h=o.axis,c=a.axis;for(let d=e;d0&&this.getParsed(e-1);for(let i=0;i<_;++i){const g=t[i],_=b?g:{};if(i=x){_.skip=!0;continue}const v=this.getParsed(i),M=s(v[f]),w=_[u]=a.getPixelForValue(v[u],i),k=_[f]=o||M?r.getBasePixel():r.getPixelForValue(l?this.applyStack(r,v,l):v[f],i);_.skip=isNaN(w)||isNaN(k)||M,_.stop=i>0&&Math.abs(v[u]-y[u])>m,p&&(_.parsed=v,_.raw=h.data[i]),d&&(_.options=c||this.resolveDataElementOptions(i,g.active?"active":n)),b||this.updateElement(g,i,_,n),y=v}}getMaxOverflow(){const t=this._cachedMeta,e=t.dataset,i=e.options&&e.options.borderWidth||0,s=t.data||[];if(!s.length)return i;const n=s[0].size(this.resolveDataElementOptions(0)),o=s[s.length-1].size(this.resolveDataElementOptions(s.length-1));return Math.max(i,n,o)/2}draw(){const t=this._cachedMeta;t.dataset.updateControlPoints(this.chart.chartArea,t.iScale.axis),super.draw()}},PieController:class extends jn{static id="pie";static defaults={cutout:0,rotation:0,circumference:360,radius:"100%"}},PolarAreaController:$n,RadarController:class extends Ns{static id="radar";static defaults={datasetElementType:"line",dataElementType:"point",indexAxis:"r",showLine:!0,elements:{line:{fill:"start"}}};static overrides={aspectRatio:1,scales:{r:{type:"radialLinear"}}};getLabelAndValue(t){const e=this._cachedMeta.vScale,i=this.getParsed(t);return{label:e.getLabels()[t],value:""+e.getLabelForValue(i[e.axis])}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta,i=e.dataset,s=e.data||[],n=e.iScale.getLabels();if(i.points=s,"resize"!==t){const e=this.resolveDatasetElementOptions(t);this.options.showLine||(e.borderWidth=0);const o={_loop:!0,_fullLoop:n.length===s.length,options:e};this.updateElement(i,void 0,o,t)}this.updateElements(s,0,s.length,t)}updateElements(t,e,i,s){const n=this._cachedMeta.rScale,o="reset"===s;for(let a=e;a0&&this.getParsed(e-1);for(let c=e;c0&&Math.abs(i[f]-_[f])>b,m&&(p.parsed=i,p.raw=h.data[c]),u&&(p.options=d||this.resolveDataElementOptions(c,e.active?"active":n)),x||this.updateElement(e,c,p,n),_=i}this.updateSharedOptions(d,n,c)}getMaxOverflow(){const t=this._cachedMeta,e=t.data||[];if(!this.options.showLine){let t=0;for(let i=e.length-1;i>=0;--i)t=Math.max(t,e[i].size(this.resolveDataElementOptions(i))/2);return t>0&&t}const i=t.dataset,s=i.options&&i.options.borderWidth||0;if(!e.length)return s;const n=e[0].size(this.resolveDataElementOptions(0)),o=e[e.length-1].size(this.resolveDataElementOptions(e.length-1));return Math.max(s,n,o)/2}}});function Un(t,e,i,s){const n=vi(t.options.borderRadius,["outerStart","outerEnd","innerStart","innerEnd"]);const o=(i-e)/2,a=Math.min(o,s*e/2),r=t=>{const e=(i-Math.min(o,t))*s/2;return J(t,0,Math.min(o,e))};return{outerStart:r(n.outerStart),outerEnd:r(n.outerEnd),innerStart:J(n.innerStart,0,a),innerEnd:J(n.innerEnd,0,a)}}function Xn(t,e,i,s){return{x:i+t*Math.cos(e),y:s+t*Math.sin(e)}}function qn(t,e,i,s,n,o){const{x:a,y:r,startAngle:l,pixelMargin:h,innerRadius:c}=e,d=Math.max(e.outerRadius+s+i-h,0),u=c>0?c+s+i+h:0;let f=0;const g=n-l;if(s){const t=((c>0?c-s:0)+(d>0?d-s:0))/2;f=(g-(0!==t?g*t/(t+s):g))/2}const p=(g-Math.max(.001,g*d-i/C)/d)/2,m=l+p+f,b=n-p-f,{outerStart:x,outerEnd:_,innerStart:y,innerEnd:v}=Un(e,u,d,b-m),M=d-x,w=d-_,k=m+x/M,S=b-_/w,P=u+y,D=u+v,O=m+y/P,A=b-v/D;if(t.beginPath(),o){const e=(k+S)/2;if(t.arc(a,r,d,k,e),t.arc(a,r,d,e,S),_>0){const e=Xn(w,S,a,r);t.arc(e.x,e.y,_,S,b+E)}const i=Xn(D,b,a,r);if(t.lineTo(i.x,i.y),v>0){const e=Xn(D,A,a,r);t.arc(e.x,e.y,v,b+E,A+Math.PI)}const s=(b-v/u+(m+y/u))/2;if(t.arc(a,r,u,b-v/u,s,!0),t.arc(a,r,u,s,m+y/u,!0),y>0){const e=Xn(P,O,a,r);t.arc(e.x,e.y,y,O+Math.PI,m-E)}const n=Xn(M,m,a,r);if(t.lineTo(n.x,n.y),x>0){const e=Xn(M,k,a,r);t.arc(e.x,e.y,x,m-E,k)}}else{t.moveTo(a,r);const e=Math.cos(k)*d+a,i=Math.sin(k)*d+r;t.lineTo(e,i);const s=Math.cos(S)*d+a,n=Math.sin(S)*d+r;t.lineTo(s,n)}t.closePath()}function Kn(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r,options:l}=e,{borderWidth:h,borderJoinStyle:c,borderDash:d,borderDashOffset:u}=l,f="inner"===l.borderAlign;if(!h)return;t.setLineDash(d||[]),t.lineDashOffset=u,f?(t.lineWidth=2*h,t.lineJoin=c||"round"):(t.lineWidth=h,t.lineJoin=c||"bevel");let g=e.endAngle;if(o){qn(t,e,i,s,g,n);for(let e=0;en?(h=n/l,t.arc(o,a,l,i+h,s-h,!0)):t.arc(o,a,n,i+E,s-E),t.closePath(),t.clip()}(t,e,g),o||(qn(t,e,i,s,g,n),t.stroke())}function Gn(t,e,i=e){t.lineCap=l(i.borderCapStyle,e.borderCapStyle),t.setLineDash(l(i.borderDash,e.borderDash)),t.lineDashOffset=l(i.borderDashOffset,e.borderDashOffset),t.lineJoin=l(i.borderJoinStyle,e.borderJoinStyle),t.lineWidth=l(i.borderWidth,e.borderWidth),t.strokeStyle=l(i.borderColor,e.borderColor)}function Zn(t,e,i){t.lineTo(i.x,i.y)}function Jn(t,e,i={}){const s=t.length,{start:n=0,end:o=s-1}=i,{start:a,end:r}=e,l=Math.max(n,a),h=Math.min(o,r),c=nr&&o>r;return{count:s,start:l,loop:e.loop,ilen:h(a+(h?r-t:t))%o,_=()=>{f!==g&&(t.lineTo(m,g),t.lineTo(m,f),t.lineTo(m,p))};for(l&&(d=n[x(0)],t.moveTo(d.x,d.y)),c=0;c<=r;++c){if(d=n[x(c)],d.skip)continue;const e=d.x,i=d.y,s=0|e;s===u?(ig&&(g=i),m=(b*m+e)/++b):(_(),t.lineTo(e,i),u=s,b=0,f=g=i),p=i}_()}function eo(t){const e=t.options,i=e.borderDash&&e.borderDash.length;return!(t._decimated||t._loop||e.tension||"monotone"===e.cubicInterpolationMode||e.stepped||i)?to:Qn}const io="function"==typeof Path2D;function so(t,e,i,s){io&&!e.options.segment?function(t,e,i,s){let n=e._path;n||(n=e._path=new Path2D,e.path(n,i,s)&&n.closePath()),Gn(t,e.options),t.stroke(n)}(t,e,i,s):function(t,e,i,s){const{segments:n,options:o}=e,a=eo(e);for(const r of n)Gn(t,o,r.style),t.beginPath(),a(t,e,r,{start:i,end:i+s-1})&&t.closePath(),t.stroke()}(t,e,i,s)}class no extends Hs{static id="line";static defaults={borderCapStyle:"butt",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderWidth:3,capBezierPoints:!0,cubicInterpolationMode:"default",fill:!1,spanGaps:!1,stepped:!1,tension:0};static defaultRoutes={backgroundColor:"backgroundColor",borderColor:"borderColor"};static descriptors={_scriptable:!0,_indexable:t=>"borderDash"!==t&&"fill"!==t};constructor(t){super(),this.animated=!0,this.options=void 0,this._chart=void 0,this._loop=void 0,this._fullLoop=void 0,this._path=void 0,this._points=void 0,this._segments=void 0,this._decimated=!1,this._pointsUpdated=!1,this._datasetIndex=void 0,t&&Object.assign(this,t)}updateControlPoints(t,e){const i=this.options;if((i.tension||"monotone"===i.cubicInterpolationMode)&&!i.stepped&&!this._pointsUpdated){const s=i.spanGaps?this._loop:this._fullLoop;hi(this._points,i,t,s,e),this._pointsUpdated=!0}}set points(t){this._points=t,delete this._segments,delete this._path,this._pointsUpdated=!1}get points(){return this._points}get segments(){return this._segments||(this._segments=zi(this,this.options.segment))}first(){const t=this.segments,e=this.points;return t.length&&e[t[0].start]}last(){const t=this.segments,e=this.points,i=t.length;return i&&e[t[i-1].end]}interpolate(t,e){const i=this.options,s=t[e],n=this.points,o=Ii(this,{property:e,start:s,end:s});if(!o.length)return;const a=[],r=function(t){return t.stepped?pi:t.tension||"monotone"===t.cubicInterpolationMode?mi:gi}(i);let l,h;for(l=0,h=o.length;l"borderDash"!==t};circumference;endAngle;fullCircles;innerRadius;outerRadius;pixelMargin;startAngle;constructor(t){super(),this.options=void 0,this.circumference=void 0,this.startAngle=void 0,this.endAngle=void 0,this.innerRadius=void 0,this.outerRadius=void 0,this.pixelMargin=0,this.fullCircles=0,t&&Object.assign(this,t)}inRange(t,e,i){const s=this.getProps(["x","y"],i),{angle:n,distance:o}=X(s,{x:t,y:e}),{startAngle:a,endAngle:r,innerRadius:h,outerRadius:c,circumference:d}=this.getProps(["startAngle","endAngle","innerRadius","outerRadius","circumference"],i),u=(this.options.spacing+this.options.borderWidth)/2,f=l(d,r-a)>=O||Z(n,a,r),g=tt(o,h+u,c+u);return f&&g}getCenterPoint(t){const{x:e,y:i,startAngle:s,endAngle:n,innerRadius:o,outerRadius:a}=this.getProps(["x","y","startAngle","endAngle","innerRadius","outerRadius"],t),{offset:r,spacing:l}=this.options,h=(s+n)/2,c=(o+a+l+r)/2;return{x:e+Math.cos(h)*c,y:i+Math.sin(h)*c}}tooltipPosition(t){return this.getCenterPoint(t)}draw(t){const{options:e,circumference:i}=this,s=(e.offset||0)/4,n=(e.spacing||0)/2,o=e.circular;if(this.pixelMargin="inner"===e.borderAlign?.33:0,this.fullCircles=i>O?Math.floor(i/O):0,0===i||this.innerRadius<0||this.outerRadius<0)return;t.save();const a=(this.startAngle+this.endAngle)/2;t.translate(Math.cos(a)*s,Math.sin(a)*s);const r=s*(1-Math.sin(Math.min(C,i||0)));t.fillStyle=e.backgroundColor,t.strokeStyle=e.borderColor,function(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r}=e;let l=e.endAngle;if(o){qn(t,e,i,s,l,n);for(let e=0;e("string"==typeof e?(i=t.push(e)-1,s.unshift({index:i,label:e})):isNaN(e)&&(i=null),i))(t,e,i,s);return n!==t.lastIndexOf(e)?i:n}function po(t){const e=this.getLabels();return t>=0&&ts=e?s:t,a=t=>n=i?n:t;if(t){const t=F(s),e=F(n);t<0&&e<0?a(0):t>0&&e>0&&o(0)}if(s===n){let e=0===n?1:Math.abs(.05*n);a(n+e),t||o(s-e)}this.min=s,this.max=n}getTickLimit(){const t=this.options.ticks;let e,{maxTicksLimit:i,stepSize:s}=t;return s?(e=Math.ceil(this.max/s)-Math.floor(this.min/s)+1,e>1e3&&(console.warn(`scales.${this.id}.ticks.stepSize: ${s} would result generating up to ${e} ticks. Limiting to 1000.`),e=1e3)):(e=this.computeTickLimit(),i=i||11),i&&(e=Math.min(i,e)),e}computeTickLimit(){return Number.POSITIVE_INFINITY}buildTicks(){const t=this.options,e=t.ticks;let i=this.getTickLimit();i=Math.max(2,i);const n=function(t,e){const i=[],{bounds:n,step:o,min:a,max:r,precision:l,count:h,maxTicks:c,maxDigits:d,includeBounds:u}=t,f=o||1,g=c-1,{min:p,max:m}=e,b=!s(a),x=!s(r),_=!s(h),y=(m-p)/(d+1);let v,M,w,k,S=B((m-p)/g/f)*f;if(S<1e-14&&!b&&!x)return[{value:p},{value:m}];k=Math.ceil(m/S)-Math.floor(p/S),k>g&&(S=B(k*S/g/f)*f),s(l)||(v=Math.pow(10,l),S=Math.ceil(S*v)/v),"ticks"===n?(M=Math.floor(p/S)*S,w=Math.ceil(m/S)*S):(M=p,w=m),b&&x&&o&&H((r-a)/o,S/1e3)?(k=Math.round(Math.min((r-a)/S,c)),S=(r-a)/k,M=a,w=r):_?(M=b?a:M,w=x?r:w,k=h-1,S=(w-M)/k):(k=(w-M)/S,k=V(k,Math.round(k),S/1e3)?Math.round(k):Math.ceil(k));const P=Math.max(U(S),U(M));v=Math.pow(10,s(l)?P:l),M=Math.round(M*v)/v,w=Math.round(w*v)/v;let D=0;for(b&&(u&&M!==a?(i.push({value:a}),Mr)break;i.push({value:t})}return x&&u&&w!==r?i.length&&V(i[i.length-1].value,r,mo(r,y,t))?i[i.length-1].value=r:i.push({value:r}):x&&w!==r||i.push({value:w}),i}({maxTicks:i,bounds:t.bounds,min:t.min,max:t.max,precision:e.precision,step:e.stepSize,count:e.count,maxDigits:this._maxDigits(),horizontal:this.isHorizontal(),minRotation:e.minRotation||0,includeBounds:!1!==e.includeBounds},this._range||this);return"ticks"===t.bounds&&j(n,this,"value"),t.reverse?(n.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),n}configure(){const t=this.ticks;let e=this.min,i=this.max;if(super.configure(),this.options.offset&&t.length){const s=(i-e)/Math.max(t.length-1,1)/2;e-=s,i+=s}this._startValue=e,this._endValue=i,this._valueRange=i-e}getLabelForValue(t){return ne(t,this.chart.options.locale,this.options.ticks.format)}}class xo extends bo{static id="linear";static defaults={ticks:{callback:ae.formatters.numeric}};determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?t:0,this.max=a(e)?e:1,this.handleTickRangeOptions()}computeTickLimit(){const t=this.isHorizontal(),e=t?this.width:this.height,i=$(this.options.ticks.minRotation),s=(t?Math.sin(i):Math.cos(i))||.001,n=this._resolveTickFontOptions(0);return Math.ceil(e/Math.min(40,n.lineHeight/s))}getPixelForValue(t){return null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getValueForPixel(t){return this._startValue+this.getDecimalForPixel(t)*this._valueRange}}const _o=t=>Math.floor(z(t)),yo=(t,e)=>Math.pow(10,_o(t)+e);function vo(t){return 1===t/Math.pow(10,_o(t))}function Mo(t,e,i){const s=Math.pow(10,i),n=Math.floor(t/s);return Math.ceil(e/s)-n}function wo(t,{min:e,max:i}){e=r(t.min,e);const s=[],n=_o(e);let o=function(t,e){let i=_o(e-t);for(;Mo(t,e,i)>10;)i++;for(;Mo(t,e,i)<10;)i--;return Math.min(i,_o(t))}(e,i),a=o<0?Math.pow(10,Math.abs(o)):1;const l=Math.pow(10,o),h=n>o?Math.pow(10,n):0,c=Math.round((e-h)*a)/a,d=Math.floor((e-h)/l/10)*l*10;let u=Math.floor((c-d)/Math.pow(10,o)),f=r(t.min,Math.round((h+d+u*Math.pow(10,o))*a)/a);for(;f=10?u=u<15?15:20:u++,u>=20&&(o++,u=2,a=o>=0?1:a),f=Math.round((h+d+u*Math.pow(10,o))*a)/a;const g=r(t.max,f);return s.push({value:g,major:vo(g),significand:u}),s}class ko extends Js{static id="logarithmic";static defaults={ticks:{callback:ae.formatters.logarithmic,major:{enabled:!0}}};constructor(t){super(t),this.start=void 0,this.end=void 0,this._startValue=void 0,this._valueRange=0}parse(t,e){const i=bo.prototype.parse.apply(this,[t,e]);if(0!==i)return a(i)&&i>0?i:null;this._zero=!0}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?Math.max(0,t):null,this.max=a(e)?Math.max(0,e):null,this.options.beginAtZero&&(this._zero=!0),this._zero&&this.min!==this._suggestedMin&&!a(this._userMin)&&(this.min=t===yo(this.min,0)?yo(this.min,-1):yo(this.min,0)),this.handleTickRangeOptions()}handleTickRangeOptions(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let i=this.min,s=this.max;const n=e=>i=t?i:e,o=t=>s=e?s:t;i===s&&(i<=0?(n(1),o(10)):(n(yo(i,-1)),o(yo(s,1)))),i<=0&&n(yo(s,-1)),s<=0&&o(yo(i,1)),this.min=i,this.max=s}buildTicks(){const t=this.options,e=wo({min:this._userMin,max:this._userMax},this);return"ticks"===t.bounds&&j(e,this,"value"),t.reverse?(e.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),e}getLabelForValue(t){return void 0===t?"0":ne(t,this.chart.options.locale,this.options.ticks.format)}configure(){const t=this.min;super.configure(),this._startValue=z(t),this._valueRange=z(this.max)-z(t)}getPixelForValue(t){return void 0!==t&&0!==t||(t=this.min),null===t||isNaN(t)?NaN:this.getPixelForDecimal(t===this.min?0:(z(t)-this._startValue)/this._valueRange)}getValueForPixel(t){const e=this.getDecimalForPixel(t);return Math.pow(10,this._startValue+e*this._valueRange)}}function So(t){const e=t.ticks;if(e.display&&t.display){const t=ki(e.backdropPadding);return l(e.font&&e.font.size,ue.font.size)+t.height}return 0}function Po(t,e,i,s,n){return t===s||t===n?{start:e-i/2,end:e+i/2}:tn?{start:e-i,end:e}:{start:e,end:e+i}}function Do(t){const e={l:t.left+t._padding.left,r:t.right-t._padding.right,t:t.top+t._padding.top,b:t.bottom-t._padding.bottom},i=Object.assign({},e),s=[],o=[],a=t._pointLabels.length,r=t.options.pointLabels,l=r.centerPointLabels?C/a:0;for(let u=0;ue.r&&(r=(s.end-e.r)/o,t.r=Math.max(t.r,e.r+r)),n.starte.b&&(l=(n.end-e.b)/a,t.b=Math.max(t.b,e.b+l))}function Oo(t,e,i){const s=t.drawingArea,{extra:n,additionalAngle:o,padding:a,size:r}=i,l=t.getPointPosition(e,s+n+a,o),h=Math.round(Y(G(l.angle+E))),c=function(t,e,i){90===i||270===i?t-=e/2:(i>270||i<90)&&(t-=e);return t}(l.y,r.h,h),d=function(t){if(0===t||180===t)return"center";if(t<180)return"left";return"right"}(h),u=function(t,e,i){"right"===i?t-=e:"center"===i&&(t-=e/2);return t}(l.x,r.w,d);return{visible:!0,x:l.x,y:c,textAlign:d,left:u,top:c,right:u+r.w,bottom:c+r.h}}function Ao(t,e){if(!e)return!0;const{left:i,top:s,right:n,bottom:o}=t;return!(Re({x:i,y:s},e)||Re({x:i,y:o},e)||Re({x:n,y:s},e)||Re({x:n,y:o},e))}function To(t,e,i){const{left:n,top:o,right:a,bottom:r}=i,{backdropColor:l}=e;if(!s(l)){const i=wi(e.borderRadius),s=ki(e.backdropPadding);t.fillStyle=l;const h=n-s.left,c=o-s.top,d=a-n+s.width,u=r-o+s.height;Object.values(i).some((t=>0!==t))?(t.beginPath(),He(t,{x:h,y:c,w:d,h:u,radius:i}),t.fill()):t.fillRect(h,c,d,u)}}function Lo(t,e,i,s){const{ctx:n}=t;if(i)n.arc(t.xCenter,t.yCenter,e,0,O);else{let i=t.getPointPosition(0,e);n.moveTo(i.x,i.y);for(let o=1;ot,padding:5,centerPointLabels:!1}};static defaultRoutes={"angleLines.color":"borderColor","pointLabels.color":"color","ticks.color":"color"};static descriptors={angleLines:{_fallback:"grid"}};constructor(t){super(t),this.xCenter=void 0,this.yCenter=void 0,this.drawingArea=void 0,this._pointLabels=[],this._pointLabelItems=[]}setDimensions(){const t=this._padding=ki(So(this.options)/2),e=this.width=this.maxWidth-t.width,i=this.height=this.maxHeight-t.height;this.xCenter=Math.floor(this.left+e/2+t.left),this.yCenter=Math.floor(this.top+i/2+t.top),this.drawingArea=Math.floor(Math.min(e,i)/2)}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!1);this.min=a(t)&&!isNaN(t)?t:0,this.max=a(e)&&!isNaN(e)?e:0,this.handleTickRangeOptions()}computeTickLimit(){return Math.ceil(this.drawingArea/So(this.options))}generateTickLabels(t){bo.prototype.generateTickLabels.call(this,t),this._pointLabels=this.getLabels().map(((t,e)=>{const i=d(this.options.pointLabels.callback,[t,e],this);return i||0===i?i:""})).filter(((t,e)=>this.chart.getDataVisibility(e)))}fit(){const t=this.options;t.display&&t.pointLabels.display?Do(this):this.setCenterPoint(0,0,0,0)}setCenterPoint(t,e,i,s){this.xCenter+=Math.floor((t-e)/2),this.yCenter+=Math.floor((i-s)/2),this.drawingArea-=Math.min(this.drawingArea/2,Math.max(t,e,i,s))}getIndexAngle(t){return G(t*(O/(this._pointLabels.length||1))+$(this.options.startAngle||0))}getDistanceFromCenterForValue(t){if(s(t))return NaN;const e=this.drawingArea/(this.max-this.min);return this.options.reverse?(this.max-t)*e:(t-this.min)*e}getValueForDistanceFromCenter(t){if(s(t))return NaN;const e=t/(this.drawingArea/(this.max-this.min));return this.options.reverse?this.max-e:this.min+e}getPointLabelContext(t){const e=this._pointLabels||[];if(t>=0&&t=0;n--){const e=t._pointLabelItems[n];if(!e.visible)continue;const o=s.setContext(t.getPointLabelContext(n));To(i,o,e);const a=Si(o.font),{x:r,y:l,textAlign:h}=e;Ne(i,t._pointLabels[n],r,l+a.lineHeight/2,a,{color:o.color,textAlign:h,textBaseline:"middle"})}}(this,o),s.display&&this.ticks.forEach(((t,e)=>{if(0!==e){r=this.getDistanceFromCenterForValue(t.value);const i=this.getContext(e),a=s.setContext(i),l=n.setContext(i);!function(t,e,i,s,n){const o=t.ctx,a=e.circular,{color:r,lineWidth:l}=e;!a&&!s||!r||!l||i<0||(o.save(),o.strokeStyle=r,o.lineWidth=l,o.setLineDash(n.dash),o.lineDashOffset=n.dashOffset,o.beginPath(),Lo(t,i,a,s),o.closePath(),o.stroke(),o.restore())}(this,a,r,o,l)}})),i.display){for(t.save(),a=o-1;a>=0;a--){const s=i.setContext(this.getPointLabelContext(a)),{color:n,lineWidth:o}=s;o&&n&&(t.lineWidth=o,t.strokeStyle=n,t.setLineDash(s.borderDash),t.lineDashOffset=s.borderDashOffset,r=this.getDistanceFromCenterForValue(e.ticks.reverse?this.min:this.max),l=this.getPointPosition(a,r),t.beginPath(),t.moveTo(this.xCenter,this.yCenter),t.lineTo(l.x,l.y),t.stroke())}t.restore()}}drawBorder(){}drawLabels(){const t=this.ctx,e=this.options,i=e.ticks;if(!i.display)return;const s=this.getIndexAngle(0);let n,o;t.save(),t.translate(this.xCenter,this.yCenter),t.rotate(s),t.textAlign="center",t.textBaseline="middle",this.ticks.forEach(((s,a)=>{if(0===a&&!e.reverse)return;const r=i.setContext(this.getContext(a)),l=Si(r.font);if(n=this.getDistanceFromCenterForValue(this.ticks[a].value),r.showLabelBackdrop){t.font=l.string,o=t.measureText(s.label).width,t.fillStyle=r.backdropColor;const e=ki(r.backdropPadding);t.fillRect(-o/2-e.left,-n-l.size/2-e.top,o+e.width,l.size+e.height)}Ne(t,s.label,0,-n,l,{color:r.color,strokeColor:r.textStrokeColor,strokeWidth:r.textStrokeWidth})})),t.restore()}drawTitle(){}}const Ro={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},Io=Object.keys(Ro);function zo(t,e){return t-e}function Fo(t,e){if(s(e))return null;const i=t._adapter,{parser:n,round:o,isoWeekday:r}=t._parseOpts;let l=e;return"function"==typeof n&&(l=n(l)),a(l)||(l="string"==typeof n?i.parse(l,n):i.parse(l)),null===l?null:(o&&(l="week"!==o||!N(r)&&!0!==r?i.startOf(l,o):i.startOf(l,"isoWeek",r)),+l)}function Vo(t,e,i,s){const n=Io.length;for(let o=Io.indexOf(t);o=e?i[s]:i[n]]=!0}}else t[e]=!0}function Wo(t,e,i){const s=[],n={},o=e.length;let a,r;for(a=0;a=0&&(e[l].major=!0);return e}(t,s,n,i):s}class No extends Js{static id="time";static defaults={bounds:"data",adapters:{},time:{parser:!1,unit:!1,round:!1,isoWeekday:!1,minUnit:"millisecond",displayFormats:{}},ticks:{source:"auto",callback:!1,major:{enabled:!1}}};constructor(t){super(t),this._cache={data:[],labels:[],all:[]},this._unit="day",this._majorUnit=void 0,this._offsets={},this._normalized=!1,this._parseOpts=void 0}init(t,e={}){const i=t.time||(t.time={}),s=this._adapter=new Rn._date(t.adapters.date);s.init(e),x(i.displayFormats,s.formats()),this._parseOpts={parser:i.parser,round:i.round,isoWeekday:i.isoWeekday},super.init(t),this._normalized=e.normalized}parse(t,e){return void 0===t?null:Fo(this,t)}beforeLayout(){super.beforeLayout(),this._cache={data:[],labels:[],all:[]}}determineDataLimits(){const t=this.options,e=this._adapter,i=t.time.unit||"day";let{min:s,max:n,minDefined:o,maxDefined:r}=this.getUserBounds();function l(t){o||isNaN(t.min)||(s=Math.min(s,t.min)),r||isNaN(t.max)||(n=Math.max(n,t.max))}o&&r||(l(this._getLabelBounds()),"ticks"===t.bounds&&"labels"===t.ticks.source||l(this.getMinMax(!1))),s=a(s)&&!isNaN(s)?s:+e.startOf(Date.now(),i),n=a(n)&&!isNaN(n)?n:+e.endOf(Date.now(),i)+1,this.min=Math.min(s,n-1),this.max=Math.max(s+1,n)}_getLabelBounds(){const t=this.getLabelTimestamps();let e=Number.POSITIVE_INFINITY,i=Number.NEGATIVE_INFINITY;return t.length&&(e=t[0],i=t[t.length-1]),{min:e,max:i}}buildTicks(){const t=this.options,e=t.time,i=t.ticks,s="labels"===i.source?this.getLabelTimestamps():this._generate();"ticks"===t.bounds&&s.length&&(this.min=this._userMin||s[0],this.max=this._userMax||s[s.length-1]);const n=this.min,o=nt(s,n,this.max);return this._unit=e.unit||(i.autoSkip?Vo(e.minUnit,this.min,this.max,this._getLabelCapacity(n)):function(t,e,i,s,n){for(let o=Io.length-1;o>=Io.indexOf(i);o--){const i=Io[o];if(Ro[i].common&&t._adapter.diff(n,s,i)>=e-1)return i}return Io[i?Io.indexOf(i):0]}(this,o.length,e.minUnit,this.min,this.max)),this._majorUnit=i.major.enabled&&"year"!==this._unit?function(t){for(let e=Io.indexOf(t)+1,i=Io.length;e+t.value)))}initOffsets(t=[]){let e,i,s=0,n=0;this.options.offset&&t.length&&(e=this.getDecimalForValue(t[0]),s=1===t.length?1-e:(this.getDecimalForValue(t[1])-e)/2,i=this.getDecimalForValue(t[t.length-1]),n=1===t.length?i:(i-this.getDecimalForValue(t[t.length-2]))/2);const o=t.length<3?.5:.25;s=J(s,0,o),n=J(n,0,o),this._offsets={start:s,end:n,factor:1/(s+1+n)}}_generate(){const t=this._adapter,e=this.min,i=this.max,s=this.options,n=s.time,o=n.unit||Vo(n.minUnit,e,i,this._getLabelCapacity(e)),a=l(s.ticks.stepSize,1),r="week"===o&&n.isoWeekday,h=N(r)||!0===r,c={};let d,u,f=e;if(h&&(f=+t.startOf(f,"isoWeek",r)),f=+t.startOf(f,h?"day":o),t.diff(i,e,o)>1e5*a)throw new Error(e+" and "+i+" are too far apart with stepSize of "+a+" "+o);const g="data"===s.ticks.source&&this.getDataTimestamps();for(d=f,u=0;d+t))}getLabelForValue(t){const e=this._adapter,i=this.options.time;return i.tooltipFormat?e.format(t,i.tooltipFormat):e.format(t,i.displayFormats.datetime)}format(t,e){const i=this.options.time.displayFormats,s=this._unit,n=e||i[s];return this._adapter.format(t,n)}_tickFormatFunction(t,e,i,s){const n=this.options,o=n.ticks.callback;if(o)return d(o,[t,e,i],this);const a=n.time.displayFormats,r=this._unit,l=this._majorUnit,h=r&&a[r],c=l&&a[l],u=i[e],f=l&&c&&u&&u.major;return this._adapter.format(t,s||(f?c:h))}generateTickLabels(t){let e,i,s;for(e=0,i=t.length;e0?a:1}getDataTimestamps(){let t,e,i=this._cache.data||[];if(i.length)return i;const s=this.getMatchingVisibleMetas();if(this._normalized&&s.length)return this._cache.data=s[0].controller.getAllParsedValues(this);for(t=0,e=s.length;t=t[r].pos&&e<=t[l].pos&&({lo:r,hi:l}=it(t,"pos",e)),({pos:s,time:o}=t[r]),({pos:n,time:a}=t[l])):(e>=t[r].time&&e<=t[l].time&&({lo:r,hi:l}=it(t,"time",e)),({time:s,pos:o}=t[r]),({time:n,pos:a}=t[l]));const h=n-s;return h?o+(a-o)*(e-s)/h:o}var jo=Object.freeze({__proto__:null,CategoryScale:class extends Js{static id="category";static defaults={ticks:{callback:po}};constructor(t){super(t),this._startValue=void 0,this._valueRange=0,this._addedLabels=[]}init(t){const e=this._addedLabels;if(e.length){const t=this.getLabels();for(const{index:i,label:s}of e)t[i]===s&&t.splice(i,1);this._addedLabels=[]}super.init(t)}parse(t,e){if(s(t))return null;const i=this.getLabels();return((t,e)=>null===t?null:J(Math.round(t),0,e))(e=isFinite(e)&&i[e]===t?e:go(i,t,l(e,t),this._addedLabels),i.length-1)}determineDataLimits(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let{min:i,max:s}=this.getMinMax(!0);"ticks"===this.options.bounds&&(t||(i=0),e||(s=this.getLabels().length-1)),this.min=i,this.max=s}buildTicks(){const t=this.min,e=this.max,i=this.options.offset,s=[];let n=this.getLabels();n=0===t&&e===n.length-1?n:n.slice(t,e+1),this._valueRange=Math.max(n.length-(i?0:1),1),this._startValue=this.min-(i?.5:0);for(let i=t;i<=e;i++)s.push({value:i});return s}getLabelForValue(t){return po.call(this,t)}configure(){super.configure(),this.isHorizontal()||(this._reversePixels=!this._reversePixels)}getPixelForValue(t){return"number"!=typeof t&&(t=this.parse(t)),null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getValueForPixel(t){return Math.round(this._startValue+this.getDecimalForPixel(t)*this._valueRange)}getBasePixel(){return this.bottom}},LinearScale:xo,LogarithmicScale:ko,RadialLinearScale:Eo,TimeScale:No,TimeSeriesScale:class extends No{static id="timeseries";static defaults=No.defaults;constructor(t){super(t),this._table=[],this._minPos=void 0,this._tableRange=void 0}initOffsets(){const t=this._getTimestampsForTable(),e=this._table=this.buildLookupTable(t);this._minPos=Ho(e,this.min),this._tableRange=Ho(e,this.max)-this._minPos,super.initOffsets(t)}buildLookupTable(t){const{min:e,max:i}=this,s=[],n=[];let o,a,r,l,h;for(o=0,a=t.length;o=e&&l<=i&&s.push(l);if(s.length<2)return[{time:e,pos:0},{time:i,pos:1}];for(o=0,a=s.length;ot-e))}_getTimestampsForTable(){let t=this._cache.all||[];if(t.length)return t;const e=this.getDataTimestamps(),i=this.getLabelTimestamps();return t=e.length&&i.length?this.normalize(e.concat(i)):e.length?e:i,t=this._cache.all=t,t}getDecimalForValue(t){return(Ho(this._table,t)-this._minPos)/this._tableRange}getValueForPixel(t){const e=this._offsets,i=this.getDecimalForPixel(t)/e.factor-e.end;return Ho(this._table,i*this._tableRange+this._minPos,!0)}}});const $o=["rgb(54, 162, 235)","rgb(255, 99, 132)","rgb(255, 159, 64)","rgb(255, 205, 86)","rgb(75, 192, 192)","rgb(153, 102, 255)","rgb(201, 203, 207)"],Yo=$o.map((t=>t.replace("rgb(","rgba(").replace(")",", 0.5)")));function Uo(t){return $o[t%$o.length]}function Xo(t){return Yo[t%Yo.length]}function qo(t){let e=0;return(i,s)=>{const n=t.getDatasetMeta(s).controller;n instanceof jn?e=function(t,e){return t.backgroundColor=t.data.map((()=>Uo(e++))),e}(i,e):n instanceof $n?e=function(t,e){return t.backgroundColor=t.data.map((()=>Xo(e++))),e}(i,e):n&&(e=function(t,e){return t.borderColor=Uo(e),t.backgroundColor=Xo(e),++e}(i,e))}}function Ko(t){let e;for(e in t)if(t[e].borderColor||t[e].backgroundColor)return!0;return!1}var Go={id:"colors",defaults:{enabled:!0,forceOverride:!1},beforeLayout(t,e,i){if(!i.enabled)return;const{data:{datasets:s},options:n}=t.config,{elements:o}=n;if(!i.forceOverride&&(Ko(s)||(a=n)&&(a.borderColor||a.backgroundColor)||o&&Ko(o)))return;var a;const r=qo(t);s.forEach(r)}};function Zo(t){if(t._decimated){const e=t._data;delete t._decimated,delete t._data,Object.defineProperty(t,"data",{configurable:!0,enumerable:!0,writable:!0,value:e})}}function Jo(t){t.data.datasets.forEach((t=>{Zo(t)}))}var Qo={id:"decimation",defaults:{algorithm:"min-max",enabled:!1},beforeElementsUpdate:(t,e,i)=>{if(!i.enabled)return void Jo(t);const n=t.width;t.data.datasets.forEach(((e,o)=>{const{_data:a,indexAxis:r}=e,l=t.getDatasetMeta(o),h=a||e.data;if("y"===Pi([r,t.options.indexAxis]))return;if(!l.controller.supportsDecimation)return;const c=t.scales[l.xAxisID];if("linear"!==c.type&&"time"!==c.type)return;if(t.options.parsing)return;let{start:d,count:u}=function(t,e){const i=e.length;let s,n=0;const{iScale:o}=t,{min:a,max:r,minDefined:l,maxDefined:h}=o.getUserBounds();return l&&(n=J(it(e,o.axis,a).lo,0,i-1)),s=h?J(it(e,o.axis,r).hi+1,n,i)-n:i-n,{start:n,count:s}}(l,h);if(u<=(i.threshold||4*n))return void Zo(e);let f;switch(s(a)&&(e._data=h,delete e.data,Object.defineProperty(e,"data",{configurable:!0,enumerable:!0,get:function(){return this._decimated},set:function(t){this._data=t}})),i.algorithm){case"lttb":f=function(t,e,i,s,n){const o=n.samples||s;if(o>=i)return t.slice(e,e+i);const a=[],r=(i-2)/(o-2);let l=0;const h=e+i-1;let c,d,u,f,g,p=e;for(a[l++]=t[p],c=0;cu&&(u=f,d=t[s],g=s);a[l++]=d,p=g}return a[l++]=t[h],a}(h,d,u,n,i);break;case"min-max":f=function(t,e,i,n){let o,a,r,l,h,c,d,u,f,g,p=0,m=0;const b=[],x=e+i-1,_=t[e].x,y=t[x].x-_;for(o=e;og&&(g=l,d=o),p=(m*p+a.x)/++m;else{const i=o-1;if(!s(c)&&!s(d)){const e=Math.min(c,d),s=Math.max(c,d);e!==u&&e!==i&&b.push({...t[e],x:p}),s!==u&&s!==i&&b.push({...t[s],x:p})}o>0&&i!==u&&b.push(t[i]),b.push(a),h=e,m=0,f=g=l,c=d=u=o}}return b}(h,d,u,n);break;default:throw new Error(`Unsupported decimation algorithm '${i.algorithm}'`)}e._decimated=f}))},destroy(t){Jo(t)}};function ta(t,e,i,s){if(s)return;let n=e[t],o=i[t];return"angle"===t&&(n=G(n),o=G(o)),{property:t,start:n,end:o}}function ea(t,e,i){for(;e>t;e--){const t=i[e];if(!isNaN(t.x)&&!isNaN(t.y))break}return e}function ia(t,e,i,s){return t&&e?s(t[i],e[i]):t?t[i]:e?e[i]:0}function sa(t,e){let i=[],s=!1;return n(t)?(s=!0,i=t):i=function(t,e){const{x:i=null,y:s=null}=t||{},n=e.points,o=[];return e.segments.forEach((({start:t,end:e})=>{e=ea(t,e,n);const a=n[t],r=n[e];null!==s?(o.push({x:a.x,y:s}),o.push({x:r.x,y:s})):null!==i&&(o.push({x:i,y:a.y}),o.push({x:i,y:r.y}))})),o}(t,e),i.length?new no({points:i,options:{tension:0},_loop:s,_fullLoop:s}):null}function na(t){return t&&!1!==t.fill}function oa(t,e,i){let s=t[e].fill;const n=[e];let o;if(!i)return s;for(;!1!==s&&-1===n.indexOf(s);){if(!a(s))return s;if(o=t[s],!o)return!1;if(o.visible)return s;n.push(s),s=o.fill}return!1}function aa(t,e,i){const s=function(t){const e=t.options,i=e.fill;let s=l(i&&i.target,i);void 0===s&&(s=!!e.backgroundColor);if(!1===s||null===s)return!1;if(!0===s)return"origin";return s}(t);if(o(s))return!isNaN(s.value)&&s;let n=parseFloat(s);return a(n)&&Math.floor(n)===n?function(t,e,i,s){"-"!==t&&"+"!==t||(i=e+i);if(i===e||i<0||i>=s)return!1;return i}(s[0],e,n,i):["origin","start","end","stack","shape"].indexOf(s)>=0&&s}function ra(t,e,i){const s=[];for(let n=0;n=0;--e){const i=n[e].$filler;i&&(i.line.updateControlPoints(o,i.axis),s&&i.fill&&da(t.ctx,i,o))}},beforeDatasetsDraw(t,e,i){if("beforeDatasetsDraw"!==i.drawTime)return;const s=t.getSortedVisibleDatasetMetas();for(let e=s.length-1;e>=0;--e){const i=s[e].$filler;na(i)&&da(t.ctx,i,t.chartArea)}},beforeDatasetDraw(t,e,i){const s=e.meta.$filler;na(s)&&"beforeDatasetDraw"===i.drawTime&&da(t.ctx,s,t.chartArea)},defaults:{propagate:!0,drawTime:"beforeDatasetDraw"}};const ba=(t,e)=>{let{boxHeight:i=e,boxWidth:s=e}=t;return t.usePointStyle&&(i=Math.min(i,e),s=t.pointStyleWidth||Math.min(s,e)),{boxWidth:s,boxHeight:i,itemHeight:Math.max(e,i)}};class xa extends Hs{constructor(t){super(),this._added=!1,this.legendHitBoxes=[],this._hoveredItem=null,this.doughnutMode=!1,this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this.legendItems=void 0,this.columnSizes=void 0,this.lineWidths=void 0,this.maxHeight=void 0,this.maxWidth=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.height=void 0,this.width=void 0,this._margins=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e,i){this.maxWidth=t,this.maxHeight=e,this._margins=i,this.setDimensions(),this.buildLabels(),this.fit()}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=this._margins.left,this.right=this.width):(this.height=this.maxHeight,this.top=this._margins.top,this.bottom=this.height)}buildLabels(){const t=this.options.labels||{};let e=d(t.generateLabels,[this.chart],this)||[];t.filter&&(e=e.filter((e=>t.filter(e,this.chart.data)))),t.sort&&(e=e.sort(((e,i)=>t.sort(e,i,this.chart.data)))),this.options.reverse&&e.reverse(),this.legendItems=e}fit(){const{options:t,ctx:e}=this;if(!t.display)return void(this.width=this.height=0);const i=t.labels,s=Si(i.font),n=s.size,o=this._computeTitleHeight(),{boxWidth:a,itemHeight:r}=ba(i,n);let l,h;e.font=s.string,this.isHorizontal()?(l=this.maxWidth,h=this._fitRows(o,n,a,r)+10):(h=this.maxHeight,l=this._fitCols(o,s,a,r)+10),this.width=Math.min(l,t.maxWidth||this.maxWidth),this.height=Math.min(h,t.maxHeight||this.maxHeight)}_fitRows(t,e,i,s){const{ctx:n,maxWidth:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.lineWidths=[0],h=s+a;let c=t;n.textAlign="left",n.textBaseline="middle";let d=-1,u=-h;return this.legendItems.forEach(((t,f)=>{const g=i+e/2+n.measureText(t.text).width;(0===f||l[l.length-1]+g+2*a>o)&&(c+=h,l[l.length-(f>0?0:1)]=0,u+=h,d++),r[f]={left:0,top:u,row:d,width:g,height:s},l[l.length-1]+=g+a})),c}_fitCols(t,e,i,s){const{ctx:n,maxHeight:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.columnSizes=[],h=o-t;let c=a,d=0,u=0,f=0,g=0;return this.legendItems.forEach(((t,o)=>{const{itemWidth:p,itemHeight:m}=function(t,e,i,s,n){const o=function(t,e,i,s){let n=t.text;n&&"string"!=typeof n&&(n=n.reduce(((t,e)=>t.length>e.length?t:e)));return e+i.size/2+s.measureText(n).width}(s,t,e,i),a=function(t,e,i){let s=t;"string"!=typeof e.text&&(s=_a(e,i));return s}(n,s,e.lineHeight);return{itemWidth:o,itemHeight:a}}(i,e,n,t,s);o>0&&u+m+2*a>h&&(c+=d+a,l.push({width:d,height:u}),f+=d+a,g++,d=u=0),r[o]={left:f,top:u,col:g,width:p,height:m},d=Math.max(d,p),u+=m+a})),c+=d,l.push({width:d,height:u}),c}adjustHitBoxes(){if(!this.options.display)return;const t=this._computeTitleHeight(),{legendHitBoxes:e,options:{align:i,labels:{padding:s},rtl:n}}=this,o=Oi(n,this.left,this.width);if(this.isHorizontal()){let n=0,a=ft(i,this.left+s,this.right-this.lineWidths[n]);for(const r of e)n!==r.row&&(n=r.row,a=ft(i,this.left+s,this.right-this.lineWidths[n])),r.top+=this.top+t+s,r.left=o.leftForLtr(o.x(a),r.width),a+=r.width+s}else{let n=0,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height);for(const r of e)r.col!==n&&(n=r.col,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height)),r.top=a,r.left+=this.left+s,r.left=o.leftForLtr(o.x(r.left),r.width),a+=r.height+s}}isHorizontal(){return"top"===this.options.position||"bottom"===this.options.position}draw(){if(this.options.display){const t=this.ctx;Ie(t,this),this._draw(),ze(t)}}_draw(){const{options:t,columnSizes:e,lineWidths:i,ctx:s}=this,{align:n,labels:o}=t,a=ue.color,r=Oi(t.rtl,this.left,this.width),h=Si(o.font),{padding:c}=o,d=h.size,u=d/2;let f;this.drawTitle(),s.textAlign=r.textAlign("left"),s.textBaseline="middle",s.lineWidth=.5,s.font=h.string;const{boxWidth:g,boxHeight:p,itemHeight:m}=ba(o,d),b=this.isHorizontal(),x=this._computeTitleHeight();f=b?{x:ft(n,this.left+c,this.right-i[0]),y:this.top+c+x,line:0}:{x:this.left+c,y:ft(n,this.top+x+c,this.bottom-e[0].height),line:0},Ai(this.ctx,t.textDirection);const _=m+c;this.legendItems.forEach(((y,v)=>{s.strokeStyle=y.fontColor,s.fillStyle=y.fontColor;const M=s.measureText(y.text).width,w=r.textAlign(y.textAlign||(y.textAlign=o.textAlign)),k=g+u+M;let S=f.x,P=f.y;r.setWidth(this.width),b?v>0&&S+k+c>this.right&&(P=f.y+=_,f.line++,S=f.x=ft(n,this.left+c,this.right-i[f.line])):v>0&&P+_>this.bottom&&(S=f.x=S+e[f.line].width+c,f.line++,P=f.y=ft(n,this.top+x+c,this.bottom-e[f.line].height));if(function(t,e,i){if(isNaN(g)||g<=0||isNaN(p)||p<0)return;s.save();const n=l(i.lineWidth,1);if(s.fillStyle=l(i.fillStyle,a),s.lineCap=l(i.lineCap,"butt"),s.lineDashOffset=l(i.lineDashOffset,0),s.lineJoin=l(i.lineJoin,"miter"),s.lineWidth=n,s.strokeStyle=l(i.strokeStyle,a),s.setLineDash(l(i.lineDash,[])),o.usePointStyle){const a={radius:p*Math.SQRT2/2,pointStyle:i.pointStyle,rotation:i.rotation,borderWidth:n},l=r.xPlus(t,g/2);Ee(s,a,l,e+u,o.pointStyleWidth&&g)}else{const o=e+Math.max((d-p)/2,0),a=r.leftForLtr(t,g),l=wi(i.borderRadius);s.beginPath(),Object.values(l).some((t=>0!==t))?He(s,{x:a,y:o,w:g,h:p,radius:l}):s.rect(a,o,g,p),s.fill(),0!==n&&s.stroke()}s.restore()}(r.x(S),P,y),S=gt(w,S+g+u,b?S+k:this.right,t.rtl),function(t,e,i){Ne(s,i.text,t,e+m/2,h,{strikethrough:i.hidden,textAlign:r.textAlign(i.textAlign)})}(r.x(S),P,y),b)f.x+=k+c;else if("string"!=typeof y.text){const t=h.lineHeight;f.y+=_a(y,t)+c}else f.y+=_})),Ti(this.ctx,t.textDirection)}drawTitle(){const t=this.options,e=t.title,i=Si(e.font),s=ki(e.padding);if(!e.display)return;const n=Oi(t.rtl,this.left,this.width),o=this.ctx,a=e.position,r=i.size/2,l=s.top+r;let h,c=this.left,d=this.width;if(this.isHorizontal())d=Math.max(...this.lineWidths),h=this.top+l,c=ft(t.align,c,this.right-d);else{const e=this.columnSizes.reduce(((t,e)=>Math.max(t,e.height)),0);h=l+ft(t.align,this.top,this.bottom-e-t.labels.padding-this._computeTitleHeight())}const u=ft(a,c,c+d);o.textAlign=n.textAlign(ut(a)),o.textBaseline="middle",o.strokeStyle=e.color,o.fillStyle=e.color,o.font=i.string,Ne(o,e.text,u,h,i)}_computeTitleHeight(){const t=this.options.title,e=Si(t.font),i=ki(t.padding);return t.display?e.lineHeight+i.height:0}_getLegendItemAt(t,e){let i,s,n;if(tt(t,this.left,this.right)&&tt(e,this.top,this.bottom))for(n=this.legendHitBoxes,i=0;it.chart.options.color,boxWidth:40,padding:10,generateLabels(t){const e=t.data.datasets,{labels:{usePointStyle:i,pointStyle:s,textAlign:n,color:o,useBorderRadius:a,borderRadius:r}}=t.legend.options;return t._getSortedDatasetMetas().map((t=>{const l=t.controller.getStyle(i?0:void 0),h=ki(l.borderWidth);return{text:e[t.index].label,fillStyle:l.backgroundColor,fontColor:o,hidden:!t.visible,lineCap:l.borderCapStyle,lineDash:l.borderDash,lineDashOffset:l.borderDashOffset,lineJoin:l.borderJoinStyle,lineWidth:(h.width+h.height)/4,strokeStyle:l.borderColor,pointStyle:s||l.pointStyle,rotation:l.rotation,textAlign:n||l.textAlign,borderRadius:a&&(r||l.borderRadius),datasetIndex:t.index}}),this)}},title:{color:t=>t.chart.options.color,display:!1,position:"center",text:""}},descriptors:{_scriptable:t=>!t.startsWith("on"),labels:{_scriptable:t=>!["generateLabels","filter","sort"].includes(t)}}};class va extends Hs{constructor(t){super(),this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this._padding=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e){const i=this.options;if(this.left=0,this.top=0,!i.display)return void(this.width=this.height=this.right=this.bottom=0);this.width=this.right=t,this.height=this.bottom=e;const s=n(i.text)?i.text.length:1;this._padding=ki(i.padding);const o=s*Si(i.font).lineHeight+this._padding.height;this.isHorizontal()?this.height=o:this.width=o}isHorizontal(){const t=this.options.position;return"top"===t||"bottom"===t}_drawArgs(t){const{top:e,left:i,bottom:s,right:n,options:o}=this,a=o.align;let r,l,h,c=0;return this.isHorizontal()?(l=ft(a,i,n),h=e+t,r=n-i):("left"===o.position?(l=i+t,h=ft(a,s,e),c=-.5*C):(l=n-t,h=ft(a,e,s),c=.5*C),r=s-e),{titleX:l,titleY:h,maxWidth:r,rotation:c}}draw(){const t=this.ctx,e=this.options;if(!e.display)return;const i=Si(e.font),s=i.lineHeight/2+this._padding.top,{titleX:n,titleY:o,maxWidth:a,rotation:r}=this._drawArgs(s);Ne(t,e.text,0,0,i,{color:e.color,maxWidth:a,rotation:r,textAlign:ut(e.align),textBaseline:"middle",translation:[n,o]})}}var Ma={id:"title",_element:va,start(t,e,i){!function(t,e){const i=new va({ctx:t.ctx,options:e,chart:t});as.configure(t,i,e),as.addBox(t,i),t.titleBlock=i}(t,i)},stop(t){const e=t.titleBlock;as.removeBox(t,e),delete t.titleBlock},beforeUpdate(t,e,i){const s=t.titleBlock;as.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"bold"},fullSize:!0,padding:10,position:"top",text:"",weight:2e3},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const wa=new WeakMap;var ka={id:"subtitle",start(t,e,i){const s=new va({ctx:t.ctx,options:i,chart:t});as.configure(t,s,i),as.addBox(t,s),wa.set(t,s)},stop(t){as.removeBox(t,wa.get(t)),wa.delete(t)},beforeUpdate(t,e,i){const s=wa.get(t);as.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"normal"},fullSize:!0,padding:0,position:"top",text:"",weight:1500},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const Sa={average(t){if(!t.length)return!1;let e,i,s=0,n=0,o=0;for(e=0,i=t.length;e-1?t.split("\n"):t}function Ca(t,e){const{element:i,datasetIndex:s,index:n}=e,o=t.getDatasetMeta(s).controller,{label:a,value:r}=o.getLabelAndValue(n);return{chart:t,label:a,parsed:o.getParsed(n),raw:t.data.datasets[s].data[n],formattedValue:r,dataset:o.getDataset(),dataIndex:n,datasetIndex:s,element:i}}function Oa(t,e){const i=t.chart.ctx,{body:s,footer:n,title:o}=t,{boxWidth:a,boxHeight:r}=e,l=Si(e.bodyFont),h=Si(e.titleFont),c=Si(e.footerFont),d=o.length,f=n.length,g=s.length,p=ki(e.padding);let m=p.height,b=0,x=s.reduce(((t,e)=>t+e.before.length+e.lines.length+e.after.length),0);if(x+=t.beforeBody.length+t.afterBody.length,d&&(m+=d*h.lineHeight+(d-1)*e.titleSpacing+e.titleMarginBottom),x){m+=g*(e.displayColors?Math.max(r,l.lineHeight):l.lineHeight)+(x-g)*l.lineHeight+(x-1)*e.bodySpacing}f&&(m+=e.footerMarginTop+f*c.lineHeight+(f-1)*e.footerSpacing);let _=0;const y=function(t){b=Math.max(b,i.measureText(t).width+_)};return i.save(),i.font=h.string,u(t.title,y),i.font=l.string,u(t.beforeBody.concat(t.afterBody),y),_=e.displayColors?a+2+e.boxPadding:0,u(s,(t=>{u(t.before,y),u(t.lines,y),u(t.after,y)})),_=0,i.font=c.string,u(t.footer,y),i.restore(),b+=p.width,{width:b,height:m}}function Aa(t,e,i,s){const{x:n,width:o}=i,{width:a,chartArea:{left:r,right:l}}=t;let h="center";return"center"===s?h=n<=(r+l)/2?"left":"right":n<=o/2?h="left":n>=a-o/2&&(h="right"),function(t,e,i,s){const{x:n,width:o}=s,a=i.caretSize+i.caretPadding;return"left"===t&&n+o+a>e.width||"right"===t&&n-o-a<0||void 0}(h,t,e,i)&&(h="center"),h}function Ta(t,e,i){const s=i.yAlign||e.yAlign||function(t,e){const{y:i,height:s}=e;return it.height-s/2?"bottom":"center"}(t,i);return{xAlign:i.xAlign||e.xAlign||Aa(t,e,i,s),yAlign:s}}function La(t,e,i,s){const{caretSize:n,caretPadding:o,cornerRadius:a}=t,{xAlign:r,yAlign:l}=i,h=n+o,{topLeft:c,topRight:d,bottomLeft:u,bottomRight:f}=wi(a);let g=function(t,e){let{x:i,width:s}=t;return"right"===e?i-=s:"center"===e&&(i-=s/2),i}(e,r);const p=function(t,e,i){let{y:s,height:n}=t;return"top"===e?s+=i:s-="bottom"===e?n+i:n/2,s}(e,l,h);return"center"===l?"left"===r?g+=h:"right"===r&&(g-=h):"left"===r?g-=Math.max(c,u)+n:"right"===r&&(g+=Math.max(d,f)+n),{x:J(g,0,s.width-e.width),y:J(p,0,s.height-e.height)}}function Ea(t,e,i){const s=ki(i.padding);return"center"===e?t.x+t.width/2:"right"===e?t.x+t.width-s.right:t.x+s.left}function Ra(t){return Pa([],Da(t))}function Ia(t,e){const i=e&&e.dataset&&e.dataset.tooltip&&e.dataset.tooltip.callbacks;return i?t.override(i):t}const za={beforeTitle:e,title(t){if(t.length>0){const e=t[0],i=e.chart.data.labels,s=i?i.length:0;if(this&&this.options&&"dataset"===this.options.mode)return e.dataset.label||"";if(e.label)return e.label;if(s>0&&e.dataIndex{const e={before:[],lines:[],after:[]},n=Ia(i,t);Pa(e.before,Da(Fa(n,"beforeLabel",this,t))),Pa(e.lines,Fa(n,"label",this,t)),Pa(e.after,Da(Fa(n,"afterLabel",this,t))),s.push(e)})),s}getAfterBody(t,e){return Ra(Fa(e.callbacks,"afterBody",this,t))}getFooter(t,e){const{callbacks:i}=e,s=Fa(i,"beforeFooter",this,t),n=Fa(i,"footer",this,t),o=Fa(i,"afterFooter",this,t);let a=[];return a=Pa(a,Da(s)),a=Pa(a,Da(n)),a=Pa(a,Da(o)),a}_createItems(t){const e=this._active,i=this.chart.data,s=[],n=[],o=[];let a,r,l=[];for(a=0,r=e.length;at.filter(e,s,n,i)))),t.itemSort&&(l=l.sort(((e,s)=>t.itemSort(e,s,i)))),u(l,(e=>{const i=Ia(t.callbacks,e);s.push(Fa(i,"labelColor",this,e)),n.push(Fa(i,"labelPointStyle",this,e)),o.push(Fa(i,"labelTextColor",this,e))})),this.labelColors=s,this.labelPointStyles=n,this.labelTextColors=o,this.dataPoints=l,l}update(t,e){const i=this.options.setContext(this.getContext()),s=this._active;let n,o=[];if(s.length){const t=Sa[i.position].call(this,s,this._eventPosition);o=this._createItems(i),this.title=this.getTitle(o,i),this.beforeBody=this.getBeforeBody(o,i),this.body=this.getBody(o,i),this.afterBody=this.getAfterBody(o,i),this.footer=this.getFooter(o,i);const e=this._size=Oa(this,i),a=Object.assign({},t,e),r=Ta(this.chart,i,a),l=La(i,a,r,this.chart);this.xAlign=r.xAlign,this.yAlign=r.yAlign,n={opacity:1,x:l.x,y:l.y,width:e.width,height:e.height,caretX:t.x,caretY:t.y}}else 0!==this.opacity&&(n={opacity:0});this._tooltipItems=o,this.$context=void 0,n&&this._resolveAnimations().update(this,n),t&&i.external&&i.external.call(this,{chart:this.chart,tooltip:this,replay:e})}drawCaret(t,e,i,s){const n=this.getCaretPosition(t,i,s);e.lineTo(n.x1,n.y1),e.lineTo(n.x2,n.y2),e.lineTo(n.x3,n.y3)}getCaretPosition(t,e,i){const{xAlign:s,yAlign:n}=this,{caretSize:o,cornerRadius:a}=i,{topLeft:r,topRight:l,bottomLeft:h,bottomRight:c}=wi(a),{x:d,y:u}=t,{width:f,height:g}=e;let p,m,b,x,_,y;return"center"===n?(_=u+g/2,"left"===s?(p=d,m=p-o,x=_+o,y=_-o):(p=d+f,m=p+o,x=_-o,y=_+o),b=p):(m="left"===s?d+Math.max(r,h)+o:"right"===s?d+f-Math.max(l,c)-o:this.caretX,"top"===n?(x=u,_=x-o,p=m-o,b=m+o):(x=u+g,_=x+o,p=m+o,b=m-o),y=x),{x1:p,x2:m,x3:b,y1:x,y2:_,y3:y}}drawTitle(t,e,i){const s=this.title,n=s.length;let o,a,r;if(n){const l=Oi(i.rtl,this.x,this.width);for(t.x=Ea(this,i.titleAlign,i),e.textAlign=l.textAlign(i.titleAlign),e.textBaseline="middle",o=Si(i.titleFont),a=i.titleSpacing,e.fillStyle=i.titleColor,e.font=o.string,r=0;r0!==t))?(t.beginPath(),t.fillStyle=n.multiKeyBackground,He(t,{x:e,y:g,w:h,h:l,radius:r}),t.fill(),t.stroke(),t.fillStyle=a.backgroundColor,t.beginPath(),He(t,{x:i,y:g+1,w:h-2,h:l-2,radius:r}),t.fill()):(t.fillStyle=n.multiKeyBackground,t.fillRect(e,g,h,l),t.strokeRect(e,g,h,l),t.fillStyle=a.backgroundColor,t.fillRect(i,g+1,h-2,l-2))}t.fillStyle=this.labelTextColors[i]}drawBody(t,e,i){const{body:s}=this,{bodySpacing:n,bodyAlign:o,displayColors:a,boxHeight:r,boxWidth:l,boxPadding:h}=i,c=Si(i.bodyFont);let d=c.lineHeight,f=0;const g=Oi(i.rtl,this.x,this.width),p=function(i){e.fillText(i,g.x(t.x+f),t.y+d/2),t.y+=d+n},m=g.textAlign(o);let b,x,_,y,v,M,w;for(e.textAlign=o,e.textBaseline="middle",e.font=c.string,t.x=Ea(this,m,i),e.fillStyle=i.bodyColor,u(this.beforeBody,p),f=a&&"right"!==m?"center"===o?l/2+h:l+2+h:0,y=0,M=s.length;y0&&e.stroke()}_updateAnimationTarget(t){const e=this.chart,i=this.$animations,s=i&&i.x,n=i&&i.y;if(s||n){const i=Sa[t.position].call(this,this._active,this._eventPosition);if(!i)return;const o=this._size=Oa(this,t),a=Object.assign({},i,this._size),r=Ta(e,t,a),l=La(t,a,r,e);s._to===l.x&&n._to===l.y||(this.xAlign=r.xAlign,this.yAlign=r.yAlign,this.width=o.width,this.height=o.height,this.caretX=i.x,this.caretY=i.y,this._resolveAnimations().update(this,l))}}_willRender(){return!!this.opacity}draw(t){const e=this.options.setContext(this.getContext());let i=this.opacity;if(!i)return;this._updateAnimationTarget(e);const s={width:this.width,height:this.height},n={x:this.x,y:this.y};i=Math.abs(i)<.001?0:i;const o=ki(e.padding),a=this.title.length||this.beforeBody.length||this.body.length||this.afterBody.length||this.footer.length;e.enabled&&a&&(t.save(),t.globalAlpha=i,this.drawBackground(n,t,s,e),Ai(t,e.textDirection),n.y+=o.top,this.drawTitle(n,t,e),this.drawBody(n,t,e),this.drawFooter(n,t,e),Ti(t,e.textDirection),t.restore())}getActiveElements(){return this._active||[]}setActiveElements(t,e){const i=this._active,s=t.map((({datasetIndex:t,index:e})=>{const i=this.chart.getDatasetMeta(t);if(!i)throw new Error("Cannot find a dataset at index "+t);return{datasetIndex:t,element:i.data[e],index:e}})),n=!f(i,s),o=this._positionChanged(s,e);(n||o)&&(this._active=s,this._eventPosition=e,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(t,e,i=!0){if(e&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;const s=this.options,n=this._active||[],o=this._getActiveElements(t,n,e,i),a=this._positionChanged(o,t),r=e||!f(o,n)||a;return r&&(this._active=o,(s.enabled||s.external)&&(this._eventPosition={x:t.x,y:t.y},this.update(!0,e))),r}_getActiveElements(t,e,i,s){const n=this.options;if("mouseout"===t.type)return[];if(!s)return e;const o=this.chart.getElementsAtEventForMode(t,n.mode,n,i);return n.reverse&&o.reverse(),o}_positionChanged(t,e){const{caretX:i,caretY:s,options:n}=this,o=Sa[n.position].call(this,t,e);return!1!==o&&(i!==o.x||s!==o.y)}}var Ba={id:"tooltip",_element:Va,positioners:Sa,afterInit(t,e,i){i&&(t.tooltip=new Va({chart:t,options:i}))},beforeUpdate(t,e,i){t.tooltip&&t.tooltip.initialize(i)},reset(t,e,i){t.tooltip&&t.tooltip.initialize(i)},afterDraw(t){const e=t.tooltip;if(e&&e._willRender()){const i={tooltip:e};if(!1===t.notifyPlugins("beforeTooltipDraw",{...i,cancelable:!0}))return;e.draw(t.ctx),t.notifyPlugins("afterTooltipDraw",i)}},afterEvent(t,e){if(t.tooltip){const i=e.replay;t.tooltip.handleEvent(e.event,i,e.inChartArea)&&(e.changed=!0)}},defaults:{enabled:!0,external:null,position:"average",backgroundColor:"rgba(0,0,0,0.8)",titleColor:"#fff",titleFont:{weight:"bold"},titleSpacing:2,titleMarginBottom:6,titleAlign:"left",bodyColor:"#fff",bodySpacing:2,bodyFont:{},bodyAlign:"left",footerColor:"#fff",footerSpacing:2,footerMarginTop:6,footerFont:{weight:"bold"},footerAlign:"left",padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(t,e)=>e.bodyFont.size,boxWidth:(t,e)=>e.bodyFont.size,multiKeyBackground:"#fff",displayColors:!0,boxPadding:0,borderColor:"rgba(0,0,0,0)",borderWidth:0,animation:{duration:400,easing:"easeOutQuart"},animations:{numbers:{type:"number",properties:["x","y","width","height","caretX","caretY"]},opacity:{easing:"linear",duration:200}},callbacks:za},defaultRoutes:{bodyFont:"font",footerFont:"font",titleFont:"font"},descriptors:{_scriptable:t=>"filter"!==t&&"itemSort"!==t&&"external"!==t,_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:"animation"}},additionalOptionScopes:["interaction"]};return An.register(Yn,jo,fo,t),An.helpers={...Wi},An._adapters=Rn,An.Animation=Cs,An.Animations=Os,An.animator=xt,An.controllers=en.controllers.items,An.DatasetController=Ns,An.Element=Hs,An.elements=fo,An.Interaction=Xi,An.layouts=as,An.platforms=Ss,An.Scale=Js,An.Ticks=ae,Object.assign(An,Yn,jo,fo,t,Ss),An.Chart=An,"undefined"!=typeof window&&(window.Chart=An),An})); +//# sourceMappingURL=chart.umd.js.map diff --git a/web/public/js/date-filter.js b/web/public/js/date-filter.js new file mode 100644 index 0000000..6bbe27f --- /dev/null +++ b/web/public/js/date-filter.js @@ -0,0 +1,513 @@ +/** + * Date Filter Component + * Provides a reusable date filtering UI using seasons data + */ + +class DateFilter { + constructor(containerId, options = {}) { + this.container = document.getElementById(containerId); + if (!this.container) { + console.error(`[Date Filter] Container #${containerId} not found`); + return; + } + + this.options = { + onFilterChange: options.onFilterChange || (() => {}), + includeCustomRange: options.includeCustomRange !== false, + includeCumulative: options.includeCumulative !== false, + defaultFilter: options.defaultFilter || 'all-time', + ...options + }; + + this.seasonsFilter = window.seasonsFilter; + this.currentFilter = { + type: 'all-time', // 'all-time', 'season', 'week', 'custom', 'cumulative' + startDate: null, + endDate: null, + season: null, + week: null + }; + + this.init(); + } + + t(key, params = {}) { + let value = (window.__t && window.__t(key)) || key; + Object.entries(params).forEach(([name, replacement]) => { + value = value.replace(`{${name}}`, replacement); + }); + return value; + } + + async init() { + // Load seasons data + await this.seasonsFilter.loadSeasons(); + + // Render the UI + this.render(); + + // Apply default filter + if (this.options.defaultFilter !== 'all-time') { + this.applyPresetFilter(this.options.defaultFilter); + } + } + + render() { + const html = ` +
+
+ +
+ + + + ${this.options.includeCumulative ? ` + + ` : ''} + ${this.options.includeCustomRange ? ` + + ` : ''} +
+ + + + + + + + + + + + +
+
+ `; + + this.container.innerHTML = html; + this.attachEventListeners(); + this.populateSelectors(); + } + + attachEventListeners() { + // Filter type buttons + const filterButtons = this.container.querySelectorAll('[data-filter-type]'); + filterButtons.forEach(btn => { + btn.addEventListener('click', (e) => { + const filterType = e.currentTarget.dataset.filterType; + this.showFilterSection(filterType); + }); + }); + + // Season selection + const seasonSelect = this.container.querySelector('#season-select'); + if (seasonSelect) { + seasonSelect.addEventListener('change', (e) => { + const weekSelect = this.container.querySelector('#week-select'); + if (e.target.value) { + this.seasonsFilter.populateWeekSelect(weekSelect, e.target.value, true); + weekSelect.disabled = false; + } else { + weekSelect.disabled = true; + weekSelect.innerHTML = ``; + } + }); + } + + // Cumulative season selection + const cumulativeSeasonSelect = this.container.querySelector('#cumulative-season-select'); + if (cumulativeSeasonSelect) { + cumulativeSeasonSelect.addEventListener('change', (e) => { + const weekSelect = this.container.querySelector('#cumulative-week-select'); + if (e.target.value) { + this.seasonsFilter.populateWeekSelect(weekSelect, e.target.value, false); + weekSelect.disabled = false; + } else { + weekSelect.disabled = true; + weekSelect.innerHTML = ``; + } + }); + } + + // Apply buttons + const applySeasonBtn = this.container.querySelector('#apply-season-filter'); + if (applySeasonBtn) { + applySeasonBtn.addEventListener('click', () => this.applySeasonFilter()); + } + + const applyCumulativeBtn = this.container.querySelector('#apply-cumulative-filter'); + if (applyCumulativeBtn) { + applyCumulativeBtn.addEventListener('click', () => this.applyCumulativeFilter()); + } + + const applyCustomBtn = this.container.querySelector('#apply-custom-filter'); + if (applyCustomBtn) { + applyCustomBtn.addEventListener('click', () => this.applyCustomFilter()); + } + + // Clear filter + const clearBtn = this.container.querySelector('#clear-filter'); + if (clearBtn) { + clearBtn.addEventListener('click', () => this.clearFilter()); + } + } + + populateSelectors() { + const seasonSelect = this.container.querySelector('#season-select'); + const cumulativeSeasonSelect = this.container.querySelector('#cumulative-season-select'); + + if (seasonSelect) { + this.seasonsFilter.populateSeasonSelect(seasonSelect, false); + } + + if (cumulativeSeasonSelect) { + this.seasonsFilter.populateSeasonSelect(cumulativeSeasonSelect, false); + } + } + + showFilterSection(filterType) { + // Update button states + const buttons = this.container.querySelectorAll('[data-filter-type]'); + buttons.forEach(btn => { + if (btn.dataset.filterType === filterType) { + btn.classList.add('filter-button-active'); + btn.classList.remove('filter-button'); + } else { + btn.classList.remove('filter-button-active'); + btn.classList.add('filter-button'); + } + }); + + // Hide all sections + this.container.querySelector('#season-selector')?.classList.add('hidden'); + this.container.querySelector('#cumulative-selector')?.classList.add('hidden'); + this.container.querySelector('#custom-range-selector')?.classList.add('hidden'); + + // Show appropriate section + switch (filterType) { + case 'all-time': + this.applyAllTimeFilter(); + break; + case 'current-season': + this.applyCurrentSeasonFilter(); + break; + case 'season': + this.container.querySelector('#season-selector')?.classList.remove('hidden'); + break; + case 'cumulative': + this.container.querySelector('#cumulative-selector')?.classList.remove('hidden'); + break; + case 'custom': + this.container.querySelector('#custom-range-selector')?.classList.remove('hidden'); + break; + } + } + + applyAllTimeFilter() { + this.currentFilter = { + type: 'all-time', + startDate: null, + endDate: null, + season: null, + week: null + }; + this.updateFilterDisplay(this.t('dateFilter.allTimeStatistics')); + this.options.onFilterChange(this.currentFilter); + } + + applyCurrentSeasonFilter() { + const currentSeasonInfo = this.seasonsFilter.getCurrentSeason(); + if (!currentSeasonInfo) { + console.error('[Date Filter] No current season found'); + return; + } + + const dateRange = this.seasonsFilter.getSeasonDateRange(currentSeasonInfo.name); + if (!dateRange) return; + + this.currentFilter = { + type: 'season', + startDate: dateRange.startDate, + endDate: dateRange.endDate, + season: currentSeasonInfo.name, + week: null + }; + + this.updateFilterDisplay(this.t('dateFilter.currentSeasonValue', { season: currentSeasonInfo.name })); + this.options.onFilterChange(this.currentFilter); + } + + applySeasonFilter() { + const seasonSelect = this.container.querySelector('#season-select'); + const weekSelect = this.container.querySelector('#week-select'); + + const seasonName = seasonSelect.value; + if (!seasonName) { + alert(this.t('dateFilter.alertSelectSeason')); + return; + } + + const weekValue = weekSelect.value; + let dateRange; + let description; + + if (weekValue === 'all') { + // Entire season + dateRange = this.seasonsFilter.getSeasonDateRange(seasonName); + description = this.t('dateFilter.seasonValue', { season: seasonName }); + this.currentFilter = { + type: 'season', + startDate: dateRange.startDate, + endDate: dateRange.endDate, + season: seasonName, + week: null + }; + } else { + // Specific week + const weekNumber = weekValue === 'final' ? null : parseInt(weekValue); + dateRange = this.seasonsFilter.getWeekDateRange(seasonName, weekNumber); + const season = this.seasonsFilter.getSeason(seasonName); + const week = season.weeks.find(w => + (w.weekNumber === weekNumber) || (weekValue === 'final' && w.weekNumber === null) + ); + description = `${seasonName} - ${week.displayName}`; + this.currentFilter = { + type: 'week', + startDate: dateRange.startDate, + endDate: dateRange.endDate, + season: seasonName, + week: weekNumber + }; + } + + this.updateFilterDisplay(description); + this.options.onFilterChange(this.currentFilter); + } + + applyCumulativeFilter() { + const seasonSelect = this.container.querySelector('#cumulative-season-select'); + const weekSelect = this.container.querySelector('#cumulative-week-select'); + + const seasonName = seasonSelect.value; + const weekValue = weekSelect.value; + + if (!seasonName || !weekValue) { + alert(this.t('dateFilter.alertSelectSeasonWeek')); + return; + } + + const weekNumber = weekValue === 'final' ? null : parseInt(weekValue); + const dateRange = this.seasonsFilter.getWeekDateRange(seasonName, weekNumber); + + if (!dateRange) return; + + const season = this.seasonsFilter.getSeason(seasonName); + const week = season.weeks.find(w => + (w.weekNumber === weekNumber) || (weekValue === 'final' && w.weekNumber === null) + ); + + this.currentFilter = { + type: 'cumulative', + startDate: null, // No start date for cumulative + endDate: dateRange.endDate, + season: seasonName, + week: weekNumber + }; + + this.updateFilterDisplay(this.t('dateFilter.cumulativeValue', { season: seasonName, week: week.displayName })); + this.options.onFilterChange(this.currentFilter); + } + + applyCustomFilter() { + const startDateInput = this.container.querySelector('#start-date'); + const endDateInput = this.container.querySelector('#end-date'); + + const startDate = startDateInput.value ? new Date(startDateInput.value) : null; + const endDate = endDateInput.value ? new Date(endDateInput.value) : null; + + if (!startDate && !endDate) { + alert(this.t('dateFilter.alertSelectDate')); + return; + } + + if (startDate && endDate && startDate > endDate) { + alert(this.t('dateFilter.alertStartBeforeEnd')); + return; + } + + this.currentFilter = { + type: 'custom', + startDate: startDate, + endDate: endDate, + season: null, + week: null + }; + + let description = this.t('dateFilter.customRangePrefix') + ' '; + if (startDate && endDate) { + description += `${startDate.toLocaleDateString()} - ${endDate.toLocaleDateString()}`; + } else if (startDate) { + description += this.t('dateFilter.fromDate', { date: startDate.toLocaleDateString() }); + } else { + description += this.t('dateFilter.upToDate', { date: endDate.toLocaleDateString() }); + } + + this.updateFilterDisplay(description); + this.options.onFilterChange(this.currentFilter); + } + + clearFilter() { + this.applyAllTimeFilter(); + } + + updateFilterDisplay(description) { + const filterDisplay = this.container.querySelector('#current-filter-display'); + const filterDescription = this.container.querySelector('#filter-description'); + + if (this.currentFilter.type === 'all-time') { + filterDisplay?.classList.add('hidden'); + } else { + filterDisplay?.classList.remove('hidden'); + if (filterDescription) { + filterDescription.textContent = description; + } + } + } + + applyPresetFilter(preset) { + // Apply a preset filter (e.g., 'current-season', 'all-time') + this.showFilterSection(preset); + } + + getCurrentFilter() { + return this.currentFilter; + } + + getAPIQueryParams() { + // Generate query parameters for API requests + const params = new URLSearchParams(); + + if (this.currentFilter.type === 'all-time') { + // No params needed for all-time + return ''; + } + + if (this.currentFilter.startDate) { + const startDateISO = this.currentFilter.startDate.toISOString(); + params.append('start_date', startDateISO); + console.log('[Date Filter] Start Date:', this.currentFilter.startDate, '→', startDateISO); + } + + if (this.currentFilter.endDate) { + const endDate = new Date(this.currentFilter.endDate); + endDate.setHours(23, 59, 59, 999); + const endDateISO = endDate.toISOString(); + params.append('end_date', endDateISO); + console.log('[Date Filter] End Date:', endDate, '→', endDateISO); + } + + if (this.currentFilter.season) { + params.append('season', this.currentFilter.season); + } + + if (this.currentFilter.week !== null) { + params.append('week', this.currentFilter.week); + } + + const queryString = params.toString() ? '?' + params.toString() : ''; + console.log('[Date Filter] API Query Params:', queryString); + return queryString; + } +} + +// Export for use in other scripts +window.DateFilter = DateFilter; diff --git a/web/public/js/header-search.js b/web/public/js/header-search.js new file mode 100644 index 0000000..165fe4f --- /dev/null +++ b/web/public/js/header-search.js @@ -0,0 +1,82 @@ +// Universal Header Search Functionality +let headerSearchTimeout; + +async function headerSearchPlayers() { + const searchTerm = document.getElementById('headerPlayerSearch').value.trim(); + const resultsDiv = document.getElementById('headerSearchResults'); + + // Clear previous timeout + clearTimeout(headerSearchTimeout); + + if (searchTerm.length < 2) { + resultsDiv.classList.remove('show'); + return; + } + + // Debounce search (increased to 500ms for better performance) + headerSearchTimeout = setTimeout(async () => { + try { + const response = await window.apiClient.searchPlayers(searchTerm); + displayHeaderSearchResults(response.results, resultsDiv); + } catch (error) { + console.error('Search error:', error); + resultsDiv.innerHTML = '
' + (window.__t ? __t('js.searchError') : 'Search error. Please try again.') + '
'; + resultsDiv.classList.add('show'); + } + }, 500); +} + +function displayHeaderSearchResults(players, resultsDiv) { + if (!players || players.length === 0) { + resultsDiv.innerHTML = '
' + (window.__t ? __t('js.noPlayersFound') : 'No players found') + '
'; + } else { + resultsDiv.innerHTML = players.slice(0, 8).map(player => { + const squadronTag = player.squadron_name ? `${escapeHtml(player.squadron_name)}` : ''; + return ` +
+
${squadronTag}${escapeHtml(player.nick)}
+
${formatHeaderNumber(player.total_kills || 0)} ${window.__t ? __t('js.killsSuffix') : 'kills'} • ${(player.win_rate || 0).toFixed(1)}% ${window.__t ? __t('js.winRateSuffix') : 'win rate'}
+
+ `; + }).join(''); + } + resultsDiv.classList.add('show'); +} + +function navigateToPlayer(uid) { + window.location.href = `/players/${uid}`; +} + +function hideHeaderSearchResults() { + const el = document.getElementById('headerSearchResults'); + if (el) el.classList.remove('show'); +} + +function handleHeaderKeydown(event) { + if (event.key === 'Escape') { + hideHeaderSearchResults(); + document.getElementById('headerPlayerSearch').blur(); + } +} + +function formatHeaderNumber(num) { + if (num >= 1000000) { + return (num / 1000000).toFixed(1) + 'M'; + } else if (num >= 1000) { + return (num / 1000).toFixed(1) + 'K'; + } + return num.toString(); +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// Hide search results when clicking outside +document.addEventListener('click', function(event) { + if (!event.target.closest('.nav-search')) { + hideHeaderSearchResults(); + } +}); diff --git a/web/public/js/main.js b/web/public/js/main.js new file mode 100644 index 0000000..660ba43 --- /dev/null +++ b/web/public/js/main.js @@ -0,0 +1,483 @@ +// DOM Content Loaded +document.addEventListener('DOMContentLoaded', function() { + // Mobile Navigation + const hamburger = document.querySelector('.hamburger'); + const navMenu = document.querySelector('.nav-menu'); + const navLinks = document.querySelectorAll('.nav-link'); + + if (hamburger && navMenu) { + hamburger.addEventListener('click', function() { + hamburger.classList.toggle('active'); + navMenu.classList.toggle('active'); + + // Prevent body scroll when menu is open + if (navMenu.classList.contains('active')) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + }); + + // Keyboard navigation support + hamburger.addEventListener('keydown', function(e) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + hamburger.click(); + } + }); + + // Close mobile menu when clicking on nav links + navLinks.forEach(link => { + link.addEventListener('click', function() { + hamburger.classList.remove('active'); + navMenu.classList.remove('active'); + document.body.style.overflow = ''; + }); + }); + + // Close mobile menu when clicking outside + document.addEventListener('click', function(e) { + if (hamburger && navMenu && !hamburger.contains(e.target) && !navMenu.contains(e.target)) { + hamburger.classList.remove('active'); + navMenu.classList.remove('active'); + document.body.style.overflow = ''; + } + }); + + // Close mobile menu on window resize if desktop size + window.addEventListener('resize', function() { + if (window.innerWidth > 768) { + hamburger.classList.remove('active'); + navMenu.classList.remove('active'); + document.body.style.overflow = ''; + } + }); + } + + // Smooth Scrolling for Navigation Links + document.querySelectorAll('a[href^="#"]').forEach(anchor => { + anchor.addEventListener('click', function (e) { + e.preventDefault(); + const target = document.querySelector(this.getAttribute('href')); + if (target) { + target.scrollIntoView({ + behavior: 'smooth', + block: 'start' + }); + } + }); + }); + + // Button Click Handlers + const inviteButtons = [ + 'inviteBtn', + 'inviteBtnMobile', + 'heroInviteBtn', + 'ctaInviteBtn', + 'footerInviteBtn', + 'freePlanInviteBtn' + ]; + + const supportButtons = [ + 'supportBtn', + 'heroSupportBtn', + 'footerSupportBtn' + ]; + + // Handle Invite Button Clicks + inviteButtons.forEach(buttonId => { + const button = document.getElementById(buttonId); + if (button) { + button.addEventListener('click', function() { + handleInviteClick(); + }); + } + }); + + // Handle Support Button Clicks + supportButtons.forEach(buttonId => { + const button = document.getElementById(buttonId); + if (button) { + button.addEventListener('click', function() { + handleSupportClick(); + }); + } + }); + + // Fetch and Update Stats + updateStats(); + + // Update stats every 30 seconds + setInterval(updateStats, 30000); + + // New nav mobile menu toggle + const mobileMenuBtn = document.getElementById('mobileMenuBtn'); + const mobileMenu = document.getElementById('mobileMenu'); + if (mobileMenuBtn && mobileMenu) { + mobileMenuBtn.addEventListener('click', () => { + mobileMenu.classList.toggle('hidden'); + }); + } + + // Navbar Scroll Effect + const navbar = document.querySelector('.navbar'); + if (navbar) { + window.addEventListener('scroll', function() { + if (window.scrollY > 100) { + navbar.style.background = 'rgba(13, 14, 15, 0.98)'; + } else { + navbar.style.background = 'rgba(13, 14, 15, 1)'; + } + }); + } + + // Animate Numbers on Stats Section Intersection + const statsSection = document.querySelector('#stats'); + if (statsSection) { + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + animateNumbers(); + observer.unobserve(entry.target); + } + }); + }, { threshold: 0.5 }); + + observer.observe(statsSection); + } + + // Handle touch events for better mobile experience + let touchStartY = 0; + let touchEndY = 0; + + document.addEventListener('touchstart', function(e) { + touchStartY = e.changedTouches[0].screenY; + }, { passive: true }); + + document.addEventListener('touchend', function(e) { + touchEndY = e.changedTouches[0].screenY; + handleSwipe(); + }, { passive: true }); + + function handleSwipe() { + const swipeThreshold = 50; + const diff = touchStartY - touchEndY; + + // Close mobile menu on upward swipe when menu is open + if (navMenu && hamburger && navMenu.classList.contains('active') && diff > swipeThreshold) { + hamburger.classList.remove('active'); + navMenu.classList.remove('active'); + document.body.style.overflow = ''; + } + } +}); + +// Handle Invite Button Click +async function handleInviteClick() { + try { + // Direct invite URL for Toothless SQB Bot + const inviteUrl = 'https://discord.com/oauth2/authorize?client_id=1254679514466877540&permissions=2048&scope=bot%20applications.commands'; + + window.open(inviteUrl, '_blank'); + showNotification(window.__t ? __t('js.openingDiscordInvite') : 'Opening Discord invite!', 'success'); + } catch (error) { + console.error('Error opening invite link:', error); + showNotification(window.__t ? __t('js.errorOpeningInvite') : 'Error opening invite link. Please try again later.', 'error'); + + // Fallback - same URL but ensure it opens + const fallbackUrl = 'https://discord.com/oauth2/authorize?client_id=1254679514466877540&permissions=2048&scope=bot%20applications.commands'; + window.open(fallbackUrl, '_blank'); + } +} + +// Handle Support Button Click +async function handleSupportClick() { + try { + showNotification(window.__t ? __t('js.gettingSupportLink') : 'Getting support server link...', 'info'); + + const response = await fetch('/api/support'); + const data = await response.json(); + + if (data.supportUrl) { + window.open(data.supportUrl, '_blank'); + showNotification(window.__t ? __t('js.openingSupportServer') : 'Opening support server!', 'success'); + } else { + throw new Error('No support URL received'); + } + } catch (error) { + console.error('Error getting support link:', error); + showNotification(window.__t ? __t('js.errorGettingSupport') : 'Error getting support link. Please try again later.', 'error'); + + // Fallback - Real support server invite + const fallbackUrl = 'https://discord.gg/BCvkK8JhPe'; + window.open(fallbackUrl, '_blank'); + } +} + +// Update Stats from API +async function updateStats() { + try { + let stats; + + // Check if API client is available, if not use fallback + if (window.apiClient && window.apiClient.getStats) { + stats = await window.apiClient.getStats(); + } else { + // Fallback for pages without API client + const response = await fetch('/api/stats'); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + stats = await response.json(); + } + + // Update server count + const serverCountEl = document.getElementById('serverCount'); + if (serverCountEl && stats.servers) { + serverCountEl.textContent = formatNumber(stats.servers); + } + + // Update user count + const userCountEl = document.getElementById('userCount'); + if (userCountEl && stats.users) { + userCountEl.textContent = formatNumber(stats.users) + '+'; + } + + // Update command count + const commandCountEl = document.getElementById('commandCount'); + if (commandCountEl && stats.commands) { + commandCountEl.textContent = stats.commands + '+'; + } + + } catch (error) { + // Silently ignore — stat counters are non-critical and failures flash an annoying banner + } +} + +// Format numbers with commas +function formatNumber(num) { + return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); +} + +// Animate numbers when stats section comes into view +function animateNumbers() { + const numbers = document.querySelectorAll('.stat-number'); + + numbers.forEach(number => { + const target = parseInt(number.textContent.replace(/[^0-9]/g, '')); + const duration = 2000; + const start = performance.now(); + + function updateNumber(currentTime) { + const elapsed = currentTime - start; + const progress = Math.min(elapsed / duration, 1); + + // Easing function + const easeOutQuart = 1 - Math.pow(1 - progress, 4); + const current = Math.floor(target * easeOutQuart); + + if (number.textContent.includes('+')) { + number.textContent = formatNumber(current) + '+'; + } else { + number.textContent = formatNumber(current); + } + + if (progress < 1) { + requestAnimationFrame(updateNumber); + } + } + + requestAnimationFrame(updateNumber); + }); +} + +// Show notification system +function showNotification(message, type = 'info') { + // Remove existing notifications + const existingNotification = document.querySelector('.notification'); + if (existingNotification) { + existingNotification.remove(); + } + + // Create notification element + const notification = document.createElement('div'); + notification.className = `notification notification-${type}`; + notification.textContent = message; + + // Style the notification + notification.style.cssText = ` + position: fixed; + top: 100px; + right: 20px; + background: ${type === 'success' ? '#4caf50' : type === 'error' ? '#f44336' : '#2196f3'}; + color: white; + padding: 15px 20px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + z-index: 10000; + font-weight: 500; + opacity: 0; + transform: translateX(100%); + transition: all 0.3s ease; + `; + + document.body.appendChild(notification); + + // Animate in + setTimeout(() => { + notification.style.opacity = '1'; + notification.style.transform = 'translateX(0)'; + }, 100); + + // Auto remove after 3 seconds + setTimeout(() => { + notification.style.opacity = '0'; + notification.style.transform = 'translateX(100%)'; + setTimeout(() => { + if (notification.parentNode) { + notification.remove(); + } + }, 300); + }, 3000); +} + +// Easter egg - Konami code +let konamiCode = []; +const konamiSequence = [ + 'ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', + 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', + 'KeyB', 'KeyA' +]; + +function triggerKonamiEasterEgg() { + var t = typeof __t === 'function' ? __t : null; + + // --- 1. Screen shake --- + document.body.style.transition = 'none'; + var shakeFrames = [ + '3px 0', '-3px 1px', '2px -1px', '-2px 2px', + '1px -2px', '-1px 1px', '2px 0', '0 0' + ]; + var si = 0; + var shakeInterval = setInterval(function () { + if (si >= shakeFrames.length) { clearInterval(shakeInterval); document.body.style.transform = ''; return; } + document.body.style.transform = 'translate(' + shakeFrames[si] + ')'; + si++; + }, 40); + + // --- 2. Confetti burst --- + var colors = ['#ff6b6b', '#ffd93d', '#6bcb77', '#4d96ff', '#ff922b', '#cc5de8', '#20c997']; + var confettiCount = 80; + var container = document.createElement('div'); + container.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:99999;overflow:hidden;'; + document.body.appendChild(container); + for (var i = 0; i < confettiCount; i++) { + var piece = document.createElement('div'); + var size = Math.random() * 8 + 4; + var color = colors[Math.floor(Math.random() * colors.length)]; + var startX = 50 + (Math.random() - 0.5) * 20; + var startY = 50 + (Math.random() - 0.5) * 10; + var dx = (Math.random() - 0.5) * 120; + var dy = -(Math.random() * 60 + 30); + var rot = Math.random() * 720 - 360; + var dur = Math.random() * 1.5 + 1.5; + piece.style.cssText = + 'position:absolute;width:' + size + 'px;height:' + (size * 0.6) + 'px;' + + 'background:' + color + ';border-radius:2px;' + + 'left:' + startX + '%;top:' + startY + '%;' + + 'opacity:1;pointer-events:none;'; + piece.animate([ + { transform: 'translate(0,0) rotate(0deg)', opacity: 1 }, + { transform: 'translate(' + dx + 'vw,' + dy + 'vh) rotate(' + rot + 'deg)', opacity: 0 } + ], { duration: dur * 1000, easing: 'cubic-bezier(.25,.8,.25,1)', fill: 'forwards' }); + container.appendChild(piece); + } + setTimeout(function () { container.remove(); }, 4000); + + // --- 3. Barrel roll --- + setTimeout(function () { + var style = document.createElement('style'); + style.textContent = '@keyframes konamiBarrelRoll{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}'; + document.head.appendChild(style); + document.body.style.transformOrigin = 'center center'; + document.body.style.animation = 'konamiBarrelRoll 1s ease-in-out'; + document.body.addEventListener('animationend', function handler() { + document.body.style.animation = ''; + document.body.style.transformOrigin = ''; + style.remove(); + document.body.removeEventListener('animationend', handler); + }); + }, 350); + + // --- 4. Themed notification --- + var msg = t ? t('js.konamiActivated') : 'Achievement Unlocked: Secret Code!'; + var notif = document.createElement('div'); + notif.style.cssText = + 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%) scale(0);' + + 'background:linear-gradient(135deg,rgba(30,30,30,0.95),rgba(50,50,50,0.95));' + + 'border:2px solid #ffd93d;color:#ffd93d;padding:20px 40px;border-radius:12px;' + + 'z-index:100000;font-size:1.4rem;font-weight:700;text-align:center;' + + 'box-shadow:0 0 40px rgba(255,217,61,0.3);pointer-events:none;' + + 'font-family:inherit;letter-spacing:1px;text-transform:uppercase;' + + 'transition:transform 0.4s cubic-bezier(.34,1.56,.64,1),opacity 0.3s ease;opacity:0;'; + notif.textContent = msg; + document.body.appendChild(notif); + setTimeout(function () { + notif.style.transform = 'translate(-50%,-50%) scale(1)'; + notif.style.opacity = '1'; + }, 50); + setTimeout(function () { + notif.style.transform = 'translate(-50%,-50%) scale(0.8)'; + notif.style.opacity = '0'; + setTimeout(function () { notif.remove(); }, 400); + }, 3000); +} + +document.addEventListener('keydown', function(e) { + konamiCode.push(e.code); + + if (konamiCode.length > konamiSequence.length) { + konamiCode.shift(); + } + + if (konamiCode.join(',') === konamiSequence.join(',')) { + triggerKonamiEasterEgg(); + konamiCode = []; + } +}); + +// Mobile menu toggle +function toggleMobileMenu() { + const navMenu = document.querySelector('.nav-menu'); + const hamburger = document.querySelector('.hamburger'); + + navMenu.classList.toggle('active'); + hamburger.classList.toggle('active'); +} + +// Languages dropdown toggle +function toggleLanguagesList() { + const languagesList = document.getElementById('languagesList'); + const dropdownToggle = document.querySelector('.dropdown-toggle'); + + languagesList.classList.toggle('show'); + dropdownToggle.classList.toggle('active'); +} + +// Language switcher (ENG/RUS) +function switchLanguage(lang) { + const next = lang || (document.documentElement.lang === 'en' ? 'ru' : 'en'); + if (next === document.documentElement.lang) return; + document.cookie = 'lang=' + next + ';path=/;max-age=31536000;SameSite=Lax'; + window.location.reload(); +} + +// Language dropdown: close on outside click +document.addEventListener('click', function(e) { + var dd = document.getElementById('langDropdown'); + if (dd && !dd.contains(e.target)) { + dd.classList.remove('open'); + } +}); \ No newline at end of file diff --git a/web/public/js/player-details-modal.js b/web/public/js/player-details-modal.js new file mode 100644 index 0000000..a60d04f --- /dev/null +++ b/web/public/js/player-details-modal.js @@ -0,0 +1,827 @@ +// Player Details Modal - Quick-peek popup for player stats +(function () { + let modalInjected = false; + let currentTab = 'overview'; + + function T(key) { + return (window.__t && window.__t(key)) || key; + } + + function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + function formatNumber(n) { + return (n || 0).toLocaleString(); + } + + function getWinRateColor(wr) { + if (wr >= 70) return '#90EE90'; + if (wr >= 60) return '#A8E6CF'; + if (wr >= 50) return '#FFD700'; + if (wr >= 40) return '#FFA500'; + return '#FF6B6B'; + } + + function getKDRColor(kdr) { + if (kdr >= 3) return '#90EE90'; + if (kdr >= 2) return '#A8E6CF'; + if (kdr >= 1.5) return '#FFD700'; + if (kdr >= 1) return '#FFA500'; + return '#FF6B6B'; + } + + function injectModal() { + if (modalInjected) return; + modalInjected = true; + + const style = document.createElement('style'); + style.textContent = ` + .pdm-overlay { + position: fixed; inset: 0; + background: rgba(0,0,0,0.7); + backdrop-filter: blur(4px); + z-index: 9999; + display: flex; align-items: center; justify-content: center; + opacity: 0; + transition: opacity 0.2s ease; + } + .pdm-overlay.pdm-visible { opacity: 1; } + .pdm-modal { + background: #1e1e1e; + border: 1px solid rgba(144,238,144,0.15); + border-radius: 12px; + max-width: 700px; width: 90%; + max-height: 85vh; + overflow-y: auto; + box-shadow: 0 20px 60px rgba(0,0,0,0.5); + transform: scale(0.95); + transition: transform 0.2s ease; + } + .pdm-overlay.pdm-visible .pdm-modal { transform: scale(1); } + .pdm-header { + display: flex; align-items: center; justify-content: space-between; + padding: 1rem 1.25rem; + border-bottom: 1px solid rgba(144,238,144,0.1); + position: sticky; top: 0; + background: #1e1e1e; + z-index: 1; + border-radius: 12px 12px 0 0; + } + .pdm-header-left { + display: flex; align-items: center; gap: 0.75rem; + min-width: 0; + } + .pdm-player-name { + font-size: 1.1rem; font-weight: 700; + color: #F5F5DC; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + .pdm-profile-link { + font-size: 0.75rem; + color: #90EE90; + text-decoration: none; + white-space: nowrap; + opacity: 0.8; + transition: opacity 0.2s; + } + .pdm-profile-link:hover { opacity: 1; text-decoration: underline; } + .pdm-close { + background: none; border: none; + color: rgba(255,255,255,0.5); + font-size: 1.4rem; cursor: pointer; + padding: 0.25rem 0.5rem; + border-radius: 6px; + transition: color 0.2s, background 0.2s; + flex-shrink: 0; + } + .pdm-close:hover { color: #fff; background: rgba(255,255,255,0.1); } + .pdm-tabs { + display: flex; justify-content: center; + padding: 0.75rem 1.25rem 0; + } + .pdm-tab-group { + position: relative; + display: inline-flex; + background: rgba(255,255,255,0.05); + border-radius: 2rem; + padding: 3px; + border: 1px solid rgba(144,238,144,0.2); + } + .pdm-tab-slider { + position: absolute; + top: 3px; left: 3px; + height: calc(100% - 6px); + background: #90EE90; + border-radius: calc(2rem - 2px); + transition: left 0.25s cubic-bezier(0.4,0,0.2,1), width 0.25s cubic-bezier(0.4,0,0.2,1); + pointer-events: none; + } + .pdm-tab { + position: relative; z-index: 1; + background: transparent; border: none; + color: rgba(255,255,255,0.45); + padding: 0.4rem 1.3rem; + border-radius: calc(2rem - 2px); + cursor: pointer; + font-size: 0.82rem; font-weight: 600; + transition: color 0.25s ease; + white-space: nowrap; + } + .pdm-tab.active { color: #1b1b1b; } + .pdm-tab:hover:not(.active) { color: rgba(255,255,255,0.8); } + .pdm-body { padding: 1rem 1.25rem 1.25rem; } + .pdm-loading { + display: flex; flex-direction: column; + align-items: center; justify-content: center; + padding: 3rem; color: rgba(255,255,255,0.5); + gap: 0.75rem; + } + .pdm-spinner { + width: 32px; height: 32px; + border: 3px solid rgba(144,238,144,0.2); + border-top-color: #90EE90; + border-radius: 50%; + animation: pdm-spin 0.8s linear infinite; + } + @keyframes pdm-spin { to { transform: rotate(360deg); } } + .pdm-error { + text-align: center; padding: 2rem; + color: #ff6b6b; + } + .pdm-error i { font-size: 2rem; margin-bottom: 0.5rem; } + + /* Overview tab */ + .pdm-stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.6rem; + } + .pdm-stats-grid-last-row { + display: flex; + justify-content: center; + gap: 0.6rem; + margin-top: 0.6rem; + } + .pdm-stats-grid-last-row .pdm-stat-card { + flex: 0 1 calc(33.333% - 0.4rem); + } + .pdm-stat-card { + background: rgba(255,255,255,0.03); + border: 1px solid rgba(144,238,144,0.08); + border-radius: 8px; + padding: 0.6rem 0.75rem; + text-align: center; + } + .pdm-stat-value { + font-size: 1.1rem; font-weight: 700; + color: #90EE90; + } + .pdm-stat-toggle { + display: flex; + justify-content: center; + gap: 0.3rem; + margin-top: 0.15rem; + } + .pdm-stat-toggle span { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.5px; + cursor: pointer; + transition: color 0.2s; + } + .pdm-stat-toggle span.pdm-toggle-active { + color: #90EE90; + font-weight: 700; + } + .pdm-stat-toggle span.pdm-toggle-inactive { + color: rgba(255,255,255,0.3); + } + .pdm-stat-toggle span.pdm-toggle-inactive:hover { + color: rgba(255,255,255,0.55); + } + .pdm-stat-toggle .pdm-toggle-sep { + color: rgba(255,255,255,0.15); + cursor: default; + } + .pdm-stat-label { + font-size: 0.7rem; + color: rgba(255,255,255,0.5); + margin-top: 0.15rem; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + /* Mini chart card */ + .pdm-chart-card { + background: rgba(255,255,255,0.03); + border: 1px solid rgba(144,238,144,0.08); + border-radius: 8px; + padding: 0.5rem 0.6rem; + margin-top: 0.6rem; + cursor: pointer; + position: relative; + overflow: hidden; + transition: border-color 0.2s; + } + .pdm-chart-card:hover { + border-color: rgba(144,238,144,0.25); + } + .pdm-chart-label { + font-size: 0.65rem; + color: rgba(255,255,255,0.5); + text-transform: uppercase; + letter-spacing: 0.5px; + text-align: center; + margin-bottom: 0.2rem; + } + .pdm-chart-hint { + font-size: 0.55rem; + color: rgba(255,255,255,0.25); + text-align: center; + margin-top: 0.15rem; + } + .pdm-mini-canvas-wrap { + position: relative; + width: 100%; + height: 60px; + } + .pdm-mini-canvas { + width: 100%; + height: 100%; + display: block; + } + + /* Vehicles tab */ + .pdm-vehicles-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.5rem; + max-height: 55vh; + overflow-y: auto; + } + .pdm-vehicle-card { + background: rgba(255,255,255,0.03); + border: 1px solid rgba(144,238,144,0.08); + border-radius: 8px; + padding: 0.6rem 0.75rem; + } + .pdm-vehicle-card.pdm-crown-card { + border-color: rgba(255,215,0,0.3); + background: rgba(255,215,0,0.03); + } + .pdm-vehicle-name { + font-weight: 600; font-size: 0.85rem; + color: #F5F5DC; + margin-bottom: 0.4rem; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + .pdm-crown { + color: #FFD700; + margin-right: 0.3rem; + font-size: 0.75rem; + } + .pdm-vehicle-stats { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.15rem 0.5rem; + font-size: 0.72rem; + color: rgba(255,255,255,0.6); + } + .pdm-vehicle-stats span { + white-space: nowrap; + } + .pdm-vs-val { font-weight: 600; } + + /* Sessions tab */ + .pdm-sessions-wrap { + max-height: 55vh; + overflow-y: auto; + } + .pdm-sessions-table { + width: 100%; + border-collapse: collapse; + font-size: 0.82rem; + } + .pdm-sessions-table th { + text-align: left; + padding: 0.55rem 0.6rem; + color: rgba(255,255,255,0.5); + font-weight: 600; + border-bottom: 1px solid rgba(144,238,144,0.1); + white-space: nowrap; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.3px; + } + .pdm-sessions-table td { + padding: 0.55rem 0.6rem; + color: rgba(255,255,255,0.75); + border-bottom: 1px solid rgba(255,255,255,0.03); + white-space: nowrap; + } + .pdm-sessions-table tr:hover td { + background: rgba(255,255,255,0.03); + } + .pdm-result-badge { + display: inline-block; + padding: 0.2rem 0.55rem; + border-radius: 4px; + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + } + .pdm-result-win { background: rgba(144,238,144,0.15); color: #90EE90; } + .pdm-result-loss { background: rgba(255,107,107,0.15); color: #ff6b6b; } + .pdm-no-data { + text-align: center; padding: 2rem; + color: rgba(255,255,255,0.4); + } + + /* Details button */ + .pdm-details-btn { + display: inline-flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid rgba(144,238,144,0.15); + color: rgba(144,238,144,0.5); + width: 22px; height: 22px; + border-radius: 4px; + cursor: pointer; + font-size: 0.65rem; + margin-left: 0.4rem; + vertical-align: middle; + transition: all 0.2s; + padding: 0; + line-height: 1; + flex-shrink: 0; + } + .pdm-details-btn:hover { + background: rgba(144,238,144,0.12); + color: #90EE90; + border-color: rgba(144,238,144,0.4); + } + + /* Scrollbar */ + .pdm-modal::-webkit-scrollbar, + .pdm-vehicles-grid::-webkit-scrollbar, + .pdm-sessions-wrap::-webkit-scrollbar { + width: 6px; + } + .pdm-modal::-webkit-scrollbar-track, + .pdm-vehicles-grid::-webkit-scrollbar-track, + .pdm-sessions-wrap::-webkit-scrollbar-track { + background: transparent; + } + .pdm-modal::-webkit-scrollbar-thumb, + .pdm-vehicles-grid::-webkit-scrollbar-thumb, + .pdm-sessions-wrap::-webkit-scrollbar-thumb { + background: rgba(144,238,144,0.15); + border-radius: 3px; + } + + @media (max-width: 640px) { + .pdm-stats-grid { grid-template-columns: repeat(2, 1fr); } + .pdm-vehicles-grid { grid-template-columns: 1fr; } + .pdm-sessions-table { font-size: 0.75rem; } + .pdm-sessions-table th, + .pdm-sessions-table td { padding: 0.45rem 0.4rem; } + } + `; + document.head.appendChild(style); + + const overlay = document.createElement('div'); + overlay.id = 'pdm-overlay'; + overlay.className = 'pdm-overlay'; + overlay.style.display = 'none'; + overlay.innerHTML = ` +
+ +
+
+
+ + + +
+
+
+
+
+ ${T('playerModal.loadingPlayerData')} +
+
+
+ `; + document.body.appendChild(overlay); + + // Events + overlay.addEventListener('click', function (e) { + if (e.target === overlay) closeModal(); + }); + document.getElementById('pdm-close').addEventListener('click', closeModal); + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape' && overlay.style.display !== 'none') closeModal(); + }); + + // Tab switching + document.getElementById('pdm-tab-group').addEventListener('click', function (e) { + const btn = e.target.closest('.pdm-tab'); + if (!btn || btn.classList.contains('active')) return; + document.querySelectorAll('.pdm-tab').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + currentTab = btn.dataset.tab; + updateSlider(); + renderTab(); + }); + + // Prevent modal scroll from propagating + document.getElementById('pdm-modal').addEventListener('click', function (e) { + e.stopPropagation(); + }); + } + + function updateSlider() { + const group = document.getElementById('pdm-tab-group'); + const slider = document.getElementById('pdm-tab-slider'); + const active = group.querySelector('.pdm-tab.active'); + if (!active || !slider) return; + slider.style.left = active.offsetLeft + 'px'; + slider.style.width = active.offsetWidth + 'px'; + } + + let playerDataCache = null; + let gamesDataCache = null; + let historyDataCache = null; + let miniChart = null; + let miniChartMetric = 'kdr'; + let currentKdrKps = 'kdr'; + const miniChartMetrics = ['kdr', 'win_rate', 'battles']; + const miniChartConfig = { + kdr: { label: T('playerModal.kdr'), color: '#64b5f6', suffix: '' }, + win_rate: { label: T('playerModal.winRate'), color: '#90EE90', suffix: '%' }, + battles: { label: T('playerModal.battles'), color: '#ffb74d', suffix: '' } + }; + + function closeModal() { + const overlay = document.getElementById('pdm-overlay'); + overlay.classList.remove('pdm-visible'); + setTimeout(() => { + overlay.style.display = 'none'; + document.body.style.overflow = ''; + }, 200); + // Destroy mini chart to prevent canvas reuse issues + if (miniChart) { + miniChart.destroy(); + miniChart = null; + } + } + + function renderTab() { + const body = document.getElementById('pdm-body'); + if (!playerDataCache) return; + + // Destroy old mini chart before re-rendering + if (miniChart) { + miniChart.destroy(); + miniChart = null; + } + + if (currentTab === 'overview') { + body.innerHTML = renderOverview(playerDataCache); + initMiniChart(); + } else if (currentTab === 'vehicles') { + body.innerHTML = renderVehicles(playerDataCache); + initVehicleKdrToggle(); + } else if (currentTab === 'sessions') { + body.innerHTML = renderSessions(gamesDataCache); + } + } + + function renderOverview(data) { + const vehicles = (data.vehicles || []).filter(v => v.vehicle !== 'DISCONNECTED'); + let totalBattles = 0, wins = 0, groundKills = 0, airKills = 0, assists = 0, deaths = 0, captures = 0; + + vehicles.forEach(v => { + const s = v.stats; + totalBattles += s.total_battles || 0; + wins += s.wins || 0; + groundKills += s.ground_kills || 0; + airKills += s.air_kills || 0; + assists += s.assists || 0; + deaths += s.deaths || 0; + captures += s.captures || 0; + }); + + const totalKills = groundKills + airKills; + const winRate = totalBattles > 0 ? ((wins / totalBattles) * 100).toFixed(1) + '%' : '0.0%'; + const kdr = deaths > 0 ? (totalKills / deaths).toFixed(2) : totalKills.toFixed(2); + const kps = totalBattles > 0 ? (totalKills / totalBattles).toFixed(2) : '0.00'; + + const stats = [ + [T('playerModal.totalBattles'), formatNumber(totalBattles)], + [T('playerModal.wins'), formatNumber(wins)], + [T('playerModal.winRate'), winRate], + [T('playerModal.totalKills'), formatNumber(totalKills)], + [T('playerModal.kdr'), kdr], + [T('playerModal.kps'), kps], + [T('playerModal.airKills'), formatNumber(airKills)], + [T('playerModal.groundKills'), formatNumber(groundKills)], + [T('playerModal.assists'), formatNumber(assists)], + [T('playerModal.deaths'), formatNumber(deaths)], + [T('playerModal.captures'), formatNumber(captures)], + ]; + + const cfg = miniChartConfig[miniChartMetric]; + const chartCard = `
+
${cfg.label}
+
+
${T('playerModal.clickToCycle')}
+
`; + + const fullRows = stats.slice(0, Math.floor(stats.length / 3) * 3); + const remainder = stats.slice(fullRows.length); + const cardHtml = s => `
${s[1]}
${s[0]}
`; + const lastRow = remainder.length ? `
${remainder.map(cardHtml).join('')}
` : ''; + + return `
${fullRows.map(cardHtml).join('')}
${lastRow}${chartCard}`; + } + + + + function initVehicleKdrToggle() { + const toggle = document.getElementById('pdm-veh-kdr-toggle'); + if (!toggle) return; + toggle.addEventListener('click', function (e) { + const span = e.target.closest('[data-mode]'); + if (!span || span.classList.contains('pdm-toggle-active')) return; + currentKdrKps = span.dataset.mode; + renderTab(); + }); + } + + function initMiniChart() { + const card = document.getElementById('pdm-chart-card'); + if (!card) return; + + card.addEventListener('click', function () { + const idx = miniChartMetrics.indexOf(miniChartMetric); + miniChartMetric = miniChartMetrics[(idx + 1) % miniChartMetrics.length]; + renderMiniChart(); + }); + + // Load history data if not cached, then render + if (historyDataCache) { + renderMiniChart(); + } else if (currentUid) { + window.apiClient.request('/api/player/' + currentUid + '/history').then(data => { + historyDataCache = data; + renderMiniChart(); + }).catch(() => { + const canvas = document.getElementById('pdm-mini-canvas'); + if (canvas) { + const ctx = canvas.getContext('2d'); + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.font = '11px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(T('playerModal.noChartData'), canvas.width / 2, canvas.height / 2); + } + }); + } + } + + function renderMiniChart() { + if (!historyDataCache || !historyDataCache.history || !historyDataCache.history.length) return; + if (typeof Chart === 'undefined') return; + + const canvas = document.getElementById('pdm-mini-canvas'); + const label = document.getElementById('pdm-chart-metric-label'); + if (!canvas) return; + + const cfg = miniChartConfig[miniChartMetric]; + if (label) label.textContent = cfg.label; + + const history = historyDataCache.history; + const dataPoints = history + .filter(d => d[miniChartMetric] != null) + .map(d => ({ x: new Date(d.period + 'T00:00:00Z').getTime(), y: d[miniChartMetric] })); + if (!dataPoints.length) return; + + if (miniChart) { + miniChart.data.datasets[0].data = dataPoints; + miniChart.data.datasets[0].borderColor = cfg.color; + miniChart.data.datasets[0].backgroundColor = cfg.color + '18'; + miniChart.data.datasets[0].pointBackgroundColor = cfg.color; + miniChart.options.plugins.tooltip.borderColor = cfg.color; + miniChart.options.plugins.tooltip.bodyColor = cfg.color; + miniChart.options.plugins.tooltip.callbacks.label = ctx => `${cfg.label}: ${ctx.parsed.y}${cfg.suffix}`; + miniChart.update(); + return; + } + + miniChart = new Chart(canvas.getContext('2d'), { + type: 'line', + data: { + datasets: [{ + data: dataPoints, + borderColor: cfg.color, + backgroundColor: cfg.color + '18', + borderWidth: 1.5, + pointBackgroundColor: cfg.color, + pointRadius: 0, + pointHoverRadius: 3, + tension: 0.15, + fill: true + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { duration: 300 }, + plugins: { + legend: { display: false }, + tooltip: { + enabled: true, + backgroundColor: 'rgba(15,15,15,0.92)', + borderColor: cfg.color, + borderWidth: 1, + titleColor: 'rgba(255,255,255,0.75)', + bodyColor: cfg.color, + padding: 6, + displayColors: false, + callbacks: { + title: () => '', + label: ctx => `${cfg.label}: ${ctx.parsed.y}${cfg.suffix}` + } + } + }, + scales: { + x: { type: 'linear', display: false, min: (function() { var y = new Date(history[0].period + 'T00:00:00Z').getUTCFullYear(); return Date.UTC(y, 0, 1); })() }, + y: { display: false, beginAtZero: true } + } + } + }); + } + + let currentUid = null; + + function renderVehicles(data) { + const vehicles = (data.vehicles || []).filter(v => v.vehicle !== 'DISCONNECTED'); + if (!vehicles.length) return `
${T('playerModal.noVehicleData')}
`; + + // Sort by total battles desc + vehicles.sort((a, b) => (b.stats.total_battles || 0) - (a.stats.total_battles || 0)); + + // Find best WR vehicle with >15 games + let bestWrVehicle = null; + let bestWr = -1; + vehicles.forEach(v => { + const battles = v.stats.total_battles || 0; + if (battles > 15) { + const wr = (v.stats.wins || 0) / battles; + if (wr > bestWr) { + bestWr = wr; + bestWrVehicle = v.vehicle; + } + } + }); + + const mode = currentKdrKps || 'kdr'; + const kdrCls = mode === 'kdr' ? 'pdm-toggle-active' : 'pdm-toggle-inactive'; + const kpsCls = mode === 'kps' ? 'pdm-toggle-active' : 'pdm-toggle-inactive'; + const toggleHeader = `
+ KDR + / + KPS +
`; + + return `${toggleHeader}
${vehicles.map(v => { + const s = v.stats; + const totalKills = (s.ground_kills || 0) + (s.air_kills || 0); + const battles = s.total_battles || 0; + const wins = s.wins || 0; + const wrNum = battles > 0 ? (wins / battles) * 100 : 0; + const wr = wrNum.toFixed(1) + '%'; + const kdrNum = s.deaths > 0 ? totalKills / s.deaths : totalKills; + const kdr = kdrNum.toFixed(2); + const kpsNum = battles > 0 ? totalKills / battles : 0; + const kps = kpsNum.toFixed(2); + const isCrown = v.vehicle === bestWrVehicle; + const crownClass = isCrown ? ' pdm-crown-card' : ''; + const crownIcon = isCrown ? '' : ''; + const wrColor = getWinRateColor(wrNum); + const activeVal = mode === 'kdr' ? kdr : kps; + const activeNum = mode === 'kdr' ? kdrNum : kpsNum; + const activeColor = getKDRColor(activeNum); + const activeLabel = mode === 'kdr' ? T('playerModal.kdr') : T('playerModal.kps'); + return `
+
${crownIcon}${escapeHtml(v.vehicle)}
+
+ ${T('playerModal.battles')}: ${battles} + ${T('playerModal.wins')}: ${wins} + ${T('playerModal.winRate')}: ${wr} + ${activeLabel}: ${activeVal} + ${T('playerModal.ground')}: ${s.ground_kills || 0} + ${T('playerModal.air')}: ${s.air_kills || 0} + ${T('playerModal.assists')}: ${s.assists || 0} + ${T('playerModal.deaths')}: ${s.deaths || 0} +
+
`; + }).join('')}
`; + } + + function renderSessions(gamesData) { + if (!gamesData || !gamesData.games || !gamesData.games.length) { + return `
${T('playerModal.noSessionData')}
`; + } + + const games = gamesData.games + .slice() + .sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)) + .slice(0, 50); + + const rows = games.map(g => { + const date = new Date(g.timestamp * 1000); + const fmt = date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const result = g.result || T('playerModal.unknown'); + const isWin = result.toLowerCase() === 'win'; + const badgeClass = isWin ? 'pdm-result-win' : 'pdm-result-loss'; + return ` + ${fmt} + ${escapeHtml(g.vehicle || g.vehicle_internal || T('playerModal.unknown'))} + ${g.stats.ground_kills || 0} + ${g.stats.air_kills || 0} + ${g.stats.assists || 0} + ${g.stats.deaths || 0} + ${result} + `; + }).join(''); + + return `
+ + + + ${rows} +
${T('playerModal.date')}${T('playerModal.vehicle')}${T('playerModal.ground')}${T('playerModal.air')}${T('playerModal.assists')}${T('playerModal.deaths')}${T('playerModal.result')}
`; + } + + window.openPlayerDetailsModal = async function (uid, nick) { + injectModal(); + + const overlay = document.getElementById('pdm-overlay'); + const body = document.getElementById('pdm-body'); + const nameEl = document.getElementById('pdm-player-name'); + const linkEl = document.getElementById('pdm-profile-link'); + + // Reset state + currentTab = 'overview'; + miniChartMetric = 'kdr'; + currentKdrKps = 'kdr'; + playerDataCache = null; + gamesDataCache = null; + historyDataCache = null; + currentUid = uid; + if (miniChart) { + miniChart.destroy(); + miniChart = null; + } + document.querySelectorAll('.pdm-tab').forEach(b => b.classList.remove('active')); + document.querySelector('.pdm-tab[data-tab="overview"]').classList.add('active'); + + nameEl.textContent = nick || uid; + linkEl.href = '/players/' + encodeURIComponent(uid); + + body.innerHTML = `
${T('playerModal.loadingPlayerData')}
`; + + overlay.style.display = 'flex'; + document.body.style.overflow = 'hidden'; + // Trigger animation + requestAnimationFrame(() => { + overlay.classList.add('pdm-visible'); + updateSlider(); + }); + + try { + const [playerData, gamesData] = await Promise.all([ + window.apiClient.getPlayer(uid), + window.apiClient.getPlayerGames(uid) + ]); + playerDataCache = playerData; + gamesDataCache = gamesData; + renderTab(); + } catch (err) { + console.error('[Player Details Modal] Error:', err); + body.innerHTML = `
${T('playerModal.failedToLoadPlayerData')}
`; + } + }; +})(); diff --git a/web/public/js/recap-modal-player.js b/web/public/js/recap-modal-player.js new file mode 100644 index 0000000..c289374 --- /dev/null +++ b/web/public/js/recap-modal-player.js @@ -0,0 +1,91 @@ +(function () { + 'use strict'; + + const modal = document.getElementById('season-recap-modal'); + const btn = document.getElementById('season-recap-btn'); + const selectEl = document.getElementById('season-recap-select'); + const genBtn = document.getElementById('season-recap-generate'); + const img = document.getElementById('season-recap-image'); + const statusEl = document.getElementById('season-recap-status'); + + if (!modal || !btn || !selectEl || !genBtn || !img) { + console.warn('[recap-player] modal elements not found; skipping init'); + return; + } + + const scriptTag = document.currentScript; + const uid = scriptTag && scriptTag.dataset.uid; + if (!uid) { + console.warn('[recap-player] no uid on script tag; disabling button'); + btn.disabled = true; + return; + } + + function openModal() { + modal.classList.remove('hidden'); + modal.setAttribute('aria-hidden', 'false'); + } + function closeModal() { + modal.classList.add('hidden'); + modal.setAttribute('aria-hidden', 'true'); + } + modal.querySelectorAll('[data-close]').forEach(el => el.addEventListener('click', closeModal)); + btn.addEventListener('click', async () => { + openModal(); + if (!selectEl.options.length) await loadSeasons(); + }); + + function t(key) { + return (window.__t && window.__t('seasonCard.' + key)) || key; + } + + async function loadSeasons() { + statusEl.textContent = t('loadingSeasons'); + try { + if (!window.apiClient) throw new Error('apiClient not available'); + const seasons = await window.apiClient.request('/api/seasons'); + const entries = Object.entries(seasons).sort((a, b) => b[1].start - a[1].start); + selectEl.innerHTML = ''; + for (const [name, range] of entries) { + const opt = document.createElement('option'); + opt.value = name; + opt.textContent = range.status === 'in_progress' + ? `${name} ${t('inProgressSuffix')}` + : name; + selectEl.appendChild(opt); + } + statusEl.textContent = ''; + } catch (err) { + console.error('[recap-player] seasons fetch failed:', err); + statusEl.textContent = t('failedSeasons'); + } + } + + function selectedTheme() { + const checked = document.querySelector('input[name="season-recap-theme"]:checked'); + return (checked && checked.value) || 'dark'; + } + + genBtn.addEventListener('click', () => { + const season = selectEl.value; + if (!season) return; + const theme = selectedTheme(); + const lang = document.documentElement.lang || 'en'; + const base = `/players/${encodeURIComponent(uid)}/recap/${encodeURIComponent(season)}.png`; + const params = new URLSearchParams({ theme, lang }); + statusEl.textContent = t('generating'); + img.style.display = 'none'; + img.onload = () => { + statusEl.textContent = ''; + img.style.display = 'block'; + }; + img.onerror = () => { + statusEl.textContent = t('failedGenerate'); + }; + const currentOpt = selectEl.options[selectEl.selectedIndex]; + const inProgressLabel = t('inProgressSuffix'); + const isInProgress = !!(currentOpt && inProgressLabel && currentOpt.textContent.indexOf(inProgressLabel) !== -1); + if (isInProgress) params.set('t', String(Date.now())); + img.src = `${base}?${params.toString()}`; + }); +})(); diff --git a/web/public/js/recap-modal.js b/web/public/js/recap-modal.js new file mode 100644 index 0000000..cc974ed --- /dev/null +++ b/web/public/js/recap-modal.js @@ -0,0 +1,93 @@ +(function () { + 'use strict'; + + const modal = document.getElementById('season-recap-modal'); + const btn = document.getElementById('season-recap-btn'); + const selectEl = document.getElementById('season-recap-select'); + const genBtn = document.getElementById('season-recap-generate'); + const img = document.getElementById('season-recap-image'); + const statusEl = document.getElementById('season-recap-status'); + + if (!modal || !btn || !selectEl || !genBtn || !img) { + console.warn('[recap] modal elements not found; skipping init'); + return; + } + + const scriptTag = document.currentScript; + const clanId = scriptTag && scriptTag.dataset.clanId; + if (!clanId) { + console.warn('[recap] no clan_id on script tag; disabling button'); + btn.disabled = true; + return; + } + + function openModal() { + modal.classList.remove('hidden'); + modal.setAttribute('aria-hidden', 'false'); + } + function closeModal() { + modal.classList.add('hidden'); + modal.setAttribute('aria-hidden', 'true'); + } + modal.querySelectorAll('[data-close]').forEach(el => el.addEventListener('click', closeModal)); + btn.addEventListener('click', async () => { + openModal(); + if (!selectEl.options.length) await loadSeasons(); + }); + + function t(key) { + return (window.__t && window.__t('seasonCard.' + key)) || key; + } + + async function loadSeasons() { + statusEl.textContent = t('loadingSeasons'); + try { + if (!window.apiClient) { + throw new Error('apiClient not available'); + } + const seasons = await window.apiClient.request('/api/seasons'); + const entries = Object.entries(seasons).sort((a, b) => b[1].start - a[1].start); + selectEl.innerHTML = ''; + for (const [name, range] of entries) { + const opt = document.createElement('option'); + opt.value = name; + opt.textContent = range.status === 'in_progress' + ? `${name} ${t('inProgressSuffix')}` + : name; + selectEl.appendChild(opt); + } + statusEl.textContent = ''; + } catch (err) { + console.error('[recap] seasons fetch failed:', err); + statusEl.textContent = t('failedSeasons'); + } + } + + function selectedTheme() { + const checked = document.querySelector('input[name="season-recap-theme"]:checked'); + return (checked && checked.value) || 'dark'; + } + + genBtn.addEventListener('click', () => { + const season = selectEl.value; + if (!season) return; + const theme = selectedTheme(); + const lang = document.documentElement.lang || 'en'; + const base = `/squadron/${encodeURIComponent(clanId)}/recap/${encodeURIComponent(season)}.png`; + const params = new URLSearchParams({ theme, lang }); + statusEl.textContent = t('generating'); + img.style.display = 'none'; + img.onload = () => { + statusEl.textContent = ''; + img.style.display = 'block'; + }; + img.onerror = () => { + statusEl.textContent = t('failedGenerate'); + }; + const currentOpt = selectEl.options[selectEl.selectedIndex]; + const inProgressLabel = t('inProgressSuffix'); + const isInProgress = !!(currentOpt && inProgressLabel && currentOpt.textContent.indexOf(inProgressLabel) !== -1); + if (isInProgress) params.set('t', String(Date.now())); + img.src = `${base}?${params.toString()}`; + }); +})(); diff --git a/web/public/js/replay-canvas.js b/web/public/js/replay-canvas.js new file mode 100644 index 0000000..d61ac0c --- /dev/null +++ b/web/public/js/replay-canvas.js @@ -0,0 +1,900 @@ +/** + * replay-canvas.js + * + * Interactive HTML5 Canvas replay viewer for War Thunder GOB replays. + */ + +const RC = { + TRAIL_MS: 18000, AIR_TRAIL_MS: 4000, DRONE_TRAIL_MS: 2000, + KILL_TTL: 8000, DMG_TTL: 4000, GHOST_TTL: 3000, DEFAULT_SPEED: 4, + WIN: '#00c800', LOSE: '#dc1e1e', + WIN_DIM: 'rgba(0,200,0,', LOSE_DIM: 'rgba(220,30,30,', + WIN_TRAIL: 'rgba(0,120,0,', LOSE_TRAIL: 'rgba(132,18,18,', + DOT_R: 5, AIR_R: 4, DRONE_R: 3, +}; + +function replayT(key) { + return (window.__t && window.__t(key)) || key; +} + +class ReplayCanvas { + constructor(containerEl, data) { + this.container = containerEl; + this.data = data; + this.playing = false; + this.speed = RC.DEFAULT_SPEED; + this.currentTime = 0; + this.tStart = Infinity; + this.tEnd = -Infinity; + this.lastFrameTime = 0; + this.highlightedPlayerId = null; + this.animFrameId = null; + this.canvasSize = 720; + this.canvas = null; + this.ctx = null; + this.mapCanvas = null; + this.mapCtx = null; + + // Store both coordinate sets + this._groundCoords = data.levelCoords; + this._airCoords = data.mapCoords || null; + this._fullMapLevel = data.fullMapLevel || null; + this._mode = 'ground'; + const hasAircraft = data.entities.some(e => e.type === 'aircraft'); + this.hasAirMode = !!(this._airCoords && this._fullMapLevel && hasAircraft); + + this.x0 = data.levelCoords.x0; + this.z0 = data.levelCoords.z0; + this.xRange = data.levelCoords.x1 - data.levelCoords.x0; + this.zRange = data.levelCoords.z1 - data.levelCoords.z0; + // Default map source rect (full image) — overwritten by _computeAutocrop + this._mapSrc = { u: 0, v: 0, w: 1, h: 1 }; + + this.players = {}; + for (const p of data.players) this.players[p.id] = p; + + this.entities = []; + for (const e of data.entities) { + if (!e.path || e.path.length === 0) continue; + const times = new Float64Array(e.path.length); + const positions = new Float32Array(e.path.length * 2); + for (let i = 0; i < e.path.length; i++) { + times[i] = e.path[i].t; + positions[i * 2] = e.path[i].x; + positions[i * 2 + 1] = e.path[i].z; + } + if (times[0] < this.tStart) this.tStart = times[0]; + if (times[times.length - 1] > this.tEnd) this.tEnd = times[times.length - 1]; + const isWinner = e.playerId > 0 + ? (this.players[e.playerId]?.team === data.teamWon) + : (e.droneTeam === data.teamWon); + this.entities.push({ + ...e, times, positions, isWinner, + deathTime: null, ghostEndTime: null, deathPos: null, + }); + } + + // Zoom into the area players actually use (like the video maker autocrop) + this._computeAutocrop(); + + // Pre-compute deaths + this._computeDeaths(); + this.currentTime = this.tStart; + } + + _computeDeaths() { + // Reset deaths so they can be recomputed after coord changes + for (const ent of this.entities) { + ent.deathTime = null; + ent.ghostEndTime = null; + ent.deathPos = null; + } + for (const k of this.data.kills) { + for (const ent of this.entities) { + const matched = (k.victimEntityIndex && ent.entityIndex === k.victimEntityIndex) + || (k.victimId && ent.playerId === k.victimId && ent.playerId !== 0); + if (matched && ent.deathTime === null) { + ent.deathTime = k.time; + ent.ghostEndTime = k.time + RC.GHOST_TTL; + if (k.victimPos) ent.deathPos = this.worldToPixel(k.victimPos.x, k.victimPos.z); + break; + } + } + } + } + + worldToPixel(x, z) { + return [ + (x - this.x0) / this.xRange * this.canvasSize, + (this.z0 + this.zRange - z) / this.zRange * this.canvasSize + ]; + } + + /** Recompute x0/z0/xRange/zRange to zoom into entity activity. + * In 'ground' mode crops to ground entities, in 'air' mode crops to aircraft+drones. */ + _computeAutocrop() { + const origX0 = this.x0, origZ0 = this.z0; + const origXR = this.xRange, origZR = this.zRange; + + const airMode = this._mode === 'air'; + let minX = Infinity, maxX = -Infinity; + let minZ = Infinity, maxZ = -Infinity; + for (const ent of this.entities) { + if (airMode) { + if (ent.type !== 'aircraft' && ent.type !== 'drone') continue; + } else { + if (ent.type !== 'ground') continue; + } + const { positions } = ent; + for (let i = 0; i < positions.length; i += 2) { + const x = positions[i], z = positions[i + 1]; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (z < minZ) minZ = z; + if (z > maxZ) maxZ = z; + } + } + if (!isFinite(minX)) return; // no relevant positions — keep full map + + // Padding: 10% of span or 50 world units, whichever is larger + const span = Math.max(maxX - minX, maxZ - minZ); + const pad = Math.max(50, span * 0.10); + minX -= pad; maxX += pad; + minZ -= pad; maxZ += pad; + + // Expand to square, with a minimum of 15% of the map range + const minSide = Math.max(origXR, origZR) * 0.15; + let side = Math.max(maxX - minX, maxZ - minZ, minSide); + const midX = (minX + maxX) / 2, midZ = (minZ + maxZ) / 2; + minX = midX - side / 2; maxX = midX + side / 2; + minZ = midZ - side / 2; maxZ = midZ + side / 2; + + // Clamp to LevelDef bounds (shift first, then hard-clamp) + const x1 = origX0 + origXR, z1 = origZ0 + origZR; + if (minX < origX0) { maxX += origX0 - minX; minX = origX0; } + if (maxX > x1) { minX -= maxX - x1; maxX = x1; } + if (minZ < origZ0) { maxZ += origZ0 - minZ; minZ = origZ0; } + if (maxZ > z1) { minZ -= maxZ - z1; maxZ = z1; } + minX = Math.max(minX, origX0); maxX = Math.min(maxX, x1); + minZ = Math.max(minZ, origZ0); maxZ = Math.min(maxZ, z1); + + // Fractional source rect for the minimap image + // Image top-left = world (origX0, origZ0+origZR), bottom-right = (origX0+origXR, origZ0) + this._mapSrc = { + u: (minX - origX0) / origXR, + v: (z1 - maxZ) / origZR, + w: (maxX - minX) / origXR, + h: (maxZ - minZ) / origZR, + }; + + // Apply new bounds — worldToPixel will now map this sub-region to the full canvas + this.x0 = minX; + this.z0 = minZ; + this.xRange = maxX - minX; + this.zRange = maxZ - minZ; + } + + getPositionAtTime(entity, time) { + const { times, positions } = entity; + if (time < times[0] || time > times[times.length - 1]) return null; + let lo = 0, hi = times.length - 1; + while (lo < hi - 1) { + const mid = (lo + hi) >> 1; + if (times[mid] <= time) lo = mid; else hi = mid; + } + const t0 = times[lo], t1 = times[hi]; + const frac = t1 > t0 ? (time - t0) / (t1 - t0) : 0; + const i0 = lo * 2, i1 = hi * 2; + return this.worldToPixel( + positions[i0] + (positions[i1] - positions[i0]) * frac, + positions[i0 + 1] + (positions[i1 + 1] - positions[i0 + 1]) * frac + ); + } + + getHeadingAtTime(entity, time) { + // Compute heading in radians (0=up/north, CW) from position delta + const dt = 500; // sample window in game ms + const p0 = this.getPositionAtTime(entity, time - dt); + const p1 = this.getPositionAtTime(entity, time); + if (!p0 || !p1) return null; + const dx = p1[0] - p0[0]; + const dy = p1[1] - p0[1]; + if (Math.abs(dx) < 0.1 && Math.abs(dy) < 0.1) return null; + return Math.atan2(dx, -dy); // 0=up, CW positive + } + + _entityScreenPos(entity, time) { + if (entity.deathTime !== null && time >= entity.deathTime) return entity.deathPos; + return this.getPositionAtTime(entity, time); + } + + _isEntityDead(entity, time) { + return entity.deathTime !== null && time >= entity.deathTime; + } + + _isEntityGone(entity, time) { + return entity.ghostEndTime !== null && time >= entity.ghostEndTime; + } + + async init() { + this._buildDOM(); + await Promise.all([this._loadMap(), this._loadEntityIcons()]); + this.playing = true; + this.playBtn.innerHTML = ''; + this.lastFrameTime = performance.now(); + this._tick = this._tick.bind(this); + this.animFrameId = requestAnimationFrame(this._tick); + } + + _buildDOM() { + this.container.innerHTML = ''; + const layout = document.createElement('div'); + layout.className = 'rc-layout'; + + // Left panel (winners) + this.leftPanel = document.createElement('div'); + this.leftPanel.className = 'rc-panel rc-panel-win'; + this._buildTeamPanel(this.leftPanel, true); + + // Center + const center = document.createElement('div'); + center.className = 'rc-center'; + + this.canvas = document.createElement('canvas'); + this.canvas.width = this.canvasSize; + this.canvas.height = this.canvasSize; + this.canvas.className = 'rc-canvas'; + this.ctx = this.canvas.getContext('2d'); + center.appendChild(this.canvas); + + // Controls + const controls = document.createElement('div'); + controls.className = 'rc-controls'; + controls.innerHTML = ` + +
+ + + + +
+ + 0:00 / 0:00 + `; + center.appendChild(controls); + + // Battle log + const logWrap = document.createElement('div'); + logWrap.className = 'rc-log-wrap'; + logWrap.innerHTML = '
'; + center.appendChild(logWrap); + this.battleLog = logWrap.querySelector('#rcBattleLog'); + + // Pre-build sorted event list for the log + this._buildEventList(); + + // Right panel (losers) + this.rightPanel = document.createElement('div'); + this.rightPanel.className = 'rc-panel rc-panel-lose'; + this._buildTeamPanel(this.rightPanel, false); + + layout.appendChild(this.leftPanel); + layout.appendChild(center); + layout.appendChild(this.rightPanel); + this.container.appendChild(layout); + + // Wire controls + this.playBtn = controls.querySelector('.rc-play'); + this.scrubber = controls.querySelector('.rc-scrub'); + this.timeDisplay = controls.querySelector('.rc-time'); + + this.playBtn.addEventListener('click', () => this._togglePlay()); + this.scrubber.addEventListener('input', () => { + this.currentTime = this.tStart + (this.scrubber.value / 1000) * (this.tEnd - this.tStart); + this._updatePanelDeathStates(); + this._updateBattleLog(); + this.render(); + }); + controls.querySelectorAll('.rc-sp').forEach(btn => { + btn.addEventListener('click', () => { + controls.querySelectorAll('.rc-sp').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + this.speed = parseInt(btn.dataset.speed); + }); + }); + + // Canvas hover — store mouse pos, re-evaluate each frame + this._mouseOnCanvas = false; + this._mouseX = 0; + this._mouseY = 0; + this.canvas.addEventListener('mousemove', (ev) => { + this._mouseOnCanvas = true; + const rect = this.canvas.getBoundingClientRect(); + this._mouseX = (ev.clientX - rect.left) * (this.canvasSize / rect.width); + this._mouseY = (ev.clientY - rect.top) * (this.canvasSize / rect.height); + }); + this.canvas.addEventListener('mouseleave', () => { + this._mouseOnCanvas = false; + this._setHighlight(null); + }); + + // Offscreen map canvas + this.mapCanvas = document.createElement('canvas'); + this.mapCanvas.width = this.canvasSize; + this.mapCanvas.height = this.canvasSize; + this.mapCtx = this.mapCanvas.getContext('2d'); + } + + _buildTeamPanel(panel, isWinner) { + // Show all players on this team, using their first entity (prefer ground) + const teamEntities = this.entities.filter(e => e.playerId > 0 && e.isWinner === isWinner); + const seen = new Set(); + const unique = []; + // First pass: ground entities + for (const e of teamEntities) { + if (e.type === 'ground' && !seen.has(e.playerId)) { seen.add(e.playerId); unique.push(e); } + } + // Second pass: any remaining players (aircraft etc) + for (const e of teamEntities) { + if (!seen.has(e.playerId)) { seen.add(e.playerId); unique.push(e); } + } + + const color = isWinner ? RC.WIN : RC.LOSE; + const dimColor = isWinner ? 'rgba(0,200,0,0.15)' : 'rgba(220,30,30,0.15)'; + // Use squadron clan tag rendered with skyquake font + const firstPlayer = unique.length > 0 ? this.players[unique[0].playerId] : null; + const clanTag = firstPlayer?.clan || ''; + const label = clanTag + ? `${this._esc(clanTag)}` + : (isWinner ? 'Winners' : 'Losers'); + + let html = `
+ ${label} +
`; + + for (const ent of unique) { + const p = this.players[ent.playerId]; + const name = p ? this._esc(p.name) : '?'; + const veh = this._esc(ent.vehicleName); + const panelIcon = ent.miniIcon ? ent.miniIcon.replace('mini:', '') : (ent.iconKey || 'medium'); + html += `
+ +
+ ${name} + ${veh} +
+ +
`; + } + html += '
'; + panel.innerHTML = html; + + // Hover + panel.querySelectorAll('.rc-row').forEach(row => { + row.addEventListener('mouseenter', () => { + const pid = parseInt(row.dataset.playerId); + const eidx = parseInt(row.dataset.entityIndex); + const ent = this.entities.find(e => e.entityIndex === eidx); + if (ent && !this._isEntityGone(ent, this.currentTime)) { + this._setHighlight(pid); + } + }); + row.addEventListener('mouseleave', () => this._setHighlight(null)); + }); + } + + _updatePanelDeathStates() { + const t = this.currentTime; + this.container.querySelectorAll('.rc-row').forEach(row => { + const eidx = parseInt(row.dataset.entityIndex); + const ent = this.entities.find(e => e.entityIndex === eidx); + if (!ent) return; + const dead = this._isEntityDead(ent, t); + const gone = this._isEntityGone(ent, t); + row.classList.toggle('rc-dead', dead); + row.classList.toggle('rc-gone', gone); + const status = row.querySelector('.rc-row-status'); + if (gone) { + status.innerHTML = ''; + row.style.cursor = 'default'; + } else if (dead) { + status.innerHTML = ''; + row.style.cursor = 'default'; + } else { + status.innerHTML = ''; + row.style.cursor = 'pointer'; + } + }); + } + + _setHighlight(playerId) { + if (this.highlightedPlayerId === playerId) return; + this.highlightedPlayerId = playerId; + this.container.querySelectorAll('.rc-row').forEach(row => { + row.classList.toggle('rc-hl', parseInt(row.dataset.playerId) === playerId); + }); + if (!this.playing) this.render(); + } + + _esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } + + _buildEventList() { + // Merge kills and damages into a single sorted timeline + this._events = []; + for (const k of this.data.kills) { + const killer = this.players[k.killerId]; + // Find victim name + let victimName = '?'; + let victimTeam = -1; + if (k.victimId && this.players[k.victimId]) { + victimName = this.players[k.victimId].name; + victimTeam = this.players[k.victimId].team; + } else if (k.victimVehicle) { + victimName = k.victimVehicle; + } + let html; + if (!killer) { + // No killer (crash / environment kill) + const victimIsWin = victimTeam === this.data.teamWon; + html = `${this._esc(victimName)}` + + ` ${replayT('replay.crashed')}`; + } else { + const killerIsWin = killer.team === this.data.teamWon; + html = `${this._esc(killer.name)}` + + ` ${replayT('replay.destroyed')} ` + + `${this._esc(victimName)}` + + (k.weapon ? `[${this._esc(k.weapon)}]` : ''); + } + this._events.push({ + time: k.time, + type: 'kill', + html, + }); + } + for (const dm of this.data.damages) { + const atk = this.players[dm.offenderId]; + const vic = this.players[dm.offendedId]; + if (!atk || !vic) continue; + const atkIsWin = atk.team === this.data.teamWon; + this._events.push({ + time: dm.time, + type: 'damage', + html: `${this._esc(atk.name)}` + + ` ${replayT('replay.hit')} ` + + `${this._esc(vic.name)}`, + }); + } + this._events.sort((a, b) => a.time - b.time); + this._lastLogIndex = -1; + } + + _updateBattleLog() { + const t = this.currentTime; + // Find how many events should be visible + let idx = -1; + for (let i = 0; i < this._events.length; i++) { + if (this._events[i].time <= t) idx = i; + else break; + } + if (idx === this._lastLogIndex) return; + this._lastLogIndex = idx; + // Rebuild log content + const log = this.battleLog; + log.innerHTML = ''; + for (let i = 0; i <= idx; i++) { + const ev = this._events[i]; + const el = document.createElement('div'); + el.className = `rc-ev rc-ev-${ev.type}`; + const elapsed = (ev.time - this.tStart) / 1000; + const mm = Math.floor(elapsed / 60); + const ss = Math.floor(elapsed % 60); + el.innerHTML = `${mm}:${String(ss).padStart(2,'0')}${ev.html}`; + log.appendChild(el); + } + // Auto-scroll to bottom + log.scrollTop = log.scrollHeight; + } + + _getTintedIcon(iconKey, color, size) { + const cacheKey = `${iconKey}_${color}_${size}`; + if (!this._tintCache) this._tintCache = {}; + if (this._tintCache[cacheKey]) return this._tintCache[cacheKey]; + const img = this._iconCache?.[iconKey]; + if (!img || !img.naturalWidth) return null; + try { + const c = document.createElement('canvas'); + c.width = size; c.height = size; + const cx = c.getContext('2d'); + const [dx, dy, dw, dh] = this._containedImageRect(img, size); + cx.drawImage(img, dx, dy, dw, dh); + cx.globalCompositeOperation = 'source-atop'; + cx.fillStyle = color; + cx.fillRect(0, 0, size, size); + cx.globalCompositeOperation = 'source-over'; + this._tintCache[cacheKey] = c; + return c; + } catch (e) { + // CORS or other canvas tainting — fall back to untinted + this._tintCache[cacheKey] = null; + return null; + } + } + + _containedImageRect(img, size) { + const ratio = img.naturalWidth && img.naturalHeight ? img.naturalWidth / img.naturalHeight : 1; + let w = size, h = size; + if (ratio > 1) h = size / ratio; + else w = size * ratio; + return [(size - w) / 2, (size - h) / 2, w, h]; + } + + _drawContainedIcon(ctx, img, x, y, size) { + const [dx, dy, dw, dh] = this._containedImageRect(img, size); + ctx.drawImage(img, x - size / 2 + dx, y - size / 2 + dy, dw, dh); + } + + async _loadEntityIcons() { + this._iconCache = {}; + this._iconDebug = {}; + const keysToLoad = new Set(); + for (const ent of this.entities) { + if (ent.miniIcon) { + const miniKey = ent.miniIcon.replace('mini:', ''); + keysToLoad.add(miniKey); + ent._canvasIconKey = miniKey; + } else if (ent.iconKey) { + keysToLoad.add(ent.iconKey); + ent._canvasIconKey = ent.iconKey; + } + } + const promises = []; + for (const key of keysToLoad) { + promises.push(new Promise((resolve) => { + const img = new Image(); + img.onload = () => { + this._iconCache[key] = img; + this._iconDebug[key] = 'ok'; + resolve(); + }; + img.onerror = () => { + this._iconDebug[key] = 'err'; + resolve(); + }; + img.src = `/api/icons/type/${key}`; + })); + } + await Promise.all(promises); + } + + async _loadMap() { + const level = this.data.mission?.level; + if (!level) { this.mapCtx.fillStyle = '#111'; this.mapCtx.fillRect(0, 0, this.canvasSize, this.canvasSize); return; } + + const loadImg = (src) => new Promise((resolve) => { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => resolve(img); + img.onerror = () => resolve(null); + img.src = src; + }); + + // Load ground (tank) map + this._groundMapImg = await loadImg(`/api/match/minimap/${level}`); + + // Load full (air) map if available + if (this._fullMapLevel) { + this._airMapImg = await loadImg(`/api/match/minimap/${this._fullMapLevel}?type=full`); + } else { + this._airMapImg = null; + } + + this._drawMapToCanvas(); + } + + _drawMapToCanvas() { + const img = this._mode === 'air' ? (this._airMapImg || this._groundMapImg) : this._groundMapImg; + if (!img) { + this.mapCtx.fillStyle = '#111'; + this.mapCtx.fillRect(0, 0, this.canvasSize, this.canvasSize); + return; + } + const { u, v, w, h } = this._mapSrc; + const sx = u * img.naturalWidth, sy = v * img.naturalHeight; + const sw = w * img.naturalWidth, sh = h * img.naturalHeight; + this.mapCtx.drawImage(img, sx, sy, sw, sh, 0, 0, this.canvasSize, this.canvasSize); + } + + setMode(mode) { + if (mode === this._mode) return; + if (mode === 'air' && !this.hasAirMode) return; + this._mode = mode; + + // Swap coordinate system + const coords = mode === 'air' ? this._airCoords : this._groundCoords; + this.x0 = coords.x0; + this.z0 = coords.z0; + this.xRange = coords.x1 - coords.x0; + this.zRange = coords.z1 - coords.z0; + this._mapSrc = { u: 0, v: 0, w: 1, h: 1 }; + + // Recompute autocrop for the new entity filter + this._computeAutocrop(); + // Redraw map background with new crop region + this._drawMapToCanvas(); + // Recompute death positions in new coordinate space + this._computeDeaths(); + // Render immediately + this.render(); + } + + _togglePlay() { + this.playing = !this.playing; + this.playBtn.innerHTML = this.playing ? '' : ''; + if (this.playing) { + if (this.currentTime >= this.tEnd) this.currentTime = this.tStart; + this.lastFrameTime = performance.now(); + } + } + + _tick(now) { + if (this.playing) { + const dt = now - this.lastFrameTime; + this.lastFrameTime = now; + this.currentTime += dt * this.speed; + if (this.currentTime >= this.tEnd) { + this.currentTime = this.tEnd; + this.playing = false; + this.playBtn.innerHTML = ''; + } + } + this.render(); + this._updateControls(); + // Update panel death states + battle log every ~250ms + if (!this._lastPanelUpdate || now - this._lastPanelUpdate > 250) { + this._updatePanelDeathStates(); + this._updateBattleLog(); + this._lastPanelUpdate = now; + } + this.animFrameId = requestAnimationFrame(this._tick); + } + + _updateControls() { + const frac = (this.currentTime - this.tStart) / (this.tEnd - this.tStart); + const pct = Math.round(frac * 1000); + this.scrubber.value = pct; + this.scrubber.style.setProperty('--rc-progress', (frac * 100).toFixed(1) + '%'); + const cur = (this.currentTime - this.tStart) / 1000; + const total = (this.tEnd - this.tStart) / 1000; + const fmt = (s) => `${Math.floor(s/60)}:${String(Math.floor(s%60)).padStart(2,'0')}`; + this.timeDisplay.textContent = `${fmt(cur)} / ${fmt(total)}`; + } + + _updateCanvasHighlight() { + if (!this._mouseOnCanvas) return; + let bestId = null, bestDist = 400; + for (const ent of this.entities) { + if (ent.playerId === 0) continue; + if (this._isEntityGone(ent, this.currentTime)) continue; + const pos = this._entityScreenPos(ent, this.currentTime); + if (!pos) continue; + const dx = pos[0] - this._mouseX, dy = pos[1] - this._mouseY; + const dist = dx * dx + dy * dy; + if (dist < bestDist) { bestDist = dist; bestId = ent.playerId; } + } + this._setHighlight(bestId); + } + + render() { + const ctx = this.ctx; + const t = this.currentTime; + this._updateCanvasHighlight(); + ctx.drawImage(this.mapCanvas, 0, 0); + this._drawTrails(ctx, t); + this._drawDamageLines(ctx, t); + this._drawKillLines(ctx, t); + this._drawEntities(ctx, t); + } + + _drawTrails(ctx, time) { + for (const ent of this.entities) { + if (this._isEntityGone(ent, time)) continue; + const endT = ent.deathTime !== null ? Math.min(time, ent.deathTime) : time; + const trailLen = ent.type === 'ground' ? RC.TRAIL_MS + : ent.type === 'aircraft' ? (this._mode === 'air' ? RC.TRAIL_MS : RC.AIR_TRAIL_MS) + : RC.DRONE_TRAIL_MS; + const tMin = endT - trailLen; + const baseColor = ent.isWinner ? RC.WIN_TRAIL : RC.LOSE_TRAIL; + const { times, positions } = ent; + + ctx.lineWidth = ent.type === 'ground' ? 2 : (this._mode === 'air' ? 2 : 1.5); + ctx.lineCap = 'round'; + + // Aircraft in air mode: interpolate at fixed time steps so trail + // segments are always pixel-visible (raw path points can be sub-pixel + // apart at full-map scale). + if (this._mode === 'air' && ent.type === 'aircraft') { + const step = 200; // ms between interpolated trail points + let prevPx = null, prevPy = null; + for (let t = Math.max(tMin, times[0]); t <= Math.min(endT, times[times.length - 1]); t += step) { + const pos = this.getPositionAtTime(ent, t); + if (!pos) continue; + const [px, py] = pos; + if (prevPx !== null) { + const age = time - t; + const alpha = Math.max(0.08, 1 - age / trailLen); + ctx.strokeStyle = baseColor + alpha.toFixed(2) + ')'; + ctx.beginPath(); + ctx.moveTo(prevPx, prevPy); + ctx.lineTo(px, py); + ctx.stroke(); + } + prevPx = px; prevPy = py; + } + continue; + } + + let prevPx = null, prevPy = null; + for (let i = 0; i < times.length; i++) { + if (times[i] < tMin) continue; + if (times[i] > endT) break; + const [px, py] = this.worldToPixel(positions[i*2], positions[i*2+1]); + if (prevPx !== null) { + const age = time - times[i]; + const alpha = Math.max(0.08, 1 - age / trailLen); + ctx.strokeStyle = baseColor + alpha.toFixed(2) + ')'; + ctx.beginPath(); + ctx.moveTo(prevPx, prevPy); + ctx.lineTo(px, py); + ctx.stroke(); + } + prevPx = px; prevPy = py; + } + } + } + + _drawDamageLines(ctx, time) { + for (const dm of this.data.damages) { + const age = time - dm.time; + if (age < 0 || age > RC.DMG_TTL) continue; + // Find attacker and victim positions at damage time + const attacker = this.entities.find(e => e.playerId === dm.offenderId); + const victim = this.entities.find(e => e.playerId === dm.offendedId); + if (!attacker || !victim) continue; + const aPos = this.getPositionAtTime(attacker, dm.time); + const vPos = this.getPositionAtTime(victim, dm.time); + if (!aPos || !vPos) continue; + const alpha = Math.max(0, 1 - age / RC.DMG_TTL); + ctx.globalAlpha = alpha * 0.4; + ctx.strokeStyle = '#ffcc44'; + ctx.lineWidth = 1; + ctx.setLineDash([3, 4]); + ctx.beginPath(); ctx.moveTo(aPos[0], aPos[1]); ctx.lineTo(vPos[0], vPos[1]); ctx.stroke(); + ctx.setLineDash([]); + ctx.globalAlpha = 1; + } + } + + _drawKillLines(ctx, time) { + for (const k of this.data.kills) { + const age = time - k.time; + if (age < 0 || age > RC.KILL_TTL) continue; + if (!k.killerPos || !k.victimPos) continue; + const alpha = Math.max(0, 1 - age / RC.KILL_TTL); + const [kx, ky] = this.worldToPixel(k.killerPos.x, k.killerPos.z); + const [vx, vy] = this.worldToPixel(k.victimPos.x, k.victimPos.z); + + ctx.globalAlpha = alpha * 0.6; + ctx.strokeStyle = '#ff3333'; + ctx.lineWidth = 1.5; + ctx.setLineDash([4, 3]); + ctx.beginPath(); ctx.moveTo(kx, ky); ctx.lineTo(vx, vy); ctx.stroke(); + ctx.setLineDash([]); + + // X marker + ctx.globalAlpha = alpha * 0.9; + ctx.strokeStyle = '#ff3333'; + ctx.lineWidth = 2; + const s = 5; + ctx.beginPath(); + ctx.moveTo(vx-s, vy-s); ctx.lineTo(vx+s, vy+s); + ctx.moveTo(vx+s, vy-s); ctx.lineTo(vx-s, vy+s); + ctx.stroke(); + + // Weapon label + if (k.weapon && alpha > 0.4) { + ctx.font = '600 9px system-ui, sans-serif'; + ctx.fillStyle = `rgba(255,230,100,${(alpha * 0.85).toFixed(2)})`; + ctx.fillText(k.weapon, (kx+vx)/2 + 6, (ky+vy)/2 - 6); + } + ctx.globalAlpha = 1; + } + } + + _drawEntities(ctx, time) { + const hl = this.highlightedPlayerId; + + // Draw dead entities first (fading) + for (const ent of this.entities) { + if (!this._isEntityDead(ent, time)) continue; + if (this._isEntityGone(ent, time)) continue; + const pos = ent.deathPos; + if (!pos) continue; + const [px, py] = pos; + const fade = 1 - (time - ent.deathTime) / RC.GHOST_TTL; + ctx.globalAlpha = Math.max(0, fade * 0.5); + ctx.fillStyle = '#333'; + const r = ent.type === 'ground' ? RC.DOT_R : ent.type === 'aircraft' ? RC.AIR_R : RC.DRONE_R; + ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI*2); ctx.fill(); + ctx.globalAlpha = 1; + } + + // Draw alive entities + for (const ent of this.entities) { + if (this._isEntityDead(ent, time)) continue; + const pos = this.getPositionAtTime(ent, time); + if (!pos) continue; + const [px, py] = pos; + if (px < -20 || py < -20 || px > this.canvasSize+20 || py > this.canvasSize+20) continue; + + let alpha = 1; + if (hl !== null && ent.playerId !== hl && ent.playerId !== 0) alpha = 0.25; + + const color = ent.isWinner ? RC.WIN : RC.LOSE; + const iconSize = ent.type === 'ground' ? 12 : ent.type === 'aircraft' ? 20 : 14; + const iconImg = this._iconCache?.[ent._canvasIconKey]; + + ctx.globalAlpha = alpha; + + // Highlight ring + if (hl === ent.playerId && ent.playerId !== 0) { + const hr = iconSize / 2 + 5; + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(px, py, hr, 0, Math.PI*2); ctx.stroke(); + ctx.strokeStyle = color; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.arc(px, py, hr - 2, 0, Math.PI*2); ctx.stroke(); + } + + if (iconImg && iconImg.naturalWidth) { + const s = iconSize; + const tinted = this._getTintedIcon(ent._canvasIconKey, color, s); + const drawSrc = tinted || iconImg; + // Rotate aircraft/drones to face their heading + if (ent.type === 'aircraft' || ent.type === 'drone') { + const heading = this.getHeadingAtTime(ent, time); + if (heading !== null) { + ctx.save(); + ctx.translate(px, py); + ctx.rotate(heading); + if (tinted) ctx.drawImage(drawSrc, -s/2, -s/2); + else this._drawContainedIcon(ctx, drawSrc, 0, 0, s); + ctx.restore(); + } else { + if (tinted) ctx.drawImage(drawSrc, px-s/2, py-s/2); + else this._drawContainedIcon(ctx, drawSrc, px, py, s); + } + } else { + if (tinted) ctx.drawImage(drawSrc, px-s/2, py-s/2); + else this._drawContainedIcon(ctx, drawSrc, px, py, s); + } + } else { + // Fallback: circle dot + const r = ent.type === 'ground' ? RC.DOT_R : ent.type === 'aircraft' ? RC.AIR_R : RC.DRONE_R; + ctx.fillStyle = color; + ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI*2); ctx.fill(); + ctx.strokeStyle = 'rgba(0,0,0,0.5)'; + ctx.lineWidth = 1; + ctx.stroke(); + } + ctx.globalAlpha = 1; + } + } + + destroy() { + if (this.animFrameId) { cancelAnimationFrame(this.animFrameId); this.animFrameId = null; } + this.container.innerHTML = ''; + } +} + +window.ReplayCanvas = ReplayCanvas; diff --git a/web/public/js/seasons-filter.js b/web/public/js/seasons-filter.js new file mode 100644 index 0000000..a5c51f3 --- /dev/null +++ b/web/public/js/seasons-filter.js @@ -0,0 +1,332 @@ +// Seasons Filter Utility +// Parses and manages season/week data from the seasons constant file + +class SeasonsFilter { + constructor() { + this.seasons = []; + this.loaded = false; + } + + // Parse seasons text content into structured data + parseSeasons(content) { + const seasons = []; + const lines = content.split('\n'); + let currentSeason = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + if (!line) continue; + + // Check if it's a season header (e.g., "2025-V") + const seasonMatch = line.match(/^(\d{4}-[IVX]+)$/); + if (seasonMatch) { + if (currentSeason) { + seasons.push(currentSeason); + } + currentSeason = { + name: seasonMatch[1], + weeks: [] + }; + continue; + } + + // Check if it's a week line + // Format: "week 1 (01.09 – 07.09) : max BR 14.0" + // or: "until eos (27.10 – 31.10) : max BR 5.0" + const weekMatch = line.match(/(?:week\s+(\d+)|until eos)\s+\((\d{2})\.(\d{2})\s*[–—]\s*(\d{2})\.(\d{2})\)\s*/); + if (weekMatch && currentSeason) { + const weekNum = weekMatch[1] ? parseInt(weekMatch[1]) : null; + const startDay = parseInt(weekMatch[2]); + const startMonth = parseInt(weekMatch[3]); + const endDay = parseInt(weekMatch[4]); + const endMonth = parseInt(weekMatch[5]); + const timestamp = parseInt(weekMatch[6]); + + // Extract max BR if present (preserve .0 formatting) + const brMatch = line.match(/max BR ([\d.]+)/); + const maxBR = brMatch ? brMatch[1] : null; // Keep as string to preserve ".0" + + // Use the Unix timestamp directly from the file (it's already correct!) + // The timestamp is the START of the week + const startDate = new Date(timestamp * 1000); // Convert to milliseconds + + // Calculate end date: 7 days from start (or until end of season for "until eos") + // For "until eos" entries, we'll need to calculate based on the actual end day + let endDate; + + // Determine year from season name (e.g., "2025-V" -> 2025) + const yearMatch = currentSeason.name.match(/^(\d{4})/); + const year = yearMatch ? parseInt(yearMatch[1]) : new Date().getFullYear(); + + // Build end date from the parsed end day/month + // Need to handle year rollover (e.g., December -> January) + let endYear = year; + if (endMonth < startMonth) { + endYear = year + 1; // Crossed into next year + } + endDate = new Date(endYear, endMonth - 1, endDay, 23, 59, 59, 999); + + // Build display name with max BR (preserve decimal formatting like 14.0) + let displayName; + if (weekNum) { + displayName = `Week ${weekNum} (${weekMatch[2]}.${weekMatch[3]} – ${weekMatch[4]}.${weekMatch[5]})`; + if (maxBR) displayName += ` - BR ${maxBR}`; + } else { + displayName = `Until EOS (${weekMatch[2]}.${weekMatch[3]} – ${weekMatch[4]}.${weekMatch[5]})`; + if (maxBR) displayName += ` - BR ${maxBR}`; + } + + currentSeason.weeks.push({ + weekNumber: weekNum, + name: weekNum ? `Week ${weekNum}` : 'Until EOS', + startDate: startDate, + endDate: endDate, + startTimestamp: timestamp, // Store original Unix timestamp (seconds) + maxBR: maxBR, + displayName: displayName + }); + } + } + + // Add the last season + if (currentSeason) { + seasons.push(currentSeason); + } + + // Post-process: set each week's endDate to the next week's startDate - 1ms + // This uses the exact Unix timestamps instead of the approximate text-parsed dates + seasons.forEach(season => { + season.weeks.forEach((week, i) => { + if (i + 1 < season.weeks.length) { + week.endDate = new Date(season.weeks[i + 1].startDate.getTime() - 1); + } + // Last week of each season keeps its text-parsed endDate + }); + }); + + return seasons; + } + + // Load seasons data from the constant file + async loadSeasons() { + if (this.loaded) { + return this.seasons; + } + + try { + const response = await fetch('/constants/seasons'); + if (!response.ok) { + throw new Error(`Failed to load seasons: ${response.status}`); + } + + const content = await response.text(); + this.seasons = this.parseSeasons(content); + this.loaded = true; + + console.log('[Seasons Filter] Loaded seasons:', this.seasons); + return this.seasons; + } catch (error) { + console.error('[Seasons Filter] Error loading seasons:', error); + this.seasons = []; + this.loaded = false; + return []; + } + } + + // Get all seasons + getSeasons() { + return this.seasons; + } + + // Get a specific season by name + getSeason(seasonName) { + return this.seasons.find(s => s.name === seasonName); + } + + // Get current season (based on current date) + getCurrentSeason() { + const now = new Date(); + + for (const season of this.seasons) { + for (const week of season.weeks) { + if (now >= week.startDate && now <= week.endDate) { + return season; + } + } + } + + // If no current season found, return the most recent one + return this.seasons.length > 0 ? this.seasons[this.seasons.length - 1] : null; + } + + // Get current week (based on current date) + getCurrentWeek() { + const now = new Date(); + + for (const season of this.seasons) { + for (const week of season.weeks) { + if (now >= week.startDate && now <= week.endDate) { + return { season, week }; + } + } + } + + return null; + } + + // Get date range for a specific season + getSeasonDateRange(seasonName) { + const season = this.getSeason(seasonName); + if (!season || season.weeks.length === 0) { + return null; + } + + return { + startDate: season.weeks[0].startDate, + endDate: season.weeks[season.weeks.length - 1].endDate, + season: season + }; + } + + // Get date range for a specific week in a season + getWeekDateRange(seasonName, weekNumber) { + const season = this.getSeason(seasonName); + if (!season) { + return null; + } + + const week = season.weeks.find(w => w.weekNumber === weekNumber); + if (!week) { + return null; + } + + return { + startDate: week.startDate, + endDate: week.endDate, + week: week + }; + } + + // Get all unique BR values across all seasons (returns array of strings, preserving ".0") + getAllBRValues() { + const brSet = new Set(); + + this.seasons.forEach(season => { + season.weeks.forEach(week => { + if (week.maxBR) { + brSet.add(week.maxBR); + } + }); + }); + + // Sort descending (convert to float for comparison, but keep as strings) + return Array.from(brSet).sort((a, b) => parseFloat(b) - parseFloat(a)); + } + + // Get all weeks that match a specific BR + getWeeksByBR(maxBR) { + const matchingWeeks = []; + + this.seasons.forEach(season => { + season.weeks.forEach(week => { + if (week.maxBR === maxBR) { + matchingWeeks.push({ + season: season, + week: week, + displayName: `${season.name} - ${week.name}` + }); + } + }); + }); + + return matchingWeeks; + } + + // Get combined date range for all weeks with a specific BR (for filtering multiple weeks) + getDateRangeForBR(maxBR) { + const weeks = this.getWeeksByBR(maxBR); + + if (weeks.length === 0) { + return null; + } + + // Return array of date ranges (one for each week with this BR) + return weeks.map(({ season, week }) => ({ + startDate: week.startDate, + endDate: week.endDate, + seasonName: season.name, + weekName: week.name, + displayName: `${season.name} - ${week.displayName}` + })); + } + + // Populate a select element with season options + populateSeasonSelect(selectElement, includeAllOption = true) { + selectElement.innerHTML = ''; + + if (includeAllOption) { + const allOption = document.createElement('option'); + allOption.value = 'all'; + allOption.textContent = window.__t ? window.__t('leaderboard.allSeasons') : 'All Seasons'; + selectElement.appendChild(allOption); + } + + // Add seasons in reverse order (most recent first) + for (let i = this.seasons.length - 1; i >= 0; i--) { + const season = this.seasons[i]; + const option = document.createElement('option'); + option.value = season.name; + option.textContent = season.name; + selectElement.appendChild(option); + } + } + + // Populate a select element with BR options + populateBRSelect(selectElement, includeAllOption = true) { + selectElement.innerHTML = ''; + + if (includeAllOption) { + const allOption = document.createElement('option'); + allOption.value = ''; + allOption.textContent = window.__t ? window.__t('leaderboard.allBR') : 'All BR'; + selectElement.appendChild(allOption); + } + + const brValues = this.getAllBRValues(); + brValues.forEach(br => { + const option = document.createElement('option'); + option.value = br; + option.textContent = `BR ${br}`; + selectElement.appendChild(option); + }); + } + + // Populate a select element with week options for a given season + populateWeekSelect(selectElement, seasonName, includeAllOption = true) { + selectElement.innerHTML = ''; + + if (includeAllOption) { + const allOption = document.createElement('option'); + allOption.value = 'all'; + allOption.textContent = window.__t ? window.__t('leaderboard.allWeeks') : 'All Weeks'; + selectElement.appendChild(allOption); + } + + const season = this.getSeason(seasonName); + if (!season) { + return; + } + + season.weeks.forEach(week => { + const option = document.createElement('option'); + option.value = week.weekNumber !== null ? week.weekNumber.toString() : 'final'; + option.textContent = week.displayName; + selectElement.appendChild(option); + }); + } +} + +// Global instance +window.seasonsFilter = window.seasonsFilter || new SeasonsFilter(); + diff --git a/web/public/js/vehicle-i18n.js b/web/public/js/vehicle-i18n.js new file mode 100644 index 0000000..781a8c3 --- /dev/null +++ b/web/public/js/vehicle-i18n.js @@ -0,0 +1,227 @@ +// Client-side vehicle name translator. +// +// Loads the localized vehicle name map produced by the bot's +// init_vehicle_translation_cache() (BOT/utils.py) and exposes: +// +// 1. window.vehicleI18n.translate(internal, fallback, lang?) +// Synchronous lookup. Returns the localized name when available, +// otherwise English, otherwise the supplied fallback. +// +// 2. window.vehicleI18n.apply(root?) +// Walks `root` (defaults to ) and rewrites the textContent of +// every element carrying `data-vehicle-internal=""` to the +// localized name. The element's *original* text is captured into +// `data-vehicle-fallback` on first apply so re-applies (e.g. after +// lang switch) don't lose the fallback. +// +// On startup the module: +// - Eagerly loads the multilang map (via window.apiClient — /api/* is +// gated by the website's apiSecurityCheck middleware). +// - On first successful load + DOMContentLoaded, runs apply(document.body) +// and installs a MutationObserver so any future inserts auto-translate. +// +// Server-side EJS or client-side template strings only need to add the +// data attribute — no per-page wiring required. + +(function () { + const STORAGE_KEY = 'vehicleI18nCache_v2'; + const TTL_MS = 24 * 60 * 60 * 1000; + + let _map = null; // { internal: { en, ru, ... } } + let _source = null; // 'multilang' | 'english_only' | 'none' + let _loadingPromise = null; + let _observerInstalled = false; + + function readCache() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (!parsed || !parsed.fetchedAt || (Date.now() - parsed.fetchedAt) > TTL_MS) return null; + if (parsed.source !== 'multilang') return null; + return parsed.vehicles || null; + } catch (e) { + return null; + } + } + + function writeCache(vehicles, source) { + if (source !== 'multilang') return; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ fetchedAt: Date.now(), source, vehicles })); + } catch (e) { + // quota exceeded or storage disabled + } + } + + function fireReady(detail) { + // Defer to next tick so any listeners installed after this module + // executes still see the event. + setTimeout(() => document.dispatchEvent(new CustomEvent('vehicle-i18n-ready', { detail })), 0); + } + + async function ensureLoaded() { + if (_map) return _map; + const cached = readCache(); + if (cached) { + _map = cached; + _source = 'multilang'; + fireReady({ source: 'multilang', cached: true }); + return _map; + } + if (_loadingPromise) return _loadingPromise; + _loadingPromise = (async () => { + try { + if (!window.apiClient) { + await new Promise((resolve) => { + const tick = () => window.apiClient ? resolve() : setTimeout(tick, 50); + tick(); + }); + } + const body = await window.apiClient.request('/api/i18n/vehicles'); + _map = body && body.vehicles ? body.vehicles : {}; + _source = body && body.source ? body.source : 'none'; + writeCache(_map, _source); + fireReady({ source: _source }); + return _map; + } catch (e) { + console.error('vehicle i18n load failed', e); + _map = {}; + _source = 'none'; + return _map; + } finally { + _loadingPromise = null; + } + })(); + return _loadingPromise; + } + + function currentLang() { + const m = (document.cookie || '').match(/(?:^|;\s*)lang=([\w-]+)/); + return (m && m[1]) || (document.documentElement.lang || 'en'); + } + + // Case-insensitive map lookup. The DB historically stored mixed casings of + // vehicle_internal (e.g. germ_leopard_I vs germ_leopard_i), and the API + // now lowercases them; the bot's translation cache may or may not match + // that casing depending on its source. Try the literal key first, then + // lowercase, then a one-time-built lowercased index for everything else. + let _lowerIndex = null; + function lookup(internal) { + if (!_map || !internal) return null; + if (_map[internal]) return _map[internal]; + const lower = String(internal).toLowerCase(); + if (_map[lower]) return _map[lower]; + if (!_lowerIndex) { + _lowerIndex = {}; + for (const k of Object.keys(_map)) _lowerIndex[k.toLowerCase()] = _map[k]; + } + return _lowerIndex[lower] || null; + } + + function translate(internal, fallback, lang) { + const lng = lang || currentLang(); + if (!_map || !internal) return fallback || internal || ''; + const entry = lookup(internal); + if (!entry) return fallback || internal; + const localized = entry[lng] || entry.en || fallback || internal; + // The player-rendered display the DB stores can have a leading + // country-leak / event glyph (▄ ◘ ◢ ␗ etc.) that WT's client prepends + // at draw time. The translation map only knows the canonical name + // without that prefix, so naive replacement would lose it — making + // e.g. "▄F-16A ADF" (Italy) and "F-16A ADF" (no leak) look identical. + // Preserve any leading run of non-letter / non-digit / non-space chars + // from the original fallback when the translation doesn't already + // include it. + if (fallback) { + const m = String(fallback).match(/^[^\p{L}\p{N}\s]+/u); + if (m && !localized.startsWith(m[0])) return m[0] + localized; + } + return localized; + } + + function applyToElement(el) { + if (!el || !el.dataset || !el.dataset.vehicleInternal) return; + const internal = el.dataset.vehicleInternal; + if (!internal) return; + // Capture the rendered fallback once so language re-switches still have + // something to fall back on if the map ever loses an entry. + if (el.dataset.vehicleFallback === undefined) { + el.dataset.vehicleFallback = el.textContent || ''; + } + const lng = currentLang(); + // Cache the lang we last applied so we don't fight the DOM on every + // mutation tick. + if (el.dataset.vehicleAppliedLang === lng) return; + el.textContent = translate(internal, el.dataset.vehicleFallback, lng); + el.dataset.vehicleAppliedLang = lng; + } + + function apply(root) { + if (!_map) return; + const node = root || document.body; + if (!node) return; + if (node.nodeType === 1 && node.dataset && node.dataset.vehicleInternal) { + applyToElement(node); + } + if (node.querySelectorAll) { + node.querySelectorAll('[data-vehicle-internal]').forEach(applyToElement); + } + } + + function installMutationObserver() { + if (_observerInstalled || typeof MutationObserver === 'undefined' || !document.body) return; + _observerInstalled = true; + const obs = new MutationObserver((mutations) => { + for (const m of mutations) { + for (const node of m.addedNodes) { + if (node.nodeType === 1) apply(node); + } + if (m.type === 'attributes' && m.target && m.target.dataset && m.target.dataset.vehicleInternal) { + delete m.target.dataset.vehicleAppliedLang; + applyToElement(m.target); + } + } + }); + obs.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['data-vehicle-internal'], + }); + } + + function autoApply() { + if (!document.body) return; + apply(document.body); + installMutationObserver(); + } + + document.addEventListener('vehicle-i18n-ready', autoApply); + + window.vehicleI18n = { + ensureLoaded, + translate, + apply, + get ready() { return _map !== null; }, + get source() { return _source; }, + get currentLang() { return currentLang(); }, + invalidate() { + try { localStorage.removeItem(STORAGE_KEY); } catch (e) { /* ignore */ } + _map = null; + _source = null; + _lowerIndex = null; + // Also clear apply-state markers so a refresh re-translates. + document.querySelectorAll('[data-vehicle-applied-lang]').forEach(el => { + delete el.dataset.vehicleAppliedLang; + }); + }, + }; + + // Kick off load. apply() runs from the ready event handler above. + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', ensureLoaded); + } else { + ensureLoaded(); + } +})(); diff --git a/web/public/robots.txt b/web/public/robots.txt new file mode 100644 index 0000000..8e7c3fc --- /dev/null +++ b/web/public/robots.txt @@ -0,0 +1,8 @@ +# Robots.txt for Toothless SQB Bot Website +# This file prevents web crawlers from scraping the site + +User-agent: * +Disallow: / + +# Block all crawlers from all content +# This includes search engines, scrapers, and bots \ No newline at end of file diff --git a/web/server.js b/web/server.js new file mode 100644 index 0000000..c3ac1f7 --- /dev/null +++ b/web/server.js @@ -0,0 +1,2941 @@ +const express = require('express'); +const path = require('path'); +const cors = require('cors'); +const fetch = require('node-fetch'); +const crypto = require('crypto'); +const { exec, execFile } = require('child_process'); +const compression = require('compression'); + +// Load environment variables if dotenv is available +try { + require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') }); +} catch (err) { + // dotenv not installed, continue without it (production uses env vars) +} + +const fs = require('fs'); +const fsp = require('fs/promises'); +const seasonsUtil = require('./utils/seasons'); + +// ── i18n: load translation files ── +const locales = { + en: JSON.parse(fs.readFileSync(path.join(__dirname, 'locales', 'en.json'), 'utf8')), + ru: JSON.parse(fs.readFileSync(path.join(__dirname, 'locales', 'ru.json'), 'utf8')), + fr: JSON.parse(fs.readFileSync(path.join(__dirname, 'locales', 'fr.json'), 'utf8')), + it: JSON.parse(fs.readFileSync(path.join(__dirname, 'locales', 'it.json'), 'utf8')), + uk: JSON.parse(fs.readFileSync(path.join(__dirname, 'locales', 'uk.json'), 'utf8')), + de: JSON.parse(fs.readFileSync(path.join(__dirname, 'locales', 'de.json'), 'utf8')), + es: JSON.parse(fs.readFileSync(path.join(__dirname, 'locales', 'es.json'), 'utf8')), + pl: JSON.parse(fs.readFileSync(path.join(__dirname, 'locales', 'pl.json'), 'utf8')), + cs: JSON.parse(fs.readFileSync(path.join(__dirname, 'locales', 'cs.json'), 'utf8')), + 'zh-CN': JSON.parse(fs.readFileSync(path.join(__dirname, 'locales', 'zh-CN.json'), 'utf8')) +}; + +function mergeLocale(base, override) { + if (!override || typeof override !== 'object' || Array.isArray(override)) { + return override ?? base; + } + const out = { ...base }; + for (const [key, val] of Object.entries(override)) { + const baseVal = base && base[key]; + out[key] = val && typeof val === 'object' && !Array.isArray(val) + ? mergeLocale(baseVal && typeof baseVal === 'object' ? baseVal : {}, val) + : val; + } + return out; +} + +function getLang(req) { + // 1. query param 2. cookie 3. default + if (req.query.lang && locales[req.query.lang]) return req.query.lang; + const cookies = req.headers.cookie || ''; + const match = cookies.match(/(?:^|;\s*)lang=([\w-]+)/); + if (match && locales[match[1]]) return match[1]; + return 'en'; +} + +function t(lang, key) { + const parts = key.split('.'); + let val = locales[lang]; + for (const p of parts) { val = val && val[p]; } + if (val !== undefined) return val; + // fallback to English + val = locales.en; + for (const p of parts) { val = val && val[p]; } + return val !== undefined ? val : key; +} + +function getRosterMemberByUid(uid) { + return new Promise((resolve) => { + if (!squadronsDb || !uid) return resolve(null); + squadronsDb.get( + ` + SELECT sm.uid, sm.nick, sm.clan_id, sm.points, sd.tag_name, sd.short_name, sd.long_name + FROM squadron_members sm + LEFT JOIN squadrons_data sd ON sm.clan_id = sd.clan_id + WHERE sm.uid = ? + LIMIT 1 + `, + [uid], + (err, row) => { + if (err) { + log.error('Failed to load roster member by UID', err, { uid }); + return resolve(null); + } + resolve(row || null); + } + ); + }); +} + +const sqlite3 = require('sqlite3').verbose(); +const STORAGE_ROOT = (process.env.SREBOT_STORAGE_VOL_PATH || '').trim(); +if (!STORAGE_ROOT) { + throw new Error('SREBOT_STORAGE_VOL_PATH must be set'); +} +const REPLAYS_ROOT = path.join(STORAGE_ROOT, 'REPLAYS'); +fs.mkdirSync(REPLAYS_ROOT, { recursive: true }); +const LEGACY_REPLAYS_ROOT = path.join(__dirname, '..', 'replays'); +const ENTITLEMENTS_DB_PATH = path.join(STORAGE_ROOT, 'entitlements.db'); +const SQUADRONS_DB_PATH = path.join(STORAGE_ROOT, 'squadrons.db'); +const entitlementsDb = new sqlite3.Database(ENTITLEMENTS_DB_PATH); +const squadronsDb = fs.existsSync(SQUADRONS_DB_PATH) + ? new sqlite3.Database(SQUADRONS_DB_PATH, sqlite3.OPEN_READONLY) + : null; +entitlementsDb.run(`CREATE TABLE IF NOT EXISTS guild_entitlements ( + guild_id TEXT PRIMARY KEY, + whop_membership_id TEXT, + status TEXT DEFAULT 'active', + renewed_at INTEGER DEFAULT (strftime('%s','now')), + tier TEXT +)`); +entitlementsDb.run(`CREATE TABLE IF NOT EXISTS manual_entitlements ( + guild_id TEXT PRIMARY KEY, + expires_at INTEGER NOT NULL, + created_at INTEGER DEFAULT (strftime('%s','now')), + tier TEXT +)`); +entitlementsDb.run(`CREATE TABLE IF NOT EXISTS discord_entitlements ( + guild_id TEXT PRIMARY KEY, + sku_id TEXT, + updated_at INTEGER DEFAULT (strftime('%s','now')), + tier TEXT +)`); + +// Ensure `tier` column exists on pre-existing DBs, then backfill NULL rows to 'standard'. +// Both operations are idempotent and safe to run on every boot. +entitlementsDb.serialize(() => { + for (const table of ['guild_entitlements', 'manual_entitlements', 'discord_entitlements']) { + // ALTER fails with "duplicate column" if the column already exists — ignore that error. + entitlementsDb.run(`ALTER TABLE ${table} ADD COLUMN tier TEXT`, (err) => { + if (err && !/duplicate column/i.test(err.message)) { + console.error(`[ENTITLEMENTS] ALTER ${table} failed:`, err.message); + } + }); + entitlementsDb.run( + `UPDATE ${table} SET tier='standard' WHERE tier IS NULL OR tier=''`, + (err) => { + if (err) console.error(`[ENTITLEMENTS] backfill ${table} failed:`, err.message); + } + ); + } +}); + +// Production-safe logging +const isDev = process.env.NODE_ENV !== 'production'; +const log = { + info: (...args) => isDev && console.log(...args), + warn: (...args) => console.warn(...args), + error: (...args) => console.error(...args), + debug: (...args) => isDev && console.log('[DEBUG]', ...args) +}; + +const REPO_ROOT = path.join(__dirname, '..'); +const SQUADRON_RECAP_CACHE_DIR = path.join(STORAGE_ROOT, 'RECAPS', 'squadrons'); +const PLAYER_RECAP_CACHE_DIR = path.join(STORAGE_ROOT, 'RECAPS', 'players'); +const RECAP_TTL_MS = 24 * 60 * 60 * 1000; // in-progress season TTL +const RECAP_RENDER_TIMEOUT_MS = 30_000; +const PYTHON_BIN = path.join(REPO_ROOT, '.venv', 'bin', 'python'); +const RECAP_SCRIPT = path.join(REPO_ROOT, 'BOT', 'render_recap.py'); + +function resolveReplaySessionDir(sessionId) { + const sid = String(sessionId).toLowerCase(); + const candidates = [ + path.join(REPLAYS_ROOT, sid), + path.join(REPLAYS_ROOT, `0${sid}`), + path.join(LEGACY_REPLAYS_ROOT, sid), + path.join(LEGACY_REPLAYS_ROOT, `0${sid}`) + ]; + + for (const dir of candidates) { + if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) return dir; + } + + return path.join(REPLAYS_ROOT, sid); +} + +const app = express(); +const PORT = process.env.PORT || 3001; +const IS_PRIMARY_WORKER = !process.env.NODE_APP_INSTANCE || process.env.NODE_APP_INSTANCE === '0'; + +// Enable compression for all responses +app.use(compression({ + level: 6, // Good balance of compression vs CPU + threshold: 1024, // Only compress responses > 1KB + filter: (req, res) => { + if (req.headers['x-no-compression']) { + return false; + } + return compression.filter(req, res); + } +})); + +// CORS Configuration +const corsOptions = { + origin: function (origin, callback) { + const allowedOrigins = process.env.NODE_ENV === 'production' + ? [process.env.PRODUCTION_DOMAIN || 'https://srebot-meow.ing'] + : ['http://localhost:3000', 'http://localhost:3001']; + + + if (!origin || allowedOrigins.includes(origin)) { + callback(null, true); + } else { + log.warn(`[CORS] Blocked general request from origin: ${origin}`); + callback(new Error('Not allowed by CORS policy')); + } + }, + credentials: true, + methods: ['GET', 'POST', 'HEAD', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'], + exposedHeaders: ['X-Total-Count'], + maxAge: 86400 // 24 hours +}; + + +const apiCorsOptions = { + origin: function (origin, callback) { + const allowedOrigins = process.env.NODE_ENV === 'production' + ? [process.env.PRODUCTION_DOMAIN || 'https://srebot-meow.ing'] + : ['http://localhost:3000', 'http://localhost:3001']; + + + // Allow our domains AND same-origin requests (null origin from legitimate browser AJAX) + if (!origin || allowedOrigins.includes(origin)) { + callback(null, true); + } else { + log.warn(`[CORS] Blocked API request from origin: ${origin || 'null/direct'}`); + callback(new Error('API access restricted to authorized domains only')); + } + }, + credentials: false, + methods: ['GET', 'OPTIONS'], // Only allow GET and preflight + allowedHeaders: ['Content-Type', 'X-Requested-With'], + maxAge: 3600 +}; + +app.use(cors(corsOptions)); +app.use(express.json({ + limit: '10mb', + verify: (req, res, buf) => { req.rawBody = buf; } +})); +app.use(express.urlencoded({ extended: true, limit: '10mb' })); + +// Serve obfuscated JS in production +if (process.env.NODE_ENV === 'production') { + app.use('/js', (req, res, next) => { + if (req.path.endsWith('.js')) { + const obfuscatedPath = path.join(__dirname, 'public', 'js', 'dist', path.basename(req.path)); + if (require('fs').existsSync(obfuscatedPath)) { + return res.sendFile(obfuscatedPath); + } + } + next(); + }); +} + +// Serve static files with proper MIME types and caching +app.use(express.static('public', { + etag: true, + lastModified: true, + setHeaders: (res, filePath) => { + // Fonts never change in-place — safe to cache forever + if (filePath.match(/\.(ttf|woff|woff2)$/)) { + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + // CSS, JS, and images are updated in-place — allow revalidation after 10 minutes + } else if (filePath.match(/\.(css|js|jpg|jpeg|png|gif|webp|svg|ico)$/)) { + res.setHeader('Cache-Control', 'public, max-age=600, must-revalidate'); + // Don't cache HTML + } else if (filePath.endsWith('.html')) { + res.setHeader('Cache-Control', 'no-cache, must-revalidate'); + } + } +})); + +// Font files with proper headers and CORS +app.use('/fonts', express.static('Fonts', { + setHeaders: (res, path) => { + if (path.endsWith('.ttf')) { + res.setHeader('Content-Type', 'font/ttf'); + res.setHeader('Cache-Control', 'public, max-age=31536000'); + res.setHeader('Access-Control-Allow-Origin', '*'); + } else if (path.endsWith('.woff')) { + res.setHeader('Content-Type', 'font/woff'); + res.setHeader('Cache-Control', 'public, max-age=31536000'); + res.setHeader('Access-Control-Allow-Origin', '*'); + } else if (path.endsWith('.woff2')) { + res.setHeader('Content-Type', 'font/woff2'); + res.setHeader('Cache-Control', 'public, max-age=31536000'); + res.setHeader('Access-Control-Allow-Origin', '*'); + } + } +})); +app.use('/Fonts', express.static('Fonts', { + setHeaders: (res, path) => { + if (path.endsWith('.ttf')) { + res.setHeader('Content-Type', 'font/ttf'); + res.setHeader('Cache-Control', 'public, max-age=31536000'); + res.setHeader('Access-Control-Allow-Origin', '*'); + } else if (path.endsWith('.woff')) { + res.setHeader('Content-Type', 'font/woff'); + res.setHeader('Cache-Control', 'public, max-age=31536000'); + res.setHeader('Access-Control-Allow-Origin', '*'); + } else if (path.endsWith('.woff2')) { + res.setHeader('Content-Type', 'font/woff2'); + res.setHeader('Cache-Control', 'public, max-age=31536000'); + res.setHeader('Access-Control-Allow-Origin', '*'); + } + } +})); + +app.use('/ICONS', express.static('ICONS')); +app.use('/MAPS', express.static('MAPS')); +app.use('/constants', express.static('constants')); + +// Leaderboard Cache System (5-minute intervals) +const leaderboardCache = { + players: { data: null, lastUpdated: 0, updating: false }, + vehicles: { data: null, lastUpdated: 0, updating: false }, + stats: { data: null, lastUpdated: 0, updating: false }, + squadrons: { data: null, lastUpdated: 0, updating: false } +}; + +const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds + +// LRU Cache for search results +class LRUCache { + constructor(maxSize = 500, ttlMs = 15 * 60 * 1000) { + this.cache = new Map(); + this.maxSize = maxSize; + this.ttlMs = ttlMs; + } + + get(key) { + if (!this.cache.has(key)) return null; + + const item = this.cache.get(key); + // Check if expired + if (Date.now() - item.timestamp > this.ttlMs) { + this.cache.delete(key); + return null; + } + + // Move to end (most recently used) + this.cache.delete(key); + this.cache.set(key, item); + return item.data; + } + + set(key, data) { + // Remove if already exists + if (this.cache.has(key)) { + this.cache.delete(key); + } + + // Remove oldest if at capacity + if (this.cache.size >= this.maxSize) { + const firstKey = this.cache.keys().next().value; + this.cache.delete(firstKey); + } + + this.cache.set(key, { + data, + timestamp: Date.now() + }); + } + + size() { + return this.cache.size; + } + + clear() { + this.cache.clear(); + } +} + +const searchCache = new LRUCache(500); // Cache up to 500 unique searches +const squadronProfileCache = new LRUCache(1000, 10 * 60 * 1000); // 10 minute TTL + +// Search request deduplication +const pendingSearchRequests = new Map(); +const pendingSquadronRequests = new Map(); +const SQUADRON_API_TIMEOUT_MS = 120000; + +// Function to update cache for a specific type +async function updateCache(type) { + const cache = leaderboardCache[type]; + + // Prevent multiple simultaneous updates + if (cache.updating) { + log.debug(`[CACHE] Cache update for ${type} already in progress, skipping...`); + return; + } + + cache.updating = true; + log.info(`[CACHE] Updating ${type} cache...`); + + try { + // For the player leaderboard, send a bounded date range so the backend + // doesn't reject the request. The backend's /api/leaderboard/players + // refuses uncached all-time queries because the DB aggregation is + // expensive (grouping every player across all time). A one-year + // window covers all active squadrons for the homepage search without + // scanning the full history. + let urlSuffix = `/api/leaderboard/${type}`; + if (type === 'players') { + const since = new Date(); + since.setFullYear(since.getFullYear() - 1); + // Truncate to date-only so all 3 web instances hit the same API cache key + urlSuffix += `?start_date=${since.toISOString().slice(0, 10)}`; + } + if (type === 'vehicles') { + const since = new Date(); + since.setFullYear(since.getFullYear() - 1); + // Truncate to date-only so all 3 web instances share one 133s cache entry + urlSuffix += `?start_date=${since.toISOString().slice(0, 10)}&limit=500`; + } + const apiUrl = `${process.env.EXTERNAL_API_URL || 'http://127.0.0.1:6000'}${urlSuffix}`; + log.debug(`[CACHE] Fetching from: ${apiUrl}`); + const response = await fetch(apiUrl, { signal: AbortSignal.timeout(30000) }); + + if (!response.ok) { + log.error(`[CACHE] API Error Response:`, { + status: response.status, + statusText: response.statusText, + url: apiUrl, + headers: Object.fromEntries(response.headers.entries()) + }); + + // Try to get error details from response body + try { + const errorText = await response.text(); + log.error(`[CACHE] API Error Body:`, errorText); + } catch (e) { + log.error(`[CACHE] Could not read error response body`); + } + + throw new Error(`API returned status ${response.status} for ${type} endpoint`); + } + + const data = await response.json(); + + // Store the data and update timestamp + cache.data = data; + cache.lastUpdated = Date.now(); + + log.debug(`[CACHE] ${type} cache updated successfully. Data contains:`, { + players: data.players?.length || 0, + vehicles: data.vehicles?.length || 0, + total_players: data.total_players || 0, + total_vehicles_used: data.total_vehicles_used || 0 + }); + + } catch (error) { + log.error(`[CACHE] Error updating ${type} cache:`, error); + + // For critical debugging, let's also log what we know about the backend + if (error.message.includes('500')) { + log.error(`[CACHE] Backend API is returning 500 errors. Possible causes:`); + log.error(`[CACHE] - Database connection issues`); + log.error(`[CACHE] - Backend API server errors`); + log.error(`[CACHE] - Invalid SQL queries in backend`); + log.error(`[CACHE] - Check backend logs for more details`); + } + // Keep existing data if update fails + } finally { + cache.updating = false; + } +} + +// Function to get cached data or trigger update if needed +async function getCachedData(type) { + const cache = leaderboardCache[type]; + const now = Date.now(); + const isExpired = now - cache.lastUpdated > CACHE_DURATION; + + // If cache is empty or expired, update it + if (!cache.data || isExpired) { + if (!cache.updating) { + // Don't await - let it update in background + updateCache(type).catch(log.error); + } + + // If we have old data, return it while updating + if (cache.data) { + log.debug(`[CACHE] Serving slightly stale ${type} cache while updating...`); + return cache.data; + } + + // If no data at all, wait for the update. The API fetch itself has a + // 30s timeout, so wait up to ~32s — longer than the API can take but + // short enough to surface a real error to the user. + if (cache.updating) { + log.debug(`[CACHE] Waiting for initial ${type} cache...`); + const waitStart = Date.now(); + const waitMs = 32000; + while (cache.updating && Date.now() - waitStart < waitMs) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + if (cache.updating) { + log.warn(`[CACHE] Timed out waiting for ${type} cache after ${waitMs / 1000}s`); + } + } + } + + return cache.data; +} + +// Initialize cache on startup — staggered to avoid overloading SQLite +async function initializeCache() { + log.info('[CACHE] Initializing leaderboard cache (staggered)...'); + // Stats is lightest, do it first + await updateCache('stats'); + log.info('[CACHE] Stats cache ready'); + // Squadrons next + await updateCache('squadrons'); + log.info('[CACHE] Squadrons cache ready'); + // Players is heaviest + await updateCache('players'); + log.info('[CACHE] Players cache ready'); + // Vehicles last + await updateCache('vehicles'); + log.info('[CACHE] Vehicles cache ready — all caches populated!'); +} + +// Auto-refresh caches staggered to avoid hammering the API all at once +const cacheTypes = ['players', 'vehicles', 'stats', 'squadrons']; +cacheTypes.forEach((type, i) => { + setInterval(async () => { + log.debug(`[CACHE] Auto-refreshing ${type} cache...`); + await updateCache(type).catch(log.error); + }, CACHE_DURATION + i * 15000); // stagger each by 15s +}); + +// Log search cache statistics every 10 minutes +setInterval(() => { + log.info('[SEARCH CACHE] Statistics:', { + size: searchCache.size(), + maxSize: 500, + utilization: `${((searchCache.size() / 500) * 100).toFixed(1)}%`, + pendingRequests: pendingSearchRequests.size + }); +}, 10 * 60 * 1000); // 10 minutes + +const rateLimitMap = new Map(); + +// Cleanup old rate limit entries every 5 minutes to prevent memory leaks +setInterval(() => { + const now = Date.now(); + const toDelete = []; + for (const [ip, data] of rateLimitMap.entries()) { + if (now > data.resetTime + 300000) { // 5 minutes after reset time + toDelete.push(ip); + } + } + toDelete.forEach(ip => rateLimitMap.delete(ip)); + if (toDelete.length > 0) { + log.debug(`[RATE LIMIT] Cleaned up ${toDelete.length} old entries`); + } +}, 300000); // 5 minutes + +const rateLimit = (req, res, next) => { + const clientIP = req.ip || req.connection.remoteAddress; + const now = Date.now(); + const windowMs = 60000; // 1 minute + const maxRequests = 100; // Max requests per window + + if (!rateLimitMap.has(clientIP)) { + rateLimitMap.set(clientIP, { count: 1, resetTime: now + windowMs }); + return next(); + } + + const clientData = rateLimitMap.get(clientIP); + + if (now > clientData.resetTime) { + clientData.count = 1; + clientData.resetTime = now + windowMs; + return next(); + } + + if (clientData.count >= maxRequests) { + return res.status(429).json({ error: 'Too many requests, please try again later.' }); + } + + clientData.count++; + next(); +}; + +app.use(rateLimit); + +app.use((req, res, next) => { + // Enhanced security headers + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-XSS-Protection', '1; mode=block'); + res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + + // Remove server signature + res.removeHeader('X-Powered-By'); + + // HSTS (only in production with HTTPS) + if (process.env.NODE_ENV === 'production') { + res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload'); + } + + // Content Security Policy + if (req.path.startsWith('/api/')) { + res.setHeader('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none';"); + } else { + res.setHeader('Content-Security-Policy', + "default-src 'self'; " + + "script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com; " + + "style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com; " + + "font-src 'self' data: https://cdnjs.cloudflare.com https://fonts.gstatic.com; " + + "img-src 'self' data: https:; " + + "media-src 'self' blob:; " + + "connect-src 'self' https://cloudflareinsights.com; " + + "frame-ancestors 'none';" + ); + } + + // Additional security headers + res.setHeader('X-DNS-Prefetch-Control', 'off'); + res.setHeader('X-Download-Options', 'noopen'); + res.setHeader('X-Permitted-Cross-Domain-Policies', 'none'); + + next(); +}); + +// Enhanced API security with rotation and signing +const API_SECRET = process.env.API_SECRET || 'your-super-secret-key-change-this-in-production'; + +// Cache for API keys to avoid regenerating on every request +let apiKeyCache = { + key: null, + date: null +}; + +// Generate daily rotating API key with caching +const generateDailyAPIKey = () => { + const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD + + // Return cached key if still valid for today + if (apiKeyCache.key && apiKeyCache.date === today) { + return apiKeyCache.key; + } + + // Generate new key and cache it + const newKey = crypto.createHash('sha256').update(`frontend-${API_SECRET}-${today}`).digest('hex').substring(0, 32); + apiKeyCache = { key: newKey, date: today }; + return newKey; +}; + +// Generate simple hash signature for request validation (matches client-side) +const signRequest = (data, timestamp) => { + // Match the client-side simple hashing algorithm + let hash = 0; + const combined = `${data}-${timestamp}-${API_SECRET}`; + for (let i = 0; i < combined.length; i++) { + const char = combined.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32bit integer + } + return Math.abs(hash).toString(16); +}; + +// Validate request signature to prevent replay attacks +const validateSignature = (data, timestamp, signature) => { + const expectedSignature = signRequest(data, timestamp); + const now = Date.now(); + const requestTime = parseInt(timestamp); + + // Reject requests older than 5 minutes or from future + if (now - requestTime > 300000 || requestTime > now + 60000) { + return false; + } + + // Simple string comparison for now (client-side signing is basic) + return signature === expectedSignature; +}; + +// IP-based restrictions for API keys +const validateAPIKeyForIP = (apiKey, clientIP) => { + // Allow current daily key + const currentKey = generateDailyAPIKey(); + + // Allow previous day's key for 1 hour overlap during rotation + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const yesterdayKey = crypto.createHash('sha256') + .update(`frontend-${API_SECRET}-${yesterday.toISOString().split('T')[0]}`) + .digest('hex').substring(0, 32); + + // Debug logging in development + if (process.env.NODE_ENV !== 'production') { + log.debug(`API Key validation:`); + log.debug(` Received: ${apiKey?.substring(0, 8)}...`); + log.debug(` Expected current: ${currentKey.substring(0, 8)}...`); + log.debug(` Expected yesterday: ${yesterdayKey.substring(0, 8)}...`); + } + + if (apiKey === currentKey || apiKey === yesterdayKey) { + // In production, you could add IP whitelist here + if (process.env.NODE_ENV === 'production' && process.env.ALLOWED_IPS) { + const allowedIPs = process.env.ALLOWED_IPS.split(','); + return allowedIPs.some(ip => { + // Handle IPv6-mapped IPv4 addresses + const normalizedClientIP = clientIP.replace('::ffff:', ''); + return normalizedClientIP.includes(ip.trim()) || ip.trim().includes(normalizedClientIP); + }); + } + return true; + } + + return false; +}; + +const FRONTEND_API_KEY = generateDailyAPIKey(); + +// Only log API key in development +if (process.env.NODE_ENV !== 'production') { + log.info(`Frontend API Key: ${FRONTEND_API_KEY}`); + log.info(`Key rotates daily at midnight UTC`); +} + +const apiSecurityCheck = (req, res, next) => { + // Only apply to API routes + if (!req.path.startsWith('/api/')) { + return next(); + } + + // Special case: allow /api-key and /api/stats without authentication. + // Some proxies/routers may preserve a trailing slash or otherwise normalize + // the path, so match by prefix after trimming any trailing slash. + const normalizedPath = req.path.replace(/\/+$/, ''); + if (normalizedPath === '/api-key' || normalizedPath.startsWith('/api/stats')) { + return next(); + } + + // Allow video/minimap/icon endpoints without auth (loaded via /