from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import FileResponse, JSONResponse, StreamingResponse, Response, RedirectResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from typing import Optional import math, itertools, asyncio, re, time, json, threading, queue, gzip, zlib, base64, hashlib, shutil, subprocess, tempfile, sqlite3 from concurrent.futures import ThreadPoolExecutor, as_completed from contextlib import contextmanager import requests as _requests import msgpack as _msgpack import websocket as _websocket from decimal import Decimal, ROUND_HALF_UP, getcontext from pathlib import Path from urllib.parse import quote as _url_quote getcontext().prec = 50 # ───────────────────────────────────────────────────────────────── # DISCORD CONFIG # ───────────────────────────────────────────────────────────────── import os as _os APP_DIR = Path(__file__).resolve().parent STATIC_DIR = APP_DIR / "static" TOOLS_DIR = APP_DIR / "tools" SM_LOADBALANCER_BASE = _os.getenv("SM_LOADBALANCER_BASE", "https://loadbalancer.swordmasters.io").rstrip("/") SM_LOOKUP_WORLD = _os.getenv("SM_LOOKUP_WORLD", "world_1") # Pro Lookup-Phase (connect/join/reply-wait). GEMESSEN bimodal: erreichbare Accounts antworten # <0,8s (langsamster echter Responder 2,3s), nicht erreichbare NIE -> der alte 5s-Default liess # einen toten Lookup ROOM_ATTEMPTS x ~5s ≈ 12s haengen und blockierte solange einen Worker-Thread # (-> machte auch das Leaderboard traege). 3s trennt Responder sauber ab, Nicht-Responder fallen # schnell auf den Store-Fallback (_sm_cached_leaderboard_row_for_username); der 2. Attempt # (SM_LOOKUP_ROOM_ATTEMPTS) faengt Accounts in anderen Welt-Instanzen weiterhin ab. SM_LOOKUP_TIMEOUT = max(2.0, float(_os.getenv("SM_LOOKUP_TIMEOUT", "3.0"))) SM_LOOKUP_CACHE_SECONDS = max(1.0, float(_os.getenv("SM_LOOKUP_CACHE_SECONDS", "15.0"))) SM_LOOKUP_MAX_USERNAMES = max(1, int(_os.getenv("SM_LOOKUP_MAX_USERNAMES", "50"))) SM_LOOKUP_QUERY_INTERVAL = max(0.0, float(_os.getenv("SM_LOOKUP_QUERY_INTERVAL", "0.02"))) SM_LOOKUP_ROOM_ATTEMPTS = max(1, min(3, int(_os.getenv("SM_LOOKUP_ROOM_ATTEMPTS", "2")))) SM_LOOKUP_RETRY_DELAY = max(0.0, float(_os.getenv("SM_LOOKUP_RETRY_DELAY", "0.2"))) # Serverseitige Wiederholungen fuer KLEINE Lookups (v.a. Account-Info = 1 Name), wenn der # Live-Server (aktuell haeufig unter DDoS) nicht rechtzeitig antwortet -> der Nutzer muss # nicht mehr selbst 2-3x klicken. Nur wenige Namen, damit Massen-Refreshes nicht haemmern. SM_LOOKUP_RETRY_ROUNDS = max(0, min(4, int(_os.getenv("SM_LOOKUP_RETRY_ROUNDS", "2")))) SM_LOOKUP_RETRY_MAX_NAMES = max(1, int(_os.getenv("SM_LOOKUP_RETRY_MAX_NAMES", "3"))) SM_CLAN_CACHE_SECONDS = max(5.0, float(_os.getenv("SM_CLAN_CACHE_SECONDS", "30.0"))) SM_LEVEL_RANK_CACHE_SECONDS = max(60.0, float(_os.getenv("SM_LEVEL_RANK_CACHE_SECONDS", "1800.0"))) SM_LEVEL_RANK_MAX_PAGES = max(1, int(_os.getenv("SM_LEVEL_RANK_MAX_PAGES", "100"))) SM_LEVEL_RANK_PAGE_SIZE = 108 SM_LEVEL_RANK_TIE_PAGE_LIMIT = max( 1, int(_os.getenv("SM_LEVEL_RANK_TIE_PAGE_LIMIT", "32")) ) SM_LEVEL_PLAYER_COUNT_CACHE_SECONDS = max( 300.0, float(_os.getenv("SM_LEVEL_PLAYER_COUNT_CACHE_SECONDS", "21600.0")) ) # Observed on 2026-06-07. Starting near the current tail keeps the exact count # lookup to two requests while still allowing the search to move in either direction. SM_LEVEL_PLAYER_COUNT_HINT_PAGE = max( 1, int(_os.getenv("SM_LEVEL_PLAYER_COUNT_HINT_PAGE", "98046")) ) SM_LEVEL_PLAYER_COUNT_MAX_PAGE = max( SM_LEVEL_PLAYER_COUNT_HINT_PAGE + 1, int(_os.getenv("SM_LEVEL_PLAYER_COUNT_MAX_PAGE", "1000000")), ) SM_LEVEL_PLAYER_COUNT_SEED = max( 1, int(_os.getenv("SM_LEVEL_PLAYER_COUNT_SEED", "10589082")) ) SM_LEVEL_100_COUNT_SEED = max( 1, int( _os.getenv( "SM_LEVEL_100_COUNT_SEED", _os.getenv("SM_LEVEL_QUALIFIED_COUNT_SEED", "1557896"), ) ), ) SM_LEVEL_100_COUNT_HINT_PAGE = max( 1, int( _os.getenv( "SM_LEVEL_100_COUNT_HINT_PAGE", _os.getenv("SM_LEVEL_QUALIFIED_COUNT_HINT_PAGE", "14425"), ) ), ) SM_LEVEL_1000_COUNT_SEED = max( 1, int(_os.getenv("SM_LEVEL_1000_COUNT_SEED", "348874")) ) SM_LEVEL_1000_COUNT_HINT_PAGE = max( 1, int(_os.getenv("SM_LEVEL_1000_COUNT_HINT_PAGE", "3231")) ) SM_LEVEL_PERCENTILE_TIERS = { 100: { "seed": SM_LEVEL_100_COUNT_SEED, "hint_page": SM_LEVEL_100_COUNT_HINT_PAGE, }, 1000: { "seed": SM_LEVEL_1000_COUNT_SEED, "hint_page": SM_LEVEL_1000_COUNT_HINT_PAGE, }, } SM_LEADERBOARD_MIN_LEVEL = max(1, int(_os.getenv("SM_LEADERBOARD_MIN_LEVEL", "1000"))) # Was tatsaechlich GESPEICHERT wird (>= dies). Oeffentlich sichtbar bleibt SM_LEADERBOARD_MIN_LEVEL # (1000); die Mod-Ansicht zeigt bis STORE_MIN runter. Default = MIN_LEVEL (kein Effekt), auf 100 # setzen um Level 100-1000 fuer die Mod-Seite mitzuspeichern. SM_LEADERBOARD_STORE_MIN_LEVEL = max(1, int(_os.getenv("SM_LEADERBOARD_STORE_MIN_LEVEL", str(SM_LEADERBOARD_MIN_LEVEL)))) # Geheimer Token fuer die versteckte Mod-Ansicht (zeigt bis STORE_MIN). Leer = Mod-Ansicht aus. SM_MOD_KEY = (_os.getenv("SM_MOD_KEY") or "").strip() def _sm_resolve_leaderboard_cache_path() -> Path: configured = (_os.getenv("SM_LEADERBOARD_CACHE_FILE") or "").strip() if configured: path = Path(configured) return path if path.is_absolute() else APP_DIR / path if _os.getenv("SPACE_ID"): return Path("/data") / "leaderboard_cache.json" return APP_DIR / "leaderboard_cache.json" SM_LEADERBOARD_CACHE_PATH = _sm_resolve_leaderboard_cache_path() SM_LEADERBOARD_LEGACY_CACHE_PATH = APP_DIR / "leaderboard_cache.json" SM_LEADERBOARD_BOOTSTRAP_PATH = APP_DIR / "leaderboard_bootstrap.json" SM_LEADERBOARD_CACHE_USES_DATA_PATH = ( str(SM_LEADERBOARD_CACHE_PATH).replace("\\", "/").startswith("/data/") ) SM_LEADERBOARD_CACHE_PERSISTENT = bool( SM_LEADERBOARD_CACHE_USES_DATA_PATH and _os.path.ismount("/data") ) # Eigener, dauerhafter Room-Socket (kein Churn, getrennt von den Lookups). WICHTIG: # Der Spielserver drosselt die IP des Space, wenn zu schnell abgefragt wird -> dann werden # AUCH die interaktiven Lookups langsam. Deshalb bewusst GEDROSSELT (~2/s), damit die IP # gesund bleibt und Lookups schnell sind. Voller Durchlauf dauert so laenger (Trade-off). SM_LEADERBOARD_BATCH_SIZE = max(1, int(_os.getenv("SM_LEADERBOARD_BATCH_SIZE", "12"))) SM_LEADERBOARD_SCAN_INTERVAL = max(0.1, float(_os.getenv("SM_LEADERBOARD_SCAN_INTERVAL", "6.0"))) SM_LEADERBOARD_ERROR_INTERVAL = max(SM_LEADERBOARD_SCAN_INTERVAL, float(_os.getenv("SM_LEADERBOARD_ERROR_INTERVAL", "30.0"))) SM_LEADERBOARD_STARTUP_DELAY = max(0.0, float(_os.getenv("SM_LEADERBOARD_STARTUP_DELAY", "20.0"))) # Bei ~1,6 Mio 100+-Accounts ist das Speichern (~600 MB JSON) teuer und laeuft UNTER dem # Leaderboard-Lock -> waehrend der paar Sekunden Serialisieren+Gzip+Schreiben blockiert es JEDEN # Zugriff (Ingest/Reads/health). Alle 3 min war das die Hauptquelle der periodischen Stalls. # Der Cache ist nur der Warmstart-Stand (Daten werden ohnehin laufend neu gescannt), also selten # schreiben: 30 min. Nachteil (bewusst in Kauf genommen): bei einem Neustart geht der Ingest der # letzten <=30 min aus dem persistierten Cache verloren -> naechster Sweep fuellt ihn wieder. SM_LEADERBOARD_SAVE_INTERVAL = max(5.0, float(_os.getenv("SM_LEADERBOARD_SAVE_INTERVAL", "1800.0"))) SM_LEADERBOARD_QUERY_LIMIT = max(25, int(_os.getenv("SM_LEADERBOARD_QUERY_LIMIT", "250"))) # Deckel MUSS ueber der echten 100+-Population (~1,71 Mio) liegen, sonst wird das Leaderboard # abgeschnitten. War 500k -> hat das Board bei ~500k gedeckelt UND (per aeltester-Zeitstempel- # Eviction) ausgerechnet die zuerst gescannten TOP-Accounts rausgeworfen -> Total-Power sprang # und die Anzahl blieb bei 500k haengen. Jetzt 2 Mio (Headroom) + Eviction nach Power (unten). SM_LEADERBOARD_MAX_ROWS = max(1000, int(_os.getenv("SM_LEADERBOARD_MAX_ROWS", "2000000"))) # ── Power/Level/Gold Zeitreihe (pro Scan) ──────────────────────────────────────────────────── # Eigener, vom 600-MB-Leaderboard-Cache komplett GETRENNTER Store (eigene Datei, eigener Lock, # eigenes kurzes Save-Intervall) -> beruehrt den kritischen Leaderboard-Schreibpfad NICHT. # Nur Accounts mit >= 1 Qnt Power (1e18) bekommen Punkte; jeder Lookup/Ingest haengt (gedrosselt # auf <=1 Punkt/Min/Account) einen SEKUNDENgenauen Punkt an. Punktformat (kompakt): # [t_sec, level, power_float, gold_float, sword_type, shield_type] # sword/shield = Spiel-Typ-Code (kleine Zahl) der AKTIVEN Waffe, -1 wenn unbekannt. def _sm_resolve_power_series_path() -> Path: configured = (_os.getenv("SM_POWER_SERIES_FILE") or "").strip() if configured: path = Path(configured) return path if path.is_absolute() else APP_DIR / path if _os.getenv("SPACE_ID"): return Path("/data") / "power_series.json" return APP_DIR / "power_series.json" SM_POWER_SERIES_PATH = _sm_resolve_power_series_path() SM_POWER_SERIES_MIN_POWER = float(_os.getenv("SM_POWER_SERIES_MIN_POWER", "1e18")) # 1 Qnt SM_POWER_SERIES_MIN_GAP = max(0.0, float(_os.getenv("SM_POWER_SERIES_MIN_GAP", "900.0"))) # <=1 Punkt/15min/Account # Identische Folge-Punkte (gleiches level/power/gold/gear) werden uebersprungen -> die Reihe # speichert nur ECHTE Aenderungen. Nur alle HEARTBEAT Sekunden wird ein unveraenderter Punkt doch # behalten, damit flache Verlaeufe bis ~jetzt eine Linie haben (sonst endet die Linie beim letzten # Change). Default 1 Tag. SM_POWER_SERIES_HEARTBEAT = max(0.0, float(_os.getenv("SM_POWER_SERIES_HEARTBEAT", "86400.0"))) SM_POWER_SERIES_MAX_POINTS = max(100, int(_os.getenv("SM_POWER_SERIES_MAX_POINTS", "20000"))) # Sicherheitsdeckel/Account SM_POWER_SERIES_SAVE_INTERVAL = max(10.0, float(_os.getenv("SM_POWER_SERIES_SAVE_INTERVAL", "120.0"))) # Separater Store fuer die aus dem Discord-#bot-commands-Kanal importierten HISTORISCHEN Punkte # (src=3, name-basiert, UNSICHERER). Getrennt von der exakten Reihe, damit (a) der heisse Recorder # nie damit kollidiert und (b) ein Re-Import die exakten Scans nie anfasst (idempotent pro Account). SM_POWER_SERIES_DISCORD_PATH = SM_POWER_SERIES_PATH.with_name( SM_POWER_SERIES_PATH.stem + "_discord" + SM_POWER_SERIES_PATH.suffix) # Ausnahme: fuer diese Accounts (per Name) werden ALLE Punkte behalten, auch unter 1 Qnt # (sonst gilt ueberall der 1e18-Boden). Komma-getrennt, env-ueberschreibbar. Default: oettaM. SM_POWER_SERIES_FULL_NAMES_RAW = [s.strip() for s in (_os.getenv("SM_POWER_SERIES_FULL_NAMES", "oettaM")).split(",") if s.strip()] def _env_truthy(name: str, default: bool = False) -> bool: value = _os.getenv(name) if value is None: return bool(default) return value.strip().lower() in {"1", "true", "yes", "on"} # Wenn ein Ingest-Key gesetzt ist, werden die Leaderboard-Daten von aussen (lokaler # Scanner ueber die schnelle Heim-IP) gepusht -> der Space fragt das Spiel dann nicht # mehr selbst ab (seine Rechenzentrums-IP wird sonst gedrosselt). Der eigene Scanner # ist dann standardmaessig AUS (per SM_LEADERBOARD_SCANNER=1 trotzdem erzwingbar). SM_LEADERBOARD_INGEST_KEY = (_os.getenv("SM_LEADERBOARD_INGEST_KEY") or "").strip() SM_LEADERBOARD_SCANNER_ENABLED = _env_truthy( "SM_LEADERBOARD_SCANNER", default=bool(_os.getenv("SPACE_ID")) and not SM_LEADERBOARD_INGEST_KEY, ) # ── Leaderboard OFFLOAD / PROXY ───────────────────────────────────────────────────────────── # When set (to the prices Space URL), the data-heavy leaderboard READ endpoints are served by # that URL (which now holds the 1.6M rows + does the expensive numpy index + serve), and A's own # periodic index rebuild + 600 MB cache save are switched OFF (see _sm_leaderboard_start_maintenance # and _sm_leaderboard_save_unlocked) -- that is the actual CPU offload. A keeps ingesting (the # pusher dual-writes) so SSE deltas, on-view refresh and a cold local fallback still work if the # proxy is unreachable. Env-gated: unset = A behaves exactly as before (safe deploy); set = switch # over; unset again = instant revert. SM_LEADERBOARD_PROXY_URL = (_os.getenv("SM_LEADERBOARD_PROXY_URL") or "").strip().rstrip("/") _SM_HTTP_LOCAL = threading.local() def _split_env_tokens(value: str) -> list[str]: raw = (value or "").strip() if not raw: return [] quoted = re.findall(r"""["']([^"']+)["']""", raw) if quoted: return [t.strip() for t in quoted if t.strip()] raw = raw.strip("[]") return [t.strip().strip("\"'") for t in re.split(r"[\s,;]+", raw) if t.strip().strip("\"'")] def _get_all_tokens(): tokens = [] tokens.extend(_split_env_tokens(_os.getenv("DISCORD_TOKENS", ""))) tokens.extend(_split_env_tokens(_os.getenv("DISCORD_TOKEN", ""))) return list(dict.fromkeys(tokens)) def _get_token(): tokens = _get_all_tokens() if not tokens: raise HTTPException(503, "Discord lookup ist serverseitig nicht konfiguriert. Setze DISCORD_TOKEN oder DISCORD_TOKENS als Hugging-Face-Secret.") return tokens[0] DISCORD_APPLICATION = _os.getenv("DISCORD_APPLICATION", "1119922811461447783") DISCORD_GUILD = _os.getenv("DISCORD_GUILD", "1176496842360487986") DISCORD_CHANNEL = _os.getenv("DISCORD_CHANNEL", "1176496842809282581") DISCORD_SESSION_ID = _os.getenv("DISCORD_SESSION_ID", "848cad563d6fa87ea3b0cc78667de44d") DISCORD_INFO_COMMAND_ID = _os.getenv("DISCORD_INFO_COMMAND_ID", "1448768054820474944") DISCORD_INFO_COMMAND_VERSION = _os.getenv("DISCORD_INFO_COMMAND_VERSION", "1448768054820474948") DISCORD_API_HOSTS = ("discord.com", "canary.discord.com", "ptb.discord.com") DISCORD_SEND_HOSTS = DISCORD_API_HOSTS DISCORD_SEND_INTERVAL = max(0.5, float(_os.getenv("DISCORD_SEND_INTERVAL", "1.25"))) DISCORD_READ_CACHE_SECONDS = max(0.5, float(_os.getenv("DISCORD_READ_CACHE_SECONDS", "0.85"))) DISCORD_READ_ERROR_CACHE_SECONDS = max(DISCORD_READ_CACHE_SECONDS, float(_os.getenv("DISCORD_READ_ERROR_CACHE_SECONDS", "1.5"))) DISCORD_HEADERS = { "Authorization": "", "Content-Type": "application/json", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } def _handle_auth_error(status_code: int): return status_code == 401 def _discord_host_variants(url: str): if "://discord.com/" not in url: return [url] return [url.replace("://discord.com/", f"://{host}/", 1) for host in DISCORD_API_HOSTS] def _discord_request(method: str, url: str, *, retries: int = 4, **kwargs): timeout = kwargs.pop("timeout", (4, 12)) session = kwargs.pop("session", None) headers = dict(kwargs.pop("headers", {}) or {}) headers.setdefault("Accept", "application/json") requester = session.request if session is not None else _requests.request last_exc = None last_resp = None for attempt in range(max(1, retries)): try: resp = requester(method, url, headers=headers, timeout=timeout, **kwargs) last_resp = resp if resp.status_code == 429 and attempt < retries - 1: try: wait = float(resp.headers.get("Retry-After", "0.8")) except ValueError: wait = 0.8 time.sleep(min(max(wait, 0.4), 3.0)) continue if resp.status_code >= 500 and attempt < retries - 1: time.sleep(0.35 * (attempt + 1)) continue return resp except Exception as exc: last_exc = exc if attempt < retries - 1: time.sleep(0.35 * (attempt + 1)) continue if last_resp is not None: return last_resp detail = f"{type(last_exc).__name__}: {str(last_exc)[:220]}" if last_exc else "no response" print(f"[Discord request failed] {method} {url}: {detail}") raise HTTPException(502, f"Discord connection failed after retries ({detail}).") def _discord_after_now_id() -> str: discord_epoch_ms = 1420070400000 now_ms = int(time.time() * 1000) return str(max(0, now_ms - discord_epoch_ms) << 22) def _discord_anchor_id(msg_url: str, headers: dict) -> str: fallback_id = _discord_after_now_id() for candidate_url in _discord_host_variants(msg_url): try: anchor_resp = _discord_request("GET", f"{candidate_url}?limit=1", headers=headers, retries=1, timeout=(2, 3)) except HTTPException as exc: if getattr(exc, "status_code", 500) == 502: print(f"[Discord anchor host failed] {candidate_url}: {exc.detail}") continue raise if anchor_resp.status_code == 401: raise HTTPException(401, "Discord token is invalid or expired.") if anchor_resp.status_code == 403: raise HTTPException(403, "Discord token cannot read the configured channel.") if anchor_resp.status_code != 200: print(f"[Discord anchor host status] {candidate_url}: {anchor_resp.status_code}: {anchor_resp.text[:180]}") continue try: messages = anchor_resp.json() except Exception: continue return messages[0]["id"] if messages else fallback_id print("[Discord anchor fallback] using snowflake anchor after all read hosts failed") return fallback_id _DISCORD_INFO_COMMAND_CACHE = {"stamp": 0.0, "command": None} _DISCORD_INFO_COMMAND_LOCK = threading.Lock() def _discord_client_headers(token: str, content_type=False) -> dict: headers = { "Authorization": token, "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) discord/1.0.9228 Chrome/138.0.7204.251 Electron/37.6.0 Safari/537.36", "X-Super-Properties": "eyJvcyI6IldpbmRvd3MiLCJicm93c2VyIjoiRGlzY29yZCBDbGllbnQiLCJyZWxlYXNlX2NoYW5uZWwiOiJzdGFibGUifQ==", "X-Discord-Locale": "de", "X-Discord-Timezone": "Europe/Berlin", } if content_type: headers["Content-Type"] = "application/json" return headers def _discord_static_info_command(): return { "id": DISCORD_INFO_COMMAND_ID, "type": 1, "application_id": DISCORD_APPLICATION, "guild_id": DISCORD_GUILD, "name": "info", "description": "Replies with player information", "options": [{"type": 3, "name": "username", "description": "Enter username of the player", "required": True}], "version": DISCORD_INFO_COMMAND_VERSION, } def _discord_pick_info_command(data): commands = [] applications = {} if isinstance(data, dict): raw = data.get("application_commands") or data.get("commands") or data.get("data") if isinstance(raw, list): commands.extend(raw) elif isinstance(data.get("id"), str): commands.append(data) apps_raw = data.get("applications") or {} if isinstance(apps_raw, dict): applications.update({str(k): v for k, v in apps_raw.items() if isinstance(v, dict)}) elif isinstance(apps_raw, list): for app_info in apps_raw: if isinstance(app_info, dict) and app_info.get("id"): applications[str(app_info["id"])] = app_info elif isinstance(data, list): commands.extend(data) best = None best_score = -1 for cmd in commands: if not isinstance(cmd, dict): continue if str(cmd.get("name", "")).lower() != "info": continue app_id = str(cmd.get("application_id") or "") if not cmd.get("id"): continue option_names = { str(option.get("name", "")).lower() for option in (cmd.get("options", []) or []) if isinstance(option, dict) } description = str(cmd.get("description") or "").lower() app_name = str(applications.get(app_id, {}).get("name", "")).lower() score = 0 if app_id == DISCORD_APPLICATION: score += 100 if "username" in option_names: score += 25 if "player" in description and "information" in description: score += 25 if "sword" in app_name or "sword" in description: score += 15 if score > best_score: best = cmd best_score = score return best if best_score >= 25 else None def _discord_fetch_info_command(token: str): headers = _discord_client_headers(token) base_urls = [ f"https://discord.com/api/v10/channels/{DISCORD_CHANNEL}/application-commands/search?type=1&query=info&limit=25&include_applications=true", f"https://discord.com/api/v9/channels/{DISCORD_CHANNEL}/application-commands/search?type=1&query=info&limit=25&include_applications=true", f"https://discord.com/api/v10/applications/{DISCORD_APPLICATION}/guilds/{DISCORD_GUILD}/commands", ] urls = [variant for url in base_urls for variant in _discord_host_variants(url)] for url in urls: try: resp = _discord_request("GET", url, headers=headers, retries=1, timeout=(3, 5)) except HTTPException as exc: print(f"[Discord command lookup failed] {exc.detail[:180]}") continue if resp.status_code != 200: print(f"[Discord command lookup status] {resp.status_code}: {resp.text[:180]}") continue try: cmd = _discord_pick_info_command(resp.json()) except Exception as exc: print(f"[Discord command lookup parse failed] {type(exc).__name__}: {str(exc)[:120]}") continue if cmd: return cmd return None def _discord_info_command(token: str, force=False): now = time.time() cached = _DISCORD_INFO_COMMAND_CACHE.get("command") if cached and not force and now - float(_DISCORD_INFO_COMMAND_CACHE.get("stamp", 0.0)) < 3600: return cached with _DISCORD_INFO_COMMAND_LOCK: now = time.time() cached = _DISCORD_INFO_COMMAND_CACHE.get("command") if cached and not force and now - float(_DISCORD_INFO_COMMAND_CACHE.get("stamp", 0.0)) < 3600: return cached # The command id/version are configured and normally stable. Avoid a # metadata search before every cold-start lookup; refresh only after a # real interaction 400/404 proves that the configured metadata is stale. cmd = (_discord_fetch_info_command(token) if force else None) or _discord_static_info_command() _DISCORD_INFO_COMMAND_CACHE.update({"stamp": now, "command": cmd}) return cmd def _discord_bot_author_ids(): ids = {DISCORD_APPLICATION} cached = _DISCORD_INFO_COMMAND_CACHE.get("command") if isinstance(cached, dict) and cached.get("application_id"): ids.add(str(cached["application_id"])) return ids def _discord_info_option_name(command: dict): for option in command.get("options", []) or []: if isinstance(option, dict) and int(option.get("type", 0) or 0) == 3: return str(option.get("name") or "username") return "username" def _discord_info_payload(username: str, token: str, nonce: str, force_command=False): command = _discord_info_command(token, force=force_command) command_id = str(command.get("id") or DISCORD_INFO_COMMAND_ID) version = str(command.get("version") or DISCORD_INFO_COMMAND_VERSION) application_id = str(command.get("application_id") or DISCORD_APPLICATION) guild_id = str(command.get("guild_id") or DISCORD_GUILD) option_name = _discord_info_option_name(command) app_command = dict(command) app_command.setdefault("id", command_id) app_command.setdefault("type", 1) app_command.setdefault("application_id", application_id) app_command.setdefault("guild_id", guild_id) app_command.setdefault("name", "info") app_command.setdefault("description", "Replies with player information") app_command.setdefault("options", [{"type": 3, "name": option_name, "description": "Enter username of the player", "required": True}]) app_command["version"] = version integration_types = command.get("integration_types") integration_type = 0 if isinstance(integration_types, list) and integration_types: try: integration_type = int(integration_types[0]) except Exception: integration_type = 0 return { "type": 2, "application_id": application_id, "guild_id": guild_id, "channel_id": DISCORD_CHANNEL, "session_id": DISCORD_SESSION_ID, "nonce": nonce, "context": 0, "analytics_location": "slash_ui", "data": { "version": version, "id": command_id, "name": "info", "type": int(command.get("type", 1) or 1), "application_id": application_id, "guild_id": guild_id, "integration_type": integration_type, "options": [{"type": 3, "name": option_name, "value": username}], "application_command": app_command, "attachments": [], }, } _DISCORD_SEND_LOCK = threading.Lock() _DISCORD_NEXT_SEND_AT = 0.0 def _discord_wait_send_slot(extra_delay=0.0): global _DISCORD_NEXT_SEND_AT with _DISCORD_SEND_LOCK: now = time.monotonic() send_at = max(now + max(0.0, float(extra_delay or 0.0)), _DISCORD_NEXT_SEND_AT) _DISCORD_NEXT_SEND_AT = send_at + DISCORD_SEND_INTERVAL wait = send_at - time.monotonic() if wait > 0: time.sleep(wait) def _discord_retry_after(resp, fallback=8.0): try: header = resp.headers.get("Retry-After") if header: return min(max(float(header), 1.0), 20.0) except (AttributeError, TypeError, ValueError): pass try: payload = resp.json() retry_after = payload.get("retry_after") if isinstance(payload, dict) else None if retry_after is not None: return min(max(float(retry_after), 1.0), 20.0) except Exception: pass return fallback def _discord_send_info_command(username: str, token: str, nonce: str, *, index=0): if index: time.sleep(min(index * 0.12, 1.2)) headers = _discord_client_headers(token, content_type=True) last_resp = None uncertain_errors = [] for force_command in (False, True): payload = _discord_info_payload(username, token, nonce, force_command=force_command) stale_metadata = False for api_ver in ("v9", "v10"): saw_response = False for host in DISCORD_SEND_HOSTS: resp = None for rate_attempt in range(2): _discord_wait_send_slot() try: resp = _discord_request("POST", f"https://{host}/api/{api_ver}/interactions", headers=headers, json=payload, retries=1, timeout=(3, 6)) except HTTPException as exc: uncertain_errors.append(f"{host}: {exc.detail}") print(f"[Discord send host failed] {host} for '{username}': {exc.detail[:180]}") break if resp.status_code != 429 or rate_attempt == 1: break cooldown = _discord_retry_after(resp) print(f"[Discord send rate limited] waiting {cooldown:.1f}s before retrying '{username}'") _discord_wait_send_slot(cooldown) if resp is None: continue saw_response = True last_resp = resp if resp.status_code in (200, 204): return True, "" if resp.status_code in (400, 404): stale_metadata = True continue text = resp.text[:220] if getattr(resp, "text", None) else "" return False, f"Discord send status {resp.status_code}: {text}" if not saw_response: break if not stale_metadata: break _DISCORD_INFO_COMMAND_CACHE.update({"stamp": 0.0, "command": None}) if uncertain_errors: detail = " | ".join(uncertain_errors[-3:]) print(f"[Discord send uncertain] polling for '{username}' after host fallbacks: {detail[:260]}") return True, f"Discord slash-command submission could not be confirmed after host fallbacks: {detail}" if last_resp is not None: return False, f"Discord send status {last_resp.status_code}: {last_resp.text[:220]}" return False, "Discord send failed: no response" app = FastAPI(title="SwordMasters Optimizer") app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) # Antworten komprimieren (index.html ist ~577KB unkomprimiert; gzip -> ~135KB). Grosser # Ladezeit-Gewinn v.a. auf langsamen/mobilen/weit entfernten Verbindungen. compresslevel=6: # fuer Text/JSON praktisch gleiche Groesse wie 9, aber ~halbe CPU (wichtig auf 2 vCPU). app.add_middleware(GZipMiddleware, minimum_size=600, compresslevel=6) class CachedStaticFiles(StaticFiles): """Statische Dateien mit Cache-Control (JS/CSS sind ueber ?v=... versioniert -> Browser darf sie zwischenspeichern; Wiederbesuche laden sie nicht jedes Mal neu).""" async def get_response(self, path, scope): resp = await super().get_response(path, scope) if resp.status_code == 200: resp.headers["Cache-Control"] = "public, max-age=86400" return resp app.mount("/static", CachedStaticFiles(directory=STATIC_DIR), name="static") app.mount("/tools", CachedStaticFiles(directory=TOOLS_DIR), name="tools") class _MemStatic: """Statische Datei EINMAL beim Start in den RAM laden, gzip vorberechnen und mit ETag ausliefern. Vorteile (kein Feature/Datenverlust): * Auslieferung laeuft auf dem Event-Loop, NICHT im begrenzten Sync-Threadpool -> die Seite laedt auch dann in ms, wenn alle Threads mit langsamen Lookups belegt sind (Hauptursache der "Seite laedt minutenlang nicht"-Reports). * Muss NICHT pro Request neu komprimieren (spart CPU auf den 2 vCPU). * ETag/304: Wiederbesuche revalidieren nur (winziger 304 statt ~150KB Download), bleiben aber IMMER aktuell. Bei jedem Deploy/Neustart wird frisch eingelesen. """ def __init__(self, path, media_type, raw=None, vary="Accept-Encoding", cache_control="no-cache"): self.path = Path(path) if path else None self.media_type = media_type self.vary = vary # Versionierte/selten wechselnde Assets duerfen zwischengespeichert werden (kein # Revalidierungs-Round-Trip pro Aufruf) -> auf einem zeitweise stockenden Netzpfad # laedt die Seite dann NICHT jedes Mal alles neu. index.html bleibt "no-cache" # (variiert nach Cookie/Mod, aendert sich bei jedem Deploy). self.cache_control = cache_control if raw is not None: self.raw = raw else: try: self.raw = self.path.read_bytes() except OSError: self.raw = b"" self.gz = gzip.compress(self.raw, 9) if self.raw else b"" self.etag = '"' + hashlib.sha1(self.raw).hexdigest()[:16] + '"' async def respond(self, request: Request): inm = request.headers.get("if-none-match", "") if inm and (inm.strip() == "*" or self.etag in [t.strip() for t in inm.split(",")]): return Response(status_code=304, headers={"ETag": self.etag, "Cache-Control": self.cache_control}) headers = {"ETag": self.etag, "Cache-Control": self.cache_control, "Vary": self.vary} if "gzip" in request.headers.get("accept-encoding", "") and self.gz: headers["Content-Encoding"] = "gzip" # GZipMiddleware ueberspringt bereits kodierte Antworten return Response(content=self.gz, media_type=self.media_type, headers=headers) return Response(content=self.raw, media_type=self.media_type, headers=headers) def _sm_strip_mod_regions(text: str) -> str: """Entfernt alle mit / bzw. /*MOD:START*//*MOD:END*/ markierten Bereiche. Fuer die OEFFENTLICHE Seite -> deren HTML/JS enthaelt dann physisch KEINE Mod-only-Elemente (Timeframe-Feld, Show-all, Zeitraum-Deltas, fruehere Namen, Player-ID). Ohne Mod-Cookie weiss/sieht niemand, dass es das gibt. Fuer die Mod-Seite bleiben die Marker als gueltige HTML-/JS-Kommentare einfach stehen (unsichtbar, kein Feature-Verlust).""" text = re.sub(r".*?", "", text, flags=re.S) text = re.sub(r"/\*MOD:START\*/.*?/\*MOD:END\*/", "", text, flags=re.S) return text try: _INDEX_RAW_TEXT = (STATIC_DIR / "index.html").read_text(encoding="utf-8", errors="ignore") except OSError: _INDEX_RAW_TEXT = "" # Oeffentlich = Mod-Bereiche herausgeschnitten; Mod = volles HTML. Beide variieren nach # Cookie (Shared-Caches duerfen die Varianten nicht vermischen). _INDEX_STATIC = _MemStatic(None, "text/html; charset=utf-8", raw=_sm_strip_mod_regions(_INDEX_RAW_TEXT).encode("utf-8"), vary="Accept-Encoding, Cookie") _INDEX_MOD_STATIC = _MemStatic(None, "text/html; charset=utf-8", raw=_INDEX_RAW_TEXT.encode("utf-8"), vary="Accept-Encoding, Cookie") _CATALOG_JS_STATIC = _MemStatic(STATIC_DIR / "game_catalog.js", "application/javascript", cache_control="public, max-age=86400") _ARMOR_JS_STATIC = _MemStatic(STATIC_DIR / "armor-render-templates.js", "application/javascript", cache_control="public, max-age=86400") # Forge-Stats-Endpoints (/forge/*) — eigener Router, DB aus HF-Dataset/lokal. # WICHTIG (Performance): die 1,5-GB-DB wird NICHT mehr beim Import geladen. Der # Download wuerde sonst waehrend des Hochfahrens um Netz/CPU konkurrieren und den # Health-Check verzoegern (Neustart-Schleifen-Gefahr). Stattdessen ein kurzer # Vorlauf nach dem Start (_delayed_forge_warm) -> ruhiger Boot, und beim ersten # echten Forge-Zugriff ist die DB meist schon bereit. _forge_warm = None def _forge_account_names(name): """Alle Namen (aktuell + fruehere/aliases) des Accounts, zu dem 'name' gehoert — damit die Forge-Stats wie die Power-Series an die Account-id gebunden sind (ueber Namenswechsel hinweg aggregiert). Unbekannt/nicht aufloesbar -> leere Liste (forge_api faellt auf den Namen zurueck). Late binding: wird erst zur Request-Zeit aufgerufen, wenn alle Helfer geladen sind.""" try: key = _sm_pseries_resolve_key(name or "", "") if not key.startswith("id:"): return [] with _SM_LEADERBOARD_LOCK: row = (_sm_leaderboard_load_unlocked().get("players") or {}).get(key) return _sm_lb_display_names(row) if row else [] except Exception: # noqa: BLE001 return [] try: from forge_api import (router as _forge_router, warm as _forge_warm, set_account_names_resolver as _forge_set_resolver) app.include_router(_forge_router) try: _forge_set_resolver(_forge_account_names) # Forge-Stats an Account-id binden except Exception as _forge_res_exc: # noqa: BLE001 print(f"[forge_api] resolver not set: {_forge_res_exc}") except Exception as _forge_exc: # noqa: BLE001 — Feature darf die App nie blockieren print(f"[forge_api] router not loaded: {_forge_exc}") # ── Ban-Grund-Index ───────────────────────────────────────────────────────────────────────────── # Gruende stammen aus dem OEFFENTLICHEN ban-logs-Kanal (fuer alle sichtbar). Diese Datei liefert # NUR den Grund; der Ban-STATUS kommt weiter aus unserer eigenen Logik (is_banned). Der Kanal # fuehrt keine Account-IDs -> Lookup ueber Username + Aliases (_forge_account_names), damit der # Grund nach einer Umbenennung beim Account bleibt. ban_reasons.json liegt im selben HF-Dataset # wie die Forge-DB (taeglich vom Updater erneuert) und wird gecacht nachgeladen. _SM_BAN = {"data": None, "ts": 0.0, "err": None} _SM_BAN_LOCK = threading.Lock() _SM_BAN_TTL = float(_os.getenv("SM_BAN_REASONS_TTL", "3600")) _SM_BAN_REPO = _os.getenv("FORGE_DATASET_REPO", "oettaM123/swordmasters-forge-stats") _SM_BAN_FILE = "ban_reasons.json" def _sm_ban_reasons(): """username_lower -> {user,reason,by,ts}. Gecacht mit TTL; Fehler nie fatal (dann alter Stand bzw. leer). Quelle: env SM_BAN_REASONS_PATH -> lokale Datei neben app.py -> HF-Dataset.""" now = time.time() with _SM_BAN_LOCK: if _SM_BAN["data"] is not None and (now - _SM_BAN["ts"]) < _SM_BAN_TTL: return _SM_BAN["data"] data = None try: p = _os.getenv("SM_BAN_REASONS_PATH") or "" if not (p and _os.path.exists(p)): local = str(Path(__file__).with_name(_SM_BAN_FILE)) p = local if _os.path.exists(local) else "" if not p: from huggingface_hub import hf_hub_download token = (_os.getenv("HF_TOKEN") or _os.getenv("HUGGING_FACE_HUB_TOKEN") or _os.getenv("HUGGINGFACE_TOKEN")) p = hf_hub_download(repo_id=_SM_BAN_REPO, repo_type="dataset", filename=_SM_BAN_FILE, token=token) with open(p, "r", encoding="utf-8") as f: j = json.load(f) d = j.get("bans") if isinstance(j, dict) else None data = d if isinstance(d, dict) else {} except Exception as exc: # noqa: BLE001 with _SM_BAN_LOCK: _SM_BAN["err"] = f"{type(exc).__name__}: {exc}" if _SM_BAN["data"] is not None: _SM_BAN["ts"] = now # alten Stand behalten, nicht bei jedem Request neu versuchen return _SM_BAN["data"] data = {} with _SM_BAN_LOCK: _SM_BAN["data"] = data _SM_BAN["ts"] = now return data @app.get("/ban-reason") def ban_reason(name: str = ""): """Grund fuer einen (laut eigener Logik) gebannten Account. Matcht Username + Aliases gegen den oeffentlichen ban-logs-Index. 'found' kann True mit leerem 'reason' sein (Ban ohne Grund).""" name = (name or "").strip() if not name: return {"found": False} bans = _sm_ban_reasons() or {} if not bans: return {"found": False} candidates = [name] + list(_forge_account_names(name) or []) seen = set() for n in candidates: k = str(n or "").strip().lower() if not k or k in seen: continue seen.add(k) hit = bans.get(k) if hit: return {"found": True, "reason": hit.get("reason") or "", "by": hit.get("by") or "", "date": (hit.get("ts") or "")[:10], "matched": hit.get("user") or n} return {"found": False} # ── VIP-Liste ──────────────────────────────────────────────────────────────────────────────── # Die VIP-Liste steht NUR im Colyseus-Room-State (room.state.VIPLeaderboard = Namen, VIPCount = # Gesamtzahl) und ist per REST/Lookup nicht holbar. Ein kleiner Reader auf dem Heim-Worker (SP7, # Wohn-IP — Cloudflare blockt datacenter-IPs mit 1020/Turnstile) joint als Gast eine Welt, liest # den State und pusht die Liste hierher (POST /ingest-vip, ingest-key-gated wie das Leaderboard). # Wir cachen sie nach /data und zeigen im Lookup ein VIP-Badge — sofort aus dem Cache (kein # Live-Call), genau wie der Ban-Grund (Username + Aliases). SM_VIP_PATH = ((Path("/data") / "vip_list.json") if (_os.getenv("SPACE_ID") and _os.path.isdir("/data")) else (APP_DIR / "vip_list.json")) _SM_VIP = {"set": None, "count": 0, "updated": 0.0, "world": "", "loaded_ts": 0.0} _SM_VIP_LOCK = threading.Lock() _SM_VIP_TTL = float(_os.getenv("SM_VIP_TTL", "300")) # /data-Datei hoechstens alle 5 min neu lesen def _sm_vip_norm(name): return str(name or "").strip().lower() def _sm_vip_load_unlocked(force=False): now = time.time() if not force and _SM_VIP["set"] is not None and (now - _SM_VIP["loaded_ts"]) < _SM_VIP_TTL: return try: with open(SM_VIP_PATH, "r", encoding="utf-8") as f: j = json.load(f) vips = j.get("vips") or [] _SM_VIP["set"] = {_sm_vip_norm(v) for v in vips if str(v or "").strip()} _SM_VIP["count"] = int(j.get("count") or len(_SM_VIP["set"])) _SM_VIP["updated"] = float(j.get("updated") or 0.0) _SM_VIP["world"] = str(j.get("world") or "") except FileNotFoundError: if _SM_VIP["set"] is None: _SM_VIP["set"] = set() except Exception: # noqa: BLE001 — kaputte Datei darf das Feature nie crashen if _SM_VIP["set"] is None: _SM_VIP["set"] = set() _SM_VIP["loaded_ts"] = now def _sm_vip_store(vips, count=0, world=""): """VIP-Liste ablegen: case-insens-Dedup (Originalschreibweise behalten) -> /data schreiben -> Cache aktualisieren. Ein LEERER Satz ueberschreibt einen vorhandenen guten Stand NICHT (Sidecar-Fehlschlag/leere Welt darf die Liste nicht loeschen). Genutzt vom Space-Sidecar (_sm_vip_refresh) UND vom optionalen /ingest-vip.""" seen, clean = set(), [] for v in (vips or []): s = str(v or "").strip() if s and s.lower() not in seen: seen.add(s.lower()) clean.append(s) with _SM_VIP_LOCK: _sm_vip_load_unlocked() if not clean and _SM_VIP["set"]: return {"ok": False, "skipped": "empty ignored", "kept": len(_SM_VIP["set"])} payload = {"vips": clean, "count": int(count or len(clean)), "world": str(world or ""), "updated": time.time()} try: SM_VIP_PATH.parent.mkdir(parents=True, exist_ok=True) tmp = str(SM_VIP_PATH) + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(payload, f, separators=(",", ":")) _os.replace(tmp, SM_VIP_PATH) except Exception as exc: # noqa: BLE001 return {"ok": False, "error": f"write failed: {exc}"} _sm_vip_load_unlocked(force=True) return {"ok": True, "count": payload["count"], "stored": len(clean)} @app.get("/vip-status") def vip_status(name: str = ""): """Ist dieser Account VIP? Matcht Username + Aliases gegen die gecachte VIP-Liste (Room-State, vom Heim-Worker gepusht). Sofort aus dem Cache, kein Live-Call.""" name = (name or "").strip() with _SM_VIP_LOCK: _sm_vip_load_unlocked() vips = _SM_VIP["set"] or set() count = _SM_VIP["count"] updated = _SM_VIP["updated"] if not name or not vips: return {"vip": False, "count": count, "updated": updated} candidates = [name] + list(_forge_account_names(name) or []) seen = set() for n in candidates: k = _sm_vip_norm(n) if not k or k in seen: continue seen.add(k) if k in vips: return {"vip": True, "count": count, "updated": updated, "matched": n} return {"vip": False, "count": count, "updated": updated} class VipIngest(BaseModel): vips: list = [] count: int = 0 world: str = "" @app.post("/ingest-vip") def ingest_vip(data: VipIngest, request: Request): """OPTIONALER Fallback-Push der VIP-Liste (ingest-key-gated wie das Leaderboard). Normal liest der Space die Liste selbst (Sidecar _sm_vip_refresh); dieser Endpoint erlaubt zusaetzlich einen externen Push, falls je gebraucht.""" import hmac provided = str(request.headers.get("X-Ingest-Key") or request.query_params.get("key") or "") if not (SM_LEADERBOARD_INGEST_KEY and hmac.compare_digest(provided, SM_LEADERBOARD_INGEST_KEY)): raise HTTPException(404) res = _sm_vip_store(data.vips, data.count, data.world) if res.get("error"): raise HTTPException(500, res["error"]) return res FORGE_WARM_DELAY = max(0, int(_os.getenv("FORGE_WARM_DELAY", "15"))) def _delayed_forge_warm(): if _forge_warm is None: return def _run(): time.sleep(FORGE_WARM_DELAY) try: _forge_warm() except Exception: # noqa: BLE001 pass threading.Thread(target=_run, daemon=True).start() @app.get("/game-catalog.js", include_in_schema=False) async def game_catalog_script(request: Request): return await _CATALOG_JS_STATIC.respond(request) @app.get("/armor-render-templates.js", include_in_schema=False) async def armor_render_templates_script(request: Request): return await _ARMOR_JS_STATIC.respond(request) @app.get("/", include_in_schema=False) async def index(request: Request): # Mod-Zugang laeuft ueber persoenliche Geraete-Sitzungen (Einmal-Links, siehe "Mod access"). # Alte ?mod=-Links und das alte sm_mod-Cookie (enthielt den rohen gemeinsamen Key) # geben KEINE Rechte mehr: Parameter aus der URL entfernen, Cookie loeschen. Das # oeffentliche HTML/JS enthaelt weiterhin keine Mod-Spur. if (request.query_params.get("mod") or "").strip(): resp = RedirectResponse(url="/", status_code=303) resp.delete_cookie("sm_mod") return resp is_mod = _sm_is_mod(request) resp = await (_INDEX_MOD_STATIC if is_mod else _INDEX_STATIC).respond(request) if request.cookies.get("sm_mod"): resp.delete_cookie("sm_mod") return resp @app.get("/health", include_in_schema=False) async def health(): return {"ok": True} @app.exception_handler(Exception) async def unexpected_error_handler(request, exc): print(f"[Unexpected error] {request.method} {request.url.path}: {exc}") return JSONResponse(status_code=500, content={"detail": f"Server error: {exc}"}) _SUFFIX_SCALE_PAIRS = [ ("", 0), ("k", 3), ("m", 6), ("b", 9), ("t", 12), ("qt", 15), ("qnt", 18), ("sxt", 21), ("spt", 24), ("oct", 27), ("nnt", 30), ] _SUFFIX_UNIT_STEMS = ["", "u", "d", "t", "qt", "qnt", "sxt", "spt", "oct", "nnt"] _SUFFIX_TENS_STEMS = ["dc", "vg", "tg", "qd", "qi", "sxg", "spg", "ocg", "nog"] _EXTRA_SUFFIXES = [ f"{unit}{tens}" for tens in _SUFFIX_TENS_STEMS for unit in _SUFFIX_UNIT_STEMS ] + ["ce"] _SUFFIX_SCALE_PAIRS.extend((suffix, 33 + i * 3) for i, suffix in enumerate(_EXTRA_SUFFIXES)) _LEGACY_EXTRA_SUFFIXES = """ dc udc ddc tdc qtdc qndc sxdc spdc ocdc nndc vg uvg dvg tvg qtvg qnvg sxvg spvg ocvg nnvg tg utg dtg ttg qttg qntg sxtg sptg octg nntg qd uqd dqd tqd qtqd qnqd sxqd spqd ocqd nnqd qi uqi dqi tqi qtqi qnqi sxqi spqi ocqi nnqi sxg usxg dsxg tsxg qtsxg qnsxg sxsxg spsxg ocsxg nnsxg spg uspg dspg tspg qtspg qnspg sxspg spspg ocspg nnspg ocg uocg docg tocg qtocg qnocg sxocg spocg ococg nnocg nog unog dnog tnog qtnog qnnog sxnog spnog ocnog nnnog ce """.split() _SUFFIX_ALIAS_SCALE_PAIRS = [(suffix, 33 + i * 3) for i, suffix in enumerate(_LEGACY_EXTRA_SUFFIXES)] _SUFFIX_ALIAS_SCALE_PAIRS.extend([ ("cmt", 303), ("cnt", 303), ("qa", 15), ("qi", 18), ("qn", 18), ("sx", 21), ("sp", 24), ("oc", 27), ("no", 30), ("dc", 33), ("ud", 36), ("dd", 39), ("td", 42), ("qad", 45), ("qid", 48), ("sxd", 51), ("spd", 54), ("ocd", 57), ("nod", 60), ("vg", 63), ("uvg", 66), ("dvg", 69), ("tvg", 72), ("qavg", 75), ("qivg", 78), ("sxvg", 81), ("spvg", 84), ("ocvg", 87), ("novg", 90), ]) _M_DEC = {suffix: Decimal(10) ** exp for suffix, exp in _SUFFIX_SCALE_PAIRS} for _suffix, _exp in _SUFFIX_ALIAS_SCALE_PAIRS: _M_DEC.setdefault(_suffix, Decimal(10) ** _exp) _M_FLOAT = {k:float(v) for k,v in _M_DEC.items()} _SUFFIXES = sorted(_M_DEC.keys(), key=len, reverse=True) def parse_float(value:str)->float: v=str(value).strip().replace(",", "").lower() if v in ("", "none", "null", "nan"): return 0.0 for s in _SUFFIXES: if v.endswith(s): raw = v[:-len(s)] if s else v try: return float(raw) * _M_FLOAT[s] except ValueError: break try: return float(v) except ValueError: raise HTTPException(400, f"Ungueltige Zahl oder unbekannter Suffix: {value!r}") _DISPLAY_SUFFIX_SCALE_PAIRS = list(_SUFFIX_SCALE_PAIRS) _DISPLAY_DEC = [(suffix, Decimal(10) ** exp) for suffix, exp in _DISPLAY_SUFFIX_SCALE_PAIRS] def fmt(num:Decimal)->str: n = abs(num) sign = "-" if num < 0 else "" for s,m in reversed(_DISPLAY_DEC): if n>=m: return f"{sign}{(n/m).quantize(Decimal('1.00'))}{s.upper()}" return str(num.quantize(Decimal('1.00'))) SCHWERTER = { "Old Sword":("10","13"),"Orc Sword":("15","19"),"Double-Edged Sword":("100","125"), "Spiked Sword":("250","313"),"Dead Man's Sword":("3k","3.75k"),"Machete Sword":("750","938"), "Scimitar Claw":("1k","1.25k"),"Morbid Will":("2k","2.5k"),"Inferno Claw":("3k","3.75k"), "Burned Spikeblade":("15k","18.8k"),"Icebreaker":("5k","6.25k"),"Frostbite":("7.5k","9.38k"), "Froststeel":("10k","12.5k"),"Frostfang":("15k","18.8k"),"Winter's Wrath":("500k","625k"), "Sandstrike":("25k","31.3k"),"Duneslicer":("40k","50k"),"Sandslasher":("60k","75k"), "Sunfire":("85k","106k"),"Emberwraith":("1m","1.25m"),"Blade of Sorrow":("100k","125k"), "Flame Heart":("150k","187k"),"Darkblade":("200k","250k"),"Fatecutter":("250k","312k"), "Devil's Blade":("10m","12.5m"),"Spear of Zeus":("15m","18.8m"),"Coral Blade":("5m","6.25m"), "Spiked Moss":("12m","15m"),"Guardian's Spear":("50m","62.5m"),"Dragon's Teeth":("90m","112.5m"), "Dragon's Poison":("200m","250m"),"Starfire Blade":("50m","62.5m"),"Nova Blade":("70m","87.5m"), "Cosmic Blader":("100m","125m"),"Netherite Blade":("500m","625m"),"Death's Scythe":("1b","1.25b"), "Dragon's Devil":("5b","6.25b"),"Einherjar's Blade":("800m","1b"),"Runebreaker":("1.5b","1.88b"), "Solbrand":("2b","2.5b"),"Divinity Edge":("8b","10b"), } SCHILDE = { "Wooden Shield":None,"Iron Shield":None,"Elite Shield":("0.25","0.3125"), "Warrior's Shield":None,"Shield of the Kingdom":None,"Sweetguard":None, "Warden's Shield":None,"Light Barrier":None,"Obsidian Canny":None, "Dead Guard's Shield":("0.5","0.625"),"Snowfall Defender":None,"Arctic Ward":None, "Icy Rampart":None,"Frozen Fortress":None,"Winter's Wrathguard":("1","1.25"), "Sunshield":None,"Barbed Sunward":("0.25","0.3125"),"Sunshroud":None, "Sandstone Shield":("0.25","0.3125"),"Emberwraith Shield":None,"Hell Shield":None, "Shield of Fire":None,"Fire Rampart":None,"Infernal Protection":None, "Lord of Hell":("0.5","0.625"),"Steel Shield":None,"Hardened Moss":None, "Guardian's Protection":("1","1.25"),"Dragon's Shell":("1","1.25"), "Dragon's Anger":("2","2.5"),"Nebula Shield":None,"Starburst Shield":None, "Lunar Shield":("1.5","1.875"),"Death's Shield":("4","5"),"Dragon's Soul":("8","10"), "Einherjar's Guard":("1.75","2.1875"),"Runeguard":("2.5","3.125"), "Sunward Bulwark":("5","6.25"),"Asgardian Aegis":("12","15"), } PETS = { "Piggie":0.0,"Chicko":0.25,"Gumbear":0.5,"Croco":0.75,"Fluffy":1.0,"Sandy":1.25, "Peppy":1.5,"Scorp":1.75,"Kamel":2.5,"Blue Axolotl":3.0,"Lava Slime":3.0, "Firebat":3.5,"Nemo":3.5,"Sharkie":4.0,"Fogo":4.25,"Purr":4.5,"Mimic":4.5, "Triwulf":4.75,"Pengoo":5.0,"Leo":6.0,"Phoenix":8.0,"Octoo":8.5,"Xen":10.0, "Inferno":10.25,"XPeppy":12.0,"XAxolotl":13.0,"XPengoo":16.0,"XLeo":17.5, "XPhoenix":19.0,"XOctoo":21.0,"XXen":23.0,"XInferno":25.0, } SM_PET_TYPE_NAMES = ( "Piggie", "Chicko", "Peppy", "Gumbear", "Croco", "Blue Axolotl", "Fluffy", "Sandy", "Pengoo", "Scorp", "Kamel", "Leo", "Lava Slime", "Firebat", "Phoenix", "Nemo", "Sharkie", "Octoo", "Fogo", "Purr", "Xen", "XPeppy", "XAxolotl", "XPengoo", "XLeo", "XPhoenix", "XOctoo", "XXen", "Mimic", "Triwulf", "Inferno", "XInferno", ) # Ore-Typ-Index des Spiels, direkt aus __game-scripts.js verifiziert (Texture-IDs # gegen einen aelteren Dump mit Klartextnamen abgeglichen). NICHT aus game_catalog # ableiten - dessen "ores" sind alphabetisch sortierte Bild-Assets ohne Typ-Index. # Jedes Ore gehoert zu genau einem Enchant: enchantmentType == Ore-Typ. # 0 Coal->Flame(70%) 1 Emerald->Thorns(55%) 2 Diamond->Looting(40%) # 3 Ruby->Freeze(25%) 4 Gold->Fortune(20%) SM_ORE_TYPE_NAMES = ("Coal", "Emerald", "Diamond", "Ruby", "Gold") HELMETS = {"None":0,"Iron Helmet":0,"King's Crown":0.25,"Sweetmallow":0.5,"Bone Helmet":0.75, "Cactus Helmet":0.75,"Lollipop Helmet":0.75,"Seashell Helmet":1.0,"Immortal Helmet":1.0, "Hellfire Hellguard":1.0,"Obsidian Helmet":1.0,"Chocohead":1.0,"Hydrostone Helmet":1.25, "Nether Helmet":1.25,"Sea Guardian's Helmet":1.5,"Starfire Helmet":1.5,"Nova Helmet":2.0, "Cosmic Helmet":2.5,"Death's Helmet":3.0,"Stormbom Helmet":3.5,"Solarflare Helmet":4.0} CHESTPLATES = {"None":0,"Iron Chestplate":0,"Leather Chestplate":0,"Golden Chestplate":0.25, "Fluffymellow":0.75,"Hydrostone Chestplate":1.0,"Sea Guardian's Chestplate":1.0, "Seashell Chestplate":1.0,"Spiked Shell":1.0,"Hellfire Chestplate":1.0,"Nether Chestplate":1.0, "Cosmic Chestplate":1.0,"Nova Chestplate":1.0,"Starfire Chestplate":1.0,"Candyshell":1.0, "Chocoblock":1.0,"Boneshell":1.25,"Immortal Chestplate":1.25,"Obsidian Chestplate":1.25, "Death's Chestplate":2.0,"Stormbom Chestplate":2.5,"Solarflare Chestplate":3.0} SHORTS = {"None":0,"Leather Short":0,"Golden Short":0.25,"Iron Short":0.25,"Softmallow Short":0.75, "Seashell Short":1.0,"Bone Short":1.0,"Cactus Short":1.0,"Hellfire Short":1.0,"Obsidian Short":1.0, "Lollishort":1.0,"Chocoshort":1.0,"Hydrostone Short":1.25,"Immortal Short":1.25,"Nether Short":1.25, "Sea Guardian's Short":1.5,"Starfire Short":1.75,"Nova Short":2.25,"Cosmic Short":2.75, "Death's Short":3.0,"Stormbom Short":3.5,"Solarflare Short":4.0} BOOTS = {"None":0,"Iron Boots":0,"Golden Boots":0.25,"Soft Boots":0.5,"Cake Boots":0.75, "Hydrostone Boots":1.0,"Sea Guardian's Boots":1.0,"Seashell Boots":1.0,"Bone Boots":1.0, "Cactus Boots":1.0,"Immortal Boots":1.0,"Hellfire Boots":1.0,"Nether Boots":1.0, "Obsidian Boots":1.0,"Candy Boots":1.0,"Nova Boots":1.25,"Starfire Boots":1.25, "Cosmic Boots":1.5,"Death's Boots":2.0,"Stormbom Boots":2.5,"Solarflare Boots":3.0} GAME_CATALOG_PATH = APP_DIR / "game_catalog.json" BOSS_OVERRIDES_PATH = APP_DIR / "boss_overrides.json" BASE_HEALTH = 100.0 HEALTH_PER_LEVEL_SQUARED = 10.0 def _read_game_catalog(): try: data = json.loads(GAME_CATALOG_PATH.read_text(encoding="utf-8")) return data if isinstance(data, dict) else {} except (OSError, json.JSONDecodeError): return {} def _read_boss_overrides(): try: data = json.loads(BOSS_OVERRIDES_PATH.read_text(encoding="utf-8")) return data if isinstance(data, dict) else {} except (OSError, json.JSONDecodeError): return {} _GAME_CATALOG = _read_game_catalog() _BOSS_OVERRIDES = _read_boss_overrides() ARMOUR_STATS = { "helmets": {}, "chestplates": {}, "shorts": {}, "boots": {}, } ARMOUR_TYPE_NAMES = { "helmets": {}, "chestplates": {}, "shorts": {}, "boots": {}, } # Direkte Spiel-Typ -> Name Abbildung fuer Schwerter/Schilde (analog ARMOUR_TYPE_NAMES). # Leer -> Fallback auf list()-Position (offset=-1), fuer den Fall ohne geladenen Katalog. SWORD_TYPE_NAMES = {} SHIELD_TYPE_NAMES = {} SWORD_HEALTH_MULTIPLIER = {} SHIELD_HEALTH_MULTIPLIER = {} # name -> minLevel to equip (= entry level of the world the item drops in). Used to # only suggest gear an account can actually equip. SWORD_MIN_LEVEL = {} SHIELD_MIN_LEVEL = {} if _GAME_CATALOG: _gear_level_scale = float(_GAME_CATALOG.get("level_scale_per_level", 0.25)) _catalog_swords = _GAME_CATALOG.get("swords") or [] _catalog_shields = _GAME_CATALOG.get("shields") or [] _catalog_pets = _GAME_CATALOG.get("pets") or [] _catalog_armour = _GAME_CATALOG.get("armor") or {} SCHWERTER = { item["name"]: ( str(float(item.get("damage", 0.0))), str(float(item.get("damage", 0.0)) * (1.0 + _gear_level_scale)), ) for item in _catalog_swords } SCHILDE = { item["name"]: ( ( str(float(item.get("damageMultiplier", 0.0))), str(float(item.get("damageMultiplier", 0.0)) * (1.0 + _gear_level_scale)), ) if float(item.get("damageMultiplier", 0.0)) != 0.0 else None ) for item in _catalog_shields } PETS = { item["name"]: float(item.get("powerMultiplier", 0.0)) for item in _catalog_pets } SM_PET_TYPE_NAMES = tuple( item["name"] for item in sorted(_catalog_pets, key=lambda row: int(row["type"])) ) HELMETS = { item["name"]: float(item.get("powerMultiplier", 0.0)) for item in _catalog_armour.get("helmets", []) } CHESTPLATES = { item["name"]: float(item.get("powerMultiplier", 0.0)) for item in _catalog_armour.get("chestplates", []) } SHORTS = { item["name"]: float(item.get("powerMultiplier", 0.0)) for item in _catalog_armour.get("shorts", []) } BOOTS = { item["name"]: float(item.get("powerMultiplier", 0.0)) for item in _catalog_armour.get("boots", []) } SWORD_HEALTH_MULTIPLIER = { item["name"]: float(item.get("healthMultiplier", 0.0)) for item in _catalog_swords } SHIELD_HEALTH_MULTIPLIER = { item["name"]: float(item.get("healthMultiplier", 0.0)) for item in _catalog_shields } SWORD_MIN_LEVEL = { item["name"]: int(float(item.get("minLevel") or 0)) for item in _catalog_swords if item.get("name") } SHIELD_MIN_LEVEL = { item["name"]: int(float(item.get("minLevel") or 0)) for item in _catalog_shields if item.get("name") } SWORD_TYPE_NAMES = { int(item["type"]): item["name"] for item in _catalog_swords if item.get("type") is not None and item.get("name") } SHIELD_TYPE_NAMES = { int(item["type"]): item["name"] for item in _catalog_shields if item.get("type") is not None and item.get("name") } ARMOUR_STATS = { category: { item["name"]: { "extra_health": float(item.get("extraHealth", 0.0)), "health_multiplier": float(item.get("healthMultiplier", 0.0)), "gold_multiplier": float(item.get("goldMultiplier", 0.0)), "power_multiplier": float(item.get("powerMultiplier", 0.0)), "price_gold": float(item.get("priceGold", 0.0)), "price_power": float(item.get("pricePower", 0.0)), } for item in _catalog_armour.get(category, []) } for category in ARMOUR_STATS } ARMOUR_TYPE_NAMES = { category: { int(item["type"]): item["name"] for item in _catalog_armour.get(category, []) if item.get("type") is not None and item.get("name") } for category in ARMOUR_TYPE_NAMES } def base_health_for_level(level) -> float: try: normalized_level = max(1, int(level)) except (TypeError, ValueError): normalized_level = 1 return BASE_HEALTH + HEALTH_PER_LEVEL_SQUARED * (normalized_level - 1) ** 2 def max_health_for_setup( level, sword_name="", sword_level=1, shield_name="", shield_level=1, helmet="None", chestplate="None", short="None", boots="None", ) -> float: health_multiplier = _gear_scale( SWORD_HEALTH_MULTIPLIER.get(sword_name, 0.0), sword_level ) health_multiplier += _gear_scale( SHIELD_HEALTH_MULTIPLIER.get(shield_name, 0.0), shield_level ) extra_health = 0.0 for category, name in ( ("helmets", helmet), ("chestplates", chestplate), ("shorts", short), ("boots", boots), ): stats = ARMOUR_STATS.get(category, {}).get(name, {}) extra_health += float(stats.get("extra_health", 0.0)) health_multiplier += float(stats.get("health_multiplier", 0.0)) return extra_health + base_health_for_level(level) * (1.0 + health_multiplier) LOOTING_MULT = {0:1.0,1:1.6,2:2.2,3:2.8,4:3.4,5:4.0,6:4.0} BOSS_HP=100e12; RESPAWN=6.15; HPS=25/9; ITEM_PV=105e15; MAX_ROOM=8 XP_PER_DAMAGE = 0.5 # Measured live: experience gain ~= server damageAmount / 2 def _boss_number(value, default, *, positive=False): try: number = float(value) except (TypeError, ValueError): return default return number if (number > 0 if positive else number >= 0) else default def _catalog_items_by_type(category): return { int(item["type"]): item for item in (_GAME_CATALOG.get(category) or []) if isinstance(item, dict) and "type" in item } def _featured_enemy_drop(enemy, category, override): list_key = "dropsSword" if category == "swords" else "dropsShield" item_key = "sword_item" if category == "swords" else "shield_item" chance_key = "sword_drop_chance" if category == "swords" else "shield_drop_chance" items = _catalog_items_by_type(category) candidates = [items[item_type] for item_type in enemy.get(list_key, []) if item_type in items] chosen = None override_name = str(override.get(item_key, "") or "").strip() if override_name: chosen = next((item for item in items.values() if item.get("name") == override_name), None) if chosen is None and candidates: chosen = min( candidates, key=lambda item: ( float(item.get("drop_chance", item.get("commonnessPercentage", 100.0)) or 100.0), -int(item.get("type", 0)), ), ) official_chance = ( float(chosen.get("drop_chance", chosen.get("commonnessPercentage", 0.0)) or 0.0) / 100.0 if chosen else 0.0 ) return { "name": chosen.get("name") if chosen else "", "chance": _boss_number(override.get(chance_key), official_chance), "official_chance": official_chance, "candidate_names": [item.get("name", "") for item in candidates], } def _build_boss_presets(): overrides = _BOSS_OVERRIDES.get("bosses") or {} first_hit_delay = _boss_number( _BOSS_OVERRIDES.get("default_first_hit_delay_seconds"), 1.15 ) presets = [] for enemy in _GAME_CATALOG.get("enemies") or []: enemy_type = int(enemy.get("type", -1)) override = overrides.get(str(enemy_type), {}) if not override.get("enabled", False): continue reward_range = list(enemy.get("coinRewardRange") or [0.0, 0.0]) sword_drop = _featured_enemy_drop(enemy, "swords", override) shield_drop = _featured_enemy_drop(enemy, "shields", override) raw_respawn = _boss_number(enemy.get("respawDelay"), 0.0) / 1000.0 presets.append({ "key": str(override.get("key") or re.sub(r"[^a-z0-9]+", "-", enemy["name"].lower()).strip("-")), "display_name": str(override.get("display_name") or enemy["name"]), "order": int(override.get("order", 999)), "enemy_type": enemy_type, "boss_hp": float(enemy.get("health", 0.0)), "raw_respawn_seconds": raw_respawn, "effective_respawn_seconds": _boss_number( override.get("effective_respawn_seconds"), raw_respawn + first_hit_delay ), "room_capacity": max(1, int(_boss_number( override.get("room_capacity"), MAX_ROOM, positive=True ))), "sword_drop": sword_drop, "shield_drop": shield_drop, "gold": { "coin_drop_hp_limit": float(enemy.get("coinDropHPLimit", 0.0)), "kill_count": int(enemy.get("coinDropCount", 0)), "reward_min": float(reward_range[0]), "reward_max": float(reward_range[1]), "normal_divisor": _boss_number( override.get("normal_gold_divisor"), 5.0, positive=True ), }, }) return sorted(presets, key=lambda row: (row["order"], row["enemy_type"])) BOSS_PRESETS = _build_boss_presets() GOLD_ENEMY_CONFIGS = [ { "enemy_type": preset["enemy_type"], "max_hp": preset["boss_hp"], **preset["gold"], } for preset in BOSS_PRESETS ] _STATIC_GEAR_DB_CACHE = None def _cfg_nonnegative(value, default): if value is None: return default try: val = float(value) except (TypeError, ValueError): return default return val if val >= 0 else default def _cfg_positive(value, default): if value is None: return default try: val = float(value) except (TypeError, ValueError): return default return val if val > 0 else default def _load_static_gear_db(): global _STATIC_GEAR_DB_CACHE if _STATIC_GEAR_DB_CACHE is not None: return _STATIC_GEAR_DB_CACHE data = {"swords": {}, "shields": {}, "pets": {}} if _GAME_CATALOG: def drop_label(item): chance = float(item.get("drop_chance", 0.0)) return f"{chance:g}%" if chance > 0 else "level-based" for item in _GAME_CATALOG.get("swords", []): data["swords"][item["name"]] = [ item.get("rarity", "Common"), item.get("world", ""), item.get("damage", 0.0), item.get("damageProtection", 0.0), item.get("healthMultiplier", 0.0), item.get("goldMultiplier", 0.0), item.get("priceGold", 0.0), item.get("source", ""), drop_label(item), ] for item in _GAME_CATALOG.get("shields", []): data["shields"][item["name"]] = [ item.get("rarity", "Common"), item.get("world", ""), item.get("damageProtection", 0.0), item.get("healthMultiplier", 0.0), item.get("goldMultiplier", 0.0), item.get("damageMultiplier", 0.0), item.get("priceGold", 0.0), item.get("source", ""), drop_label(item), ] for item in _GAME_CATALOG.get("pets", []): data["pets"][item["name"]] = [ item.get("powerMultiplier", 0.0), item.get("goldMultiplier", 0.0), item.get("speedMultiplier"), ] _STATIC_GEAR_DB_CACHE = data return data try: text = (STATIC_DIR / "index.html").read_text(encoding="utf-8", errors="ignore") marker = "const GEAR_DB" marker_pos = text.find(marker) brace_start = text.find("{", marker_pos) if marker_pos >= 0 else -1 if brace_start >= 0: depth = 0 in_string = False escaped = False end = -1 for pos in range(brace_start, len(text)): ch = text[pos] if in_string: if escaped: escaped = False elif ch == "\\": escaped = True elif ch == '"': in_string = False continue if ch == '"': in_string = True elif ch == "{": depth += 1 elif ch == "}": depth -= 1 if depth == 0: end = pos + 1 break raw = text[brace_start:end] if end > brace_start else "" else: raw = "" if raw: raw = re.sub(r"(\{|,)\s*(swords|shields|pets)\s*:", r'\1"\2":', raw) raw = re.sub(r",\s*([}\]])", r"\1", raw) parsed = json.loads(raw) if isinstance(parsed, dict): for key in data: if isinstance(parsed.get(key), dict): data[key] = parsed[key] except Exception: pass _STATIC_GEAR_DB_CACHE = data return data def _gear_scale(base, level): try: lvl = max(1, int(level or 1)) except (TypeError, ValueError): lvl = 1 return float(base or 0.0) * (1.0 + 0.25 * (lvl - 1)) def _gear_db_gold(item_type, name, level=1): if not name: return 0.0 db = _load_static_gear_db() table = db.get(item_type) or {} row = table.get(name) if not isinstance(row, list): return 0.0 try: if item_type == "swords": return _gear_scale(float(row[5] or 0.0), level) if item_type == "shields": return _gear_scale(float(row[4] or 0.0), level) if item_type == "pets": return float(row[1] or 0.0) except (TypeError, ValueError, IndexError): return 0.0 return 0.0 def _armour_gold_multiplier(armour): if armour is None: return 0.0 total = 0.0 for category, name in ( ("helmets", getattr(armour, "helmet", None)), ("chestplates", getattr(armour, "chestplate", None)), ("shorts", getattr(armour, "short", None)), ("boots", getattr(armour, "boots", None)), ): stats = ARMOUR_STATS.get(category, {}).get(name or "None", {}) total += float(stats.get("gold_multiplier", 0.0)) return total def _split_gear_label(label): text = str(label or "").strip() if not text or text == "—": return None, 1 match = re.match(r"(.+?)\s+Lv\s*(\d+)\s*$", text) if match: return match.group(1), int(match.group(2)) return text, 1 def _account_gold_multiplier(acc, sword_name=None, sword_level=None, shield_name=None, shield_level=None, pets=None): if sword_name is None and getattr(acc, "sword", None): sword_name = acc.sword.name sword_level = acc.sword.level if shield_name is None and getattr(acc, "shield", None): shield_name = acc.shield.name shield_level = acc.shield.level pet_names = list(pets if pets is not None else (getattr(acc, "pets", None) or [])) total = 0.0 total += _gear_db_gold("swords", sword_name, sword_level or 1) total += _gear_db_gold("shields", shield_name, shield_level or 1) total += sum(_gear_db_gold("pets", p, 1) for p in pet_names) total += _armour_gold_multiplier(getattr(acc, "armour", None)) return total def _gold_config_for_boss_hp(boss_hp, enemy_type=None): if enemy_type is not None: try: wanted_type = int(enemy_type) except (TypeError, ValueError): wanted_type = None if wanted_type is not None: for cfg in GOLD_ENEMY_CONFIGS: if cfg["enemy_type"] == wanted_type: return cfg hp = _cfg_positive(boss_hp, BOSS_HP) best = min(GOLD_ENEMY_CONFIGS, key=lambda cfg: abs(cfg["max_hp"] - hp) / max(cfg["max_hp"], hp, 1.0)) rel = abs(best["max_hp"] - hp) / max(best["max_hp"], hp, 1.0) return best if rel <= 0.05 else None # ───────────────────────────────────────────────────────────────── # FORMELN & SIMULATION # ───────────────────────────────────────────────────────────────── def dmg_hit(ds,power,ms): return (ds+2.0*math.sqrt(max(power,0))+1.0)*(1.0+ms) def loot_mult(tll): return LOOTING_MULT.get(min(int(tll),6),1.0) def flame_tick_dmg(ds,power,ms,fl): if fl<=0: return 0.0 return (2.0*math.sqrt(max(power,0))+ds)/(10.0-fl)*(1.0+ms) def hits_per_second_for_level(level, fallback=HPS): try: if level is None or str(level).strip() == "": raise ValueError return 1.0 + 0.5 * max(float(level), 0.0) ** (1.0 / 8.0) except (TypeError, ValueError): return _cfg_positive(fallback, HPS) def _account_hps(acc, fallback=HPS): if not isinstance(acc, dict): return hits_per_second_for_level(None, fallback) level = acc.get("level") if level is None: account = acc.get("account") level = _account_level(account) if hasattr(account, "level") else None return hits_per_second_for_level(level, fallback) def xp_from_damage(damage: float) -> float: return max(float(damage or 0.0), 0.0) * XP_PER_DAMAGE def level_base_xp(level: int | float | None) -> float: try: level_i = int(level or 0) except (TypeError, ValueError): level_i = 0 return float(max(level_i, 0) ** 4) def level_from_total_xp(total_xp: float | None): if total_xp is None or not math.isfinite(total_xp) or total_xp < 0: return None level = max(0, int(total_xp ** 0.25)) while level_base_xp(level + 1) <= total_xp: level += 1 while level > 0 and level_base_xp(level) > total_xp: level -= 1 return level def _account_level(acc): try: if acc.level is None: return None return max(0, int(acc.level)) except (TypeError, ValueError): return None def _parse_optional_number(value): if value is None: return None text = str(value).strip() if not text: return None try: num = parse_float(text) except HTTPException: return None return num if math.isfinite(num) and num >= 0 else None def _fmt_float_safe(value: float) -> str: try: return fmt(Decimal(str(value))) except Exception: return f"{value:.6e}" def _account_total_xp(acc): level = _account_level(acc) raw_total = _parse_optional_number(getattr(acc, "total_experience", None)) total = raw_total if total is not None: base = level_base_xp(level) if level is not None else 0.0 # total_experience may be supplied as progress within the current level. # The generic Discord "Experience" field is intentionally ignored here: # it is not guaranteed to be cumulative XP. if level is not None and total < base: total += base return total if level is not None: return level_base_xp(level) return None def project_account_level(acc, xp_gain: float): if acc is None: return {} total_before = _account_total_xp(acc) if total_before is None: return {} start_level = level_from_total_xp(total_before) if start_level is None: return {} total_after = total_before + max(float(xp_gain or 0.0), 0.0) after_level = level_from_total_xp(total_after) if after_level is None: return {} base = level_base_xp(after_level) next_base = level_base_xp(after_level + 1) progress = ((total_after - base) / (next_base - base) * 100.0) if next_base > base else 0.0 return { "level": start_level, "level_after": after_level, "level_delta": max(0, after_level - start_level), "level_progress_pct": round(max(0.0, min(100.0, progress)), 2), "total_xp_before": _fmt_float_safe(total_before), "total_xp_after": _fmt_float_safe(total_after), "total_xp_before_raw": str(total_before), "total_xp_after_raw": str(total_after), } def _simulate_kill(accs: list[dict], boss_hp: float = BOSS_HP, hps: float = HPS) -> dict: """ Exakte Simulation eines Boss-Kills mit korrekter Flame-Physik. Flame-Physik: Erster Tick 1s nach dem ersten Hit, dann jede Sekunde. Ticks bei t=1, 2, ..., floor(kt)-1 → also (floor(kt)-1) Ticks. Wenn floor(kt) == 0: keine Ticks. Gleichung: normal_dps * kt + total_tdph * max(0, floor(kt)-1) = BOSS_HP Lösungsstrategie: Suche ganzzahliges n = floor(kt) ≥ 0 sodass kt = (BOSS_HP - total_tdph * max(0, n-1)) / normal_dps und floor(kt) == n. Linear search: n startet bei geschätztem Maximalwert und geht runter. """ n = len(accs) dph = [dmg_hit(a["ds"], a["power"], a["ms"]) for a in accs] tdph = [flame_tick_dmg(a["ds"], a["power"], a["ms"], a.get("flame",0)) for a in accs] mult = [1.0 + a["mp"] + a["ap"] for a in accs] acc_hps = [_account_hps(a, hps) for a in accs] normal_dps = sum(dph[i] * acc_hps[i] for i in range(n)) total_tdph = sum(tdph) if normal_dps <= 0: return {"kill_time":1e9,"damage_dealt":[0.0]*n,"power_earned":[0.0]*n, "flame_ticks":[0]*n,"hits_per_second":acc_hps} if total_tdph <= 0: kt = boss_hp / normal_dps ticks = 0 else: # Suche n = floor(kt) sodass kt = (BOSS_HP - tdph*(n-1)) / normal_dps # mit floor(kt) == n. Beginne bei n_max = floor(BOSS_HP/normal_dps)+1. kt = boss_hp / normal_dps # fallback (kein Flame) ticks = 0 root = (boss_hp + total_tdph) / (normal_dps + total_tdph) base_n = max(0, int(root)) candidates = {0, base_n, base_n + 1, base_n + 2, max(0, base_n - 1), max(0, base_n - 2)} for candidate_n in sorted(candidates, reverse=True): flame_ticks_candidate = max(0, candidate_n - 1) kt_candidate = (boss_hp - total_tdph * flame_ticks_candidate) / normal_dps if kt_candidate <= 0: continue if int(kt_candidate) == candidate_n: kt = kt_candidate ticks = flame_ticks_candidate break else: # A flame tick can finish the boss exactly on an integer boundary. # Then no open interval contains a consistent kt value. for candidate_n in sorted(candidates): flame_ticks_candidate = max(0, candidate_n - 1) if flame_ticks_candidate <= 0: continue before_tick = normal_dps * candidate_n + total_tdph * (flame_ticks_candidate - 1) after_tick = before_tick + total_tdph if before_tick < boss_hp <= after_tick: kt = float(candidate_n) ticks = flame_ticks_candidate break else: kt = boss_hp / normal_dps ticks = 0 raw = [dph[i]*acc_hps[i]*kt + tdph[i]*ticks for i in range(n)] total_raw = sum(raw) sf = boss_hp / total_raw if total_raw > 0 else 1.0 damage = [r * sf for r in raw] power_out = [damage[i] * mult[i] for i in range(n)] return { "kill_time": kt, "damage_dealt": damage, "power_earned": power_out, "flame_ticks": [ticks if tdph[i] > 0 else 0 for i in range(n)], "hits_per_second": acc_hps, } def _simulate_gold_for_kill(accs: list[dict], sim: dict | None = None, boss_hp: float = BOSS_HP, hps: float = HPS, enemy_type: int | None = None) -> dict: cfg = _gold_config_for_boss_hp(boss_hp, enemy_type=enemy_type) n = len(accs) empty = {"total": 0.0, "normal": 0.0, "kill": 0.0, "events": 0.0, "per_account": [0.0] * n, "per_account_events": [0.0] * n} if not cfg or n == 0: return empty sim = sim or _simulate_kill(accs, boss_hp=boss_hp, hps=hps) kt = float(sim.get("kill_time", 0.0) or 0.0) if not math.isfinite(kt) or kt <= 0: return empty def sort_key(idx): acc = accs[idx] return tuple( round(float(acc.get(key, 0.0) or 0.0), 12) for key in ("level", "ds", "power", "ms", "mp", "ap", "hours", "lm", "flame", "gold_mult") ) order = sorted(range(n), key=sort_key) if order != list(range(n)): sorted_sim = dict(sim) for key in ("damage_dealt", "power_earned", "flame_ticks", "hits_per_second"): values = list(sim.get(key) or []) if len(values) >= n: sorted_sim[key] = [values[idx] for idx in order] sorted_result = _simulate_gold_for_kill( [accs[idx] for idx in order], sorted_sim, boss_hp=boss_hp, hps=hps, enemy_type=enemy_type ) for key in ("per_account", "per_account_events"): values = list(sorted_result.get(key) or []) if len(values) >= n: restored = [0.0] * n for sorted_idx, original_idx in enumerate(order): restored[original_idx] = values[sorted_idx] sorted_result[key] = restored return sorted_result dph = [dmg_hit(a["ds"], a["power"], a["ms"]) for a in accs] tdph = [flame_tick_dmg(a["ds"], a["power"], a["ms"], a.get("flame", 0)) for a in accs] ticks = list(sim.get("flame_ticks", [0] * n)) acc_hps = list(sim.get("hits_per_second") or [_account_hps(a, hps) for a in accs]) raw_total = sum(dph[i] * acc_hps[i] * kt + tdph[i] * ticks[i] for i in range(n)) sf = float(boss_hp) / raw_total if raw_total > 0 else 0.0 if sf <= 0: return empty hit_counts = [max(0.0, acc_hps[i] * kt) for i in range(n)] max_events = sum(int(math.floor(count)) for count in hit_counts) max_events += sum(int(ticks[i]) if tdph[i] > 0 else 0 for i in range(n)) if max_events > 25000: return _approx_gold_for_kill(accs, sim, cfg, boss_hp, hps) events = [] for i in range(n): full_hits = int(math.floor(hit_counts[i])) frac_hit = hit_counts[i] - full_hits for hit_idx in range(full_hits): t = hit_idx / acc_hps[i] if acc_hps[i] > 0 else 0.0 if t >= kt: break dmg = dph[i] * sf if dmg > 0: events.append((t, 0, i, dmg)) if frac_hit > 1e-9: t = min(kt, full_hits / acc_hps[i] if acc_hps[i] > 0 else kt) dmg = dph[i] * frac_hit * sf if dmg > 0: events.append((t, 0, i, dmg)) max_tick = max([int(x) for x in ticks] or [0]) for tick in range(1, max_tick + 1): for i in range(n): if int(ticks[i]) >= tick and tdph[i] > 0: events.append((float(tick), 1, i, tdph[i] * sf)) events.sort(key=lambda row: (row[0], row[1], row[2])) remaining_hp = float(boss_hp) bucket = 0.0 limit = float(cfg["coin_drop_hp_limit"]) avg_reward = (float(cfg["reward_min"]) + float(cfg["reward_max"])) / 2.0 divisor = float(cfg["normal_divisor"]) per_acc = [0.0] * n per_events = [0.0] * n event_count = 0 killer = 0 event_pos = 0 kill_shares = None while event_pos < len(events): if remaining_hp <= 0: break time_key, kind_key = events[event_pos][0], events[event_pos][1] group = [] while (event_pos < len(events) and events[event_pos][0] == time_key and events[event_pos][1] == kind_key): group.append(events[event_pos]) event_pos += 1 processed = [] for _time, _kind, idx, raw_damage in group: if remaining_hp <= 0: break damage = min(max(raw_damage, 0.0), remaining_hp) remaining_hp -= damage killer = idx if remaining_hp <= 1e-6: if len(group) > 1: raw_group = [(row[2], max(float(row[3] or 0.0), 0.0)) for row in group] raw_sum = sum(damage for _idx, damage in raw_group) if raw_sum > 0: kill_shares = [(share_idx, share_damage / raw_sum) for share_idx, share_damage in raw_group if share_damage > 0] break if damage > 0: processed.append((idx, damage)) if not processed: continue if len(processed) == 1: idx, damage = processed[0] bucket += damage if limit > 0 and bucket >= limit: bucket = 0.0 event_count += 1 per_events[idx] += 1 gold_mult = max(float(accs[idx].get("gold_mult", 0.0) or 0.0), 0.0) per_acc[idx] += avg_reward * gold_mult / divisor continue payouts = 0 for _idx, damage in sorted(processed, key=lambda row: row[1], reverse=True): bucket += damage if limit > 0 and bucket >= limit: bucket = 0.0 payouts += 1 if payouts: event_count += payouts group_damage = sum(damage for _idx, damage in processed) if group_damage > 0: for idx, damage in processed: share = damage / group_damage per_events[idx] += payouts * share gold_mult = max(float(accs[idx].get("gold_mult", 0.0) or 0.0), 0.0) per_acc[idx] += payouts * share * avg_reward * gold_mult / divisor kill_gold = float(cfg["kill_count"]) * float(cfg["reward_min"]) if n > 1: dealt = [max(float(x or 0.0), 0.0) for x in (sim.get("damage_dealt") or [])[:n]] dealt_total = sum(dealt) if dealt_total > 0: for idx, damage in enumerate(dealt): per_acc[idx] += kill_gold * damage / dealt_total elif per_acc: per_acc[killer] += kill_gold elif kill_shares: for idx, share in kill_shares: per_acc[idx] += kill_gold * share elif per_acc: per_acc[killer] += kill_gold return { "total": sum(per_acc), "normal": max(sum(per_acc) - kill_gold, 0.0), "kill": kill_gold, "events": float(event_count), "per_account": per_acc, "per_account_events": per_events, } def _approx_gold_for_kill(accs: list[dict], sim: dict, cfg: dict, boss_hp: float, hps: float) -> dict: n = len(accs) limit = float(cfg["coin_drop_hp_limit"]) avg_reward = (float(cfg["reward_min"]) + float(cfg["reward_max"])) / 2.0 divisor = float(cfg["normal_divisor"]) total_damage = sum(float(x) for x in sim.get("damage_dealt", []) or []) acc_hps = list(sim.get("hits_per_second") or [_account_hps(a, hps) for a in accs]) packet_count = max(float(sim.get("kill_time", 0.0) or 0.0), 0.0) * sum(acc_hps) packet_count += sum(float(x or 0.0) for x in sim.get("flame_ticks", []) or []) avg_packet_damage = total_damage / packet_count if packet_count > 0 else 0.0 packets_per_event = math.ceil(limit / avg_packet_damage) if limit > 0 and avg_packet_damage > 0 else 0 normal_events = math.floor(packet_count / packets_per_event) if packets_per_event > 0 else 0 shares = [] for i in range(n): dmg = float((sim.get("damage_dealt") or [0.0] * n)[i] or 0.0) shares.append(dmg / total_damage if total_damage > 0 else 1.0 / max(n, 1)) per_acc = [] for i, share in enumerate(shares): gold_mult = max(float(accs[i].get("gold_mult", 0.0) or 0.0), 0.0) per_acc.append(normal_events * share * avg_reward * gold_mult / divisor) kill_gold = float(cfg["kill_count"]) * float(cfg["reward_min"]) if per_acc: killer = max(range(n), key=lambda i: shares[i]) per_acc[killer] += kill_gold return { "total": sum(per_acc), "normal": max(sum(per_acc) - kill_gold, 0.0), "kill": kill_gold, "events": float(normal_events), "per_account": per_acc, "per_account_events": [normal_events * s for s in shares], } def solve_kill_time(accs: list[dict], boss_hp: float = BOSS_HP, hps: float = HPS) -> float: return _simulate_kill(accs, boss_hp=boss_hp, hps=hps)["kill_time"] def _drop_value_pair(item_pv=ITEM_PV, sword_item_pv=None, shield_item_pv=None): legacy = _cfg_nonnegative(item_pv, ITEM_PV) sword = legacy if sword_item_pv is None else _cfg_nonnegative(sword_item_pv, legacy) shield = legacy if shield_item_pv is None else _cfg_nonnegative(shield_item_pv, legacy) return sword, shield def sim_fast(accs: list[dict], item_pv: float = ITEM_PV, boss_hp: float = BOSS_HP, hps: float = HPS, respawn: float = RESPAWN, sword_item_pv: float | None = None, shield_item_pv: float | None = None, sword_drop_chance: float = 0.01, shield_drop_chance: float = 0.01) -> dict: """Schnelle Simulation für Optimizer (Float).""" tp = sorted(set([0.0]+[a["hours"] for a in accs])) sword_pv, shield_pv = _drop_value_pair(item_pv, sword_item_pv, shield_item_pv) bp=0.0; kills_total=0.0 sword_drops=0.0; shield_drops=0.0 sword_drop_power=0.0; shield_drop_power=0.0 for seg_start, seg_end in zip(tp, tp[1:]): act = [a for a in accs if a["hours"]>=seg_end] if not act: continue dur = (seg_end-seg_start)*3600.0 sim = _simulate_kill(act, boss_hp=boss_hp, hps=hps) cycle = sim["kill_time"]+respawn k = dur/cycle kills_total += k bp += k*sum(sim["power_earned"]) for a in act: sword_drop = k*sword_drop_chance*a["lm"] shield_drop = k*shield_drop_chance*a["lm"] sword_drops += sword_drop shield_drops += shield_drop sword_drop_power += sword_drop*sword_pv shield_drop_power += shield_drop*shield_pv drop_power = sword_drop_power + shield_drop_power return {"value":bp+drop_power,"boss_power":bp,"kills":kills_total, "drops":sword_drops+shield_drops,"sword_drops":sword_drops, "shield_drops":shield_drops,"drop_power":drop_power, "sword_drop_power":sword_drop_power,"shield_drop_power":shield_drop_power} def sim_decimal(accs: list[dict], item_pv: float = ITEM_PV, boss_hp: float = BOSS_HP, hps: float = HPS, respawn: float = RESPAWN, enemy_type: int | None = None, sword_item_pv: float | None = None, shield_item_pv: float | None = None, sword_drop_chance: float = 0.01, shield_drop_chance: float = 0.01) -> dict: """Exakte Simulation für Ausgabe (Decimal).""" sword_pv, shield_pv = _drop_value_pair(item_pv, sword_item_pv, shield_item_pv) sword_iv = Decimal(str(sword_pv)) shield_iv = Decimal(str(shield_pv)) tp = sorted(set([Decimal("0")]+[Decimal(str(a["hours"])) for a in accs])) bp=Decimal(0); tk=Decimal(0); gold=Decimal(0) sword_drops=Decimal(0); shield_drops=Decimal(0) sword_dp=Decimal(0); shield_dp=Decimal(0) for seg_start, seg_end in zip(tp, tp[1:]): act = [a for a in accs if Decimal(str(a["hours"]))>=seg_end] if not act: continue dur = (seg_end-seg_start)*Decimal("3600") sim = _simulate_kill([{**a} for a in act], boss_hp=boss_hp, hps=hps) cycle = Decimal(str(sim["kill_time"]))+Decimal(str(respawn)) kills = dur/cycle tk += kills gold += kills * Decimal(str(_simulate_gold_for_kill( act, sim, boss_hp=boss_hp, hps=hps, enemy_type=enemy_type )["total"])) for i in range(len(act)): bp += kills*Decimal(str(sim["power_earned"][i])) for a in act: sword_drop = kills*Decimal(str(sword_drop_chance))*Decimal(str(a["lm"])) shield_drop = kills*Decimal(str(shield_drop_chance))*Decimal(str(a["lm"])) sword_drops += sword_drop shield_drops += shield_drop sword_dp += sword_drop*sword_iv shield_dp += shield_drop*shield_iv dp = sword_dp + shield_dp return {"value":bp+dp,"boss_power":bp,"kills":tk, "drops":sword_drops+shield_drops,"sword_drops":sword_drops, "shield_drops":shield_drops,"drop_power":dp, "sword_drop_power":sword_dp,"shield_drop_power":shield_dp,"gold":gold} def _daily_account_metrics(accs: list[dict], boss_hp: float = BOSS_HP, hps: float = HPS, respawn: float = RESPAWN, enemy_type: int | None = None) -> dict: """Attribute segmented room output back to the accounts that were online.""" rows = [{"power": 0.0, "xp": 0.0, "gold": 0.0} for _ in accs] time_points = sorted(set([0.0] + [float(a.get("hours", 0.0) or 0.0) for a in accs])) for seg_start, seg_end in zip(time_points, time_points[1:]): active_indices = [ idx for idx, acc in enumerate(accs) if float(acc.get("hours", 0.0) or 0.0) >= seg_end ] if not active_indices: continue active = [accs[idx] for idx in active_indices] sim = _simulate_kill(active, boss_hp=boss_hp, hps=hps) cycle = float(sim["kill_time"]) + float(respawn) if cycle <= 0: continue kills = (seg_end - seg_start) * 3600.0 / cycle gold_once = _simulate_gold_for_kill( active, sim, boss_hp=boss_hp, hps=hps, enemy_type=enemy_type ) gold_by_account = gold_once.get("per_account") or [0.0] * len(active) for active_idx, original_idx in enumerate(active_indices): rows[original_idx]["power"] += kills * float(sim["power_earned"][active_idx]) rows[original_idx]["xp"] += kills * xp_from_damage(sim["damage_dealt"][active_idx]) rows[original_idx]["gold"] += kills * float(gold_by_account[active_idx]) return { "per_account": rows, "power": sum(row["power"] for row in rows), "xp": sum(row["xp"] for row in rows), "gold": sum(row["gold"] for row in rows), } # ───────────────────────────────────────────────────────────────── # OPTIMIZER # ───────────────────────────────────────────────────────────────── def allocate_power(gear_list: list[dict], total_power: float, hps: float = HPS) -> None: """ Power-Verteilung p_i ∝ A_i² mit A_i = (1+ms)(1+mp+ap)*hours. Min-Clamp: jeder Account bekommt ≥ 1% des Gleichanteils. Bug 3 fix: removed pointless 10-iteration loop — A never depended on power, so every iteration produced identical results. """ n = len(gear_list) if n == 0: return min_p = max(total_power / n * 0.01, 1.0) A = [_account_hps(g, hps) * (1.0 + g["ms"]) * (1.0 + g["mp"] + g["ap"]) * g["hours"] for g in gear_list] Asq = [max(a ** 2, 1e-9) for a in A] s = sum(Asq) raw = [total_power * (asq / s) for asq in Asq] clamped = [max(r, min_p) for r in raw] sf = total_power / sum(clamped) for i, g in enumerate(gear_list): g["power"] = clamped[i] * sf def _room_strength(g, hps=HPS): dps = _account_hps(g, hps) * max(float(g.get("ds", 0.0)), 0.0) * (1.0 + float(g.get("ms", 0.0))) mult = 1.0 + float(g.get("mp", 0.0)) + float(g.get("ap", 0.0)) return max(dps * mult * max(float(g.get("hours", 0.0)), 0.0), 1e-9) def _room_power_weight(room, hps=HPS): return sum(_account_hps(g, hps) * (1.0 + g["ms"]) * (1.0 + g["mp"] + g["ap"]) * g["hours"] for g in room) def _balanced_sizes(n, room_count): base, extra = divmod(n, room_count) return [base + (1 if i < extra else 0) for i in range(room_count)] def _room_sized_groups(indices, sizes): groups = [] pos = 0 for size in sizes: groups.append([indices[i] for i in range(pos, pos + size)]) pos += size return groups def _two_room_split_indices(n, max_room=MAX_ROOM): if n <= max_room or n > max_room * 2: return min_small = max(1, n - max_room) max_small = n // 2 seen = set() for small_size in range(min_small, max_small + 1): for combo in itertools.combinations(range(n), small_size): small = tuple(combo) large = tuple(i for i in range(n) if i not in small) key = tuple(sorted((large, small))) if key in seen: continue seen.add(key) yield [list(large), list(small)] def _minimum_room_pattern_indices(order, n, max_room=MAX_ROOM): min_rooms = max(1, math.ceil(n / max_room)) if min_rooms <= 1: yield [list(order)] return base_sizes = [] rem = n for room_idx in range(min_rooms): slots_left = min_rooms - room_idx - 1 size = min(max_room, rem - slots_left) base_sizes.append(size) rem -= size size_variants = {tuple(base_sizes), tuple(sorted(base_sizes)), tuple(_balanced_sizes(n, min_rooms))} for sizes in size_variants: if max(sizes) <= max_room: yield _room_sized_groups(order, sizes) def _heuristic_room_group_indices(gear_list, hps=HPS, max_room=MAX_ROOM): n = len(gear_list) if n == 0: return [] min_rooms = max(1, math.ceil(n / max_room)) strengths = [_room_strength(g, hps) for g in gear_list] desc = sorted(range(n), key=lambda i: strengths[i], reverse=True) asc = list(reversed(desc)) out = [] seen = set() def add(groups): if not groups or any(len(g) == 0 or len(g) > max_room for g in groups): return key = tuple(sorted(tuple(sorted(g)) for g in groups)) if key in seen: return seen.add(key) out.append(groups) if n <= max_room: add([list(range(n))]) add([[i] for i in range(n)]) if min_rooms == 2 and n <= 12: for groups in _two_room_split_indices(n, max_room): add(groups) for order in (desc, asc, list(range(n))): for groups in _minimum_room_pattern_indices(order, n, max_room): add(groups) for room_count in range(min_rooms, n + 1): sizes = _balanced_sizes(n, room_count) if max(sizes) > max_room: continue add(_room_sized_groups(desc, sizes)) add(_room_sized_groups(asc, sizes)) rr = [[] for _ in range(room_count)] for pos, idx in enumerate(desc): rr[pos % room_count].append(idx) add(rr) snake = [[] for _ in range(room_count)] direction = 1 pos = 0 for idx in desc: snake[pos].append(idx) if room_count > 1: if pos == room_count - 1: direction = -1 elif pos == 0: direction = 1 pos += direction add(snake) return out def _quick_room_group_indices(gear_list, hps=HPS, max_room=MAX_ROOM): n = len(gear_list) if n == 0: return [] min_rooms = max(1, math.ceil(n / max_room)) strengths = [_room_strength(g, hps) for g in gear_list] desc = sorted(range(n), key=lambda i: strengths[i], reverse=True) out = [] seen = set() def add(groups): if not groups or any(len(g) == 0 or len(g) > max_room for g in groups): return key = tuple(sorted(tuple(sorted(g)) for g in groups)) if key in seen: return seen.add(key) out.append(groups) if n <= max_room: add([list(range(n))]) add([[i] for i in range(n)]) for order in (desc, list(range(n))): for groups in _minimum_room_pattern_indices(order, n, max_room): add(groups) room_counts = {min_rooms, n} if n <= max_room: room_counts.add(2) for room_count in sorted(c for c in room_counts if c >= min_rooms): sizes = _balanced_sizes(n, room_count) if max(sizes) > max_room: continue rr = [[] for _ in range(room_count)] for pos, idx in enumerate(desc): rr[pos % room_count].append(idx) add(rr) return out def _exact_room_group_indices(n, max_room=MAX_ROOM): groups = [] def rec(idx): if idx == n: yield [g[:] for g in groups] return for group in groups: if len(group) < max_room: group.append(idx) yield from rec(idx + 1) group.pop() groups.append([idx]) yield from rec(idx + 1) groups.pop() yield from rec(0) def _sim_row(g): return { **{k: float(g[k]) if isinstance(g[k], (int, float)) else g[k] for k in ("ds", "power", "ms", "mp", "ap", "hours", "lm", "flame")}, "level": g.get("level"), } def _powered_room_groups_value(room_groups, item_pv=ITEM_PV, boss_hp=BOSS_HP, hps=HPS, respawn=RESPAWN, sword_item_pv=None, shield_item_pv=None, sword_drop_chance=0.01, shield_drop_chance=0.01): return sum( sim_fast([_sim_row(g) for g in room], item_pv=item_pv, boss_hp=boss_hp, hps=hps, respawn=respawn, sword_item_pv=sword_item_pv, shield_item_pv=shield_item_pv, sword_drop_chance=sword_drop_chance, shield_drop_chance=shield_drop_chance)["value"] for room in room_groups ) def _initial_powered_room_groups(room_groups, total_power, hps=HPS): if not room_groups: return [] a_sums = [_room_power_weight(room, hps) for room in room_groups] total_weight = sum(a_sums) result = [] for room, weight in zip(room_groups, a_sums): budget = total_power * (weight / total_weight) if total_weight > 0 else total_power / len(room_groups) cloned = [dict(g) for g in room] allocate_power(cloned, budget, hps) result.append(cloned) return result def _refine_powered_room_groups(room_groups, total_power, item_pv=ITEM_PV, boss_hp=BOSS_HP, hps=HPS, respawn=RESPAWN, sword_item_pv=None, shield_item_pv=None, sword_drop_chance=0.01, shield_drop_chance=0.01, max_passes=2): """Improve the analytic start with simulated pairwise budget transfers.""" powered = _initial_powered_room_groups(room_groups, total_power, hps) positions = [(room_idx, acc_idx) for room_idx, room in enumerate(powered) for acc_idx in range(len(room))] if len(positions) < 2 or total_power <= 0: return powered def score(): return _powered_room_groups_value( powered, item_pv=item_pv, boss_hp=boss_hp, hps=hps, respawn=respawn, sword_item_pv=sword_item_pv, shield_item_pv=shield_item_pv, sword_drop_chance=sword_drop_chance, shield_drop_chance=shield_drop_chance ) best_value = score() base_fractions = (0.0, 0.02, 0.05, 0.10, 0.20, 0.35, 0.50, 0.65, 0.80, 0.90, 0.95, 0.98, 1.0) for _pass in range(max_passes): improved = False for left_pos in range(len(positions)): left_room, left_idx = positions[left_pos] left = powered[left_room][left_idx] for right_pos in range(left_pos + 1, len(positions)): right_room, right_idx = positions[right_pos] right = powered[right_room][right_idx] pair_total = float(left["power"]) + float(right["power"]) if pair_total <= 0: continue current_fraction = float(left["power"]) / pair_total fractions = set(base_fractions) fractions.add(current_fraction) for offset in (0.01, 0.03, 0.08): fractions.add(max(0.0, current_fraction - offset)) fractions.add(min(1.0, current_fraction + offset)) pair_best_value = best_value pair_best_fraction = current_fraction for fraction in sorted(fractions): left["power"] = pair_total * fraction right["power"] = pair_total - left["power"] value = score() if value > pair_best_value * 1.000000001: pair_best_value = value pair_best_fraction = fraction left["power"] = pair_total * pair_best_fraction right["power"] = pair_total - left["power"] if pair_best_value > best_value * 1.000000001: best_value = pair_best_value improved = True if not improved: break return powered def _allocate_room_groups(room_groups, total_power, item_pv=ITEM_PV, boss_hp=BOSS_HP, hps=HPS, respawn=RESPAWN, sword_item_pv=None, shield_item_pv=None, sword_drop_chance=0.01, shield_drop_chance=0.01, refine_power=False, preserve_power=False): if preserve_power: return [[dict(g) for g in room] for room in room_groups] if refine_power: return _refine_powered_room_groups( room_groups, total_power, item_pv=item_pv, boss_hp=boss_hp, hps=hps, respawn=respawn, sword_item_pv=sword_item_pv, shield_item_pv=shield_item_pv, sword_drop_chance=sword_drop_chance, shield_drop_chance=shield_drop_chance ) return _initial_powered_room_groups(room_groups, total_power, hps) def _eval_room_groups(room_groups, total_power, item_pv=ITEM_PV, boss_hp=BOSS_HP, hps=HPS, respawn=RESPAWN, sword_item_pv=None, shield_item_pv=None, refine_power=False, preserve_power=False, sword_drop_chance=0.01, shield_drop_chance=0.01): if not room_groups: return 0.0 powered = _allocate_room_groups( room_groups, total_power, item_pv=item_pv, boss_hp=boss_hp, hps=hps, respawn=respawn, sword_item_pv=sword_item_pv, shield_item_pv=shield_item_pv, sword_drop_chance=sword_drop_chance, shield_drop_chance=shield_drop_chance, refine_power=refine_power, preserve_power=preserve_power ) return _powered_room_groups_value( powered, item_pv=item_pv, boss_hp=boss_hp, hps=hps, respawn=respawn, sword_item_pv=sword_item_pv, shield_item_pv=shield_item_pv, sword_drop_chance=sword_drop_chance, shield_drop_chance=shield_drop_chance ) def _fast_room_value(gear_list, total_power, item_pv=ITEM_PV, boss_hp=BOSS_HP, hps=HPS, respawn=RESPAWN, quick=False, sword_item_pv=None, shield_item_pv=None, sword_drop_chance=0.01, shield_drop_chance=0.01, room_capacity=MAX_ROOM): best = -1.0 group_indices = ( _quick_room_group_indices(gear_list, hps, room_capacity) if quick else _heuristic_room_group_indices(gear_list, hps, room_capacity) ) for idx_groups in group_indices: groups = [[gear_list[i] for i in group] for group in idx_groups] best = max(best, _eval_room_groups(groups, total_power, item_pv=item_pv, boss_hp=boss_hp, hps=hps, respawn=respawn, sword_item_pv=sword_item_pv, shield_item_pv=shield_item_pv, sword_drop_chance=sword_drop_chance, shield_drop_chance=shield_drop_chance)) return max(best, 0.0) def optimize_rooms(gear_list: list[dict], total_power: float=0.0, item_pv: float=ITEM_PV, boss_hp: float=BOSS_HP, hps: float=HPS, respawn: float=RESPAWN, sword_item_pv=None, shield_item_pv=None, sword_drop_chance=0.01, shield_drop_chance=0.01, room_capacity=MAX_ROOM, optimize_power=True, refine_power=True) -> tuple: """ Findet die optimale Raum-Aufteilung. """ n = len(gear_list) if n == 0: return [], 0.0 if total_power<=0: total_power=sum(g["power"] for g in gear_list) ranked = [] room_capacity = max(1, int(room_capacity)) group_iter = ( _exact_room_group_indices(n, room_capacity) if n <= room_capacity else _heuristic_room_group_indices(gear_list, hps, room_capacity) ) for idx_groups in group_iter: groups = [[gear_list[i] for i in group] for group in idx_groups] v = _eval_room_groups(groups, total_power, item_pv=item_pv, boss_hp=boss_hp, hps=hps, respawn=respawn, sword_item_pv=sword_item_pv, shield_item_pv=shield_item_pv, sword_drop_chance=sword_drop_chance, shield_drop_chance=shield_drop_chance, preserve_power=not optimize_power) ranked.append((v, groups)) if not ranked: ranked = [(0.0, [gear_list])] ranked.sort(key=lambda row: row[0], reverse=True) best_val, best_groups = ranked[0] final_groups = None if optimize_power and refine_power: refine_count = min(len(ranked), 4 if n > room_capacity else 8) for _fast_value, groups in ranked[:refine_count]: powered = _allocate_room_groups( groups, total_power, item_pv=item_pv, boss_hp=boss_hp, hps=hps, respawn=respawn, sword_item_pv=sword_item_pv, shield_item_pv=shield_item_pv, sword_drop_chance=sword_drop_chance, shield_drop_chance=shield_drop_chance, refine_power=True ) value = _powered_room_groups_value( powered, item_pv=item_pv, boss_hp=boss_hp, hps=hps, respawn=respawn, sword_item_pv=sword_item_pv, shield_item_pv=shield_item_pv, sword_drop_chance=sword_drop_chance, shield_drop_chance=shield_drop_chance ) if value > best_val: best_val, best_groups, final_groups = value, groups, powered if final_groups is None: final_groups = _allocate_room_groups( best_groups, total_power, item_pv=item_pv, boss_hp=boss_hp, hps=hps, respawn=respawn, sword_item_pv=sword_item_pv, shield_item_pv=shield_item_pv, sword_drop_chance=sword_drop_chance, shield_drop_chance=shield_drop_chance, refine_power=bool(optimize_power and refine_power), preserve_power=not optimize_power ) return final_groups, best_val ta = lambda g: {k:float(g[k]) if isinstance(g[k],(int,float)) else g[k] for k in ("ds","power","ms","mp","ap","hours","lm","flame")} def A_sum(room): return sum((1.0+g["ms"])*(1.0+g["mp"]+g["ap"])*g["hours"] for g in room) def eval_rooms(room_groups): a_sums=[A_sum(r) for r in room_groups]; tot_a=sum(a_sums) val=0.0 for room,a_s in zip(room_groups,a_sums): budget=total_power*(a_s/tot_a) if tot_a>0 else total_power/len(room_groups) rc=[dict(g) for g in room]; allocate_power(rc,budget) val+=sim_fast([ta(g) for g in rc], item_pv=item_pv, boss_hp=boss_hp, hps=hps, respawn=respawn)["value"] return val def finalize(room_groups): a_sums=[A_sum(r) for r in room_groups]; tot_a=sum(a_sums) result=[] for room,a_s in zip(room_groups,a_sums): budget=total_power*(a_s/tot_a) if tot_a>0 else total_power/len(room_groups) rc=[dict(g) for g in room]; allocate_power(rc,budget); result.append(rc) return result best_val=-1.0; best_groups=None # Kandidat: 1 Raum (nur wenn ≤8) if n<=MAX_ROOM: v=eval_rooms([gear_list]) if v>best_val: best_val=v; best_groups=[gear_list] # Kandidaten: 2-Raum-Splits für alle k=1..n//2 for k in range(1, n//2+1): if n-k>MAX_ROOM or k>MAX_ROOM: continue for combo in itertools.combinations(range(n),k): rest=[i for i in range(n) if i not in combo] groups=[[gear_list[i] for i in rest],[gear_list[i] for i in combo]] v=eval_rooms(groups) if v>best_val: best_val=v; best_groups=groups # Für n>MAX_ROOM: multi-room splits if n>MAX_ROOM: mr=math.ceil(n/MAX_ROOM); cands=set() def parts(rem,pl,mv): if pl==0: if rem==0: yield () return for sz in range(max(1,math.ceil(rem/pl)),min(rem-pl+1,mv)+1): for r in parts(rem-sz,pl-1,sz): yield (sz,)+r for p in parts(n,mr,MAX_ROOM): cands.add(tuple(sorted(p,reverse=True))) for sizes in cands: def assign(ri,rs): if not rs: yield []; return for c in itertools.combinations(ri,rs[0]): rest2=[i for i in ri if i not in c] for sub in assign(rest2,rs[1:]): yield [list(c)]+sub ev=set() for ri in assign(list(range(n)),list(sizes)): key=tuple(sorted(tuple(tuple(sorted(r)) for r in ri))) if key in ev: continue ev.add(key) groups=[[gear_list[i] for i in r] for r in ri] v=eval_rooms(groups) if v>best_val: best_val=v; best_groups=groups if best_groups is None: best_groups=[gear_list] return finalize(best_groups), best_val def resolve_ds(name, level): if not name: return 0.0 if name not in SCHWERTER: raise HTTPException(400, f"Unbekanntes Schwert: {name!r}") lv1, _lv2 = SCHWERTER[name] return _gear_scale(parse_float(lv1), level) def resolve_ms(name, level): if not name: return 0.0 if name not in SCHILDE: raise HTTPException(400, f"Unbekanntes Schild: {name!r}") if SCHILDE[name] is None: return 0.0 # Schild ohne Multiplikator → 0 lv1, _lv2 = SCHILDE[name] return _gear_scale(float(lv1), level) def resolve_mp(pets): if not pets: return 0.0 mp = 0.0 for p in pets: if not p: continue if p not in PETS: raise HTTPException(400, f"Unbekanntes Pet: {p!r}") mp += PETS[p] return mp def resolve_ap(armour): if armour is None: return 0.0 ap = 0.0 for name, table in [(armour.helmet, HELMETS), (armour.chestplate, CHESTPLATES), (armour.short, SHORTS), (armour.boots, BOOTS)]: if not name: name = "None" if name not in table: raise HTTPException(400, f"Unbekanntes Ruestungsteil: {name!r}") ap += table[name] return ap def solo_output(ds, power, ms, mp, ap, hours, flame=0, level=None, boss_hp=BOSS_HP, hps=HPS, respawn=RESPAWN): """Geschätzter Output für greedy Gear-Zuweisung.""" act = [{"ds":ds,"power":power,"ms":ms,"mp":mp,"ap":ap,"hours":hours, "lm":1.0,"flame":flame,"level":level}] sim = _simulate_kill(act, boss_hp=boss_hp, hps=hps) kills = (hours*3600.0)/(sim["kill_time"]+respawn) return kills*sum(sim["power_earned"]) # ───────────────────────────────────────────────────────────────── # PYDANTIC MODELS # ───────────────────────────────────────────────────────────────── class SwordInput(BaseModel): name: str; level: int = 1; flame_enchant: int = 0; looting_enchant: int = 0 class ShieldInput(BaseModel): name: str; level: int = 1; looting_enchant: int = 0 class ArmourInput(BaseModel): helmet: str = "None"; chestplate: str = "None"; short: str = "None"; boots: str = "None" class AccountInput(BaseModel): name: str; power: str; online_hours: float level: Optional[int] = None experience: Optional[str] = None total_experience: Optional[str] = None total_power: Optional[str] = None sword: Optional[SwordInput] = None shield: Optional[ShieldInput] = None pets: Optional[list[str]] = None armour: Optional[ArmourInput] = None class ExtraSword(BaseModel): name: str; level: int = 1; looting_enchant: int = 0; flame_enchant: int = 0 class ExtraShield(BaseModel): name: str; level: int = 1; looting_enchant: int = 0 class OptimizerInput(BaseModel): accounts: list[AccountInput] extra_sword_pool: Optional[list[ExtraSword]] = None extra_shield_pool: Optional[list[ExtraShield]] = None extra_pet_pool: Optional[list[str]] = None optimize_gear: bool = False; optimize_pets: bool = False optimize_power: bool = True; optimize_rooms: bool = True item_pv: Optional[float] = None sword_item_pv: Optional[float] = None shield_item_pv: Optional[float] = None sword_drop_chance: Optional[float] = None shield_drop_chance: Optional[float] = None room_capacity: Optional[int] = None boss_hp: Optional[float] = None respawn: Optional[float] = None hps: Optional[float] = None enemy_type: Optional[int] = None class MarketOptimizerInput(OptimizerInput): market_objective: str = "total_value" calculate_breakpoints: bool = True # #7: user-driven corrections to bad price data. # exclude_items: rules to drop from the buy pool. Each is either a NAME string # (whole item) or {name, level?, flame?, looting?, scope: item|level|exact}. # price_overrides: [{type,name,level,flame,looting,price}] forcing a power price # on that exact variant (used for both buying and selling it). exclude_items: Optional[list] = None price_overrides: Optional[list[dict]] = None class SimulateInput(BaseModel): accounts: list[AccountInput] item_pv: Optional[float] = None sword_item_pv: Optional[float] = None shield_item_pv: Optional[float] = None sword_drop_chance: Optional[float] = None shield_drop_chance: Optional[float] = None room_capacity: Optional[int] = None boss_hp: Optional[float] = None respawn: Optional[float] = None hps: Optional[float] = None enemy_type: Optional[int] = None class UpgradeLabInput(BaseModel): item_type: str = "" item_name: str = "" counts: dict = {} attempts_per_item: int = 10 target_level: int = 6 target_count: int = 1 target_kind: str = "level" strategy: str = "auto" elf_route: Optional[str] = None elf_level5_count: int = 0 player_level: Optional[int] = None fortune: int = 0 pet_use_gold: bool = False pet_helper_odds: bool = False ore_name: str = "" ore_owned: int = 0 ore_is_smelted: bool = False ore_goal: str = "enchant" ore_use_gold: bool = False ore_exact: bool = False exact: bool = False # ───────────────────────────────────────────────────────────────── # HELPER: resolve AccountInput → gear_entry dict # ───────────────────────────────────────────────────────────────── def _build_gear_entry(acc, ds, ms, mp, ap, power_val, tll, flame, sword_label, shield_label, pet_names, sword_loot=0, shield_loot=0, sword_item_name=None, sword_level=None, shield_item_name=None, shield_level=None): if sword_item_name is None: sword_item_name, parsed_level = _split_gear_label(sword_label) sword_level = sword_level or parsed_level if shield_item_name is None: shield_item_name, parsed_level = _split_gear_label(shield_label) shield_level = shield_level or parsed_level gold_mult = _account_gold_multiplier( acc, sword_name=sword_item_name, sword_level=sword_level or 1, shield_name=shield_item_name, shield_level=shield_level or 1, pets=pet_names, ) return { "account": acc, "sword_name": sword_label, "shield_name": shield_label, "pet_names": pet_names, "ds": ds, "ms": ms, "mp": mp, "ap": ap, "hours": acc.online_hours, "level": _account_level(acc), "power": power_val, "lm": loot_mult(tll), "tll": tll, "flame": flame, "sword_loot": sword_loot, "shield_loot": shield_loot, "gold_mult": gold_mult, } def _refresh_gear_entry(g): g["tll"] = int(g.get("sword_loot", 0) or 0) + int(g.get("shield_loot", 0) or 0) g["lm"] = loot_mult(g["tll"]) sword_name, sword_level = _split_gear_label(g.get("sword_name")) shield_name, shield_level = _split_gear_label(g.get("shield_name")) g["gold_mult"] = _account_gold_multiplier( g["account"], sword_name=sword_name, sword_level=sword_level or 1, shield_name=shield_name, shield_level=shield_level or 1, pets=g.get("pet_names") or [], ) def _swap_gear_slot(left, right, slot): fields = { "sword": ("sword_name", "ds", "sword_loot", "flame"), "shield": ("shield_name", "ms", "shield_loot"), "pets": ("pet_names", "mp"), }[slot] for field in fields: left[field], right[field] = right[field], left[field] _refresh_gear_entry(left) _refresh_gear_entry(right) DEFAULT_PRICE_SPACE_BASE = (_os.getenv("PRICE_SPACE_BASE") or "https://oettam123-swordmasters-prices.hf.space").rstrip("/") PRICE_API_BASE = (_os.getenv("PRICE_API_BASE") or _os.getenv("MARKET_PRICE_API_BASE") or DEFAULT_PRICE_SPACE_BASE).rstrip("/") PRICE_HISTORY_FILE = _os.getenv("PRICE_HISTORY_FILE", "") PRICE_ESTIMATED_FILE = _os.getenv("PRICE_ESTIMATED_FILE", "") PRICE_HISTORY_URL = _os.getenv("PRICE_HISTORY_URL", "").strip() PRICE_ESTIMATED_URL = _os.getenv("PRICE_ESTIMATED_URL", "").strip() PRICE_AUTO_REFRESH = _os.getenv("PRICE_AUTO_REFRESH", "1").lower() not in ("0", "false", "no", "off") # Wie oft der Space frische Preise vom Prices-Repo zieht (Resolve-URL). Damit muss das # taegliche Preis-Update NICHT mehr ins Optimizer-Repo committen -> kein taeglicher # Space-Rebuild. Der Download passiert nur, wenn es ein neueres Datum gibt (Tages-Gate), # der Check selbst ist billig. Default 3h; kurze Anlauf-Verzoegerung, damit der Boot ruhig bleibt. PRICE_REFRESH_INTERVAL = max(600, int(_os.getenv("PRICE_REFRESH_INTERVAL", str(3 * 3600)))) PRICE_REFRESH_STARTUP_DELAY = max(0, int(_os.getenv("PRICE_REFRESH_STARTUP_DELAY", "12"))) PRICE_REMOTE_HISTORY_URL = (_os.getenv("PRICE_REMOTE_HISTORY_URL") or "https://huggingface.co/spaces/oettaM123/swordmasters-prices/resolve/main/price_history.jsonl.gz.b64").strip() PRICE_REMOTE_ESTIMATED_URL = (_os.getenv("PRICE_REMOTE_ESTIMATED_URL") or "https://huggingface.co/spaces/oettaM123/swordmasters-prices/resolve/main/price_estimated.json.gz.b64").strip() _PRICE_CONF_NAMES = ["HIGH", "MEDIUM", "LOW", "ESTIMATED"] _PRICE_INDEX_CACHE = {"stamp": None, "data": None} _PRICE_HTTP_CACHE = {} _MARKET_BASE_CANDIDATE_CACHE = {"stamp": None, "data": None} _PRICE_REFRESH_STATE = {"last_check": 0.0, "running": False} def _compact_price_num(value): if value is None: return None if isinstance(value, (int, float)): return float(value) s = str(value).strip().lower() for suffix in _SUFFIXES: if suffix and s.endswith(suffix): try: return float(s[:-len(suffix)]) * _M_FLOAT[suffix] except ValueError: return None try: return float(s) except ValueError: return None def _price_entry(raw): if raw is None: return None if isinstance(raw, list): conf_idx = raw[1] if len(raw) > 1 and isinstance(raw[1], int) else 2 return { "median": _compact_price_num(raw[0] if raw else None), "confidence": _PRICE_CONF_NAMES[conf_idx] if 0 <= conf_idx < len(_PRICE_CONF_NAMES) else "LOW", "samples": raw[2] if len(raw) > 2 else 0, "swap_samples": raw[3] if len(raw) > 3 else 0, "estimate_note": "", } if isinstance(raw, dict): return { "median": _compact_price_num(raw.get("median")), "confidence": raw.get("confidence", "LOW"), "samples": raw.get("samples", 0), "swap_samples": raw.get("swap_samples", 0), "estimate_note": raw.get("estimate_note", ""), } return None def _safe_int(value, default=0): try: return int(value) except (TypeError, ValueError): return default def _price_fmt(value: float | None) -> str: if value is None: return "N/A" return fmt(Decimal(str(value))) def _read_last_jsonl(path: Path) -> dict: last = "" with path.open("r", encoding="utf-8") as fh: for line in fh: line = line.strip() if line: last = line return json.loads(last) if last else {} def _last_jsonl_from_text(text: str) -> dict: last = "" for line in (text or "").splitlines(): line = line.strip() if line: last = line return json.loads(last) if last else {} def _decode_price_b64gz(data) -> str: """Decode a base64(gzip(text)) price payload back to text. Price files are shipped as base64(gzip) text (`.gz.b64`) instead of Git LFS, so the price Space storage quota never fills up. Accepts bytes or str.""" if isinstance(data, str): data = data.encode("ascii") return gzip.decompress(base64.b64decode(data)).decode("utf-8") def _read_price_local_text(base_path: Path) -> str | None: """Read a local price file as text, preferring base64(gzip), then gzip, then raw.""" b64 = base_path.with_name(base_path.name + ".gz.b64") if b64.exists(): return _decode_price_b64gz(b64.read_bytes()) gz = base_path.with_name(base_path.name + ".gz") if gz.exists(): return gzip.decompress(gz.read_bytes()).decode("utf-8") if base_path.exists(): return base_path.read_text(encoding="utf-8") return None def _price_file_mtime(base_path: Path) -> float: """mtime of whichever price-file variant exists (.gz.b64 / .gz / raw).""" for cand in ( base_path.with_name(base_path.name + ".gz.b64"), base_path.with_name(base_path.name + ".gz"), base_path, ): try: return cand.stat().st_mtime except OSError: continue return 0.0 def _local_price_snapshot_date(path: Path) -> str: try: snap = _read_last_jsonl(path) return str(snap.get("date", "")) if isinstance(snap, dict) else "" except Exception: return "" def _write_text_atomic(path: Path, text: str): tmp = path.with_suffix(path.suffix + ".tmp") tmp.write_text(text, encoding="utf-8") tmp.replace(path) def _refresh_price_files_once(): hist_path = APP_DIR / "price_history.jsonl" est_path = APP_DIR / "price_estimated.json" try: stats_resp = _requests.get(f"{DEFAULT_PRICE_SPACE_BASE}/api/stats", timeout=4) if stats_resp.status_code != 200: return remote_date = str((stats_resp.json() or {}).get("latest_date", "")) local_date = _local_price_snapshot_date(hist_path) if remote_date and local_date and remote_date <= local_date: return if not PRICE_REMOTE_HISTORY_URL: return hist_resp = _requests.get(PRICE_REMOTE_HISTORY_URL, timeout=20) if hist_resp.status_code != 200: return hist_text = (_decode_price_b64gz(hist_resp.content) if PRICE_REMOTE_HISTORY_URL.endswith(".gz.b64") else hist_resp.text) latest = json.dumps(_last_jsonl_from_text(hist_text), separators=(",", ":")) if latest and latest != "{}": _write_text_atomic(hist_path, latest + "\n") if PRICE_REMOTE_ESTIMATED_URL: est_resp = _requests.get(PRICE_REMOTE_ESTIMATED_URL, timeout=10) if est_resp.status_code == 200 and est_resp.text.strip(): if PRICE_REMOTE_ESTIMATED_URL.endswith(".gz.b64"): # Keep the local copy compact and non-LFS: store the b64 verbatim. _write_text_atomic(est_path.with_name(est_path.name + ".gz.b64"), est_resp.text) try: est_path.unlink() except OSError: pass else: _write_text_atomic(est_path, est_resp.text) _PRICE_INDEX_CACHE.update({"stamp": None, "data": None}) _MARKET_BASE_CANDIDATE_CACHE.update({"stamp": None, "data": None}) except Exception as exc: print(f"[Price refresh] skipped: {exc}") finally: _PRICE_REFRESH_STATE["running"] = False def _maybe_start_price_refresh(): if not PRICE_AUTO_REFRESH: return now = time.time() if _PRICE_REFRESH_STATE["running"] or now - _PRICE_REFRESH_STATE["last_check"] < PRICE_REFRESH_INTERVAL: return _PRICE_REFRESH_STATE["last_check"] = now _PRICE_REFRESH_STATE["running"] = True threading.Thread(target=_refresh_price_files_once, daemon=True).start() def _price_refresh_loop(): """Periodischer Preis-Refresh, unabhaengig vom Request-Traffic. Ersetzt den Effekt des frueheren taeglichen Space-Rebuilds: der Space zieht die frischen Preise selbst aus dem Prices-Repo, ohne dass ein Commit/Rebuild noetig ist.""" time.sleep(PRICE_REFRESH_STARTUP_DELAY) while True: if not _PRICE_REFRESH_STATE["running"]: _PRICE_REFRESH_STATE["last_check"] = time.time() _PRICE_REFRESH_STATE["running"] = True _refresh_price_files_once() # setzt running=False im finally time.sleep(PRICE_REFRESH_INTERVAL) def _start_price_refresh_loop(): if PRICE_AUTO_REFRESH: threading.Thread(target=_price_refresh_loop, daemon=True).start() def _price_paths(): _maybe_start_price_refresh() hist_candidates = [] if PRICE_HISTORY_FILE: hist_candidates.append(Path(PRICE_HISTORY_FILE)) hist_candidates.extend([ APP_DIR / "price_history.jsonl", APP_DIR.parent / "Prices4" / "swordmasters-prices" / "price_history.jsonl", APP_DIR.parent / "Prices4" / "price_history.jsonl", ]) for hist in hist_candidates: if not hist.exists(): continue if PRICE_ESTIMATED_FILE: est = Path(PRICE_ESTIMATED_FILE) else: est = hist.with_name("price_estimated.json") est_available = ( est.exists() or est.with_name(est.name + ".gz.b64").exists() or est.with_name(est.name + ".gz").exists() ) return hist, est if est_available else None return None, None def _load_price_index(): hist, est = _price_paths() if hist: stamp = ( str(hist), hist.stat().st_mtime, str(est) if est else "", _price_file_mtime(est) if est else 0, ) else: stamp = ("remote", PRICE_HISTORY_URL, PRICE_ESTIMATED_URL, int(time.time() // 600)) if _PRICE_INDEX_CACHE["stamp"] == stamp: return _PRICE_INDEX_CACHE["data"] if hist: snap = _read_last_jsonl(hist) elif PRICE_HISTORY_URL: try: resp = _requests.get(PRICE_HISTORY_URL, timeout=8) if resp.status_code != 200: return None snap = _last_jsonl_from_text(resp.text) except Exception: return None else: return None prices = snap.get("p", snap.get("prices", {})) if isinstance(snap, dict) else {} estimated = {} if est: try: est_text = _read_price_local_text(est) raw_est = json.loads(est_text) if est_text else {} estimated = raw_est.get("p", raw_est if isinstance(raw_est, dict) else {}) except Exception: estimated = {} elif PRICE_ESTIMATED_URL: try: est_resp = _requests.get(PRICE_ESTIMATED_URL, timeout=6) if est_resp.status_code == 200: raw_est = est_resp.json() estimated = raw_est.get("p", raw_est if isinstance(raw_est, dict) else {}) except Exception: estimated = {} data = { "prices": prices if isinstance(prices, dict) else {}, "estimated": estimated if isinstance(estimated, dict) else {}, "date": snap.get("date", "") if isinstance(snap, dict) else "", "source": f"local snapshot {hist.name}" if hist else "remote price snapshot", } _PRICE_INDEX_CACHE.update({"stamp": stamp, "data": data}) return data def _market_price_key(item_type, item_name, level, flame=0, looting=0, currency="power"): enc_parts = [] if int(flame or 0) > 0: enc_parts.append(f"flame={int(flame)}") if int(looting or 0) > 0: enc_parts.append(f"looting={int(looting)}") lvl = "" if item_type in ("pet", "ore") else str(int(level or 1)) return "|".join([item_type, item_name, lvl, ",".join(enc_parts), "0", currency]) def _roman(level: int) -> str: return {1: "I", 2: "II", 3: "III"}.get(int(level), str(level)) def _market_enc_label(flame=0, looting=0): parts = [] if int(flame or 0) > 0: parts.append(f"Flame {_roman(int(flame))}") if int(looting or 0) > 0: parts.append(f"Looting {_roman(int(looting))}") return ", ".join(parts) def _market_spec_key(spec): level = 0 if spec.get("type") in ("pet", "ore") else int(spec.get("level", 0) or 0) return ( spec.get("type"), spec.get("name"), level, int(spec.get("flame", 0) or 0), int(spec.get("looting", 0) or 0) ) def _market_label(spec): if spec["type"] == "pet": return spec["name"] parts = [f'{spec["name"]} Lv{int(spec.get("level", 1))}'] if int(spec.get("flame", 0)) > 0: parts.append(f'F{int(spec["flame"])}') if int(spec.get("looting", 0)) > 0: parts.append(f'L{int(spec["looting"])}') return " ".join(parts) def _market_price_local(spec, index=None): idx = index or _load_price_index() if not idx: return None key = _market_price_key( spec["type"], spec["name"], spec.get("level", 1), spec.get("flame", 0), spec.get("looting", 0), "power" ) source = "history" raw = idx["prices"].get(key) if raw is None: raw = idx["estimated"].get(key) source = "estimated" entry = _price_entry(raw) if not entry or entry.get("median") is None: return None return {**entry, "source": source, "key": key, "median_fmt": _price_fmt(entry["median"])} def _market_price_http(spec): if not PRICE_API_BASE: return None key = ( spec["type"], spec["name"], int(spec.get("level", 1)), int(spec.get("flame", 0)), int(spec.get("looting", 0)) ) cached = _PRICE_HTTP_CACHE.get(key) if cached and time.time() - cached[0] < 300: return cached[1] params = {"item_type": spec["type"], "item_name": spec["name"], "currency": "power"} if spec["type"] not in ("pet", "ore"): params["level"] = str(int(spec.get("level", 1))) enc = _market_enc_label(spec.get("flame", 0), spec.get("looting", 0)) if enc: params["enc"] = enc try: resp = _requests.get(f"{PRICE_API_BASE}/api/price", params=params, timeout=2.8) if resp.status_code != 200: _PRICE_HTTP_CACHE[key] = (time.time(), None) return None data = resp.json() power = data.get("power", {}) if isinstance(data, dict) else {} median = _compact_price_num(power.get("median")) if median is None: out = None else: out = { "median": median, "median_fmt": power.get("median_fmt") or _price_fmt(median), "samples": power.get("samples", 0), "swap_samples": power.get("swap_samples", 0), "confidence": power.get("confidence", "LOW"), "estimate_note": power.get("estimate_note", ""), "source": "price api", "key": str(key), } _PRICE_HTTP_CACHE[key] = (time.time(), out) return out except Exception: _PRICE_HTTP_CACHE[key] = (time.time(), None) return None def _market_price(spec, index=None): local = _market_price_local(spec, index=index) if local: return local if index: return None return _market_price_http(spec) def _boss_preset_for_type(enemy_type): try: wanted = int(enemy_type) except (TypeError, ValueError): return None return next((preset for preset in BOSS_PRESETS if preset["enemy_type"] == wanted), None) EPIC_DROP_ITEMS = { preset["enemy_type"]: { "sword": preset["sword_drop"]["name"], "shield": preset["shield_drop"]["name"], } for preset in BOSS_PRESETS if preset["sword_drop"]["name"] and preset["shield_drop"]["name"] } def _epic_drop_market_values(enemy_type: int | None): items = EPIC_DROP_ITEMS.get(_safe_int(enemy_type, -1)) if not items: return None result = {"enemy_type": int(enemy_type), "level": 1} sources = set() for item_type in ("sword", "shield"): spec = {"type": item_type, "name": items[item_type], "level": 1, "flame": 0, "looting": 0} price = _market_price(spec) if price: sources.add(price.get("source", "price data")) result[item_type] = { "name": items[item_type], "level": 1, "price": price.get("median") if price else None, "price_fmt": price.get("median_fmt") if price else "N/A", "confidence": price.get("confidence") if price else "N/A", "source": price.get("source") if price else "fallback", } result["source"] = ", ".join(sorted(sources)) if sources else "fallback" return result def _resolved_drop_values(data): legacy = _cfg_nonnegative(getattr(data, "item_pv", None), ITEM_PV) explicit_sword = getattr(data, "sword_item_pv", None) explicit_shield = getattr(data, "shield_item_pv", None) if explicit_sword is not None or explicit_shield is not None: sword_pv, shield_pv = _drop_value_pair(legacy, explicit_sword, explicit_shield) return sword_pv, shield_pv if getattr(data, "item_pv", None) is not None: return legacy, legacy market = _epic_drop_market_values(getattr(data, "enemy_type", None)) if not market: return legacy, legacy sword_pv = market["sword"].get("price") shield_pv = market["shield"].get("price") return ( legacy if sword_pv is None else _cfg_nonnegative(sword_pv, legacy), legacy if shield_pv is None else _cfg_nonnegative(shield_pv, legacy), ) def _resolved_drop_chances(data): preset = _boss_preset_for_type(getattr(data, "enemy_type", None)) sword_default = preset["sword_drop"]["chance"] if preset else 0.01 shield_default = preset["shield_drop"]["chance"] if preset else 0.01 return ( _cfg_nonnegative(getattr(data, "sword_drop_chance", None), sword_default), _cfg_nonnegative(getattr(data, "shield_drop_chance", None), shield_default), ) def _resolved_room_capacity(data): preset = _boss_preset_for_type(getattr(data, "enemy_type", None)) default = preset["room_capacity"] if preset else MAX_ROOM value = getattr(data, "room_capacity", None) try: return max(1, min(int(value if value is not None else default), 50)) except (TypeError, ValueError): return default def _upgrade_category(item_type: str): normalized = str(item_type or "").strip().lower() if normalized in ("sword", "swords"): return "sword", "swords" if normalized in ("shield", "shields"): return "shield", "shields" raise HTTPException(400, "Upgrade Lab currently supports swords and shields.") def _catalog_item_by_name(category: str, name: str): clean = str(name or "").strip() for item in _GAME_CATALOG.get(category, []) or []: if str(item.get("name", "")).lower() == clean.lower(): return item return None def _upgrade_item_lookup(name: str, requested_type: str = ""): clean = str(name or "").strip() requested = str(requested_type or "").strip().lower() categories = [] if requested in ("sword", "swords"): categories = [("sword", "swords")] elif requested in ("shield", "shields"): categories = [("shield", "shields")] else: categories = [("sword", "swords"), ("shield", "shields")] for singular, category in categories: item = _catalog_item_by_name(category, clean) if item: return singular, category, item raise HTTPException(400, f"Unknown sword/shield item: {clean!r}") def _upgrade_item_price(item_type: str, name: str, level: int): spec = { "type": item_type, "name": name, "level": max(1, int(level or 1)), "flame": 0, "looting": 0, } price = _market_price(spec) if not price or price.get("median") is None: return {"value": None, "display": "N/A", "confidence": "N/A", "source": "missing"} value = float(price["median"]) return { "value": value, "display": price.get("median_fmt") or _price_fmt(value), "confidence": price.get("confidence", "LOW"), "source": price.get("source", "price data"), } def _fortune_luck_factor(fortune) -> float: """Fortune multiplies luck by +5% per level (not additive): L1 x1.05, L2 x1.10, L3 x1.15. Applies to both dwarfing and elf luck.""" return 1.0 + 0.05 * max(0, min(3, int(fortune or 0))) def _dwarf_luck(level: int, rarity: str, fortune: int = 0): lvl = max(1, int(level or 1)) if lvl >= 10: return 0.0 if lvl < 3: luck = 1.0 / (1.0 + 0.1 * lvl) elif lvl < 5: luck = 1.0 / (1.0 + 0.2 * lvl) else: luck = 1.0 / (1.0 + 0.3 * lvl) rarity_key = str(rarity or "").strip().lower() if rarity_key == "rare": luck *= 4.0 / 5.0 elif rarity_key == "epic": luck *= 4.0 / 7.0 elif rarity_key == "legendary": luck *= 4.0 / 10.0 return max(0.0, min(1.0, luck * _fortune_luck_factor(fortune))) def _dwarf_transition_count_distribution(count: int, level: int, rarity: str, fortune: int = 0): count = max(0, int(count or 0)) luck = _dwarf_luck(level, rarity, fortune) if count <= 0: return {0: 1.0} if luck <= 0.0: return {0: 1.0} if luck >= 1.0: return {count: 1.0} dist = {} for successes in range(count + 1): prob = math.exp( math.lgamma(count + 1) - math.lgamma(successes + 1) - math.lgamma(count - successes + 1) + successes * math.log(luck) + (count - successes) * math.log1p(-luck) ) if prob > 1e-15: dist[successes] = prob return dist def _upgrade_action_for_level(level: int, rarity: str, strategy: str, fortune: int = 0): mode = str(strategy or "auto").strip().lower() if mode in ("dwarf", "dwarft", "dwarfting"): return "dwarf" if mode in ("merge", "merging"): return "merge" dwarf_expected_rate = _dwarf_luck(level, rarity, fortune) merge_expected_rate = 1.0 / max(level + 1, 1) return "merge" if merge_expected_rate + 1e-12 >= dwarf_expected_rate else "dwarf" def _expected_levelN_per_target(rarity: str, from_level: int, target_level: int, strategy: str = "auto", fortune: int = 0): """Expected number of ``from_level`` items needed to produce ONE item at ``target_level`` from scratch, given the auto/merge/dwarf strategy. Uses the exact same per-level action + luck model as the real plan: each source level L contributes a multiplicative cost of (L+1) inputs per output when merging, or 1/luck inputs per output when dwarfing. At ``from_level == target_level`` the cost is 1 (one target is one target). Returns ``float('inf')`` if the target is unreachable (luck 0).""" start = max(1, int(from_level or 1)) target = max(1, int(target_level or 1)) if start >= target: return 1.0 needed = 1.0 for level in range(start, target): action = _upgrade_action_for_level(level, rarity, strategy, fortune) if action == "merge": needed *= (level + 1) else: luck = _dwarf_luck(level, rarity, fortune) if luck <= 0.0: return float("inf") needed *= 1.0 / luck return needed def _expected_level1_per_target(rarity: str, target_level: int, strategy: str = "auto", fortune: int = 0): """Expected number of Level-1 items needed to produce ONE item at ``target_level`` from scratch. Thin wrapper over ``_expected_levelN_per_target``.""" return _expected_levelN_per_target(rarity, 1, target_level, strategy, fortune) def _level_needed_by_source(rarity: str, target_level: int, strategy: str = "auto", fortune: int = 0): """For every source level 1..target_level: expected number of items at that source level needed to build ONE item at ``target_level``. Used by the "Materials needed" card so the user can pick which owned level to count.""" target = max(1, int(target_level or 1)) out = {} for src in range(1, target + 1): val = _expected_levelN_per_target(rarity, src, target, strategy, fortune) out[str(src)] = (val if math.isfinite(val) else None) return out def _merge_counts(raw_counts: dict): counts = {level: max(0, int(raw_counts.get(str(level), raw_counts.get(level, 0)) or 0)) for level in range(1, 11)} actions = [] for level in range(2, 11): needed = level made = counts[level - 1] // needed if made > 0: counts[level - 1] -= made * needed counts[level] += made actions.append({ "from_level": level - 1, "to_level": level, "required_each": needed, "made": made, "consumed": made * needed, }) return counts, actions def _binomial_tail(n: int, p: float, k: int): n = max(0, int(n or 0)) k = max(0, int(k or 0)) p = max(0.0, min(1.0, float(p or 0.0))) if k <= 0: return 1.0 if k > n or p <= 0.0: return 0.0 if p >= 1.0: return 1.0 logs = [] log_p = math.log(p) log_q = math.log1p(-p) base = math.lgamma(n + 1) for i in range(k, n + 1): logs.append(base - math.lgamma(i + 1) - math.lgamma(n - i + 1) + i * log_p + (n - i) * log_q) top = max(logs) return max(0.0, min(1.0, math.exp(top) * sum(math.exp(row - top) for row in logs))) def _binomial_capped_probs(n: int, p: float, cap: int): n = max(0, int(n or 0)) cap = max(1, int(cap or 1)) p = max(0.0, min(1.0, float(p or 0.0))) probs = [] for i in range(min(n, cap - 1) + 1): if p <= 0.0: prob = 1.0 if i == 0 else 0.0 elif p >= 1.0: prob = 1.0 if i == n else 0.0 else: prob = math.exp( math.lgamma(n + 1) - math.lgamma(i + 1) - math.lgamma(n - i + 1) + i * math.log(p) + (n - i) * math.log1p(-p) ) probs.append(prob) while len(probs) < cap: probs.append(0.0) tail = max(0.0, min(1.0, 1.0 - sum(probs[:cap]))) probs.append(tail) return probs def _poisson_binomial_group_tail(groups, target_count: int): target = max(1, int(target_count or 1)) dp = [0.0] * (target + 1) dp[0] = 1.0 for count, prob in groups: if count <= 0: continue row = _binomial_capped_probs(count, prob, target) ndp = [0.0] * (target + 1) for have, have_prob in enumerate(dp): if have_prob <= 0: continue for add, add_prob in enumerate(row): if add_prob <= 0: continue idx = target if have == target or add == target else min(target, have + add) ndp[idx] += have_prob * add_prob dp = ndp return max(0.0, min(1.0, dp[target])) def _upgrade_path_distribution_from_state( initial_state: dict, level_counts: dict, rarity: str, target_level: int, strategy: str, *, exact_level=False, fortune: int = 0, ): target_level = max(1, min(10, int(target_level or 1))) normalized_counts = { level: max(0, int(level_counts.get(str(level), level_counts.get(level, 0)) or 0)) for level in range(1, 11) } state = { max(0, int(count)): max(0.0, float(prob)) for count, prob in (initial_state or {0: 1.0}).items() if max(0.0, float(prob)) > 0.0 } or {0: 1.0} steps = [] for level in range(1, target_level): base_count = normalized_counts[level] combined = {} for carried, prob in state.items(): total = carried + base_count combined[total] = combined.get(total, 0.0) + prob action = _upgrade_action_for_level(level, rarity, strategy, fortune) nxt = {} expected_input = sum(count * prob for count, prob in combined.items()) expected_output = 0.0 if action == "merge": needed = level + 1 dwarf_chance = _dwarf_luck(level, rarity, fortune) expected_merged = 0.0 expected_leftover = 0.0 for count, prob in combined.items(): merged = count // needed leftover = count % needed expected_merged += merged * prob expected_leftover += leftover * prob for dwarf_made, dwarf_prob in _dwarf_transition_count_distribution(leftover, level, rarity, fortune).items(): made = merged + dwarf_made nxt[made] = nxt.get(made, 0.0) + prob * dwarf_prob expected_output += made * prob * dwarf_prob chance = 1.0 / needed step_action = "merge+dwarf" if expected_leftover > 1e-12 and dwarf_chance > 0 else "merge" else: chance = _dwarf_luck(level, rarity, fortune) step_action = action for count, prob in combined.items(): for made, made_prob in _dwarf_transition_count_distribution(count, level, rarity, fortune).items(): nxt[made] = nxt.get(made, 0.0) + prob * made_prob expected_output += made * prob * made_prob if expected_input > 1e-12 or base_count > 0: steps.append({ "from_level": level, "to_level": level + 1, "action": step_action, "chance": chance, "chance_pct": chance * 100.0, "dwarf_chance": _dwarf_luck(level, rarity, fortune), "dwarf_chance_pct": _dwarf_luck(level, rarity, fortune) * 100.0, "expected_input": expected_input, "expected_output": expected_output, "merge_required": level + 1, }) state = {count: prob for count, prob in nxt.items() if prob > 1e-15} if not state: state = {0: 1.0} fixed_target = normalized_counts[target_level] fixed_above = 0 if exact_level else sum(normalized_counts[level] for level in range(target_level + 1, 11)) final = {} for carried, prob in state.items(): total = carried + fixed_target + fixed_above final[total] = final.get(total, 0.0) + prob expected = sum(count * prob for count, prob in final.items()) return { "distribution": dict(sorted(final.items())), "expected": expected, "steps": steps, "target_level": target_level, "fixed_target": fixed_target, "fixed_above": fixed_above, } def _upgrade_path_distribution(counts: dict, rarity: str, target_level: int, strategy: str, *, exact_level=False, fortune: int = 0): return _upgrade_path_distribution_from_state( {0: 1.0}, counts, rarity, target_level, strategy, exact_level=exact_level, fortune=fortune, ) def _distribution_exact(dist: dict, target_count: int): target = max(0, int(target_count or 0)) return max(0.0, min(1.0, float((dist or {}).get(target, 0.0)))) def _distribution_tail(dist: dict, target_count: int): target = max(1, int(target_count or 1)) return max(0.0, min(1.0, sum(prob for count, prob in dist.items() if count >= target))) def _distribution_rows(dist: dict, limit: int = 24): rows = [ { "count": int(count), "probability": float(prob), "probability_pct": float(prob) * 100.0, } for count, prob in sorted((dist or {}).items()) if prob > 1e-12 ] if len(rows) <= limit: return rows selected = {rows[0]["count"]: rows[0], rows[-1]["count"]: rows[-1]} for row in sorted(rows, key=lambda item: item["probability"], reverse=True)[: max(0, limit - 2)]: selected[row["count"]] = row return [selected[key] for key in sorted(selected)] def _elf_result_distribution(level5_dist: dict, success_chance=0.8, fortune: int = 0): success_chance = min(1.0, max(0.0, float(success_chance or 0.0)) * _fortune_luck_factor(fortune)) result = {} for level5_count, level5_prob in level5_dist.items(): attempts = max(0, int(level5_count) // 10) if attempts <= 0: result[0] = result.get(0, 0.0) + level5_prob continue if success_chance <= 0.0: result[0] = result.get(0, 0.0) + level5_prob elif success_chance >= 1.0: result[attempts] = result.get(attempts, 0.0) + level5_prob else: for successes in range(attempts + 1): prob = math.exp( math.lgamma(attempts + 1) - math.lgamma(successes + 1) - math.lgamma(attempts - successes + 1) + successes * math.log(success_chance) + (attempts - successes) * math.log1p(-success_chance) ) if prob > 1e-15: result[successes] = result.get(successes, 0.0) + level5_prob * prob return dict(sorted(result.items())) # Official client formula: choose the level chance, multiply it by the XPet # divisor, round that free chance to a whole percent, then show 3x for gold. _PET_ENHANCER_CHANCES = [ {"min": 0, "max": 2000, "base": 0.01}, {"min": 2001, "max": 3000, "base": 0.03}, {"min": 3001, "max": 4000, "base": 0.05}, {"min": 4001, "max": 5000, "base": 0.07}, {"min": 5001, "max": 6000, "base": 0.10}, {"min": 6001, "max": 7000, "base": 0.15}, {"min": 7001, "max": 8000, "base": 0.20}, {"min": 8001, "max": 9000, "base": 0.25}, {"min": 9001, "max": 10000, "base": 0.30}, {"min": 10001, "max": None, "base": 0.33}, ] def _pet_enhancer_routes(): pets = sorted( (_GAME_CATALOG.get("pets") or []), key=lambda item: int(item.get("type", 0)), ) by_type = {int(item["type"]): item for item in pets} routes = [] for source_item in pets: result_type = source_item.get("enhancedPet") if result_type is None: continue result_item = by_type.get(int(result_type)) multiplier = float(source_item.get("enhancerMultiplier", 0.0) or 0.0) if not result_item or multiplier <= 0.0: continue source = str(source_item.get("name") or "") result = str(result_item.get("name") or "") routes.append({ "key": f"pet|{source}|{result}", "item_type": "pet", "source": source, "result": result, "source_rarity": source_item.get("rarity", ""), "result_rarity": result_item.get("rarity", ""), "source_type": int(source_item["type"]), "result_type": int(result_item["type"]), "source_power_multiplier": float(source_item.get("powerMultiplier", 0.0) or 0.0), "result_power_multiplier": float(result_item.get("powerMultiplier", 0.0) or 0.0), "chance_multiplier": multiplier, "paid_gold_cost": 5e15 if source.lower() == "runix" and result.lower() == "xrunix" else 1e15, }) return routes def _pet_enhancer_route_for_name(name: str): clean = str(name or "").strip().lower() for route in _pet_enhancer_routes(): if route["source"].lower() == clean or route["result"].lower() == clean: return route return None def _pet_enhancer_tier(player_level: int, helper_odds: bool = False): if helper_odds: return _PET_ENHANCER_CHANCES[-1] try: level = max(0, int(player_level or 0)) except (TypeError, ValueError): level = 0 for row in _PET_ENHANCER_CHANCES: if level >= row["min"] and (row["max"] is None or level <= row["max"]): return row return _PET_ENHANCER_CHANCES[-1] def _pet_enhancer_chance(route: dict, player_level: int, use_gold: bool, helper_odds: bool, fortune: int = 0): tier = _pet_enhancer_tier(player_level, helper_odds) multiplier = float(route.get("chance_multiplier", 0.0) or 0.0) # Fortune des angelegten Schilds wirkt auch hier multiplikativ (Spielcode: # luck *= 1 + 0.05 * fortune, gedeckelt bei 100%). raw_free_pct = float(tier["base"]) * multiplier * 100.0 * _fortune_luck_factor(fortune) displayed_free_pct = float( Decimal(str(raw_free_pct)).quantize(Decimal("1"), rounding=ROUND_HALF_UP) ) gold_boost = 3.0 if use_gold else 1.0 chance_pct = raw_free_pct * gold_boost tier_public = { "min": tier["min"], "max": tier["max"], "base_chance_pct": float(tier["base"]) * 100.0, "exact_free_chance_pct": raw_free_pct, "game_display_free_chance_pct": displayed_free_pct, "game_display_chance_pct": displayed_free_pct * gold_boost, } return max(0.0, min(1.0, chance_pct / 100.0)), chance_pct, tier_public # ── Smelter & Enchanter ─────────────────────────────────────────────────── # Alle Werte direkt aus __game-scripts.js (Stand 2026-08-24). Jedes Ore gehoert # zu genau einem Enchant (enchantmentType == Ore-Typ). SM_ORE_FORGE = [ {"index": 0, "name": "Coal", "smelt_chance_pct": 70.0, "enchant": "Flame"}, {"index": 1, "name": "Emerald", "smelt_chance_pct": 55.0, "enchant": "Thorns"}, {"index": 2, "name": "Diamond", "smelt_chance_pct": 40.0, "enchant": "Looting"}, {"index": 3, "name": "Ruby", "smelt_chance_pct": 25.0, "enchant": "Freeze"}, {"index": 4, "name": "Gold", "smelt_chance_pct": 20.0, "enchant": "Fortune"}, ] # Verteilung [Fail, Lv1, Lv2, Lv3] in Prozent, je Summe 100. SM_ENCHANT_CHANCES = { "Flame": {"applies_to": "sword", "min": [40.0, 35.0, 18.0, 7.0], "max": [15.0, 45.0, 25.0, 15.0]}, "Thorns": {"applies_to": "shield", "min": [50.0, 35.0, 12.0, 3.0], "max": [20.0, 45.0, 25.0, 10.0]}, "Looting": {"applies_to": "both", "min": [60.0, 30.0, 8.0, 2.0], "max": [25.0, 42.5, 22.5, 10.0]}, "Freeze": {"applies_to": "sword", "min": [65.0, 27.5, 6.0, 1.5], "max": [30.0, 50.0, 15.0, 5.0]}, "Fortune": {"applies_to": "shield", "min": [70.0, 25.0, 4.0, 1.0], "max": [50.0, 40.0, 7.5, 2.5]}, } SM_ENCHANT_LEVEL_FLOOR = 2000 # darunter gelten die min-Werte SM_ENCHANT_LEVEL_SPAN = 18000 # bis Level 20000 wird linear interpoliert SM_ORES_PER_SMELT = 10 # 10 rohe Ores je Schmelzversuch SM_FORGE_GOLD_COST = 1e15 # volle Chance kostet 1 QT Gold def _ore_forge_row(name: str): clean = str(name or "").strip().lower() for row in SM_ORE_FORGE: if row["name"].lower() == clean: return row return None def _ore_smelt_chance_pct(row: dict, fortune: int = 0, half: bool = False): """Schmelzchance in Prozent. Fortune wirkt multiplikativ (+5% je Stufe), danach Deckel 100. Der Gratis-Knopf zeigt floor(chance/2).""" pct = float(row.get("smelt_chance_pct") or 0.0) * (1.0 + 0.05 * max(0, min(3, int(fortune or 0)))) pct = min(100.0, pct) return float(math.floor(pct / 2.0)) if half else pct def _enchant_chance_pcts(enchant: str, player_level: int, fortune: int = 0, half: bool = False): """[Fail, Lv1, Lv2, Lv3] in Prozent - getChancesByLevel aus dem Spielclient. Fortune nimmt einen Anteil der Fail-Chance und verteilt ihn PROPORTIONAL auf die Erfolgsstufen (kein einfacher Multiplikator!).""" row = SM_ENCHANT_CHANCES.get(enchant) if not row: return [100.0, 0.0, 0.0, 0.0] try: level = max(0, int(player_level or 0)) except (TypeError, ValueError): level = 0 t = (level - SM_ENCHANT_LEVEL_FLOOR) / float(SM_ENCHANT_LEVEL_SPAN) t = max(0.0, min(1.0, t)) chances = [lo + (hi - lo) * t for lo, hi in zip(row["min"], row["max"])] f = max(0, min(3, int(fortune or 0))) if f > 0: moved = chances[0] * (0.05 * f) chances[0] -= moved total = sum(chances[1:]) if total > 0: for i in range(1, len(chances)): chances[i] += moved * (chances[i] / total) if half: # Gratis-Knopf: halbe Chance. Der Client belegt das fuer den Smelter # (floor(chance/2)); fuer den Enchanter halbieren wir analog die # Erfolgsstufen, der Rest faellt auf Fail. for i in range(1, len(chances)): chances[i] /= 2.0 chances[0] = max(0.0, 100.0 - sum(chances[1:])) return chances def _compound_binomial(source_dist: dict, p: float): """Verteilung der Erfolge, wenn die Anzahl der Versuche selbst verteilt ist.""" p = max(0.0, min(1.0, float(p or 0.0))) out = {} for attempts, weight in (source_dist or {}).items(): if weight <= 0: continue for successes, prob in _binomial_distribution(int(attempts), p).items(): out[successes] = out.get(successes, 0.0) + weight * prob return out or {0: 1.0} def _dist_expected(dist: dict): return sum(int(k) * float(v) for k, v in (dist or {}).items()) def _pet_enhancer_chance_tiers(): routes = _pet_enhancer_routes() rows = [] for tier in _PET_ENHANCER_CHANCES: free = [ float( Decimal(str(float(tier["base"]) * route["chance_multiplier"] * 100.0)).quantize( Decimal("1"), rounding=ROUND_HALF_UP ) ) for route in routes ] rows.append({ "min": tier["min"], "max": tier["max"], "base_chance_pct": float(tier["base"]) * 100.0, "free": free, "gold": [chance * 3.0 for chance in free], }) return rows def _binomial_distribution(n: int, p: float): n = max(0, int(n or 0)) p = max(0.0, min(1.0, float(p or 0.0))) if n <= 0: return {0: 1.0} if p <= 0.0: return {0: 1.0} if p >= 1.0: return {n: 1.0} dist = {} for successes in range(n + 1): prob = math.exp( math.lgamma(n + 1) - math.lgamma(successes + 1) - math.lgamma(n - successes + 1) + successes * math.log(p) + (n - successes) * math.log1p(-p) ) if prob > 1e-15: dist[successes] = prob return dist _KNOWN_ELF_ROUTES = { ("sword", "Dragon's Poison"): "Dragon's Devil", ("shield", "Dragon's Anger"): "Dragon's Soul", ("sword", "Solbrand"): "Divinity Edge", ("shield", "Sunward Bulwark"): "Asgardian Aegis", } def _elf_routes(): routes = [] used_inputs = set() used_outputs = set() for (item_type, source_name), result_name in _KNOWN_ELF_ROUTES.items(): singular, category = _upgrade_category(item_type) source = _catalog_item_by_name(category, source_name) result = _catalog_item_by_name(category, result_name) if not source or not result: continue key = f"{singular}|{source_name}|{result_name}" routes.append({ "key": key, "item_type": singular, "category": category, "source": source_name, "result": result_name, "source_rarity": source.get("rarity", ""), "result_rarity": result.get("rarity", ""), "source_world": source.get("world", ""), }) used_inputs.add((singular, source_name)) used_outputs.add((singular, result_name)) for singular, category in (("sword", "swords"), ("shield", "shields")): epics = [ item for item in (_GAME_CATALOG.get(category) or []) if item.get("rarity") == "Epic" and float(item.get("drop_chance", 0.0) or 0.0) > 0 ] legendaries = [ item for item in (_GAME_CATALOG.get(category) or []) if item.get("rarity") == "Legendary" and float(item.get("drop_chance", 0.0) or 0.0) == 0.0 ] epics.sort(key=lambda item: int(item.get("type", 0))) for result in sorted(legendaries, key=lambda item: int(item.get("type", 0))): if (singular, result.get("name")) in used_outputs: continue candidates = [ item for item in epics if (singular, item.get("name")) not in used_inputs and int(item.get("type", 0)) < int(result.get("type", 0)) ] if not candidates: continue source = max(candidates, key=lambda item: int(item.get("type", 0))) source_name = source.get("name", "") result_name = result.get("name", "") key = f"{singular}|{source_name}|{result_name}" routes.append({ "key": key, "item_type": singular, "category": category, "source": source_name, "result": result_name, "source_rarity": source.get("rarity", ""), "result_rarity": result.get("rarity", ""), "source_world": source.get("world", ""), "auto_inferred": True, }) used_inputs.add((singular, source_name)) used_outputs.add((singular, result_name)) return routes def _upgrade_lab_meta(): items = [] for singular, category in (("sword", "swords"), ("shield", "shields")): for item in (_GAME_CATALOG.get(category) or []): items.append({ "name": item.get("name"), "item_type": singular, "type": item.get("type"), "rarity": item.get("rarity"), "world": item.get("world", ""), "source": item.get("source", ""), "damage": item.get("damage", 0), "power_multiplier": item.get("powerMultiplier", 0), "gold_multiplier": item.get("goldMultiplier", 0), "health_multiplier": item.get("healthMultiplier", 0), }) for route in _pet_enhancer_routes(): items.append({ "name": route["source"], "item_type": "pet", "type": route.get("source_type"), "rarity": route.get("source_rarity") or "Pet", "world": "Enhancer", "source": "Pet Enhancer", "enhances_to": route["result"], "power_multiplier": route.get("source_power_multiplier", 0), }) return { "items": items, "swords": [ {"name": item.get("name"), "rarity": item.get("rarity"), "world": item.get("world", "")} for item in (_GAME_CATALOG.get("swords") or []) ], "shields": [ {"name": item.get("name"), "rarity": item.get("rarity"), "world": item.get("world", "")} for item in (_GAME_CATALOG.get("shields") or []) ], "pet_routes": _pet_enhancer_routes(), "pet_chance_tiers": _pet_enhancer_chance_tiers(), "ore_routes": [ { **row, "applies_to": SM_ENCHANT_CHANCES[row["enchant"]]["applies_to"], "min_chances": SM_ENCHANT_CHANCES[row["enchant"]]["min"], "max_chances": SM_ENCHANT_CHANCES[row["enchant"]]["max"], } for row in SM_ORE_FORGE ], "ore_constants": { "ores_per_smelt": SM_ORES_PER_SMELT, "gold_cost_per_forge": SM_FORGE_GOLD_COST, "gold_cost_per_forge_display": _price_fmt(SM_FORGE_GOLD_COST), "level_floor": SM_ENCHANT_LEVEL_FLOOR, "level_span": SM_ENCHANT_LEVEL_SPAN, }, "elf_routes": _elf_routes(), "dwarf_luck": { rarity: [ { "from_level": level, "to_level": level + 1, "chance": _dwarf_luck(level, rarity), "chance_pct": _dwarf_luck(level, rarity) * 100.0, } for level in range(1, 10) ] for rarity in ("Common", "Rare", "Epic", "Legendary") }, } def _upgrade_lab_ore_calculate(data: UpgradeLabInput): """Smelter + Enchanter. Kette: 10 rohe Ores -> 1 Schmelzversuch -> 1 geschmolzenes Ore -> 1 Enchant-Versuch. Ziel ist entweder die Zahl geschmolzener Ores oder ein Enchant ab einer bestimmten Stufe.""" row = _ore_forge_row(data.ore_name or data.item_name) if not row: raise HTTPException(400, f"{(data.ore_name or data.item_name)!r} is not a known ore.") enchant = row["enchant"] goal_kind = str(data.ore_goal or "enchant").strip().lower() if goal_kind not in ("smelted", "enchant"): goal_kind = "enchant" owned = max(0, int(data.ore_owned or 0)) already_smelted = bool(data.ore_is_smelted) target_count = max(1, int(data.target_count or 1)) target_level = max(1, min(3, int(data.target_level or 1))) fortune = max(0, min(3, int(data.fortune or 0))) half = not bool(data.ore_use_gold) player_level = max(0, int(data.player_level or 0)) smelt_pct = _ore_smelt_chance_pct(row, fortune, half) steps = [] # Schritt 1: schmelzen (entfaellt, wenn schon geschmolzene Ores vorliegen) if already_smelted: smelted_dist = {owned: 1.0} smelt_attempts = 0 leftovers = 0 else: smelt_attempts = owned // SM_ORES_PER_SMELT leftovers = owned % SM_ORES_PER_SMELT smelted_dist = _binomial_distribution(smelt_attempts, smelt_pct / 100.0) steps.append({ "from_level": 0, "to_level": 0, "action": "smelt", "chance": smelt_pct / 100.0, "chance_pct": smelt_pct, "expected_input": float(owned), "expected_output": _dist_expected(smelted_dist), "merge_required": SM_ORES_PER_SMELT, "from_label": f"{SM_ORES_PER_SMELT}x {row['name']} Ore", "to_label": row["name"], "attempts": smelt_attempts, "leftovers": leftovers, "gold_cost": 0.0 if half else smelt_attempts * SM_FORGE_GOLD_COST, "gold_cost_display": "0" if half else _price_fmt(smelt_attempts * SM_FORGE_GOLD_COST), }) smelted_expected = _dist_expected(smelted_dist) enchant_pcts = _enchant_chance_pcts(enchant, player_level, fortune, half) exact_count = bool(data.ore_exact) if goal_kind == "smelted": result_dist = smelted_dist probability = (_distribution_exact(result_dist, target_count) if exact_count else _distribution_tail(result_dist, target_count)) expected = smelted_expected goal_label = f"{row['name']} (smelted)" result_name = row["name"] else: # Level 3 ist das Maximum - dort gibt es kein "oder besser". exact_only = bool(data.ore_exact) or target_level >= 3 success_pct = (enchant_pcts[target_level] if exact_only else sum(enchant_pcts[target_level:])) result_dist = _compound_binomial(smelted_dist, success_pct / 100.0) # Enchant-Ziel: die Checkbox betrifft die LEVEL-Stufe, nicht die Stueckzahl. probability = _distribution_tail(result_dist, target_count) expected = _dist_expected(result_dist) goal_label = _enchant_goal_label(enchant, target_level, exact_only) result_name = row["name"] steps.append({ "from_level": 0, "to_level": target_level, "action": "enchant", "chance": success_pct / 100.0, "chance_pct": success_pct, "expected_input": smelted_expected, "expected_output": expected, "merge_required": 1, "from_label": f"1x {row['name']} (smelted)", "to_label": goal_label, "attempts": int(round(smelted_expected)), "leftovers": 0, "gold_cost": 0.0 if half else smelted_expected * SM_FORGE_GOLD_COST, "gold_cost_display": "0" if half else _price_fmt(smelted_expected * SM_FORGE_GOLD_COST), }) # Mit leerem Bestand ist die Erwartung 0 - dann interessiert stattdessen, wie # viele Ores man im Schnitt braucht (analog zu "LV1 ITEMS NEEDED" beim Dwarfen). needed_unit = "smelted" if already_smelted else "raw" needed_per_one = None p_smelt = smelt_pct / 100.0 if goal_kind == "smelted": # Geschmolzene Ores als Ziel: nur sinnvoll, wenn man roh anfaengt. needed_unit = "raw" if not already_smelted and p_smelt > 0: needed_per_one = SM_ORES_PER_SMELT / p_smelt else: p_ench = success_pct / 100.0 if p_ench > 0: if already_smelted: needed_per_one = 1.0 / p_ench elif p_smelt > 0: needed_per_one = (1.0 / p_ench) / p_smelt * SM_ORES_PER_SMELT needed_total = needed_per_one * target_count if needed_per_one is not None else None gold_cost = sum(float(s.get("gold_cost") or 0.0) for s in steps) return { "ok": True, "item": { "type": "ore", "name": row["name"], "rarity": "Ore", "world": "Smelter", "source": "Smelter / Enchanter", }, "prices": {}, "goal": { "kind": "ore", "label": goal_label, "target_count": target_count, "target_level": target_level if goal_kind == "enchant" else None, "source_name": row["name"], "result_name": result_name, "ore_goal": goal_kind, }, "plan": { "strategy": "smelter_enchanter", "probability": probability, "probability_pct": probability * 100.0, "expected_count": expected, "distribution": _distribution_rows(result_dist), "steps": steps, }, "valuation": { "gold_cost": gold_cost, "gold_cost_display": _price_fmt(gold_cost) if gold_cost else "0", }, "ore": { "name": row["name"], "index": row["index"], "enchant": enchant, "applies_to": SM_ENCHANT_CHANCES[enchant]["applies_to"], "is_smelted_input": already_smelted, "owned": owned, "smelt_attempts": smelt_attempts, "leftovers": leftovers, "smelt_chance_pct": smelt_pct, "smelted_expected": smelted_expected, "enchant_chance_pcts": enchant_pcts, "enchant_labels": ["Fail", "Level 1", "Level 2", "Level 3"], "exact_only": bool(data.ore_exact) or int(target_level) >= 3, "exact_count": exact_count and goal_kind == "smelted", "max_enchant_level": 3, "needed_per_one": needed_per_one, "needed_total": needed_total, "needed_unit": needed_unit, "use_gold": not half, "half_chance": half, "player_level": player_level, "fortune": fortune, "ores_per_smelt": SM_ORES_PER_SMELT, "gold_cost_per_forge": SM_FORGE_GOLD_COST, "gold_cost_per_forge_display": _price_fmt(SM_FORGE_GOLD_COST), }, } def _roman_level(level: int): return {1: "I", 2: "II", 3: "III"}.get(int(level or 0), str(level)) def _enchant_goal_label(enchant: str, level: int, exact_only: bool): """Level 3 ist das Maximum - dort waere ein '+' irrefuehrend.""" roman = _roman_level(level) if int(level) >= 3 or exact_only: return f"{enchant} {roman}" return f"{enchant} {roman}+" def _upgrade_lab_pet_calculate(data: UpgradeLabInput): item_name = str(data.item_name or "").strip() route = _pet_enhancer_route_for_name(item_name) if not route: raise HTTPException(400, f"{item_name!r} has no known Pet Enhancer route.") target_count = max(1, int(data.target_count or 1)) counts = data.counts or {} normal_count = max(0, int(counts.get("pet", counts.get("1", counts.get(1, 0))) or 0)) attempts = normal_count // 10 leftovers = normal_count % 10 player_level = 10001 if data.pet_helper_odds or data.player_level is None else max(0, int(data.player_level or 0)) pet_fortune = max(0, min(3, int(data.fortune or 0))) chance, chance_pct, tier = _pet_enhancer_chance( route, player_level, data.pet_use_gold, data.pet_helper_odds, pet_fortune) result_dist = _binomial_distribution(attempts, chance) exact = bool(data.exact) probability = (_distribution_exact(result_dist, target_count) if exact else _distribution_tail(result_dist, target_count)) expected = attempts * chance paid_gold_cost = float(route.get("paid_gold_cost", 1e15) or 1e15) gold_cost = attempts * paid_gold_cost if data.pet_use_gold else 0.0 return { "ok": True, "item": { "type": "pet", "name": route["source"], "rarity": route.get("source_rarity") or "Pet", "world": "Enhancer", "source": "Pet Enhancer", }, "prices": {}, "goal": { "kind": "pet", "label": route["result"], "target_count": target_count, "source_name": route["source"], "result_name": route["result"], }, "plan": { "strategy": "pet_enhancer", "exact": exact, "probability": probability, "probability_pct": probability * 100.0, "expected_count": expected, "distribution": _distribution_rows(result_dist), "steps": [{ "from_level": 0, "to_level": 0, "action": "enhance", "chance": chance, "chance_pct": chance_pct, "game_display_chance_pct": tier["game_display_chance_pct"], "expected_input": float(normal_count), "expected_output": expected, "merge_required": 10, "from_label": f"10x {route['source']}", "to_label": route["result"], "attempts": attempts, "leftovers": leftovers, "gold_cost": gold_cost, "gold_cost_display": _price_fmt(gold_cost) if gold_cost else "0", }], }, "valuation": { "gold_cost": gold_cost, "gold_cost_display": _price_fmt(gold_cost) if gold_cost else "0", }, "pet": { "route": route, "source_count": normal_count, "attempts": attempts, "leftovers": leftovers, # Mit leerem Bestand ist alles 0 - dann interessiert nur der Bedarf. "needed_per_one": (10.0 / chance) if chance > 0 else None, "needed_total": (10.0 / chance * target_count) if chance > 0 else None, "player_level": player_level, "fortune": pet_fortune, "helper_odds": bool(data.pet_helper_odds), "use_gold": bool(data.pet_use_gold), "chance": chance, "chance_pct": chance_pct, "game_display_chance_pct": tier["game_display_chance_pct"], "tier": tier, "paid_gold_cost": paid_gold_cost, "paid_gold_cost_display": _price_fmt(paid_gold_cost), }, "elf": {"eligible": False, "routes": _elf_routes(), "route": None}, } def _upgrade_lab_calculate(data: UpgradeLabInput): item_name = str(data.item_name or "").strip() target_kind = str(data.target_kind or "level").strip().lower() if target_kind in ("pet", "xpet", "pet_enhancer", "enhancer"): return _upgrade_lab_pet_calculate(data) if target_kind in ("ore", "smelt", "smelter", "enchant", "enchanter"): return _upgrade_lab_ore_calculate(data) singular, category, item = _upgrade_item_lookup(item_name, data.item_type) rarity = str(item.get("rarity") or "Common") target_level = max(1, min(10, int(data.target_level or 1))) target_count = max(1, int(data.target_count or 1)) strategy = str(data.strategy or "auto").strip().lower() if strategy not in ("auto", "dwarf", "dwarft", "dwarfting", "merge", "merging"): strategy = "auto" fortune = max(0, min(3, int(data.fortune or 0))) counts = { level: max(0, int((data.counts or {}).get(str(level), (data.counts or {}).get(level, 0)) or 0)) for level in range(1, 11) } prices = {level: _upgrade_item_price(singular, item_name, level) for level in range(1, 11)} elf_routes = _elf_routes() selected_route = None route_key = str(data.elf_route or "").strip() if route_key: selected_route = next((row for row in elf_routes if row["key"] == route_key), None) if selected_route is None: selected_route = next( (row for row in elf_routes if row["item_type"] == singular and row["source"].lower() == item_name.lower()), None, ) owned_value = 0.0 has_owned_value = True for level, count in counts.items(): price_value = prices[level]["value"] if count <= 0: continue if price_value is None: has_owned_value = False else: owned_value += count * price_value elf = {"eligible": bool(selected_route), "routes": elf_routes, "route": selected_route} if target_kind == "elf": if not selected_route: raise HTTPException(400, f"{item_name!r} has no known Elf route.") exact = bool(data.exact) path = _upgrade_path_distribution(counts, rarity, 5, strategy, exact_level=True, fortune=fortune) level5_dist = path["distribution"] legendary_l1_dist = _elf_result_distribution(level5_dist, 0.8, fortune=fortune) result_rarity = str(selected_route.get("result_rarity") or "Legendary") result_upgrade_steps = [] if target_level > 1: result_path = _upgrade_path_distribution_from_state( legendary_l1_dist, {}, result_rarity, target_level, strategy, exact_level=exact, ) result_dist = result_path["distribution"] for row in result_path["steps"]: labeled = dict(row) labeled["from_label"] = f"{selected_route['result']} Lv{row['from_level']}" labeled["to_label"] = f"{selected_route['result']} Lv{row['to_level']}" result_upgrade_steps.append(labeled) else: result_path = { "distribution": legendary_l1_dist, "expected": sum(count * prob for count, prob in legendary_l1_dist.items()), "steps": [], "target_level": 1, "fixed_target": 0, "fixed_above": 0, } result_dist = legendary_l1_dist probability = (_distribution_exact(result_dist, target_count) if exact else _distribution_tail(result_dist, target_count)) expected = sum(count * prob for count, prob in result_dist.items()) expected_attempts = sum((int(level5_count) // 10) * prob for level5_count, prob in level5_dist.items()) expected_legendary_l1 = sum(count * prob for count, prob in legendary_l1_dist.items()) source_price = _upgrade_item_price(selected_route["item_type"], selected_route["source"], 5) result_price = _upgrade_item_price(selected_route["item_type"], selected_route["result"], target_level) result_value = result_price["value"] expected_target_value = expected * result_value if result_value is not None else None source_value = source_price["value"] expected_input_value = expected_attempts * 10 * source_value if source_value is not None else None expected_margin = ( expected_target_value - expected_input_value if expected_target_value is not None and expected_input_value is not None else None ) steps = list(path["steps"]) steps.append({ "from_level": 5, "to_level": 1, "action": "elf", "chance": 0.8, "chance_pct": 80.0, "expected_input": path["expected"], "expected_output": expected_legendary_l1, "merge_required": 10, "from_label": f"{selected_route['source']} Lv5", "to_label": f"{selected_route['result']} Lv1", "expected_attempts": expected_attempts, }) steps.extend(result_upgrade_steps) plan = { "strategy": strategy, "exact": exact, "probability": probability, "probability_pct": probability * 100.0, "expected_count": expected, "distribution": _distribution_rows(result_dist), "steps": steps, "intermediate_level5": { "expected_count": path["expected"], "distribution": _distribution_rows(level5_dist), "expected_attempts": expected_attempts, }, "intermediate_legendary": { "expected_count": expected_legendary_l1, "distribution": _distribution_rows(legendary_l1_dist), "target_level": target_level, }, } goal = { "kind": "elf", "label": ( f"{selected_route['result']} Lv{target_level}" + ("" if exact else "+") if target_level > 1 else selected_route["result"] ), "target_count": target_count, "exact": exact, "source_name": selected_route["source"], "source_level": 5, "result_name": selected_route["result"], "result_level": target_level, } valuation = { "owned_value": owned_value if has_owned_value else None, "owned_value_display": _price_fmt(owned_value) if has_owned_value else "N/A", "expected_target_value": expected_target_value, "expected_target_value_display": _price_fmt(expected_target_value) if expected_target_value is not None else "N/A", "expected_input_value": expected_input_value, "expected_input_value_display": _price_fmt(expected_input_value) if expected_input_value is not None else "N/A", "expected_margin": expected_margin, "expected_margin_display": _price_fmt(expected_margin) if expected_margin is not None else "N/A", "target_price": result_price, "source_level5_price": source_price, } # Bedarf aus dem Nichts: Lv1 -> Lv5 -> (10 pro Elf-Versuch) -> Legendary Lv1 # -> Ziellevel. Jede Stufe multipliziert den Bedarf. _elf_p = min(1.0, max(0.0, 0.8) * _fortune_luck_factor(fortune)) _lvl1_per_lvl5 = _expected_level1_per_target(rarity, 5, strategy, fortune) _legendary_per_target = _expected_level1_per_target(result_rarity, target_level, strategy, fortune) _elf_needed_per_one = None if _elf_p > 0 and math.isfinite(_lvl1_per_lvl5) and math.isfinite(_legendary_per_target): _elf_needed_per_one = 10.0 * _lvl1_per_lvl5 / _elf_p * _legendary_per_target elf.update({ "level5_expected": path["expected"], "attempts_expected": expected_attempts, "expected_successes": expected_legendary_l1, "expected_target_count": expected, "target_level": target_level, "chance_at_least_target": probability, "source_price": source_price, "result_price": result_price, "needed_per_one": _elf_needed_per_one, "needed_total": (_elf_needed_per_one * target_count) if _elf_needed_per_one is not None else None, "needed_unit": f"Lv1 {item_name}", }) else: exact = bool(data.exact) path = _upgrade_path_distribution(counts, rarity, target_level, strategy, exact_level=exact, fortune=fortune) result_dist = path["distribution"] probability = (_distribution_exact(result_dist, target_count) if exact else _distribution_tail(result_dist, target_count)) expected = path["expected"] target_price = prices[target_level] target_value = target_price["value"] expected_target_value = expected * target_value if target_value is not None else None _lvl1_per_target = _expected_level1_per_target(rarity, target_level, strategy, fortune) _lvl1_finite = math.isfinite(_lvl1_per_target) plan = { "strategy": strategy, "exact": exact, "probability": probability, "probability_pct": probability * 100.0, "expected_count": expected, "distribution": _distribution_rows(result_dist), "steps": path["steps"], "level1_per_target": (_lvl1_per_target if _lvl1_finite else None), "level1_for_target_count": (_lvl1_per_target * target_count if _lvl1_finite else None), # Bedarf je Quell-Level (1..target) -> "Materials needed"-Karte kann das Level waehlen. "level_needed_by_source": _level_needed_by_source(rarity, target_level, strategy, fortune), } goal = { "kind": "level", "label": f"{item_name} Lv{target_level}" + ("" if exact else "+"), "target_count": target_count, "target_level": target_level, "item_name": item_name, "exact": exact, } valuation = { "owned_value": owned_value if has_owned_value else None, "owned_value_display": _price_fmt(owned_value) if has_owned_value else "N/A", "expected_target_value": expected_target_value, "expected_target_value_display": _price_fmt(expected_target_value) if expected_target_value is not None else "N/A", "target_price": target_price, } return { "ok": True, "item": { "type": singular, "name": item_name, "rarity": rarity, "world": item.get("world", ""), "source": item.get("source", ""), }, "prices": { str(level): prices[level] for level in range(1, 11) }, "goal": goal, "plan": plan, "valuation": valuation, "elf": elf, } @app.get("/epic-drop-values") def epic_drop_values(enemy_type: int): market = _epic_drop_market_values(enemy_type) if not market: raise HTTPException(400, f"Unsupported enemy type: {enemy_type}") for item_type in ("sword", "shield"): if market[item_type]["price"] is None: market[item_type]["price"] = ITEM_PV market[item_type]["price_fmt"] = _price_fmt(ITEM_PV) market[item_type]["fallback"] = True else: market[item_type]["fallback"] = False return market @app.get("/boss-presets") def boss_presets(): rows = json.loads(json.dumps(BOSS_PRESETS)) for row in rows: market = _epic_drop_market_values(row["enemy_type"]) if not market: continue row["sword_drop"]["price"] = market["sword"].get("price") row["sword_drop"]["price_fmt"] = market["sword"].get("price_fmt") row["shield_drop"]["price"] = market["shield"].get("price") row["shield_drop"]["price_fmt"] = market["shield"].get("price_fmt") configured_default = str(_BOSS_OVERRIDES.get("default_boss_key") or "mordolith") available_keys = {row["key"] for row in rows} default_key = ( configured_default if configured_default in available_keys else (rows[0]["key"] if rows else "") ) return { "schema_version": 1, "default_key": default_key, "presets": rows, "catalog_source": _GAME_CATALOG.get("source", {}), } @app.get("/upgrade-lab/meta") def upgrade_lab_meta(): return _upgrade_lab_meta() @app.post("/upgrade-lab") def upgrade_lab(data: UpgradeLabInput): return _upgrade_lab_calculate(data) def _parse_market_enc(enc: str): result = {"flame": 0, "looting": 0} if not enc: return result for part in enc.split(","): if not part: continue if "=" not in part: return None key, val = part.split("=", 1) key = key.strip().lower() if key not in result: return None try: result[key] = int(val) except ValueError: return None return result def _candidate_from_price_key(key: str, raw, source: str): parts = key.split("|") if len(parts) < 6 or parts[5] != "power": return None item_type, name, level_raw, enc_raw = parts[0], parts[1], parts[2], parts[3] if item_type not in ("sword", "shield", "pet"): return None if item_type == "sword" and name not in SCHWERTER: return None if item_type == "shield" and not SCHILDE.get(name): return None if item_type == "pet" and name not in PETS: return None enc = _parse_market_enc(enc_raw) if enc is None: return None entry = _price_entry(raw) if not entry or not entry.get("median") or entry["median"] <= 0: return None level = 0 if item_type in ("pet", "ore") else (int(level_raw) if str(level_raw).isdigit() else 1) cand = { "type": item_type, "name": name, "level": level, "flame": enc["flame"], "looting": enc["looting"], "price": float(entry["median"]), "confidence": entry.get("confidence", "LOW"), "samples": _safe_int(entry.get("samples", 0)) + _safe_int(entry.get("swap_samples", 0)), "source": source, } try: if item_type == "sword": cand["ds"] = resolve_ds(name, level) elif item_type == "shield": cand["ms"] = resolve_ms(name, level) elif item_type == "pet": cand["mp"] = PETS[name] else: return None except Exception: return None cand["key"] = _market_spec_key(cand) return cand def _dedupe_candidates(cands): best = {} for cand in cands: key = cand["key"] old = best.get(key) prefer = old is None or cand["price"] < old["price"] prefer = prefer or (old and old.get("source") != "history" and cand.get("source") == "history") if prefer: best[key] = cand return list(best.values()) def _pareto_candidates(cands, dims): result = [] for i, cand in enumerate(cands): dominated = False for j, other in enumerate(cands): if i == j or other["price"] > cand["price"]: continue if all(float(other.get(dim, 0)) >= float(cand.get(dim, 0)) for dim in dims): strictly = other["price"] < cand["price"] or any( float(other.get(dim, 0)) > float(cand.get(dim, 0)) for dim in dims ) if strictly: dominated = True break if not dominated: result.append(cand) return result def _candidate_perf(cand): if cand.get("type") == "sword": ds = float(cand.get("ds", 0) or 0.0) flame = int(cand.get("flame", 0) or 0) hps = hits_per_second_for_level(None, HPS) flame_dps = (ds / (10.0 - flame)) if flame > 0 else 0.0 return ds * hps + flame_dps if cand.get("type") == "shield": return float(cand.get("ms", 0)) if cand.get("type") == "pet": return float(cand.get("mp", 0)) return float(cand.get("mp", cand.get("ds", cand.get("ms", 0)))) def _cap_candidates(cands, limit, keep_keys=None): keep_keys = keep_keys or set() kept = {cand["key"]: cand for cand in cands if cand["key"] in keep_keys} rows = [cand for cand in cands if cand["key"] not in kept] by_price = sorted(rows, key=lambda c: c.get("price", 0)) spread = [] if by_price: slots = min(max(limit // 3, 4), len(by_price)) spread = [by_price[round(idx * (len(by_price) - 1) / max(slots - 1, 1))] for idx in range(slots)] rankings = [ sorted(rows, key=_candidate_perf, reverse=True), sorted(rows, key=lambda c: _candidate_perf(c) / max(c.get("price", 0), 1.0), reverse=True), by_price, spread, ] selected = {} positions = [0] * len(rankings) while len(selected) < limit: added = False for ranking_idx, ranking in enumerate(rankings): while positions[ranking_idx] < len(ranking): cand = ranking[positions[ranking_idx]] positions[ranking_idx] += 1 if cand["key"] in selected: continue selected[cand["key"]] = cand added = True break if len(selected) >= limit: break if not added: break kept_rows = sorted(kept.values(), key=lambda c: (_candidate_perf(c), -c.get("price", 0)), reverse=True) return kept_rows + list(selected.values()) def _frontier_with_keep(cands, dims, keep_keys): deduped = _dedupe_candidates(cands) kept = [c for c in deduped if c["key"] in keep_keys] rest = [c for c in deduped if c["key"] not in keep_keys] return kept + _pareto_candidates(rest, dims) def _current_market_specs(accounts: list[AccountInput]): specs = [] for acc in accounts: if acc.sword: specs.append({"account": acc.name, "slot": "sword", "type": "sword", "name": acc.sword.name, "level": acc.sword.level, "flame": acc.sword.flame_enchant, "looting": acc.sword.looting_enchant}) if acc.shield: specs.append({"account": acc.name, "slot": "shield", "type": "shield", "name": acc.shield.name, "level": acc.shield.level, "flame": 0, "looting": acc.shield.looting_enchant}) for idx, pet in enumerate(acc.pets or []): if pet: specs.append({"account": acc.name, "slot": f"pet {idx+1}", "type": "pet", "name": pet, "level": 0, "flame": 0, "looting": 0}) return specs def _candidate_from_spec(spec, price_info=None, owned_fallback=False): price_info = price_info or {} price = price_info.get("median") if price is None and not owned_fallback: return None cand = { "type": spec["type"], "name": spec["name"], "level": 0 if spec["type"] in ("pet", "ore") else int(spec.get("level", 1) or 1), "flame": int(spec.get("flame", 0) or 0), "looting": int(spec.get("looting", 0) or 0), "price": float(price or 0.0), "price_fmt": price_info.get("median_fmt") or _price_fmt(price or 0.0), "confidence": price_info.get("confidence", "OWNED" if owned_fallback else "LOW"), "samples": _safe_int(price_info.get("samples", 0)), "source": price_info.get("source", "owned"), } try: if cand["type"] == "sword" and cand["name"] in SCHWERTER: cand["ds"] = resolve_ds(cand["name"], cand["level"]) elif cand["type"] == "shield" and SCHILDE.get(cand["name"]): cand["ms"] = resolve_ms(cand["name"], cand["level"]) elif cand["type"] == "pet" and cand["name"] in PETS: cand["mp"] = PETS[cand["name"]] else: return None except Exception: return None cand["key"] = _market_spec_key(cand) return cand def _http_list_items(item_type: str, fallback: list[str]) -> list[str]: try: resp = _requests.get(f"{PRICE_API_BASE}/api/list/{item_type}", timeout=3.0) if resp.status_code != 200: return fallback data = resp.json() except Exception: return fallback raw_items = data if isinstance(data, dict): raw_items = data.get("items") or data.get(item_type) or data.get("data") or [] names = [] for item in raw_items if isinstance(raw_items, list) else []: if isinstance(item, str): names.append(item) elif isinstance(item, dict): name = item.get("name") or item.get("item_name") if name: names.append(str(name)) allowed = set(names) return [name for name in fallback if name in allowed] or fallback def _http_market_candidates(current_specs): try: _requests.get(f"{PRICE_API_BASE}/api/stats", timeout=2.0) except Exception: pass listed_swords = _http_list_items("sword", list(SCHWERTER.keys())) listed_shields = _http_list_items("shield", [name for name, vals in SCHILDE.items() if vals]) listed_pets = _http_list_items("pet", list(PETS.keys())) top_swords = sorted(listed_swords, key=lambda n: resolve_ds(n, 3) if n in SCHWERTER else 0, reverse=True)[:6] top_shields = sorted(listed_shields, key=lambda n: resolve_ms(n, 3) if SCHILDE.get(n) else 0, reverse=True)[:6] top_pets = sorted(listed_pets, key=lambda n: PETS.get(n, 0), reverse=True)[:10] sword_names = list(dict.fromkeys([s["name"] for s in current_specs if s["type"] == "sword"] + top_swords)) shield_names = list(dict.fromkeys([s["name"] for s in current_specs if s["type"] == "shield"] + top_shields)) pet_names = list(dict.fromkeys([s["name"] for s in current_specs if s["type"] == "pet"] + top_pets)) specs = [] sword_enchants = ( (0, 0), (1, 0), (2, 0), (3, 0), (0, 1), (0, 2), (0, 3), (1, 1), (2, 1), (3, 1), (1, 2), (2, 2), (3, 2), (3, 3), ) for name in sword_names: levels = tuple(dict.fromkeys([1, 3] + [ int(s["level"]) for s in current_specs if s["type"] == "sword" and s["name"] == name ])) for level in levels: for flame, looting in sword_enchants: specs.append({"type": "sword", "name": name, "level": level, "flame": flame, "looting": looting}) for name in shield_names: if not SCHILDE.get(name): continue levels = tuple(dict.fromkeys([1, 3] + [ int(s["level"]) for s in current_specs if s["type"] == "shield" and s["name"] == name ])) for level in levels: for looting in (0, 1, 2, 3): specs.append({"type": "shield", "name": name, "level": level, "flame": 0, "looting": looting}) for name in pet_names: specs.append({"type": "pet", "name": name, "level": 0, "flame": 0, "looting": 0}) found = [] with ThreadPoolExecutor(max_workers=24) as executor: futures = {executor.submit(_market_price_http, spec): spec for spec in specs} for future in as_completed(futures): info = future.result() if info: cand = _candidate_from_spec(futures[future], info) if cand: found.append(cand) return ( [c for c in found if c["type"] == "sword"], [c for c in found if c["type"] == "shield"], [c for c in found if c["type"] == "pet"], "price api sampled", ) def _spec_override_key(type_, name, level, flame=0, looting=0): """Canonical string key for a specific item variant, matched between the frontend override chips and the backend candidate/owned specs.""" lvl = 0 if str(type_) in ("pet", "ore") else int(level or 0) return "|".join([str(type_ or ""), str(name or "").strip().lower(), str(lvl), str(int(flame or 0)), str(int(looting or 0))]) def _spec_override_key_from(spec): return _spec_override_key(spec.get("type"), spec.get("name"), spec.get("level", 0), spec.get("flame", 0), spec.get("looting", 0)) def _normalize_price_overrides(price_overrides): """[{type,name,level,flame,looting,price}] -> {override_key: float_price}.""" out = {} for row in (price_overrides or []): try: price = float(row.get("price")) except (TypeError, ValueError): continue if price < 0 or not math.isfinite(price): continue out[_spec_override_key(row.get("type"), row.get("name"), row.get("level", 0), row.get("flame", 0), row.get("looting", 0))] = price return out def _normalize_exclusions(exclude_items): """Exclusion rules with a scope: - "item": whole item by name (every level & enchant) - "level": this name at this level, any enchants - "exact": this exact variant (name+level+flame+looting) Accepts legacy plain strings (treated as whole-item).""" rules = [] for e in (exclude_items or []): if isinstance(e, str): n = e.strip().lower() if n: rules.append({"scope": "item", "name": n}) continue if not isinstance(e, dict): continue name = str(e.get("name", "")).strip().lower() if not name: continue scope = str(e.get("scope", "item")).strip().lower() if scope not in ("item", "level", "exact"): scope = "item" rule = {"scope": scope, "name": name} if scope in ("level", "exact"): rule["level"] = int(e.get("level", 0) or 0) if scope == "exact": rule["flame"] = int(e.get("flame", 0) or 0) rule["looting"] = int(e.get("looting", 0) or 0) rules.append(rule) return rules def _candidate_matches_exclusion(cand, rule): if str(cand.get("name", "")).strip().lower() != rule["name"]: return False if rule["scope"] == "item": return True lvl = 0 if cand.get("type") in ("pet", "ore") else int(cand.get("level", 0) or 0) if int(rule.get("level", 0)) != lvl: return False if rule["scope"] == "level": return True return (int(cand.get("flame", 0) or 0) == int(rule.get("flame", 0)) and int(cand.get("looting", 0) or 0) == int(rule.get("looting", 0))) def _apply_market_corrections(candidates, exclude_items, overrides): """Return new candidate lists with excluded items dropped (per scope) and price overrides applied. Never mutates the input dicts (cache-shared).""" rules = _normalize_exclusions(exclude_items) def fix(rows): out = [] for cand in rows: if any(_candidate_matches_exclusion(cand, r) for r in rules): continue ov = overrides.get(_spec_override_key_from(cand)) if overrides else None if ov is not None: cand = {**cand, "price": ov, "price_fmt": _price_fmt(ov), "price_overridden": True} out.append(cand) return out return fix(candidates["swords"]), fix(candidates["shields"]), fix(candidates["pets"]) def _build_market_candidates(accounts: list[AccountInput], exclude_items=None, price_overrides=None): warnings = [] overrides = _normalize_price_overrides(price_overrides) current_specs = _current_market_specs(accounts) idx = _load_price_index() source = "local price snapshot" if idx else "price api" swords, shields, pets = [], [], [] if idx: stamp = _PRICE_INDEX_CACHE.get("stamp") cached = _MARKET_BASE_CANDIDATE_CACHE.get("data") if _MARKET_BASE_CANDIDATE_CACHE.get("stamp") == stamp else None if cached: swords, shields, pets = list(cached["swords"]), list(cached["shields"]), list(cached["pets"]) else: merged = dict(idx["estimated"]) merged.update(idx["prices"]) for key, raw in merged.items(): src = "history" if key in idx["prices"] else "estimated" cand = _candidate_from_price_key(key, raw, src) if not cand: continue if cand["type"] == "sword": swords.append(cand) elif cand["type"] == "shield": shields.append(cand) elif cand["type"] == "pet": pets.append(cand) _MARKET_BASE_CANDIDATE_CACHE.update({ "stamp": stamp, "data": {"swords": list(swords), "shields": list(shields), "pets": list(pets)}, }) else: swords, shields, pets, source = _http_market_candidates(current_specs) owned_items = [] sell_value = 0.0 for spec in current_specs: info = _market_price(spec, index=idx) ov = overrides.get(_spec_override_key_from(spec)) if overrides else None if ov is not None: sell_value += ov owned_items.append({**spec, "price": ov, "price_fmt": _price_fmt(ov), "confidence": "OVERRIDE", "price_overridden": True}) elif info and info.get("median") is not None: sell_value += float(info["median"]) owned_items.append({**spec, "price": float(info["median"]), "price_fmt": info.get("median_fmt", _price_fmt(info["median"])), "confidence": info.get("confidence", "LOW")}) else: warnings.append(f"No power price found for {_market_label(spec)}; sell value counted as 0.") owned_items.append({**spec, "price": 0.0, "price_fmt": "N/A", "confidence": "UNKNOWN"}) cand = _candidate_from_spec(spec, info, owned_fallback=True) if cand: if cand["type"] == "sword": swords.append(cand) elif cand["type"] == "shield": shields.append(cand) elif cand["type"] == "pet": pets.append(cand) # #7: drop excluded item names and force overridden prices BEFORE the # frontier/cap so the selection can never rebuild the excluded/mispriced item. swords, shields, pets = _apply_market_corrections( {"swords": swords, "shields": shields, "pets": pets}, exclude_items, overrides) current_keys = {_market_spec_key(s) for s in current_specs} swords = _cap_candidates(_frontier_with_keep(swords, ["ds", "flame", "looting"], current_keys), 48, current_keys) shields = _cap_candidates(_frontier_with_keep(shields, ["ms", "looting"], current_keys), 24, current_keys) pets = _cap_candidates(_frontier_with_keep(pets, ["mp"], current_keys), 12, current_keys) return { "swords": swords, "shields": shields, "pets": pets, "owned_items": owned_items, "sell_value": sell_value, "warnings": warnings, "source": source, } def _current_pet_triple_keys(accounts: list[AccountInput]): keys = set() for acc in accounts: pet_specs = [ {"type": "pet", "name": p, "level": 0, "flame": 0, "looting": 0} for p in (acc.pets or []) if p ] if len(pet_specs) >= 3: keys.add(tuple(sorted(_market_spec_key(p) for p in pet_specs[:3]))) return keys def _pet_triples(pets, keep_keys=None): keep_keys = keep_keys or set() triples = [] for combo in itertools.combinations_with_replacement(pets, 3): names = [p["name"] for p in combo] cost = sum(p["price"] for p in combo) mp = sum(p["mp"] for p in combo) triples.append({ "type": "pet_triple", "names": names, "items": list(combo), "mp": mp, "price": cost, "price_fmt": _price_fmt(cost), "key": tuple(sorted(_market_spec_key(p) for p in combo)), }) kept_raw = [row for row in triples if row["key"] in keep_keys] triples = kept_raw + _pareto_candidates([row for row in triples if row["key"] not in keep_keys], ["mp"]) return _cap_candidates(triples, 30, keep_keys) def _loadout_level_ok(loadout, acc): """Equippable only if the account's level reaches the highest sword/shield minLevel (items come from level-gated worlds you must be able to enter). Unknown level -> allow.""" lvl = _account_level(acc) if lvl is None: return True return int(loadout.get("min_level", 0) or 0) <= int(lvl) def _build_loadouts(swords, shields, pet_sets): loadouts = [] for sw in swords: sw_ml = SWORD_MIN_LEVEL.get(sw.get("name"), 0) for sh in shields: ml = max(sw_ml, SHIELD_MIN_LEVEL.get(sh.get("name"), 0)) for pt in pet_sets: cost = sw["price"] + sh["price"] + pt["price"] loadouts.append({ "sword": sw, "shield": sh, "pets": pt, "cost": cost, "ds": sw["ds"], "ms": sh["ms"], "mp": pt["mp"], "flame": sw["flame"], "looting": sw["looting"] + sh["looting"], "key": (sw["key"], sh["key"], pt["key"]), "min_level": ml, }) return loadouts def _current_market_loadouts(accounts, loadouts): by_key = {row["key"]: row for row in loadouts} selections = [] for acc in accounts: if not acc.sword or not acc.shield or len(acc.pets or []) < 3: return None sword_key = _market_spec_key({ "type": "sword", "name": acc.sword.name, "level": acc.sword.level, "flame": acc.sword.flame_enchant, "looting": acc.sword.looting_enchant, }) shield_key = _market_spec_key({ "type": "shield", "name": acc.shield.name, "level": acc.shield.level, "flame": 0, "looting": acc.shield.looting_enchant, }) pet_key = tuple(sorted(_market_spec_key({ "type": "pet", "name": pet, "level": 0, "flame": 0, "looting": 0, }) for pet in acc.pets[:3])) loadout = by_key.get((sword_key, shield_key, pet_key)) if not loadout: return None selections.append(loadout) return selections def _market_solo_value(loadout, acc: AccountInput, power: float, cfg: dict): if not _loadout_level_ok(loadout, acc): return -1e30 # can't equip -> never suggest it for this account ap = resolve_ap(acc.armour) sim_acc = [{ "ds": loadout["ds"], "power": max(power, 0.0), "ms": loadout["ms"], "mp": loadout["mp"], "ap": ap, "hours": acc.online_hours, "lm": loot_mult(loadout["looting"]), "flame": loadout["flame"], "level": _account_level(acc), }] return sim_fast(sim_acc, item_pv=cfg["item_pv"], boss_hp=cfg["boss_hp"], hps=cfg["hps"], respawn=cfg["respawn"], sword_item_pv=cfg.get("sword_item_pv"), shield_item_pv=cfg.get("shield_item_pv"), sword_drop_chance=cfg.get("sword_drop_chance", 0.01), shield_drop_chance=cfg.get("shield_drop_chance", 0.01))["value"] def _market_fast_score(loadout, acc: AccountInput, power: float, cfg: dict, ap: float | None = None): if not _loadout_level_ok(loadout, acc): return -1e30 # can't equip -> never suggest it for this account ap = resolve_ap(acc.armour) if ap is None else ap if cfg.get("boss_hp") is not None and cfg.get("respawn") is not None: return sim_fast([{ "ds": loadout["ds"], "power": max(power, 0.0), "ms": loadout["ms"], "mp": loadout["mp"], "ap": ap, "hours": acc.online_hours, "lm": loot_mult(loadout["looting"]), "flame": loadout["flame"], "level": _account_level(acc), }], item_pv=cfg.get("item_pv", ITEM_PV), boss_hp=cfg.get("boss_hp", BOSS_HP), hps=cfg.get("hps", HPS), respawn=cfg.get("respawn", RESPAWN), sword_item_pv=cfg.get("sword_item_pv"), shield_item_pv=cfg.get("shield_item_pv"), sword_drop_chance=cfg.get("sword_drop_chance", 0.01), shield_drop_chance=cfg.get("shield_drop_chance", 0.01))["value"] hit = dmg_hit(loadout["ds"], max(power, 0.0), loadout["ms"]) hps = hits_per_second_for_level(_account_level(acc), cfg["hps"]) flame_dps = flame_tick_dmg(loadout["ds"], max(power, 0.0), loadout["ms"], loadout["flame"]) return (hit * hps + flame_dps) * (1.0 + loadout["mp"] + ap) * acc.online_hours def _market_entries(accounts, selections): entries = [] for acc, loadout in zip(accounts, selections): entries.append({ "ds": loadout["ds"], "power": 0.0, "ms": loadout["ms"], "mp": loadout["mp"], "ap": resolve_ap(acc.armour), "hours": acc.online_hours, "lm": loot_mult(loadout["looting"]), "flame": loadout["flame"], "level": _account_level(acc), }) return entries def _market_state_value(accounts, selections, remaining_power, cfg): entries = _market_entries(accounts, selections) return _fast_room_value(entries, max(remaining_power, 0.0), item_pv=cfg["item_pv"], boss_hp=cfg["boss_hp"], hps=cfg["hps"], respawn=cfg["respawn"], quick=True, sword_item_pv=cfg.get("sword_item_pv"), shield_item_pv=cfg.get("shield_item_pv"), sword_drop_chance=cfg.get("sword_drop_chance", 0.01), shield_drop_chance=cfg.get("shield_drop_chance", 0.01), room_capacity=cfg.get("room_capacity", MAX_ROOM)) def _market_precise_state_value(accounts, selections, remaining_power, cfg): entries = _market_entries(accounts, selections) _rooms, value = optimize_rooms( entries, max(remaining_power, 0.0), item_pv=cfg["item_pv"], boss_hp=cfg["boss_hp"], hps=cfg["hps"], respawn=cfg["respawn"], sword_item_pv=cfg.get("sword_item_pv"), shield_item_pv=cfg.get("shield_item_pv"), sword_drop_chance=cfg.get("sword_drop_chance", 0.01), shield_drop_chance=cfg.get("shield_drop_chance", 0.01), room_capacity=cfg.get("room_capacity", MAX_ROOM), optimize_power=True, refine_power=True ) return value def _market_precise_partition(accounts, selections, remaining_power, cfg): entries = _market_entries(accounts, selections) for idx, entry in enumerate(entries): entry["_market_idx"] = idx rooms, value = optimize_rooms( entries, max(remaining_power, 0.0), item_pv=cfg["item_pv"], boss_hp=cfg["boss_hp"], hps=cfg["hps"], respawn=cfg["respawn"], sword_item_pv=cfg.get("sword_item_pv"), shield_item_pv=cfg.get("shield_item_pv"), sword_drop_chance=cfg.get("sword_drop_chance", 0.01), shield_drop_chance=cfg.get("shield_drop_chance", 0.01), room_capacity=cfg.get("room_capacity", MAX_ROOM), optimize_power=True, refine_power=True ) total_power = sum(float(entry.get("power", 0.0) or 0.0) for room in rooms for entry in room) shares = [0.0] * len(entries) if total_power > 0: for room in rooms: for entry in room: shares[int(entry["_market_idx"])] = float(entry.get("power", 0.0) or 0.0) / total_power return [[int(entry["_market_idx"]) for entry in room] for room in rooms], shares, value def _market_partition_state_value(accounts, selections, remaining_power, cfg, partition, power_shares=None): entries = _market_entries(accounts, selections) if power_shares and len(power_shares) == len(entries): for idx, entry in enumerate(entries): entry["power"] = max(remaining_power, 0.0) * max(float(power_shares[idx]), 0.0) groups = [[entries[idx] for idx in group] for group in partition] return _eval_room_groups( groups, max(remaining_power, 0.0), item_pv=cfg["item_pv"], boss_hp=cfg["boss_hp"], hps=cfg["hps"], respawn=cfg["respawn"], sword_item_pv=cfg.get("sword_item_pv"), shield_item_pv=cfg.get("shield_item_pv"), sword_drop_chance=cfg.get("sword_drop_chance", 0.01), shield_drop_chance=cfg.get("shield_drop_chance", 0.01), refine_power=False, preserve_power=bool(power_shares), ) def _exact_market_select(accounts, loadout_lists, liquid_power, cfg, max_states=250000): total_states = 1 for rows in loadout_lists: total_states *= max(len(rows), 1) if total_states > max_states: return None best = None best_val = -1.0 for selections in itertools.product(*loadout_lists): cost = sum(row["cost"] for row in selections) if cost > liquid_power: continue remaining = max(liquid_power - cost, 0.0) val = _market_state_value(accounts, selections, remaining, cfg) if val > best_val: best_val = val best = {"cost": cost, "selections": list(selections), "fast_value": val, "search_mode": "exact"} return best def _diverse_market_beam(states, width, liquid_power): """Keep strong partial plans across the budget range so later accounts can complement them.""" ranked = sorted(states, key=lambda state: state["rank"], reverse=True) if len(ranked) <= width: return ranked chosen = [] seen = set() def add(state): signature = tuple(id(row) for row in state["selections"]) if signature not in seen and len(chosen) < width: seen.add(signature) chosen.append(state) for state in ranked[:max(1, int(width * 0.55))]: add(state) bucket_count = min(24, max(6, width // 4)) buckets = [[] for _ in range(bucket_count)] for state in states: ratio = min(max(float(state["cost"]) / max(float(liquid_power), 1.0), 0.0), 1.0) bucket_idx = min(int(ratio * bucket_count), bucket_count - 1) buckets[bucket_idx].append(state) for bucket in buckets: if bucket: add(max(bucket, key=lambda state: state["rank"])) add(max(bucket, key=lambda state: state["base"])) for state in sorted(states, key=lambda state: state["base"], reverse=True)[:max(1, width // 10)]: add(state) for state in sorted(states, key=lambda state: state["cost"], reverse=True)[:max(1, width // 12)]: add(state) for state in ranked: add(state) return chosen def _select_market_loadouts(accounts, loadouts, liquid_power, cfg, quick=False, initial_selections=None): n = len(accounts) if n == 0 or not loadouts: return None affordable_all = [row for row in loadouts if row["cost"] <= liquid_power] if not affordable_all: return None if n == 1: feasible = [row for row in affordable_all if _loadout_level_ok(row, accounts[0])] pool = feasible or affordable_all # fallback (never empty: current gear is always feasible) return _exact_market_select(accounts, [pool], liquid_power, cfg, max_states=len(pool) + 1) avg_power = max(liquid_power / max(n, 1), 0.0) shortlists = [] for acc_idx, acc in enumerate(accounts): approx = [] ap = resolve_ap(acc.armour) for loadout in loadouts: if loadout["cost"] > liquid_power: continue if not _loadout_level_ok(loadout, acc): continue est_power = max(avg_power - loadout["cost"] * 0.35, 0.0) raw = _market_fast_score(loadout, acc, est_power, cfg, ap=ap) eff = raw / max(loadout["cost"], avg_power * 0.02, 1.0) approx.append({"loadout": loadout, "raw": raw, "eff": eff}) by_key = {} raw_take = 44 if quick else 90 eff_take = 44 if quick else 90 cheap_take = 18 if quick else 35 for row in sorted(approx, key=lambda r: r["raw"], reverse=True)[:raw_take]: by_key[id(row["loadout"])] = row for row in sorted(approx, key=lambda r: r["eff"], reverse=True)[:eff_take]: by_key[id(row["loadout"])] = row for row in sorted(approx, key=lambda r: r["loadout"]["cost"])[:cheap_take]: by_key[id(row["loadout"])] = row if initial_selections and acc_idx < len(initial_selections): loadout = initial_selections[acc_idx] if loadout["cost"] <= liquid_power: by_key[id(loadout)] = {"loadout": loadout} shortlist = [] for row in by_key.values(): loadout = row["loadout"] est_power = max(avg_power - loadout["cost"] * 0.35, 0.0) raw = _market_solo_value(loadout, acc, est_power, cfg) shortlist.append({ "loadout": loadout, "raw": raw, "eff": raw / max(loadout["cost"], avg_power * 0.02, 1.0), }) if not shortlist: return None shortlists.append(shortlist) exact_limit = 70000 if quick else 250000 exact = _exact_market_select(accounts, [[r["loadout"] for r in rows] for rows in shortlists], liquid_power, cfg, max_states=exact_limit) if exact: if quick: exact["search_mode"] = "quick-exact" return exact beam = [{"cost": 0.0, "selections": [], "base": 0.0, "rank": 0.0}] beam_width = (55 if n <= 8 else 40) if quick else (145 if n <= 8 else 100) for shortlist in shortlists: new_beam = [] for state in beam: for row in shortlist: loadout = row["loadout"] cost = state["cost"] + loadout["cost"] if cost > liquid_power: continue base = state["base"] + row["raw"] remaining = max(liquid_power - cost, 0.0) power_factor = 0.2 + 0.8 * math.sqrt(remaining / max(liquid_power, 1.0)) new_beam.append({ "cost": cost, "selections": state["selections"] + [loadout], "base": base, "rank": base * power_factor, }) if not new_beam: return None beam = _diverse_market_beam(new_beam, beam_width, liquid_power) best = None best_val = -1.0 evaluated_states = [] eval_states = 18 if quick else (35 if n > 8 else 60) for state in _diverse_market_beam(beam, eval_states, liquid_power): remaining = max(liquid_power - state["cost"], 0.0) val = _market_state_value(accounts, state["selections"], remaining, cfg) evaluated_states.append((val, state)) if val > best_val: best_val = val best = state if initial_selections and len(initial_selections) == n: initial_cost = sum(row["cost"] for row in initial_selections) if initial_cost <= liquid_power: initial_value = _market_state_value( accounts, initial_selections, max(liquid_power - initial_cost, 0.0), cfg ) if initial_value > best_val: best_val = initial_value best = {"cost": initial_cost, "selections": list(initial_selections), "fast_value": initial_value, "search_mode": "current+local"} if best: best["fast_value"] = best_val best["search_mode"] = "quick-beam" if quick else "beam" local_limit = 28 if n > 8 else None local_pools = [[row["loadout"] for row in rows[:local_limit]] if local_limit else [row["loadout"] for row in rows] for rows in shortlists] improved = True passes = 0 max_passes = 1 if quick else (2 if n > 8 else 3) while improved and passes < max_passes: improved = False passes += 1 for idx, pool in enumerate(local_pools): current = best["selections"][idx] other_cost = best["cost"] - current["cost"] for alt in pool: if alt["key"] == current["key"]: continue cost = other_cost + alt["cost"] if cost > liquid_power: continue trial = list(best["selections"]) trial[idx] = alt remaining = max(liquid_power - cost, 0.0) val = _market_state_value(accounts, trial, remaining, cfg) if val > best["fast_value"] * 1.0000001: best = {"cost": cost, "selections": trial, "fast_value": val, "search_mode": "quick-beam+local" if quick else "beam+local"} improved = True current = alt other_cost = cost - current["cost"] if not quick: precise_candidates = [(best["fast_value"], best)] precise_candidates.extend(sorted(evaluated_states, key=lambda row: row[0], reverse=True)) if initial_selections and len(initial_selections) == n: initial_cost = sum(row["cost"] for row in initial_selections) if initial_cost <= liquid_power: precise_candidates.append((0.0, { "cost": initial_cost, "selections": list(initial_selections), "search_mode": "current", })) precise_best = None seen_signatures = set() precise_limit = 4 if n > 8 else 7 for _approx_value, state in precise_candidates: signature = _market_signature(state["selections"]) if signature in seen_signatures: continue seen_signatures.add(signature) try: remaining = max(liquid_power - state["cost"], 0.0) precise_value = _market_precise_state_value( accounts, state["selections"], remaining, cfg ) except Exception: continue if precise_best is None or precise_value > precise_best["precise_value"]: precise_best = { "cost": state["cost"], "selections": list(state["selections"]), "fast_value": _market_state_value( accounts, state["selections"], remaining, cfg ), "precise_value": precise_value, "search_mode": f'{state.get("search_mode", "beam")}+room-rerank', } if len(seen_signatures) >= precise_limit: break if precise_best: best = precise_best return best def _polish_market_selection(accounts, loadouts, liquid_power, cfg, selected): """Pull cheap multi-account improvements from the future-step pool into the current plan.""" if not selected: return selected current = selected for _pass in range(2): pool = {row["key"]: row for row in _market_step_pool(loadouts)} for row in current["selections"]: pool[row["key"]] = row polished = _select_market_loadouts( accounts, list(pool.values()), liquid_power, cfg, quick=True, initial_selections=current["selections"], ) if not polished: break old_remaining = max(liquid_power - current["cost"], 0.0) new_remaining = max(liquid_power - polished["cost"], 0.0) try: old_value = _market_precise_state_value(accounts, current["selections"], old_remaining, cfg) new_value = _market_precise_state_value(accounts, polished["selections"], new_remaining, cfg) except Exception: break if new_value <= old_value * 1.000000001: break polished["search_mode"] = f'{current.get("search_mode", "beam")}+polish' polished["precise_value"] = new_value current = polished return current def _market_component_key(loadout, component): part = loadout.get(component) return part.get("key") if isinstance(part, dict) else None def _market_refinement_context(accounts, loadouts, liquid_power, cfg): by_sword_shield = {} by_sword_pets = {} by_shield_pets = {} for row in loadouts: sword_key = _market_component_key(row, "sword") shield_key = _market_component_key(row, "shield") pet_key = _market_component_key(row, "pets") if sword_key is None or shield_key is None or pet_key is None: continue by_sword_shield.setdefault((sword_key, shield_key), []).append(row) by_sword_pets.setdefault((sword_key, pet_key), []).append(row) by_shield_pets.setdefault((shield_key, pet_key), []).append(row) avg_power = max(liquid_power / max(len(accounts), 1), 0.0) score_cfg = { "item_pv": cfg.get("item_pv", ITEM_PV), "sword_item_pv": cfg.get("sword_item_pv"), "shield_item_pv": cfg.get("shield_item_pv"), "sword_drop_chance": cfg.get("sword_drop_chance", 0.01), "shield_drop_chance": cfg.get("shield_drop_chance", 0.01), "hps": cfg.get("hps", HPS), "boss_hp": cfg.get("boss_hp", BOSS_HP), "respawn": cfg.get("respawn", RESPAWN), } combined = [] for acc in accounts: ranked = [] ap = resolve_ap(acc.armour) for row in loadouts: if row["cost"] > liquid_power: continue est_power = max(avg_power - row["cost"] * 0.35, 0.0) raw = _market_fast_score(row, acc, est_power, score_cfg, ap=ap) ranked.append((raw, raw / max(row["cost"], avg_power * 0.02, 1.0), row)) selected = {} for rank_idx in (0, 1): for entry in sorted(ranked, key=lambda item: item[rank_idx], reverse=True)[:45]: selected[entry[2]["key"]] = entry[2] for _raw, _eff, row in sorted(ranked, key=lambda item: item[2]["cost"])[:20]: selected[row["key"]] = row combined.append(list(selected.values())) return { "by_sword_shield": by_sword_shield, "by_sword_pets": by_sword_pets, "by_shield_pets": by_shield_pets, "combined": combined, } def _market_refinement_pools(accounts, loadouts, liquid_power, cfg, selections, context=None): """Build broad per-account pools while always retaining every one-component replacement.""" context = context or _market_refinement_context(accounts, loadouts, liquid_power, cfg) pools = [] for acc_idx, current in enumerate(selections): selected = {current["key"]: current} sword_key = _market_component_key(current, "sword") shield_key = _market_component_key(current, "shield") pet_key = _market_component_key(current, "pets") for index, key in ( (context["by_sword_shield"], (sword_key, shield_key)), (context["by_sword_pets"], (sword_key, pet_key)), (context["by_shield_pets"], (shield_key, pet_key)), ): for row in index.get(key, []): if row["cost"] <= liquid_power: selected[row["key"]] = row for row in context["combined"][acc_idx]: selected[row["key"]] = row acc = accounts[acc_idx] # drop anything this account can't equip (current gear stays) pools.append([row for row in selected.values() if _loadout_level_ok(row, acc)]) return pools def _market_precise_cleanup(accounts, loadouts, liquid_power, cfg, selected, candidate_limit=14, refinement_context=None): """Precisely challenge the best nearby sword, shield and pet alternatives.""" if not selected or not selected.get("selections"): return selected rows = list(selected["selections"]) cost = sum(row["cost"] for row in rows) try: partition, shares, precise_value = _market_precise_partition( accounts, rows, max(liquid_power - cost, 0.0), cfg ) except Exception: return selected pools = _market_refinement_pools( accounts, loadouts, liquid_power, cfg, rows, refinement_context ) current_proxy = _market_partition_state_value( accounts, rows, max(liquid_power - cost, 0.0), cfg, partition, shares ) challenges = [] for idx, pool in enumerate(pools): current = rows[idx] other_cost = cost - current["cost"] by_component = {"sword": [], "shield": [], "pets": []} for alt in pool: changed = [ component for component in ("sword", "shield", "pets") if _market_component_key(alt, component) != _market_component_key(current, component) ] if len(changed) != 1: continue trial_cost = other_cost + alt["cost"] if trial_cost > liquid_power: continue trial = list(rows) trial[idx] = alt proxy = _market_partition_state_value( accounts, trial, max(liquid_power - trial_cost, 0.0), cfg, partition, shares, ) by_component[changed[0]].append((proxy, trial_cost, trial)) for component, candidates in by_component.items(): if candidates: proxy, trial_cost, trial = max(candidates, key=lambda row: row[0]) challenges.append((proxy, trial_cost, trial, component)) best = { **selected, "cost": cost, "selections": rows, "precise_value": precise_value, } # Near-ties are where analytic power allocation is most likely to mis-rank a change. quotas = {"sword": 4, "shield": 6, "pets": 4} selected_challenges = [] for component, quota in quotas.items(): component_rows = [row for row in challenges if row[3] == component] component_rows.sort(key=lambda row: row[0] - current_proxy, reverse=True) selected_challenges.extend(component_rows[:quota]) selected_challenges.sort(key=lambda row: row[0] - current_proxy, reverse=True) for _proxy, trial_cost, trial, _component in selected_challenges[:candidate_limit]: try: value = _market_precise_state_value( accounts, trial, max(liquid_power - trial_cost, 0.0), cfg ) except Exception: continue if value > best["precise_value"] * 1.000000001: best = { "cost": trial_cost, "selections": trial, "fast_value": _market_state_value( accounts, trial, max(liquid_power - trial_cost, 0.0), cfg ), "precise_value": value, "search_mode": f'{selected.get("search_mode", "beam")}+precise-cleanup', } return best def _refine_market_selection(accounts, loadouts, liquid_power, cfg, selected, max_passes=3): """ Deterministic best-response refinement after the broad market search. The beam search finds coordinated multi-account plans. This phase then removes its shortlist blind spots by checking every sword-only, shield-only and pet-only change, plus strong combined replacements, against the complete room-aware objective. """ if not selected or not selected.get("selections"): return selected current = list(selected["selections"]) if len(current) != len(accounts): return selected required = ("cost", "ds", "ms", "mp", "flame", "looting", "key") if any(any(key not in row for key in required) for row in current): return selected refinement_context = _market_refinement_context(accounts, loadouts, liquid_power, cfg) fast_cache = {} def state_cost(rows): return sum(row["cost"] for row in rows) partition = None power_shares = None if all(key in cfg for key in ("item_pv", "boss_hp", "respawn", "hps")): try: current_cost = state_cost(current) partition, power_shares, _partition_value = _market_precise_partition( accounts, current, max(liquid_power - current_cost, 0.0), cfg ) except Exception: partition = None power_shares = None def fast_value(rows, cost=None): signature = (_market_signature(rows), tuple(tuple(group) for group in partition or [])) if signature not in fast_cache: resolved_cost = state_cost(rows) if cost is None else cost remaining = max(liquid_power - resolved_cost, 0.0) if partition: fast_cache[signature] = _market_partition_state_value( accounts, rows, remaining, cfg, partition, power_shares ) else: fast_cache[signature] = _market_state_value(accounts, rows, remaining, cfg) return fast_cache[signature] current_cost = state_cost(current) current_fast = fast_value(current, current_cost) candidate_states = { _market_signature(current): { "cost": current_cost, "selections": list(current), "fast_value": current_fast, } } for _pass in range(max_passes): pools = _market_refinement_pools( accounts, loadouts, liquid_power, cfg, current, refinement_context ) changed = False for idx, pool in enumerate(pools): old = current[idx] other_cost = current_cost - old["cost"] best_alt = old best_cost = current_cost best_fast = current_fast for alt in pool: if alt["key"] == old["key"]: continue cost = other_cost + alt["cost"] if cost > liquid_power: continue trial = list(current) trial[idx] = alt value = fast_value(trial, cost) if value > best_fast * 1.000000001: best_alt, best_cost, best_fast = alt, cost, value if best_alt["key"] == old["key"]: continue current[idx] = best_alt current_cost = best_cost current_fast = best_fast changed = True if changed: candidate_states[_market_signature(current)] = { "cost": current_cost, "selections": list(current), "fast_value": current_fast, } if not changed: break # Fast scoring is only a search guide. The original and each completed pass/state # are finally compared with refined power allocation and room assignment. original_signature = _market_signature(selected["selections"]) candidate_states[original_signature] = { "cost": selected["cost"], "selections": list(selected["selections"]), "fast_value": selected.get("fast_value", fast_value(selected["selections"], selected["cost"])), } precise_best = None for state in candidate_states.values(): try: precise = _market_precise_state_value( accounts, state["selections"], max(liquid_power - state["cost"], 0.0), cfg, ) except Exception: continue if precise_best is None or precise > precise_best["precise_value"] * 1.000000001: precise_best = { **state, "precise_value": precise, "search_mode": f'{selected.get("search_mode", "beam")}+deterministic-refine', } precise_best = precise_best or selected if all(key in cfg for key in ("item_pv", "boss_hp", "respawn", "hps")): precise_best = _market_precise_cleanup( accounts, loadouts, liquid_power, cfg, precise_best, refinement_context=refinement_context, ) return precise_best def _loadout_label(loadout): return ( f'{_market_label(loadout["sword"])} + ' f'{_market_label(loadout["shield"])} + ' f'{", ".join(loadout["pets"]["names"])}' ) def _final_accounts_from_market(accounts, selections, remaining_power): n = max(len(accounts), 1) each_power = max(remaining_power, 0.0) / n out = [] for acc, loadout in zip(accounts, selections): sw = loadout["sword"] sh = loadout["shield"] out.append(AccountInput( name=acc.name, power=f"{each_power:.12g}", online_hours=acc.online_hours, level=acc.level, experience=acc.experience, total_experience=acc.total_experience, total_power=acc.total_power, sword=SwordInput(name=sw["name"], level=sw["level"], flame_enchant=sw["flame"], looting_enchant=sw["looting"]), shield=ShieldInput(name=sh["name"], level=sh["level"], looting_enchant=sh["looting"]), pets=list(loadout["pets"]["names"]), armour=acc.armour, )) return out def _account_items_from_loadout(account_name, loadout): items = [ {"account": account_name, "slot": "sword", **loadout["sword"]}, {"account": account_name, "slot": "shield", **loadout["shield"]}, ] for idx, pet in enumerate(loadout["pets"]["items"]): items.append({"account": account_name, "slot": f"pet {idx+1}", **pet}) return items def _market_item_identity(item): """Clean identity fields for a buy/sell suggestion so the frontend can build exclusion/override keys without parsing the display label.""" return { "name": item.get("name", ""), "item_type": item.get("type", ""), "level": 0 if item.get("type") in ("pet", "ore") else int(item.get("level", 0) or 0), "flame": int(item.get("flame", 0) or 0), "looting": int(item.get("looting", 0) or 0), "overridden": bool(item.get("price_overridden")), } def _market_actions(accounts, selections, owned_items): from collections import Counter, defaultdict owned_by_acc = defaultdict(list) for item in owned_items: owned_by_acc[item["account"]].append(item) actions = [] for acc, loadout in zip(accounts, selections): old_items = owned_by_acc.get(acc.name, []) target_items = _account_items_from_loadout(acc.name, loadout) old_counter = Counter(_market_spec_key(i) for i in old_items) target_counter = Counter(_market_spec_key(i) for i in target_items) keep_keys = old_counter & target_counter sell, buy = [], [] keep_left = Counter(keep_keys) for item in old_items: key = _market_spec_key(item) if keep_left[key] > 0: keep_left[key] -= 1 else: sell.append({"label": _market_label(item), "price": item.get("price", 0.0), "price_fmt": item.get("price_fmt", _price_fmt(item.get("price", 0.0))), **_market_item_identity(item)}) buy_left = target_counter - old_counter for item in target_items: key = _market_spec_key(item) if buy_left[key] > 0: buy.append({"label": _market_label(item), "price": item.get("price", 0.0), "price_fmt": item.get("price_fmt", _price_fmt(item.get("price", 0.0))), **_market_item_identity(item)}) buy_left[key] -= 1 sell_total = sum(i["price"] for i in sell) buy_total = sum(i["price"] for i in buy) if not sell and not buy: continue actions.append({ "account": acc.name, "sell": sell, "buy": buy, "sell_total": sell_total, "sell_total_fmt": _price_fmt(sell_total), "buy_total": buy_total, "buy_total_fmt": _price_fmt(buy_total), "net_power": sell_total - buy_total, "net_power_fmt": _price_fmt(abs(sell_total - buy_total)), }) return actions def _market_keep_actions(accounts, owned_items): return [] def _market_loadout_perf(loadout): hps = hits_per_second_for_level(None, HPS) hit = dmg_hit(loadout["ds"], 0.0, loadout["ms"]) flame_dps = flame_tick_dmg(loadout["ds"], 0.0, loadout["ms"], loadout["flame"]) return ( (hit * hps + flame_dps) * (1.0 + loadout["mp"]) ) def _market_step_pool(loadouts): if len(loadouts) <= 320: return loadouts selected = {} by_cost = sorted(loadouts, key=lambda l: l["cost"]) stride = max(len(by_cost) // 100, 1) pools = [ by_cost[:60], by_cost[::stride][:110], sorted(loadouts, key=_market_loadout_perf, reverse=True)[:110], sorted(loadouts, key=lambda l: _market_loadout_perf(l) / max(l["cost"], 1.0), reverse=True)[:110], ] for pool in pools: for row in pool: selected[id(row)] = row return list(selected.values()) def _market_signature(selections): return tuple(row["key"] for row in selections) def _market_change_text(accounts, old_selections, new_selections): chunks = [] for acc, old, new in zip(accounts, old_selections, new_selections): changes = [] if old["sword"]["key"] != new["sword"]["key"]: changes.append(f'sword to {_market_label(new["sword"])}') if old["shield"]["key"] != new["shield"]["key"]: changes.append(f'shield to {_market_label(new["shield"])}') if old["pets"]["key"] != new["pets"]["key"]: changes.append(f'pets to {", ".join(new["pets"]["names"])}') if changes: chunks.append(f'{acc.name}: ' + ", ".join(changes)) return "; ".join(chunks) def _select_for_total_power(accounts, loadouts, total_power, sell_value, cfg, quick=False, initial_selections=None): liquid = max(total_power, 0.0) + max(sell_value, 0.0) return _select_market_loadouts( accounts, loadouts, liquid, cfg, quick=quick, initial_selections=initial_selections ) def _market_future_probe_totals(base_power, sell_value, min_total, last_cost, loadouts, account_count): n = max(int(account_count or 1), 1) min_total = max(float(min_total or 0.0), 0.0) candidates = [] positive_costs = sorted({ float(row.get("cost", 0.0)) for row in loadouts if row.get("cost", 0.0) and math.isfinite(float(row.get("cost", 0.0))) }) seed = max(min_total, 1.0) if positive_costs: cheapest_full_team = max(positive_costs[0] * n - sell_value, positive_costs[0] - sell_value, 1.0) if min_total <= 0: seed = cheapest_full_team else: seed = max(seed, min(cheapest_full_team, min_total * 2.0)) for mult in (1.005, 1.02, 1.05, 1.10, 1.20, 1.40, 1.70, 2.20, 3.00, 4.50, 7.00, 11.00, 18.00, 30.00, 55.00, 100.00, 180.00, 320.00, 560.00, 1000.00, 1800.00, 3200.00): candidates.append(seed * mult) if positive_costs: idxs = {0, len(positive_costs) - 1} for q in (0.03, 0.08, 0.15, 0.25, 0.40, 0.60, 0.80, 0.93): idxs.add(min(len(positive_costs) - 1, max(0, int(len(positive_costs) * q)))) avg_current = last_cost / n for idx in idxs: cost = positive_costs[idx] candidates.append(cost - sell_value) candidates.append(cost * n - sell_value) candidates.append(last_cost - avg_current + cost - sell_value) for cost in positive_costs: needed = cost * n - sell_value if needed > min_total * 1.001: candidates.append(needed) break ceiling = max(positive_costs[-1] * n - sell_value, seed * 2.0) scan = max(seed * 1.01, min_total * 1.002, 1.0) while scan < ceiling * 1.02 and len(candidates) < 140: candidates.append(scan) scan *= 1.38 floor = min_total * 1.001 out = [] for raw in sorted(candidates): if not math.isfinite(raw): continue total = max(float(raw), 0.0) if total <= floor: continue if out and total <= out[-1] * 1.003: continue out.append(total) if len(out) >= 60: break return out def _market_breakpoint_candidate_totals(last_total, sell_value, last_cost, last_selections, loadouts, account_count, limit=240): n = max(int(account_count or 1), 1) floor = float(last_total) + max(abs(float(last_total)) * 1e-9, 1.0) candidates = [] def add(raw): try: total = float(raw) except Exception: return if math.isfinite(total) and total > floor: candidates.append(max(total, 0.0)) for mult in (1.001, 1.0025, 1.005, 1.01, 1.02, 1.035, 1.05, 1.08, 1.12, 1.18, 1.25, 1.35, 1.50, 1.75, 2.00, 2.50, 3.00, 4.00, 6.00, 9.00, 14.00, 22.00, 35.00): add(max(float(last_total), 1.0) * mult) current_costs = [float(row.get("cost", 0.0)) for row in last_selections] current_desc = sorted(current_costs, reverse=True) current_asc = sorted(current_costs) prefix_desc = [0.0] prefix_asc = [0.0] for cost in current_desc: prefix_desc.append(prefix_desc[-1] + cost) for cost in current_asc: prefix_asc.append(prefix_asc[-1] + cost) for current in last_selections: base_without_current = last_cost - float(current.get("cost", 0.0)) for alt in loadouts: if alt["key"] == current["key"]: continue add(base_without_current + float(alt.get("cost", 0.0)) - sell_value) ks = set(range(2, min(n, 6) + 1)) ks.add(n) for alt in loadouts: alt_cost = float(alt.get("cost", 0.0)) for k in sorted(ks): if k > n: continue add(last_cost - prefix_desc[k] + alt_cost * k - sell_value) add(last_cost - prefix_asc[k] + alt_cost * k - sell_value) positive_costs = sorted({ float(row.get("cost", 0.0)) for row in loadouts if row.get("cost", 0.0) and math.isfinite(float(row.get("cost", 0.0))) }) if positive_costs: idxs = {0, len(positive_costs) - 1} for q in (0.02, 0.05, 0.10, 0.18, 0.28, 0.40, 0.55, 0.72, 0.88, 0.96): idxs.add(min(len(positive_costs) - 1, max(0, int(len(positive_costs) * q)))) for idx in idxs: cost = positive_costs[idx] add(cost - sell_value) add(cost * n - sell_value) deduped = [] for total in sorted(candidates): if total <= floor: continue if deduped and total <= deduped[-1] * 1.0000005: continue deduped.append(total) if len(deduped) <= limit: return deduped head_count = max(40, int(limit * 0.65)) keep = set(range(min(head_count, len(deduped)))) tail_slots = max(limit - len(keep), 1) span = len(deduped) - 1 for i in range(1, tail_slots + 1): keep.add(min(span, int(round(span * i / tail_slots)))) return [deduped[i] for i in sorted(keep)[:limit]] def _market_next_steps_legacy(accounts, selections, loadouts, base_power, sell_value, buy_cost, cfg): last_total = max(base_power, 0.0) last_cost = buy_cost last_selections = list(selections) step_by_key = {row["key"]: row for row in _market_step_pool(loadouts)} for row in selections: step_by_key[row["key"]] = row step_loadouts = list(step_by_key.values()) out = [] deadline = time.perf_counter() + (15.0 if len(accounts) > 5 else 6.0) def min_step_gap(total): return max(abs(float(total)) * 1e-9, 1.0) def selection_is_better(threshold, new_cost, new_selections, precise=False): if _market_signature(new_selections) == _market_signature(last_selections): return False new_remaining = max(threshold + sell_value - new_cost, 0.0) old_remaining = max(threshold + sell_value - last_cost, 0.0) value_fn = _market_precise_state_value if precise else _market_state_value new_value = value_fn(accounts, new_selections, new_remaining, cfg) old_value = value_fn(accounts, last_selections, old_remaining, cfg) return new_value > old_value * 1.0001 def append_step(threshold, new_cost, new_selections): nonlocal last_total, last_cost, last_selections threshold = max(float(threshold), last_total) if threshold <= last_total + min_step_gap(last_total): return False if not selection_is_better(threshold, new_cost, new_selections): return False if not selection_is_better(threshold, new_cost, new_selections, precise=True): return False lower = max(last_total, new_cost - sell_value) upper = threshold for _ in range(10): mid = (lower + upper) / 2.0 if mid <= lower + min_step_gap(lower): break if selection_is_better(mid, new_cost, new_selections): upper = mid else: lower = mid precise_upper = threshold precise_lower = lower if selection_is_better(upper, new_cost, new_selections, precise=True): precise_upper = upper else: precise_lower = upper for _ in range(2): mid = (precise_lower + precise_upper) / 2.0 if mid <= precise_lower + min_step_gap(precise_lower): break if selection_is_better(mid, new_cost, new_selections, precise=True): precise_upper = mid else: precise_lower = mid threshold = precise_upper merge_with_previous = bool( out and threshold <= out[-1]["_threshold_raw"] + max(abs(out[-1]["_threshold_raw"]) * 1e-6, 1.0) ) from_selections = out[-1]["_from_selections"] if merge_with_previous else list(last_selections) change_text = _market_change_text(accounts, from_selections, new_selections) if not change_text: return False extra = max(threshold - base_power, 0.0) rendered = { "threshold": _price_fmt(threshold), "extra_power": _price_fmt(extra), "text": f'When you gain {_price_fmt(extra)} more power (total power {_price_fmt(threshold)}), {change_text}.', "_threshold_raw": threshold, "_from_selections": from_selections, } if merge_with_previous: out[-1] = rendered else: out.append(rendered) last_total = threshold last_cost = new_cost last_selections = list(new_selections) return True while len(out) < 3: if out and time.perf_counter() > deadline: break if out and len(accounts) > 5 and time.perf_counter() > deadline - 1.0: break candidates = _market_breakpoint_candidate_totals( last_total, sell_value, last_cost, last_selections, step_loadouts, len(accounts), limit=260 if len(accounts) <= 5 else 180 ) if not candidates: break found = False local_ideas = [] indexed_current = list(enumerate(last_selections)) by_low_perf = sorted(indexed_current, key=lambda row: _market_loadout_perf(row[1])) by_high_cost = sorted(indexed_current, key=lambda row: float(row[1].get("cost", 0.0)), reverse=True) bundle_sizes = set(range(2, min(len(accounts), 5) + 1)) bundle_sizes.add(len(accounts)) for idx, current in indexed_current: current_perf = _market_loadout_perf(current) for alt in step_loadouts: if alt["key"] == current["key"]: continue perf_gain = _market_loadout_perf(alt) - current_perf if perf_gain <= 0 and alt["cost"] >= current["cost"]: continue new_cost = last_cost - current["cost"] + alt["cost"] threshold = max(last_total, new_cost - sell_value) if threshold <= last_total + min_step_gap(last_total): continue trial = list(last_selections) trial[idx] = alt local_ideas.append((threshold, -perf_gain, new_cost, trial)) for alt in step_loadouts: alt_perf = _market_loadout_perf(alt) for ordered in (by_low_perf, by_high_cost): replaceable = [(idx, cur) for idx, cur in ordered if cur["key"] != alt["key"]] for k in sorted(bundle_sizes): if k <= 1 or k > len(replaceable): continue chosen = replaceable[:k] old_cost = sum(float(cur.get("cost", 0.0)) for _idx, cur in chosen) perf_gain = sum(alt_perf - _market_loadout_perf(cur) for _idx, cur in chosen) new_cost = last_cost - old_cost + float(alt.get("cost", 0.0)) * k threshold = max(last_total, new_cost - sell_value) if threshold <= last_total + min_step_gap(last_total): continue trial = list(last_selections) for idx, _cur in chosen: trial[idx] = alt local_ideas.append((threshold, -perf_gain, new_cost, trial)) local_ideas.sort(key=lambda row: (row[0], row[1])) local_evals = 220 if len(accounts) <= 3 else (150 if len(accounts) <= 5 else 35) for threshold, _neg_gain, new_cost, trial in local_ideas[:local_evals]: if append_step(threshold, new_cost, trial): found = True break if found: continue select_budget = 6 full_retry_cap = 0 if len(accounts) <= 1 else (4 if not out else 1) select_calls = 0 full_retries = 0 checked = {} def check_idx(idx): nonlocal select_calls, full_retries if idx in checked: return checked[idx] if select_calls >= select_budget: return None threshold = candidates[idx] select_calls += 1 selected = _select_for_total_power( accounts, step_loadouts, threshold, sell_value, cfg, quick=True, initial_selections=last_selections ) ok = bool(selected and selection_is_better(threshold, selected["cost"], selected["selections"])) retry_floor = last_total * (1.015 if len(accounts) > 5 else 1.0) if (not ok and selected and _market_signature(selected["selections"]) != _market_signature(last_selections) and threshold >= retry_floor and full_retries < full_retry_cap and select_calls < select_budget): full_retries += 1 select_calls += 1 confirmed = _select_for_total_power( accounts, step_loadouts, threshold, sell_value, cfg, quick=False, initial_selections=last_selections ) if confirmed and selection_is_better(threshold, confirmed["cost"], confirmed["selections"]): selected = confirmed ok = True checked[idx] = (ok, selected) return checked[idx] low_idx = -1 high_idx = None last_idx = len(candidates) - 1 probe_indices = [] for factor in (1.01, 1.08, 1.20, 1.50, 2.00, 3.00): target = max(last_total, 1.0) * factor idx = min(range(len(candidates)), key=lambda row_idx: abs(candidates[row_idx] - target)) if idx not in probe_indices: probe_indices.append(idx) for idx in probe_indices: if select_calls >= select_budget: break checked_row = check_idx(idx) if checked_row and checked_row[0]: high_idx = idx break low_idx = idx if high_idx is not None: best_idx = high_idx if len(accounts) <= 5: left = low_idx + 1 right = high_idx while left <= right and select_calls < select_budget: mid = (left + right) // 2 checked_row = check_idx(mid) if checked_row and checked_row[0]: best_idx = mid right = mid - 1 else: left = mid + 1 checked_row = check_idx(best_idx) if checked_row and checked_row[0]: best_threshold = candidates[best_idx] best_selected = checked_row[1] lower = candidates[best_idx - 1] if best_idx > 0 else last_total if len(accounts) <= 5: for _ in range(5): if select_calls >= select_budget: break mid = (lower + best_threshold) / 2.0 if mid <= lower + min_step_gap(lower): break select_calls += 1 mid_selected = _select_for_total_power( accounts, step_loadouts, mid, sell_value, cfg, quick=True, initial_selections=last_selections ) if mid_selected and selection_is_better(mid, mid_selected["cost"], mid_selected["selections"]): best_threshold = mid best_selected = mid_selected else: lower = mid if len(accounts) <= 5: confirmed = _select_for_total_power( accounts, step_loadouts, best_threshold, sell_value, cfg, quick=False, initial_selections=last_selections ) if confirmed and selection_is_better(best_threshold, confirmed["cost"], confirmed["selections"]): best_selected = confirmed if append_step(best_threshold, best_selected["cost"], best_selected["selections"]): found = True if not found: break if not out: out.append({ "threshold": _price_fmt(base_power), "extra_power": _price_fmt(0), "text": "No further gear breakpoint was found in the priced market candidates. Keep the optimized setup and put new gains into power until daily prices change." }) for step in out: step.pop("_threshold_raw", None) step.pop("_from_selections", None) return out def _market_single_component_trials(selections, loadouts): by_sword_shield = {} by_sword_pets = {} by_shield_pets = {} for row in loadouts: sword_key = _market_component_key(row, "sword") shield_key = _market_component_key(row, "shield") pet_key = _market_component_key(row, "pets") if sword_key is None or shield_key is None or pet_key is None: continue by_sword_shield.setdefault((sword_key, shield_key), []).append(row) by_sword_pets.setdefault((sword_key, pet_key), []).append(row) by_shield_pets.setdefault((shield_key, pet_key), []).append(row) trials = [] seen = set() for idx, current in enumerate(selections): sword_key = _market_component_key(current, "sword") shield_key = _market_component_key(current, "shield") pet_key = _market_component_key(current, "pets") pools = ( ("pets", by_sword_shield.get((sword_key, shield_key), [])), ("shield", by_sword_pets.get((sword_key, pet_key), [])), ("sword", by_shield_pets.get((shield_key, pet_key), [])), ) for component, pool in pools: for alt in pool: if alt["key"] == current["key"]: continue signature = (idx, alt["key"]) if signature in seen: continue seen.add(signature) trial = list(selections) trial[idx] = alt trials.append({ "account_idx": idx, "component": component, "loadout": alt, "selections": trial, "cost": sum(row["cost"] for row in trial), }) return trials def _market_breakpoint_grid(last_total, affordability, ceiling): gap = max(abs(float(last_total)) * 1e-10, 1e-9) start = max(float(last_total) + gap, float(affordability), 1.0) values = [start] for factor in ( 1.000001, 1.00001, 1.0001, 1.0005, 1.001, 1.0025, 1.005, 1.01, 1.02, 1.04, 1.08, 1.15, 1.30, 1.60, 2.0, 3.0, 5.0, 10.0, 20.0, 50.0, 100.0, 250.0, 500.0, 1000.0, ): values.append(max(float(last_total), 1.0) * factor) values.append(start * factor) result = [] for value in sorted(values): if value < start or value > ceiling: continue if result and value <= result[-1] * 1.0000001: continue result.append(value) if not result or result[-1] < ceiling: result.append(ceiling) return result def _market_find_proxy_breakpoint(accounts, old_selections, old_cost, trial, last_total, sell_value, cfg, partition, power_shares, ceiling, deadline, old_cache=None): new_selections = trial["selections"] new_cost = trial["cost"] affordability = max(new_cost - sell_value, 0.0) minimum = max(float(last_total), affordability) old_cache = old_cache if old_cache is not None else {} def value(rows, cost, total): remaining = max(total + sell_value - cost, 0.0) return _market_partition_state_value( accounts, rows, remaining, cfg, partition, power_shares ) def better(total): if time.perf_counter() > deadline: return False old_value = old_cache.get(total) if old_value is None: old_value = value(old_selections, old_cost, total) old_cache[total] = old_value return value(new_selections, new_cost, total) > old_value * 1.000000001 lower = float(last_total) upper = None for probe in _market_breakpoint_grid(last_total, affordability, ceiling): if better(probe): upper = probe break lower = max(lower, probe) if upper is None: return None lower = max(float(last_total), min(lower, upper)) for _ in range(24): if time.perf_counter() > deadline: break mid = (lower + upper) / 2.0 if mid <= lower + max(abs(lower) * 1e-10, 1e-9): break if mid < minimum: lower = mid elif better(mid): upper = mid else: lower = mid return upper def _market_find_score_breakpoint(accounts, old_selections, old_cost, trial, last_total, sell_value, cfg, power_shares, ceiling, deadline): new_selections = trial["selections"] new_cost = trial["cost"] affordability = max(new_cost - sell_value, 0.0) ap_values = [resolve_ap(acc.armour) for acc in accounts] score_cfg = { "item_pv": cfg.get("item_pv", ITEM_PV), "sword_item_pv": cfg.get("sword_item_pv"), "shield_item_pv": cfg.get("shield_item_pv"), "sword_drop_chance": cfg.get("sword_drop_chance", 0.01), "shield_drop_chance": cfg.get("shield_drop_chance", 0.01), "hps": cfg.get("hps", HPS), "boss_hp": cfg.get("boss_hp", BOSS_HP), "respawn": cfg.get("respawn", RESPAWN), } def score(rows, cost, total): remaining = max(total + sell_value - cost, 0.0) return sum( _market_fast_score( row, accounts[idx], remaining * power_shares[idx], score_cfg, ap=ap_values[idx], ) for idx, row in enumerate(rows) ) def better(total): return ( score(new_selections, new_cost, total) > score(old_selections, old_cost, total) * 1.000000001 ) lower = float(last_total) upper = None for probe in _market_breakpoint_grid(last_total, affordability, ceiling): if time.perf_counter() > deadline: return None if better(probe): upper = probe break lower = max(lower, probe) if upper is None: return None for _ in range(18): mid = (lower + upper) / 2.0 if mid <= lower + max(abs(lower) * 1e-10, 1e-9): break if mid < affordability: lower = mid elif better(mid): upper = mid else: lower = mid return upper def _market_confirm_breakpoint(accounts, old_selections, old_cost, trial, proxy_threshold, last_total, sell_value, cfg, ceiling, deadline): new_selections = trial["selections"] new_cost = trial["cost"] affordability = max(new_cost - sell_value, 0.0) lower = max(float(last_total), affordability) upper = max(float(proxy_threshold), lower + max(abs(lower) * 1e-9, 1e-9)) def better(total, precise=False): value_fn = _market_precise_state_value if precise else _market_state_value old_value = value_fn( accounts, old_selections, max(total + sell_value - old_cost, 0.0), cfg ) new_value = value_fn( accounts, new_selections, max(total + sell_value - new_cost, 0.0), cfg ) return new_value > old_value * 1.000000001 while upper < ceiling and not better(upper): if time.perf_counter() > deadline: return None lower = upper upper = min(ceiling, max(upper * 1.08, upper + max(abs(upper) * 0.01, 1e-9))) if not better(upper): return None for _ in range(10): if time.perf_counter() > deadline: break mid = (lower + upper) / 2.0 if mid <= lower + max(abs(lower) * 1e-10, 1e-9): break if better(mid): upper = mid else: lower = mid precise_upper = min( ceiling, max(upper * 1.005, upper + max(abs(upper) * 0.001, 1e-9)), ) attempts = 0 precise_ok = better(precise_upper, precise=True) while precise_upper < ceiling and not precise_ok: if time.perf_counter() > deadline: return None attempts += 1 if attempts >= 4: return None precise_upper = min( ceiling, max(precise_upper * 1.01, precise_upper + max(abs(precise_upper) * 0.002, 1e-9)), ) precise_ok = better(precise_upper, precise=True) if not precise_ok: return None return precise_upper def _market_find_selection_scan_breakpoint(accounts, old_selections, old_cost, last_total, sell_value, loadouts, cfg, deadline): old_signature = _market_signature(old_selections) def selected_is_better(total, selected, precise=False): if not selected or _market_signature(selected["selections"]) == old_signature: return False value_fn = _market_precise_state_value if precise else _market_state_value old_value = value_fn( accounts, old_selections, max(total + sell_value - old_cost, 0.0), cfg ) new_value = value_fn( accounts, selected["selections"], max(total + sell_value - selected["cost"], 0.0), cfg ) return new_value > old_value * 1.000000001 candidates = _market_future_probe_totals( last_total, sell_value, last_total, old_cost, loadouts, len(accounts) ) lower = float(last_total) upper = None upper_selected = None for total in candidates: if time.perf_counter() > deadline: return None selected = _select_for_total_power( accounts, loadouts, total, sell_value, cfg, quick=True, initial_selections=old_selections, ) if selected_is_better(total, selected): upper = float(total) upper_selected = selected break lower = float(total) if upper is None or upper_selected is None: return None for _ in range(12): if time.perf_counter() > deadline: break mid = (lower + upper) / 2.0 if mid <= lower + max(abs(lower) * 1e-9, 1.0): break selected = _select_for_total_power( accounts, loadouts, mid, sell_value, cfg, quick=True, initial_selections=old_selections, ) if selected_is_better(mid, selected): upper = mid upper_selected = selected else: lower = mid if time.perf_counter() > deadline: return None confirmed = _select_for_total_power( accounts, loadouts, upper, sell_value, cfg, quick=False, initial_selections=old_selections, ) if selected_is_better(upper, confirmed, precise=True): upper_selected = confirmed elif not selected_is_better(upper, upper_selected, precise=True): return None return upper, upper_selected def _market_next_steps(accounts, selections, loadouts, base_power, sell_value, buy_cost, cfg, deadline_seconds: Optional[float] = None): if not accounts or not selections: return [] last_total = max(float(base_power), 0.0) last_cost = float(buy_cost) last_selections = list(selections) previous_signature = None out = [] default_deadline = 18.0 if len(accounts) > 5 else 9.0 deadline_span = default_deadline if deadline_seconds is not None: try: deadline_span = max(1.0, min(float(deadline_seconds), 90.0)) except Exception: deadline_span = default_deadline extended = deadline_span > default_deadline + 0.5 deadline = time.perf_counter() + deadline_span max_loadout_cost = max((float(row.get("cost", 0.0)) for row in loadouts), default=0.0) while len(out) < 3 and time.perf_counter() < deadline: remaining = max(last_total + sell_value - last_cost, 0.0) try: partition, shares, _value = _market_precise_partition( accounts, last_selections, remaining, cfg ) except Exception: break trials = _market_single_component_trials(last_selections, loadouts) if previous_signature is not None: trials = [ trial for trial in trials if _market_signature(trial["selections"]) != previous_signature ] if not trials: break ceiling = max( max(last_total, 1.0) * 1000.0, max_loadout_cost * max(len(accounts), 1) - sell_value, max((trial["cost"] for trial in trials), default=0.0) - sell_value, 1.0, ) score_candidates = [] for trial in trials: if time.perf_counter() > deadline: break threshold = _market_find_score_breakpoint( accounts, last_selections, last_cost, trial, last_total, sell_value, cfg, shares, ceiling, deadline, ) if threshold is not None: score_candidates.append((threshold, trial)) if not score_candidates: break score_candidates.sort(key=lambda row: row[0]) proxy_candidates = [] old_proxy_cache = {} proxy_limit = (180 if len(accounts) > 5 else 260) if extended else (64 if len(accounts) > 5 else 120) for _score_threshold, trial in score_candidates[:proxy_limit]: if time.perf_counter() > deadline: break threshold = _market_find_proxy_breakpoint( accounts, last_selections, last_cost, trial, last_total, sell_value, cfg, partition, shares, ceiling, deadline, old_cache=old_proxy_cache, ) if threshold is not None: proxy_candidates.append((threshold, trial)) if not proxy_candidates: break proxy_candidates.sort(key=lambda row: row[0]) confirmed = [] confirm_limit = (34 if len(accounts) > 5 else 48) if extended else (10 if len(accounts) > 5 else 18) for proxy_threshold, trial in proxy_candidates[:confirm_limit]: if time.perf_counter() > deadline: break threshold = _market_confirm_breakpoint( accounts, last_selections, last_cost, trial, proxy_threshold, last_total, sell_value, cfg, ceiling, deadline, ) if threshold is not None: confirmed.append((threshold, trial)) if not confirmed: fallback = ( _market_find_selection_scan_breakpoint( accounts, last_selections, last_cost, last_total, sell_value, loadouts, cfg, deadline, ) if out and extended else None ) if fallback is None: break threshold, winner = fallback else: threshold, winner = min(confirmed, key=lambda row: row[0]) change_text = _market_change_text( accounts, last_selections, winner["selections"] ) if not change_text: break extra = max(threshold - base_power, 0.0) out.append({ "threshold": _price_fmt(threshold), "extra_power": _price_fmt(extra), "text": ( f'When you gain {_price_fmt(extra)} more power ' f'(total power {_price_fmt(threshold)}), {change_text}.' ), }) previous_signature = _market_signature(last_selections) last_total = threshold last_cost = winner["cost"] last_selections = list(winner["selections"]) return out def _normal_optimizer_result(data: MarketOptimizerInput): return optimize_accounts(OptimizerInput( accounts=data.accounts, extra_sword_pool=data.extra_sword_pool, extra_shield_pool=data.extra_shield_pool, extra_pet_pool=data.extra_pet_pool, optimize_gear=data.optimize_gear, optimize_pets=data.optimize_pets, optimize_power=data.optimize_power, optimize_rooms=data.optimize_rooms, item_pv=data.item_pv, sword_item_pv=data.sword_item_pv, shield_item_pv=data.shield_item_pv, sword_drop_chance=data.sword_drop_chance, shield_drop_chance=data.shield_drop_chance, room_capacity=data.room_capacity, boss_hp=data.boss_hp, respawn=data.respawn, hps=data.hps, enemy_type=data.enemy_type, )) def _result_value_num(result: dict) -> float: try: return parse_float(str(result.get("total_value_per_day", "0"))) except Exception: return 0.0 def _market_unavailable_result(data: MarketOptimizerInput, reason: str): fallback = _normal_optimizer_result(data) fallback["market"] = {"available": False, "reason": reason} return fallback def _run_market_optimizer(data: MarketOptimizerInput, calculate_breakpoints: Optional[bool] = None, breakpoint_deadline_seconds: Optional[float] = None): if not data.accounts: return _market_unavailable_result(data, "No accounts entered.") _sword_item_pv, _shield_item_pv = _resolved_drop_values(data) _item_pv = (_sword_item_pv + _shield_item_pv) / 2.0 _sword_drop_chance, _shield_drop_chance = _resolved_drop_chances(data) _room_capacity = _resolved_room_capacity(data) _boss_hp = _cfg_positive(data.boss_hp, BOSS_HP) _respawn = _cfg_nonnegative(data.respawn, RESPAWN) _hps = _cfg_nonnegative(data.hps, HPS) cfg = {"item_pv": _item_pv, "sword_item_pv": _sword_item_pv, "shield_item_pv": _shield_item_pv, "sword_drop_chance": _sword_drop_chance, "shield_drop_chance": _shield_drop_chance, "room_capacity": _room_capacity, "boss_hp": _boss_hp, "respawn": _respawn, "hps": _hps, "enemy_type": data.enemy_type} base_power = sum(parse_float(a.power) for a in data.accounts) candidates = _build_market_candidates(data.accounts, data.exclude_items, data.price_overrides) liquid_power = base_power + candidates["sell_value"] if liquid_power <= 0: return _market_unavailable_result(data, "No usable power budget found.") if not candidates["swords"] or not candidates["shields"] or not candidates["pets"]: return _market_unavailable_result(data, "Not enough priced sword, shield, or pet candidates.") pet_sets = _pet_triples(candidates["pets"], _current_pet_triple_keys(data.accounts)) loadouts = _build_loadouts(candidates["swords"], candidates["shields"], pet_sets) if len(data.accounts) == 1: # Single account: restrict the whole search to gear it can actually equip (items # come from level-gated worlds). Covers every downstream path at once. Current gear # is always feasible, so the list never ends up empty. _feasible = [lo for lo in loadouts if _loadout_level_ok(lo, data.accounts[0])] if _feasible: loadouts = _feasible if not loadouts: return _market_unavailable_result(data, "No market loadouts could be built from price data.") current_selections = _current_market_loadouts(data.accounts, loadouts) selected = _select_market_loadouts( data.accounts, loadouts, liquid_power, cfg, initial_selections=current_selections ) if not selected: return _market_unavailable_result(data, "No affordable market setup found.") selected = _polish_market_selection(data.accounts, loadouts, liquid_power, cfg, selected) selected = _refine_market_selection(data.accounts, loadouts, liquid_power, cfg, selected) buy_cost = selected["cost"] remaining_power = max(liquid_power - buy_cost, 0.0) final_accounts = _final_accounts_from_market(data.accounts, selected["selections"], remaining_power) final_result = optimize_accounts(OptimizerInput( accounts=final_accounts, optimize_gear=False, optimize_pets=False, optimize_power=True, optimize_rooms=data.optimize_rooms, item_pv=data.item_pv, sword_item_pv=_sword_item_pv, shield_item_pv=_shield_item_pv, sword_drop_chance=_sword_drop_chance, shield_drop_chance=_shield_drop_chance, room_capacity=_room_capacity, boss_hp=data.boss_hp, respawn=data.respawn, hps=data.hps, enemy_type=data.enemy_type, )) actions = _market_actions(data.accounts, selected["selections"], candidates["owned_items"]) warnings = candidates["warnings"][:8] if len(candidates["warnings"]) > 8: warnings.append(f"{len(candidates['warnings']) - 8} more items had no usable power price.") if _sword_item_pv + _shield_item_pv <= 2.0: warnings.append("Drop value is zero/near-zero, so looting only survives if current market prices make that item cheaper than the non-looting alternative.") should_calculate_breakpoints = data.calculate_breakpoints if calculate_breakpoints is None else bool(calculate_breakpoints) next_steps = [] if should_calculate_breakpoints: next_steps = _market_next_steps( data.accounts, selected["selections"], loadouts, base_power, candidates["sell_value"], buy_cost, cfg, deadline_seconds=breakpoint_deadline_seconds, ) market_info = { "available": True, "objective": "total value per day", "price_source": candidates["source"], "base_power": base_power, "base_power_fmt": _price_fmt(base_power), "sell_value": candidates["sell_value"], "sell_value_fmt": _price_fmt(candidates["sell_value"]), "total_liquid_power": liquid_power, "total_liquid_power_fmt": _price_fmt(liquid_power), "buy_cost": buy_cost, "buy_cost_fmt": _price_fmt(buy_cost), "remaining_power": remaining_power, "remaining_power_fmt": _price_fmt(remaining_power), "candidate_counts": { "swords": len(candidates["swords"]), "shields": len(candidates["shields"]), "pets": len(candidates["pets"]), "loadouts": len(loadouts), }, "search_mode": selected.get("search_mode", "beam"), "actions": actions, "next_steps": next_steps, "breakpoints_pending": not should_calculate_breakpoints, "warnings": warnings, } final_result["market"] = market_info baseline_result = _normal_optimizer_result(data) baseline_value = _result_value_num(baseline_result) market_value = _result_value_num(final_result) if baseline_value > market_value * 1.002: market_info.update({ "note": "Current owned setup has a higher total value than the best priced market plan found. Do not buy anything right now.", "buy_cost": 0.0, "buy_cost_fmt": _price_fmt(0.0), "remaining_power": base_power, "remaining_power_fmt": _price_fmt(base_power), "actions": _market_keep_actions(data.accounts, candidates["owned_items"]), "next_steps": ( _market_next_steps( data.accounts, current_selections, loadouts, base_power, candidates["sell_value"], sum(row["cost"] for row in current_selections), cfg, deadline_seconds=breakpoint_deadline_seconds, ) if should_calculate_breakpoints and current_selections else [] ), "breakpoints_pending": not should_calculate_breakpoints, }) market_info["warnings"] = [ f"Market plan was rejected because it tested lower ({final_result['total_value_per_day']}) than current setup ({baseline_result['total_value_per_day']})." ] + warnings baseline_result["market"] = market_info return baseline_result return final_result # ───────────────────────────────────────────────────────────────── # /optimize-accounts # ───────────────────────────────────────────────────────────────── @app.post("/optimize-accounts") def optimize_accounts(data: OptimizerInput): n = len(data.accounts) accs_s = sorted(data.accounts, key=lambda x: x.online_hours, reverse=True) total_power = sum(parse_float(a.power) for a in accs_s) _sword_item_pv, _shield_item_pv = _resolved_drop_values(data) _item_pv = (_sword_item_pv + _shield_item_pv) / 2.0 _sword_drop_chance, _shield_drop_chance = _resolved_drop_chances(data) _room_capacity = _resolved_room_capacity(data) _boss_hp = _cfg_positive(data.boss_hp, BOSS_HP) _respawn = _cfg_nonnegative(data.respawn, RESPAWN) _hps = _cfg_nonnegative(data.hps, HPS) def _fallback_current_setup(): return optimize_accounts(OptimizerInput( accounts=data.accounts, extra_sword_pool=data.extra_sword_pool, extra_shield_pool=data.extra_shield_pool, extra_pet_pool=data.extra_pet_pool, optimize_gear=False, optimize_pets=False, optimize_power=data.optimize_power, optimize_rooms=data.optimize_rooms, item_pv=data.item_pv, sword_item_pv=_sword_item_pv, shield_item_pv=_shield_item_pv, sword_drop_chance=_sword_drop_chance, shield_drop_chance=_shield_drop_chance, room_capacity=_room_capacity, boss_hp=data.boss_hp, respawn=data.respawn, hps=data.hps, enemy_type=data.enemy_type, )) if data.optimize_gear: sw_pool = [] for i, acc in enumerate(accs_s): if acc.sword: sw_pool.append({"id": f"acc_sw_{i}", "name": acc.sword.name, "level": acc.sword.level, "ds": resolve_ds(acc.sword.name, acc.sword.level), "loot": acc.sword.looting_enchant, "flame": acc.sword.flame_enchant}) for i, s in enumerate(data.extra_sword_pool or []): sw_pool.append({"id": f"ext_sw_{i}", "name": s.name, "level": s.level, "ds": resolve_ds(s.name, s.level), "loot": s.looting_enchant, "flame": s.flame_enchant}) if not sw_pool: return _fallback_current_setup() sh_pool = [] for i, acc in enumerate(accs_s): if acc.shield and SCHILDE.get(acc.shield.name): sh_pool.append({"id": f"acc_sh_{i}", "name": acc.shield.name, "level": acc.shield.level, "ms": resolve_ms(acc.shield.name, acc.shield.level), "loot": acc.shield.looting_enchant}) for i, s in enumerate(data.extra_shield_pool or []): if SCHILDE.get(s.name): sh_pool.append({"id": f"ext_sh_{i}", "name": s.name, "level": s.level, "ms": resolve_ms(s.name, s.level), "loot": s.looting_enchant}) if not sh_pool: return _fallback_current_setup() if data.optimize_pets: pet_pool_raw = [] for acc in accs_s: pet_pool_raw.extend(acc.pets or []) pet_pool_raw.extend(data.extra_pet_pool or []) for p in pet_pool_raw: if p not in PETS: raise HTTPException(400, f"Unbekanntes Pet: {p!r}") if len(pet_pool_raw) < n * 3: return _fallback_current_setup() else: pet_pool_raw = None est_power = total_power / max(n, 1) used_sw, used_sh, used_pets = set(), set(), [] gear_list = [] def candidate_output(acc, sw, sh, mp, ap): return sim_fast([{ "ds": sw["ds"], "power": est_power, "ms": sh["ms"], "mp": mp, "ap": ap, "hours": acc.online_hours, "lm": loot_mult(sw["loot"] + sh["loot"]), "flame": sw["flame"], "level": _account_level(acc), }], item_pv=_item_pv, boss_hp=_boss_hp, hps=_hps, respawn=_respawn, sword_item_pv=_sword_item_pv, shield_item_pv=_shield_item_pv, sword_drop_chance=_sword_drop_chance, shield_drop_chance=_shield_drop_chance)["value"] for acc in accs_s: ap = resolve_ap(acc.armour) if data.optimize_pets: rem = list(pet_pool_raw) for up in used_pets: if up in rem: rem.remove(up) if len(rem) < 3: raise HTTPException(400, f"Nicht genug Pets fuer {acc.name!r}") # Build combos correctly: use Counter to allow duplicates up to available count from collections import Counter counts = Counter(PETS[p] for p in rem) unique_vals = sorted(counts.keys(), reverse=True) top_c = set() for v1 in unique_vals: for v2 in unique_vals: if v2 > v1: continue if counts[v2] - (1 if v2==v1 else 0) < 1: continue for v3 in unique_vals: if v3 > v2: continue c3 = counts[v3] - (1 if v3==v1 else 0) - (1 if v3==v2 else 0) if c3 < 1: continue top_c.add((v1, v2, v3)) top_c = sorted(top_c, key=sum, reverse=True)[:20] if not top_c: raise HTTPException(400, f"Nicht genug Pets fuer {acc.name!r}") else: top_c = None best = None; best_out = -1.0 for sw in sw_pool: if sw["id"] in used_sw: continue for sh in sh_pool: if sh["id"] in used_sh: continue if top_c is not None: for mc in top_c: mp = sum(mc) o = candidate_output(acc, sw, sh, mp, ap) if o > best_out: best_out = o; best = (sw, sh, list(mc), mp) else: if not acc.pets: raise HTTPException(400, f"Account {acc.name!r} hat keine Pets.") mp = resolve_mp(acc.pets) o = candidate_output(acc, sw, sh, mp, ap) if o > best_out: best_out = o; best = (sw, sh, None, mp) if best is None: raise HTTPException(400, f"Nicht genug Gear fuer {acc.name!r}") sw, sh, pc_mp, mp = best used_sw.add(sw["id"]); used_sh.add(sh["id"]) if pc_mp is not None: rem_c = list(pet_pool_raw) for up in used_pets: if up in rem_c: rem_c.remove(up) chosen = [] for tgt_mp in pc_mp: # Find first available pet with matching mp match = next((p for p in rem_c if PETS[p] == tgt_mp), None) if match: chosen.append(match) used_pets.append(match) rem_c.remove(match) else: # Fallback: nearest available mp fallback = min(rem_c, key=lambda p: abs(PETS[p] - tgt_mp), default=None) if fallback: chosen.append(fallback) used_pets.append(fallback) rem_c.remove(fallback) pet_names = chosen else: pet_names = list(acc.pets) tll = sw["loot"] + sh["loot"] gear_list.append(_build_gear_entry(acc, sw["ds"], sh["ms"], mp, ap, 0.0, tll, sw["flame"], f'{sw["name"]} Lv{sw["level"]}', f'{sh["name"]} Lv{sh["level"]}', pet_names, sword_loot=sw["loot"], shield_loot=sh["loot"], sword_item_name=sw["name"], sword_level=sw["level"], shield_item_name=sh["name"], shield_level=sh["level"])) else: gear_list = [] for acc in accs_s: ds = resolve_ds(acc.sword.name if acc.sword else None, acc.sword.level if acc.sword else 1) ms = resolve_ms(acc.shield.name if acc.shield else None, acc.shield.level if acc.shield else 1) mp = resolve_mp(acc.pets) if acc.pets else 0.0 ap = resolve_ap(acc.armour) tll = (acc.sword.looting_enchant if acc.sword else 0) + (acc.shield.looting_enchant if acc.shield else 0) flame = acc.sword.flame_enchant if acc.sword else 0 sword_label = f'{acc.sword.name} Lv{acc.sword.level}' if acc.sword else '—' shield_label = f'{acc.shield.name} Lv{acc.shield.level}' if acc.shield else '—' pet_names = list(acc.pets) if acc.pets else [] gear_list.append(_build_gear_entry(acc, ds, ms, mp, ap, parse_float(acc.power), tll, flame, sword_label, shield_label, pet_names, sword_loot=(acc.sword.looting_enchant if acc.sword else 0), shield_loot=(acc.shield.looting_enchant if acc.shield else 0), sword_item_name=(acc.sword.name if acc.sword else None), sword_level=(acc.sword.level if acc.sword else 1), shield_item_name=(acc.shield.name if acc.shield else None), shield_level=(acc.shield.level if acc.shield else 1))) if data.optimize_gear and len(gear_list) > 1: swap_slots = ["sword", "shield"] + (["pets"] if data.optimize_pets else []) current_value = _fast_room_value( gear_list, total_power, item_pv=_item_pv, boss_hp=_boss_hp, hps=_hps, respawn=_respawn, quick=True, sword_item_pv=_sword_item_pv, shield_item_pv=_shield_item_pv, sword_drop_chance=_sword_drop_chance, shield_drop_chance=_shield_drop_chance, room_capacity=_room_capacity ) for _pass in range(2): improved = False for slot in swap_slots: for left_idx in range(len(gear_list)): for right_idx in range(left_idx + 1, len(gear_list)): left, right = gear_list[left_idx], gear_list[right_idx] _swap_gear_slot(left, right, slot) value = _fast_room_value( gear_list, total_power, item_pv=_item_pv, boss_hp=_boss_hp, hps=_hps, respawn=_respawn, quick=True, sword_item_pv=_sword_item_pv, shield_item_pv=_shield_item_pv, sword_drop_chance=_sword_drop_chance, shield_drop_chance=_shield_drop_chance, room_capacity=_room_capacity ) if value > current_value * 1.000000001: current_value = value improved = True else: _swap_gear_slot(left, right, slot) if not improved: break # Effective item drop value for this request # Bug 2 fix: previously allocate_power was called unconditionally before optimize_rooms, # which was pointless — optimize_rooms calls allocate_power internally per room group # and completely overwrites any pre-allocation. The logic is now clear: if data.optimize_rooms: # optimize_rooms handles all power allocation internally if not data.optimize_power: # Preserve individual powers; optimize_rooms will still distribute per-room for acc, g in zip(accs_s, gear_list): g["power"] = parse_float(acc.power) best_rooms, _ = optimize_rooms(gear_list, total_power, item_pv=_item_pv, boss_hp=_boss_hp, hps=_hps, respawn=_respawn, sword_item_pv=_sword_item_pv, shield_item_pv=_shield_item_pv, sword_drop_chance=_sword_drop_chance, shield_drop_chance=_shield_drop_chance, room_capacity=_room_capacity, optimize_power=data.optimize_power) else: if data.optimize_power: best_rooms = _allocate_room_groups( [[g] for g in gear_list], total_power, item_pv=_item_pv, boss_hp=_boss_hp, hps=_hps, respawn=_respawn, sword_item_pv=_sword_item_pv, shield_item_pv=_shield_item_pv, sword_drop_chance=_sword_drop_chance, shield_drop_chance=_shield_drop_chance, refine_power=True ) else: for acc, g in zip(accs_s, gear_list): g["power"] = parse_float(acc.power) best_rooms = [[g] for g in gear_list] total_bp = Decimal(0); total_drops = 0.0; total_val = Decimal(0); total_gold = Decimal(0) total_sword_drops = 0.0; total_shield_drops = 0.0 total_sword_drop_power = Decimal(0); total_shield_drop_power = Decimal(0) room_details = [] for i, room in enumerate(best_rooms): ad = [{"ds": g["ds"], "power": g["power"], "ms": g["ms"], "mp": g["mp"], "ap": g["ap"], "hours": g["hours"], "lm": g["lm"], "flame": g["flame"], "gold_mult": g.get("gold_mult", 0.0), "level": g.get("level")} for g in room] stats = sim_decimal(ad, item_pv=_item_pv, boss_hp=_boss_hp, hps=_hps, respawn=_respawn, enemy_type=data.enemy_type, sword_item_pv=_sword_item_pv, shield_item_pv=_shield_item_pv, sword_drop_chance=_sword_drop_chance, shield_drop_chance=_shield_drop_chance) total_bp += stats["boss_power"]; total_drops += float(stats["drops"]); total_val += stats["value"]; total_gold += stats.get("gold", Decimal(0)) total_sword_drops += float(stats["sword_drops"]); total_shield_drops += float(stats["shield_drops"]) total_sword_drop_power += stats["sword_drop_power"]; total_shield_drop_power += stats["shield_drop_power"] sim = _simulate_kill(ad, boss_hp=_boss_hp, hps=_hps) daily = _daily_account_metrics(ad, boss_hp=_boss_hp, hps=_hps, respawn=_respawn, enemy_type=data.enemy_type) kt = sim["kill_time"] total_dmg = sum(sim["damage_dealt"]) group_dps = _boss_hp / kt if kt > 0 else 0.0 per_acc = [] room_xp = Decimal(0) for j, g in enumerate(room): dph = dmg_hit(g["ds"], g["power"], g["ms"]) pwr = daily["per_account"][j]["power"] xp = daily["per_account"][j]["xp"] gold_gain = daily["per_account"][j]["gold"] room_xp += Decimal(str(xp)) level_projection = project_account_level(g["account"], xp) share = sim["damage_dealt"][j] / total_dmg if total_dmg > 0 else 0 # Flame-Anteil in Prozent flame_pct = 0.0 if g["flame"] > 0: tdph = flame_tick_dmg(g["ds"], g["power"], g["ms"], g["flame"]) flame_dmg = tdph * sim["flame_ticks"][j] normal_dmg = dmg_hit(g["ds"], g["power"], g["ms"]) * sim["hits_per_second"][j] * kt total_acc_dmg = normal_dmg + flame_dmg flame_pct = round(flame_dmg / total_acc_dmg * 100, 1) if total_acc_dmg > 0 else 0 per_acc.append({ "account": g["account"].name, "sword": g["sword_name"], "shield": g["shield_name"], "pets": g["pet_names"], "allocated_power": fmt(Decimal(str(g["power"]))), "ap": round(g["ap"], 2), "allocated_power_raw": str(g["power"]), "ds_raw": str(g["ds"]), "ms_raw": str(g["ms"]), "mp_raw": str(g["mp"]), "ap_raw": str(g["ap"]), "lm_raw": str(g["lm"]), "hours_raw": str(g["hours"]), "gold_mult_raw": str(g.get("gold_mult", 0.0) or 0.0), "sword_looting": g.get("sword_loot", 0), "shield_looting": g.get("shield_loot", 0), "looting_tll": g["tll"], "flame_level": g["flame"], "flame_ticks_per_kill": sim["flame_ticks"][j], "flame_power_pct": flame_pct, "damage_per_hit": fmt(Decimal(str(dph))), "hits_per_second": round(sim["hits_per_second"][j], 6), "damage_share": f"{share*100:.1f}%", "power_per_day": fmt(Decimal(str(pwr))), "xp_per_day": fmt(Decimal(str(xp))), "xp_per_day_raw": str(xp), "gold_per_day": fmt(Decimal(str(gold_gain))), "gold_multiplier": round(float(g.get("gold_mult", 0.0) or 0.0), 2), **level_projection, }) room_details.append({ "room": f"Room {i+1}", "accounts": [g["account"].name for g in room], "kill_time_seconds": round(kt, 4), "group_dps": fmt(Decimal(str(group_dps))), "kills_per_day": round(float(stats["kills"]), 2), "boss_power": fmt(stats["boss_power"]), "drop_power": fmt(stats["drop_power"]), "sword_drop_power": fmt(stats["sword_drop_power"]), "shield_drop_power": fmt(stats["shield_drop_power"]), "total_value": fmt(stats["value"]), "gold_per_day": fmt(stats.get("gold", Decimal(0))), "xp_per_day": fmt(room_xp), "xp_per_second": fmt(room_xp / Decimal("86400")), "expected_drops": round(float(stats["drops"]), 2), "expected_sword_drops": round(float(stats["sword_drops"]), 2), "expected_shield_drops": round(float(stats["shield_drops"]), 2), "per_account": per_acc, }) return { "room_details": room_details, "total_boss_power_per_day": fmt(total_bp), "expected_drops": round(total_drops, 2), "expected_sword_drops": round(total_sword_drops, 2), "expected_shield_drops": round(total_shield_drops, 2), "sword_drop_power_per_day": fmt(total_sword_drop_power), "shield_drop_power_per_day": fmt(total_shield_drop_power), "drop_prices": {"sword": _price_fmt(_sword_item_pv), "shield": _price_fmt(_shield_item_pv)}, "total_value_per_day": fmt(total_val), "total_value_per_day_raw": str(total_val), "total_boss_power_per_day_raw": str(total_bp), "total_gold_per_day_raw": str(total_gold), "total_gold_per_day": fmt(total_gold), } # ───────────────────────────────────────────────────────────────── @app.post("/market-optimize") def market_optimize(data: MarketOptimizerInput): try: return _run_market_optimizer(data) except HTTPException: raise except Exception as exc: print(f"[Market optimizer] fallback: {exc}") return _market_unavailable_result(data, str(exc)) @app.post("/market-breakpoints") def market_breakpoints(data: MarketOptimizerInput): try: result = _run_market_optimizer( data, calculate_breakpoints=True, breakpoint_deadline_seconds=45.0, ) market = result.get("market") or {} return { "available": bool(market.get("available")), "next_steps": market.get("next_steps") or [], "warnings": market.get("warnings") or [], "note": market.get("note"), } except HTTPException: raise except Exception as exc: print(f"[Market breakpoints] failed: {exc}") return {"available": False, "next_steps": [], "warnings": [str(exc)]} # SHARED HELPERS # ───────────────────────────────────────────────────────────────── def _build_room_accs(room_accs): result = [] for acc in room_accs: ds = resolve_ds(acc.sword.name if acc.sword else None, acc.sword.level if acc.sword else 1) ms = resolve_ms(acc.shield.name if acc.shield else None, acc.shield.level if acc.shield else 1) mp = resolve_mp(acc.pets) if acc.pets else 0.0 ap = resolve_ap(acc.armour) tll = (acc.sword.looting_enchant if acc.sword else 0) + (acc.shield.looting_enchant if acc.shield else 0) result.append({"ds": ds, "power": parse_float(acc.power), "ms": ms, "mp": mp, "ap": ap, "hours": acc.online_hours, "lm": loot_mult(tll), "flame": acc.sword.flame_enchant if acc.sword else 0, "level": _account_level(acc), "gold_mult": _account_gold_multiplier(acc), "account": acc}) return result def _format_room_result(accs, names, stats, boss_hp=BOSS_HP, hps=HPS, respawn=RESPAWN, enemy_type=None): sim_result = _simulate_kill(accs, boss_hp=boss_hp, hps=hps) daily = _daily_account_metrics( accs, boss_hp=boss_hp, hps=hps, respawn=respawn, enemy_type=enemy_type ) kt = sim_result["kill_time"] kills_f = float(stats["kills"]) total_dmg = sum(sim_result["damage_dealt"]) group_dps = boss_hp / kt if kt > 0 else 0.0 per_acc = [] room_xp = Decimal(0) for i, a in enumerate(accs): account = a.get("account") if isinstance(a, dict) else None pwr = daily["per_account"][i]["power"] xp = daily["per_account"][i]["xp"] gold_gain = daily["per_account"][i]["gold"] room_xp += Decimal(str(xp)) level_projection = project_account_level(account, xp) if account else {} share = sim_result["damage_dealt"][i] / total_dmg if total_dmg > 0 else 0 per_acc.append({ "account": names[i], "sword": f"{account.sword.name} Lv{account.sword.level}" if account and account.sword else "-", "shield": f"{account.shield.name} Lv{account.shield.level}" if account and account.shield else "-", "pets": list(account.pets or []) if account else [], "allocated_power": fmt(Decimal(str(a["power"]))), "allocated_power_raw": str(a["power"]), "ds_raw": str(a["ds"]), "ms_raw": str(a["ms"]), "mp_raw": str(a["mp"]), "ap_raw": str(a["ap"]), "lm_raw": str(a["lm"]), "hours_raw": str(a["hours"]), "gold_mult_raw": str(a.get("gold_mult", 0.0) or 0.0), "sword_looting": account.sword.looting_enchant if account and account.sword else 0, "shield_looting": account.shield.looting_enchant if account and account.shield else 0, "flame_level": account.sword.flame_enchant if account and account.sword else 0, "damage_per_hit": fmt(Decimal(str(dmg_hit(a["ds"], a["power"], a["ms"])))), "hits_per_second": round(sim_result["hits_per_second"][i], 6), "damage_share": f"{share*100:.1f}%", "flame_ticks": sim_result["flame_ticks"][i], "power_per_day": fmt(Decimal(str(pwr))), "xp_per_day": fmt(Decimal(str(xp))), "xp_per_day_raw": str(xp), "gold_per_day": fmt(Decimal(str(gold_gain))), "gold_multiplier": round(float(a.get("gold_mult", 0.0) or 0.0), 2), **level_projection, }) return { "kill_time_seconds": round(kt, 4), "group_dps": fmt(Decimal(str(group_dps))), "kills_per_day": round(kills_f, 2), "boss_power": fmt(stats["boss_power"]), "total_value": fmt(stats["value"]), "gold_per_day": fmt(stats.get("gold", Decimal(0))), "xp_per_day": fmt(room_xp), "xp_per_second": fmt(room_xp / Decimal("86400")), "expected_drops": round(float(stats["drops"]), 2), "expected_sword_drops": round(float(stats["sword_drops"]), 2), "expected_shield_drops": round(float(stats["shield_drops"]), 2), "sword_drop_power": fmt(stats["sword_drop_power"]), "shield_drop_power": fmt(stats["shield_drop_power"]), "per_account": per_acc, } # ───────────────────────────────────────────────────────────────── # /simulate # ───────────────────────────────────────────────────────────────── @app.post("/simulate") def simulate(data: SimulateInput): _sword_item_pv, _shield_item_pv = _resolved_drop_values(data) _item_pv = (_sword_item_pv + _shield_item_pv) / 2.0 _sword_drop_chance, _shield_drop_chance = _resolved_drop_chances(data) _room_capacity = _resolved_room_capacity(data) _boss_hp = _cfg_positive(data.boss_hp, BOSS_HP) _respawn = _cfg_nonnegative(data.respawn, RESPAWN) _hps = _cfg_nonnegative(data.hps, HPS) rooms_input = [ data.accounts[i:i+_room_capacity] for i in range(0, len(data.accounts), _room_capacity) ] total_bp = Decimal(0); total_drops = 0.0; total_val = Decimal(0); total_gold = Decimal(0) total_sword_drops = 0.0; total_shield_drops = 0.0 total_sword_drop_power = Decimal(0); total_shield_drop_power = Decimal(0) all_room_results = [] for room_accs in rooms_input: accs = _build_room_accs(room_accs) stats = sim_decimal(accs, item_pv=_item_pv, boss_hp=_boss_hp, hps=_hps, respawn=_respawn, enemy_type=data.enemy_type, sword_item_pv=_sword_item_pv, shield_item_pv=_shield_item_pv, sword_drop_chance=_sword_drop_chance, shield_drop_chance=_shield_drop_chance) total_bp += stats["boss_power"]; total_drops += float(stats["drops"]); total_val += stats["value"]; total_gold += stats.get("gold", Decimal(0)) total_sword_drops += float(stats["sword_drops"]); total_shield_drops += float(stats["shield_drops"]) total_sword_drop_power += stats["sword_drop_power"]; total_shield_drop_power += stats["shield_drop_power"] all_room_results.append(_format_room_result( accs, [a.name for a in room_accs], stats, boss_hp=_boss_hp, hps=_hps, respawn=_respawn, enemy_type=data.enemy_type )) return { "rooms": all_room_results, "total_boss_power_per_day": fmt(total_bp), "expected_drops": round(total_drops, 2), "expected_sword_drops": round(total_sword_drops, 2), "expected_shield_drops": round(total_shield_drops, 2), "sword_drop_power_per_day": fmt(total_sword_drop_power), "shield_drop_power_per_day": fmt(total_shield_drop_power), "drop_prices": {"sword": _price_fmt(_sword_item_pv), "shield": _price_fmt(_shield_item_pv)}, "total_value_per_day": fmt(total_val), "total_value_per_day_raw": str(total_val), "total_boss_power_per_day_raw": str(total_bp), "total_gold_per_day_raw": str(total_gold), "total_gold_per_day": fmt(total_gold), } # ───────────────────────────────────────────────────────────────── # /check-setup # Simuliert das aktuelle Setup mit expliziten Räumen (ohne Optimierung). # Gibt Ergebnis zurück identisch zu /simulate (rooms-Format). # ───────────────────────────────────────────────────────────────── class RoomInput(BaseModel): accounts: list[AccountInput] class CheckSetupInput(BaseModel): rooms: list[RoomInput] item_pv: Optional[float] = None sword_item_pv: Optional[float] = None shield_item_pv: Optional[float] = None sword_drop_chance: Optional[float] = None shield_drop_chance: Optional[float] = None room_capacity: Optional[int] = None boss_hp: Optional[float] = None respawn: Optional[float] = None hps: Optional[float] = None enemy_type: Optional[int] = None @app.post("/check-setup") def check_setup(data: CheckSetupInput): _sword_item_pv, _shield_item_pv = _resolved_drop_values(data) _item_pv = (_sword_item_pv + _shield_item_pv) / 2.0 _sword_drop_chance, _shield_drop_chance = _resolved_drop_chances(data) _room_capacity = _resolved_room_capacity(data) _boss_hp = _cfg_positive(data.boss_hp, BOSS_HP) _respawn = _cfg_nonnegative(data.respawn, RESPAWN) _hps = _cfg_nonnegative(data.hps, HPS) total_bp = Decimal(0); total_drops = 0.0; total_val = Decimal(0); total_gold = Decimal(0) total_sword_drops = 0.0; total_shield_drops = 0.0 total_sword_drop_power = Decimal(0); total_shield_drop_power = Decimal(0) all_room_results = [] for room_input in data.rooms: if not room_input.accounts: continue if len(room_input.accounts) > _room_capacity: raise HTTPException( 400, f"Room has {len(room_input.accounts)} accounts but the selected boss allows " f"at most {_room_capacity}.", ) accs = _build_room_accs(room_input.accounts) stats = sim_decimal(accs, item_pv=_item_pv, boss_hp=_boss_hp, hps=_hps, respawn=_respawn, enemy_type=data.enemy_type, sword_item_pv=_sword_item_pv, shield_item_pv=_shield_item_pv, sword_drop_chance=_sword_drop_chance, shield_drop_chance=_shield_drop_chance) total_bp += stats["boss_power"]; total_drops += float(stats["drops"]); total_val += stats["value"]; total_gold += stats.get("gold", Decimal(0)) total_sword_drops += float(stats["sword_drops"]); total_shield_drops += float(stats["shield_drops"]) total_sword_drop_power += stats["sword_drop_power"]; total_shield_drop_power += stats["shield_drop_power"] all_room_results.append(_format_room_result( accs, [a.name for a in room_input.accounts], stats, boss_hp=_boss_hp, hps=_hps, respawn=_respawn, enemy_type=data.enemy_type )) return { "rooms": all_room_results, "total_boss_power_per_day": fmt(total_bp), "expected_drops": round(total_drops, 2), "expected_sword_drops": round(total_sword_drops, 2), "expected_shield_drops": round(total_shield_drops, 2), "sword_drop_power_per_day": fmt(total_sword_drop_power), "shield_drop_power_per_day": fmt(total_shield_drop_power), "drop_prices": {"sword": _price_fmt(_sword_item_pv), "shield": _price_fmt(_shield_item_pv)}, "total_value_per_day": fmt(total_val), "total_value_per_day_raw": str(total_val), "total_boss_power_per_day_raw": str(total_bp), "total_gold_per_day_raw": str(total_gold), "total_gold_per_day": fmt(total_gold), } # ───────────────────────────────────────────────────────────────── # /gear-info # ───────────────────────────────────────────────────────────────── @app.get("/gear-info") def gear_info(): swords = [] for name, values in SCHWERTER.items(): base = parse_float(values[0]) swords.append({ "name": name, "ds_lv1": fmt(Decimal(str(base))), "ds_lv2": fmt(Decimal(str(_gear_scale(base, 2)))), "ds_per_level": fmt(Decimal(str(base * 0.25))), }) shields = [] for name, values in SCHILDE.items(): base = float(values[0]) if values else None shields.append({ "name": name, "ms_lv1": base, "ms_lv2": _gear_scale(base, 2) if base is not None else None, "has_multiplier": values is not None, }) pets = [{"name": n, "mp": m} for n, m in sorted(PETS.items(), key=lambda x: x[1])] armour = { category: [ {"name": name, **stats} for name, stats in category_stats.items() ] for category, category_stats in ARMOUR_STATS.items() } return { "swords": swords, "shields": shields, "pets": pets, "armour": armour, "health_formula": { "base_health": BASE_HEALTH, "level_squared_factor": HEALTH_PER_LEVEL_SQUARED, }, } # ───────────────────────────────────────────────────────────────── # /live-lookup # Compatibility aliases keep older frontends working while account data is # loaded from the public in-game statue lookup instead of Discord. # ───────────────────────────────────────────────────────────────── # Live account lookup. The game client uses this read-only statue request to # render public player statues. One guest room connection serves a bulk refresh. _SM_LOOKUP_LOCK = threading.RLock() _SM_LOOKUP_KEY_LOCKS = {} _SM_LOOKUP_CACHE = {} _SM_LIVE_ROOM_QUEUE = queue.Queue(maxsize=256) # Eigener, dauerhafter Room-Socket NUR fuer den Leaderboard-Scanner. Wird ausschliesslich # vom Scanner-Thread benutzt (deshalb kein Lock noetig) und ueber Batches wiederverwendet. _SM_SCANNER_SOCKET = None _SM_LIVE_ROOM_THREAD = None _SM_LIVE_ROOM_THREAD_LOCK = threading.Lock() _SM_CLAN_LOCK = threading.Lock() _SM_CLAN_CACHE = {} _SM_LEVEL_RANK_CACHE = {} _SM_LEVEL_RANK_PAGE_CACHE = {} _SM_LEVEL_PLAYER_COUNT_CACHE = { "value": { "stamp": 0.0, "count": SM_LEVEL_PLAYER_COUNT_SEED, "last_page": math.ceil(SM_LEVEL_PLAYER_COUNT_SEED / SM_LEVEL_RANK_PAGE_SIZE), }, "refreshing": False, } _SM_LEVEL_QUALIFIED_COUNT_CACHE = { threshold: { "value": { "stamp": 0.0, "count": config["seed"], }, "refreshing": False, } for threshold, config in SM_LEVEL_PERCENTILE_TIERS.items() } _SM_PROBE_CACHE = {} SM_PROBE_CACHE_SECONDS = max(5.0, float(_os.getenv("SM_PROBE_CACHE_SECONDS", "600.0"))) @contextmanager def _sm_lookup_key_scope(usernames): keys = sorted({ _sm_live_username_key(username) for username in usernames if _sm_live_username_key(username) }) with _SM_LOOKUP_LOCK: locks = [ _SM_LOOKUP_KEY_LOCKS.setdefault(key, threading.RLock()) for key in keys ] for lock in locks: lock.acquire() try: yield finally: for lock in reversed(locks): lock.release() def _sm_public_error(exc) -> str: print(f"[SwordMasters live lookup failed] {type(exc).__name__}: {str(exc)[:240]}") return ("The live SwordMasters server didn't respond for this name — it's under heavy " "load right now (the game servers have been getting DDoSed). This is usually " "temporary, so try again in a moment. If it keeps failing, double-check the spelling.") def _sm_json_post(url: str, payload: dict): session = getattr(_SM_HTTP_LOCAL, "session", None) if session is None: session = _requests.Session() if hasattr(_requests, "Session") else _requests _SM_HTTP_LOCAL.session = session last_error = None for attempt in range(3): try: resp = session.post( url, json=payload, headers={"Accept": "application/json", "User-Agent": "SwordMasters Optimizer live lookup"}, timeout=(3.0, SM_LOOKUP_TIMEOUT), ) resp.raise_for_status() data = resp.json() if not isinstance(data, dict): raise ValueError("Unexpected SwordMasters JSON response.") return data except Exception as exc: last_error = exc if attempt < 2: time.sleep(0.15 * (attempt + 1)) raise last_error _SM_CCU_CACHE = {"stamp": 0.0, "value": None} _SM_CCU_TTL = 25.0 def _sm_ccu(token: str | None = None): """Global concurrent-user count (all levels, whole game). Cached ~25s so the leaderboard 'online now' figure stays fresh without hammering the endpoint.""" now = time.monotonic() cached = _SM_CCU_CACHE.get("value") if cached is not None and now - _SM_CCU_CACHE.get("stamp", 0.0) < _SM_CCU_TTL: return cached tok = token or _sm_get_guest_token() data = _sm_json_post(f"{SM_LOADBALANCER_BASE}/api/server/CCU", {"token": tok}) value = int((data or {}).get("CCU") or 0) _SM_CCU_CACHE["value"] = value _SM_CCU_CACHE["stamp"] = now return value def _sm_level_rank_entry(user: dict, page: int, row_index: int): if not isinstance(user, dict): return None username = str(user.get("username") or "").strip() key = _sm_live_username_key(username) if not key: return None try: level = int(user.get("level")) except (TypeError, ValueError): level = None entry = { "username": username, "rank": (int(page) - 1) * SM_LEVEL_RANK_PAGE_SIZE + int(row_index) + 1, "page": int(page), "page_position": int(row_index) + 1, "player_id": str(user.get("_id") or ""), "is_banned": bool(user.get("isBanned")), "is_dead": bool(user.get("isDead")), } if level is not None: entry["level"] = level return key, entry def _sm_get_guest_token(): guest = _sm_json_post(f"{SM_LOADBALANCER_BASE}/api/server/findServer", {"token": None}) token = guest.get("token") if not token: raise ValueError("SwordMasters loadbalancer did not return a guest token.") return token def _sm_level_rank_page(page: int, token: str, force_refresh: bool = False): now = time.monotonic() cached = _SM_LEVEL_RANK_PAGE_CACHE.get(page) if ( not force_refresh and cached and now - cached.get("stamp", 0.0) < SM_LEVEL_RANK_CACHE_SECONDS ): return list(cached.get("users") or []) data = _sm_json_post( f"{SM_LOADBALANCER_BASE}/api/server/getPlayersPagination", {"token": token, "page": int(page)}, ) users = data.get("users") if isinstance(data.get("users"), list) else [] _SM_LEVEL_RANK_PAGE_CACHE[page] = {"stamp": now, "users": list(users)} return users def _sm_calculate_level_player_count(token: str): page_counts = {} def page_count(page: int): page = max(1, int(page)) if page not in page_counts: page_counts[page] = len(_sm_level_rank_page(page, token)) return page_counts[page] hint = SM_LEVEL_PLAYER_COUNT_HINT_PAGE hint_count = page_count(hint) next_count = page_count(hint + 1) if hint_count and not next_count: last_page = hint elif hint_count: low = hint + 1 step = 1 high = min(SM_LEVEL_PLAYER_COUNT_MAX_PAGE, low + step) while page_count(high): if high >= SM_LEVEL_PLAYER_COUNT_MAX_PAGE: raise ValueError("SwordMasters player count exceeded the configured search range.") low = high step *= 2 high = min(SM_LEVEL_PLAYER_COUNT_MAX_PAGE, high + step) while low + 1 < high: middle = (low + high) // 2 if page_count(middle): low = middle else: high = middle last_page = low else: high = hint low = max(1, hint // 2) while low > 1 and not page_count(low): high = low low = max(1, low // 2) if not page_count(low): return 0 while low + 1 < high: middle = (low + high) // 2 if page_count(middle): low = middle else: high = middle last_page = low last_page_count = page_count(last_page) total = (last_page - 1) * SM_LEVEL_RANK_PAGE_SIZE + last_page_count _SM_LEVEL_PLAYER_COUNT_CACHE["value"] = { "stamp": time.monotonic(), "count": int(total), "last_page": int(last_page), } return int(total) def _sm_refresh_level_player_count(): try: _sm_calculate_level_player_count(_sm_get_guest_token()) except Exception as exc: print(f"[SwordMasters player count refresh failed] {type(exc).__name__}: {str(exc)[:240]}") finally: _SM_LEVEL_PLAYER_COUNT_CACHE["refreshing"] = False def _sm_level_player_count(token: str | None = None): now = time.monotonic() cached = _SM_LEVEL_PLAYER_COUNT_CACHE.get("value") if cached and now - cached.get("stamp", 0.0) < SM_LEVEL_PLAYER_COUNT_CACHE_SECONDS: return int(cached["count"]) if cached: if not _SM_LEVEL_PLAYER_COUNT_CACHE.get("refreshing"): _SM_LEVEL_PLAYER_COUNT_CACHE["refreshing"] = True threading.Thread(target=_sm_refresh_level_player_count, daemon=True).start() return int(cached["count"]) return _sm_calculate_level_player_count(token or _sm_get_guest_token()) def _sm_percentile_min_level(player_level: int): if player_level >= 1000: return 1000 if player_level >= 100: return 100 return 0 def _sm_calculate_level_qualified_count(token: str, threshold: int): config = SM_LEVEL_PERCENTILE_TIERS.get(int(threshold)) if not config: raise ValueError(f"Unsupported level percentile threshold: {threshold}") cached_total = _SM_LEVEL_PLAYER_COUNT_CACHE.get("value") or {} last_page = int(cached_total.get("last_page") or 0) if last_page < 1: last_page = SM_LEVEL_PLAYER_COUNT_HINT_PAGE + 1024 threshold = int(threshold) def qualifying_on_page(page: int): users = _sm_level_rank_page(page, token) return users, sum( 1 for user in users if isinstance(user, dict) and int(user.get("level") or 0) >= threshold ) hint_page = min(last_page, int(config["hint_page"])) hint_users, hint_qualified = qualifying_on_page(hint_page) if 0 < hint_qualified < len(hint_users): total = (hint_page - 1) * SM_LEVEL_RANK_PAGE_SIZE + hint_qualified else: if hint_users and hint_qualified == len(hint_users): low, high = hint_page + 1, last_page else: low, high = 1, max(1, hint_page - 1) while low < high: middle = (low + high) // 2 users, qualified = qualifying_on_page(middle) if users and qualified == len(users): low = middle + 1 else: high = middle boundary_page = low boundary_users, boundary_qualified = qualifying_on_page(boundary_page) if boundary_users and boundary_qualified == len(boundary_users) and boundary_page == last_page: total = int(cached_total.get("count") or boundary_page * SM_LEVEL_RANK_PAGE_SIZE) else: total = (boundary_page - 1) * SM_LEVEL_RANK_PAGE_SIZE + boundary_qualified cache = _SM_LEVEL_QUALIFIED_COUNT_CACHE.setdefault( threshold, {"value": None, "refreshing": False} ) cache["value"] = { "stamp": time.monotonic(), "count": int(total), } return int(total) def _sm_refresh_level_qualified_count(threshold: int): try: _sm_calculate_level_qualified_count(_sm_get_guest_token(), threshold) except Exception as exc: print(f"[SwordMasters qualified player count refresh failed] {type(exc).__name__}: {str(exc)[:240]}") finally: cache = _SM_LEVEL_QUALIFIED_COUNT_CACHE.get(threshold) if cache: cache["refreshing"] = False def _sm_level_qualified_count(threshold: int, token: str | None = None): threshold = int(threshold) cache = _SM_LEVEL_QUALIFIED_COUNT_CACHE.get(threshold) if cache is None: raise ValueError(f"Unsupported level percentile threshold: {threshold}") now = time.monotonic() cached = cache.get("value") if cached and now - cached.get("stamp", 0.0) < SM_LEVEL_PLAYER_COUNT_CACHE_SECONDS: return int(cached["count"]) if cached: if not cache.get("refreshing"): cache["refreshing"] = True threading.Thread( target=_sm_refresh_level_qualified_count, args=(threshold,), daemon=True, ).start() return int(cached["count"]) return _sm_calculate_level_qualified_count( token or _sm_get_guest_token(), threshold ) def _sm_level_rank_from_page(users: list, page: int, wanted_key: str): now = time.monotonic() for row_index, user in enumerate(users): parsed = _sm_level_rank_entry(user, page, row_index) if not parsed: continue key, entry = parsed _SM_LEVEL_RANK_CACHE[key] = {"stamp": now, "entry": dict(entry)} if key == wanted_key: return entry return None def _sm_level_rank_from_page_by_player_id(users: list, page: int, wanted_id: str): wanted_id = str(wanted_id or "").strip() if not wanted_id: return None now = time.monotonic() for row_index, user in enumerate(users): parsed = _sm_level_rank_entry(user, page, row_index) if not parsed: continue key, entry = parsed _SM_LEVEL_RANK_CACHE[key] = {"stamp": now, "entry": dict(entry)} if str(entry.get("player_id") or "").strip() == wanted_id: return entry return None def _sm_level_rank_for_player_id( player_id: str, target_level: int | None, token: str, last_page: int, known_page: int | None = None, force_refresh: bool = False, ): wanted_id = str(player_id or "").strip() if not wanted_id or last_page < 1: return None checked_pages = set() def check_page(page: int): page = int(page) if page < 1 or page > last_page or page in checked_pages: return None checked_pages.add(page) return _sm_level_rank_from_page_by_player_id( _sm_level_rank_page(page, token, force_refresh=force_refresh), page, wanted_id, ) try: known_page_int = int(known_page) if known_page is not None else 0 except (TypeError, ValueError): known_page_int = 0 if known_page_int > 0: for distance in range(0, 4): for page in ( known_page_int - distance, known_page_int + distance, ): entry = check_page(page) if entry: return entry try: target_level_int = int(target_level) except (TypeError, ValueError): target_level_int = None if target_level_int is None or target_level_int < 1000: return None low = 1 high = int(last_page) candidate_page = None candidate_first_level = None candidate_last_level = None while low <= high: page = (low + high) // 2 users = _sm_level_rank_page(page, token, force_refresh=force_refresh) checked_pages.add(page) if not users: high = page - 1 continue try: first_level = int(users[0].get("level")) last_level_value = int(users[-1].get("level")) except (AttributeError, TypeError, ValueError): return None if target_level_int > first_level: high = page - 1 elif target_level_int < last_level_value: low = page + 1 else: candidate_page = page candidate_first_level = first_level candidate_last_level = last_level_value entry = _sm_level_rank_from_page_by_player_id(users, page, wanted_id) if entry: return entry break if candidate_page is None: return None pages_checked_around_level = 1 left_active = candidate_first_level == target_level_int right_active = candidate_last_level == target_level_int distance = 1 while ( pages_checked_around_level < SM_LEVEL_RANK_TIE_PAGE_LIMIT and (left_active or right_active) ): if left_active and pages_checked_around_level < SM_LEVEL_RANK_TIE_PAGE_LIMIT: page = candidate_page - distance if page < 1: left_active = False else: users = _sm_level_rank_page(page, token, force_refresh=force_refresh) pages_checked_around_level += 1 checked_pages.add(page) matching_level = any( isinstance(user, dict) and str(user.get("level") or "") == str(target_level_int) for user in users ) if matching_level: entry = _sm_level_rank_from_page_by_player_id(users, page, wanted_id) if entry: return entry else: left_active = False if right_active and pages_checked_around_level < SM_LEVEL_RANK_TIE_PAGE_LIMIT: page = candidate_page + distance if page > last_page: right_active = False else: users = _sm_level_rank_page(page, token, force_refresh=force_refresh) pages_checked_around_level += 1 checked_pages.add(page) matching_level = any( isinstance(user, dict) and str(user.get("level") or "") == str(target_level_int) for user in users ) if matching_level: entry = _sm_level_rank_from_page_by_player_id(users, page, wanted_id) if entry: return entry else: right_active = False distance += 1 return None def _sm_level_rank_for_username( username: str, target_level: int, token: str, last_page: int, ): wanted_key = _sm_live_username_key(username) if not wanted_key or target_level < 0 or last_page < 1: return None low = 1 high = int(last_page) candidate_page = None candidate_users = [] candidate_first_level = None candidate_last_level = None while low <= high: page = (low + high) // 2 users = _sm_level_rank_page(page, token) if not users: high = page - 1 continue try: first_level = int(users[0].get("level")) last_level = int(users[-1].get("level")) except (AttributeError, TypeError, ValueError): return None if target_level > first_level: high = page - 1 elif target_level < last_level: low = page + 1 else: candidate_page = page candidate_users = users candidate_first_level = first_level candidate_last_level = last_level break if candidate_page is None: return None entry = _sm_level_rank_from_page(candidate_users, candidate_page, wanted_key) if entry: return entry checked_pages = 1 left_active = candidate_first_level == target_level right_active = candidate_last_level == target_level distance = 1 while checked_pages < SM_LEVEL_RANK_TIE_PAGE_LIMIT and (left_active or right_active): if left_active and checked_pages < SM_LEVEL_RANK_TIE_PAGE_LIMIT: page = candidate_page - distance if page < 1: left_active = False else: users = _sm_level_rank_page(page, token) checked_pages += 1 matching_level = any( isinstance(user, dict) and user.get("level") == target_level for user in users ) if matching_level: entry = _sm_level_rank_from_page(users, page, wanted_key) if entry: return entry else: left_active = False if right_active and checked_pages < SM_LEVEL_RANK_TIE_PAGE_LIMIT: page = candidate_page + distance if page > last_page: right_active = False else: users = _sm_level_rank_page(page, token) checked_pages += 1 matching_level = any( isinstance(user, dict) and user.get("level") == target_level for user in users ) if matching_level: entry = _sm_level_rank_from_page(users, page, wanted_key) if entry: return entry else: right_active = False distance += 1 return None def _sm_level_ranks_for_usernames(usernames: list[str], levels_by_key: dict | None = None): names_by_key = { _sm_live_username_key(username): str(username).strip() for username in usernames if _sm_live_username_key(username) } wanted = set(names_by_key) if not wanted: return {} levels_by_key = levels_by_key or {} now = time.monotonic() found = {} missing = set() for key in wanted: cached = _SM_LEVEL_RANK_CACHE.get(key) if cached and now - cached.get("stamp", 0.0) < SM_LEVEL_RANK_CACHE_SECONDS: if cached.get("entry"): found[key] = dict(cached["entry"]) else: missing.add(key) if not missing: return found token = _sm_get_guest_token() targeted = {} for key in missing: try: targeted[key] = int(levels_by_key.get(key)) except (TypeError, ValueError): continue if targeted: cached_count = _SM_LEVEL_PLAYER_COUNT_CACHE.get("value") or {} last_page = int(cached_count.get("last_page") or 0) if last_page < 1: last_page = SM_LEVEL_PLAYER_COUNT_HINT_PAGE + 1024 for key, level in targeted.items(): entry = _sm_level_rank_for_username( names_by_key[key], level, token, last_page ) if entry: found[key] = dict(entry) missing.discard(key) fallback_missing = missing - set(targeted) if fallback_missing: for page in range(1, SM_LEVEL_RANK_MAX_PAGES + 1): users = _sm_level_rank_page(page, token) if not users: break for key in list(fallback_missing): entry = _sm_level_rank_from_page(users, page, key) if entry: found[key] = dict(entry) missing.discard(key) fallback_missing.discard(key) if not fallback_missing or len(users) < SM_LEVEL_RANK_PAGE_SIZE: break for key in missing: _SM_LEVEL_RANK_CACHE[key] = {"stamp": now, "entry": None} return found def _sm_attach_level_ranks_unlocked(by_key: dict, usernames: list[str]): candidates = [ username for username in usernames if isinstance(by_key.get(_sm_live_username_key(username)), dict) and by_key[_sm_live_username_key(username)].get("ok") ] if not candidates: return levels_by_key = { _sm_live_username_key(username): by_key[_sm_live_username_key(username)].get("level") for username in candidates } try: ranks = _sm_level_ranks_for_usernames(candidates, levels_by_key=levels_by_key) except Exception as exc: print(f"[SwordMasters level rank lookup failed] {type(exc).__name__}: {str(exc)[:240]}") return player_counts = {} if ranks: try: needed_thresholds = set() for username in candidates: try: player_level = int( by_key[_sm_live_username_key(username)].get("level") ) except (TypeError, ValueError): continue needed_thresholds.add(_sm_percentile_min_level(player_level)) for threshold in needed_thresholds: player_counts[threshold] = ( _sm_level_player_count() if threshold == 0 else _sm_level_qualified_count(threshold) ) except Exception as exc: print(f"[SwordMasters player count lookup failed] {type(exc).__name__}: {str(exc)[:240]}") for username in candidates: key = _sm_live_username_key(username) entry = ranks.get(key) result = by_key.get(key) if not entry or not isinstance(result, dict): continue result["level_rank"] = entry.get("rank") result["level_rank_page"] = entry.get("page") result["level_rank_page_position"] = entry.get("page_position") result["is_banned"] = bool(entry.get("is_banned")) result["is_dead"] = bool(entry.get("is_dead")) try: player_level = int(result.get("level")) except (TypeError, ValueError): player_level = None threshold = _sm_percentile_min_level(player_level) if player_level is not None else None total_players = player_counts.get(threshold) if threshold is not None: result["level_rank_percentile_min_level"] = threshold if total_players and entry.get("rank"): rank = int(entry["rank"]) result["level_rank_total_players"] = int(total_players) result["level_rank_top_percent"] = min( 100.0, max(0.0, rank / total_players * 100.0) ) if entry.get("player_id"): result["player_id"] = entry.get("player_id") def _sm_leaderboard_default_state(): return { "schema_version": 1, "created_at": int(time.time() * 1000), "updated_at": 0, "players": {}, "scanner": { "enabled": bool(SM_LEADERBOARD_SCANNER_ENABLED), "running": False, "page": 1, "row": 0, "cycle": 0, "scanned": 0, "updated": 0, "last_batch_at": 0, "last_error": "", "last_error_at": 0, "last_user": "", "min_level": SM_LEADERBOARD_MIN_LEVEL, "batch_size": SM_LEADERBOARD_BATCH_SIZE, "scan_interval_seconds": SM_LEADERBOARD_SCAN_INTERVAL, "cache_path": str(SM_LEADERBOARD_CACHE_PATH), "cache_uses_data_path": bool(SM_LEADERBOARD_CACHE_USES_DATA_PATH), "cache_persistent": bool(SM_LEADERBOARD_CACHE_PERSISTENT), "cache_restored_from": "", }, } _SM_LEADERBOARD_LOCK = threading.RLock() _SM_LEADERBOARD_STATE = _sm_leaderboard_default_state() _SM_LEADERBOARD_LOADED = False _SM_LEADERBOARD_THREAD = None _SM_LEADERBOARD_LAST_SAVE_MONOTONIC = 0.0 _SM_LEADERBOARD_LOOKUP_QUEUE = queue.Queue(maxsize=512) _SM_LEADERBOARD_LOOKUP_THREAD = None _SM_LEADERBOARD_LOOKUP_THREAD_LOCK = threading.Lock() def _sm_leaderboard_read_cache_file(path: Path): if not path.exists(): return None blob = path.read_bytes() if blob[:2] == b"\x1f\x8b": # gzip-Magic -> unser komprimierter Cache blob = gzip.decompress(blob) # sonst Klartext-JSON (Bootstrap im Repo bzw. alter, unkomprimierter /data-Cache) data = json.loads(blob.decode("utf-8")) if isinstance(data, dict) and isinstance(data.get("players"), dict): return data return None def _sm_leaderboard_state_from_cache(data: dict, restored_from: str = ""): state = _sm_leaderboard_default_state() state.update({k: v for k, v in data.items() if k != "scanner"}) state["players"] = { str(key): value for key, value in (data.get("players") or {}).items() if isinstance(value, dict) } scanner = dict(state["scanner"]) scanner.update(data.get("scanner") or {}) scanner["enabled"] = bool(SM_LEADERBOARD_SCANNER_ENABLED) scanner["min_level"] = SM_LEADERBOARD_MIN_LEVEL scanner["batch_size"] = SM_LEADERBOARD_BATCH_SIZE scanner["scan_interval_seconds"] = SM_LEADERBOARD_SCAN_INTERVAL scanner["cache_path"] = str(SM_LEADERBOARD_CACHE_PATH) scanner["cache_uses_data_path"] = bool(SM_LEADERBOARD_CACHE_USES_DATA_PATH) scanner["cache_persistent"] = bool(SM_LEADERBOARD_CACHE_PERSISTENT) # Immer die TATSAECHLICHE Quelle dieses Boots setzen (nicht nur bei legacy/bootstrap), # sonst bleibt ein altes "bootstrap" im gespeicherten Scanner-Dict kleben und taeuscht # bei jedem spaeteren /data-Load einen Reset vor, der nie stattfand. scanner["cache_restored_from"] = restored_from or "data" state["scanner"] = scanner return state def _sm_leaderboard_write_state_unlocked(state: dict): # gzip: bei ~1,6 Mio Zeilen ~600 MB JSON -> ~150 MB. Passt eher in eine enge /data-Quota # (wichtig fuer Persistenz ueber Neustarts) und schreibt auf dem HF-Storage schneller. payload = json.dumps(state, ensure_ascii=True, separators=(",", ":")).encode("utf-8") SM_LEADERBOARD_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) tmp_path = SM_LEADERBOARD_CACHE_PATH.with_suffix(SM_LEADERBOARD_CACHE_PATH.suffix + ".tmp") tmp_path.write_bytes(gzip.compress(payload, 4)) tmp_path.replace(SM_LEADERBOARD_CACHE_PATH) _sm_leaderboard_snapshot_daily(state) # einmal/Tag Historie-Snapshot fuer Diffs def _sm_leaderboard_load_unlocked(): global _SM_LEADERBOARD_LOADED, _SM_LEADERBOARD_STATE, _SM_LEADERBOARD_LAST_SAVE_MONOTONIC if _SM_LEADERBOARD_LOADED: return _SM_LEADERBOARD_STATE _SM_LEADERBOARD_LOADED = True try: sources = [(SM_LEADERBOARD_CACHE_PATH, "")] if SM_LEADERBOARD_LEGACY_CACHE_PATH != SM_LEADERBOARD_CACHE_PATH: sources.append((SM_LEADERBOARD_LEGACY_CACHE_PATH, "legacy")) if SM_LEADERBOARD_BOOTSTRAP_PATH not in {path for path, _ in sources}: sources.append((SM_LEADERBOARD_BOOTSTRAP_PATH, "bootstrap")) for path, restored_from in sources: data = _sm_leaderboard_read_cache_file(path) if not data: continue _SM_LEADERBOARD_STATE = _sm_leaderboard_state_from_cache(data, restored_from) # Heal legacy name-only shadows left behind before player ids were # tracked, so each account is stored (and shown) exactly once. healed = _sm_leaderboard_cleanup_players_unlocked( _SM_LEADERBOARD_STATE.get("players") or {} ) if path != SM_LEADERBOARD_CACHE_PATH or healed: _sm_leaderboard_write_state_unlocked(_SM_LEADERBOARD_STATE) _SM_LEADERBOARD_LAST_SAVE_MONOTONIC = time.monotonic() break except Exception as exc: print(f"[SwordMasters leaderboard cache load failed] {type(exc).__name__}: {str(exc)[:240]}") _SM_LEADERBOARD_STATE = _sm_leaderboard_default_state() return _SM_LEADERBOARD_STATE def _sm_leaderboard_save_unlocked(min_interval: float = 0.0): global _SM_LEADERBOARD_LAST_SAVE_MONOTONIC if SM_LEADERBOARD_PROXY_URL: return False # proxy mode: prices is the source of truth -> skip the ~600 MB cache save state = _sm_leaderboard_load_unlocked() now = time.monotonic() if min_interval and _SM_LEADERBOARD_LAST_SAVE_MONOTONIC: if now - _SM_LEADERBOARD_LAST_SAVE_MONOTONIC < float(min_interval): return False players = state.get("players") if isinstance(state.get("players"), dict) else {} if len(players) > SM_LEADERBOARD_MAX_ROWS: # Sicherheitsventil (sollte bei Deckel 2 Mio > Population 1,71 Mio nie greifen): # nach POWER behalten, damit der sichtbare Leaderboard-Kopf + die Total-Power NIE # abgeschnitten werden. (Frueher nach updated_at -> warf die Top-Accounts raus.) keep = sorted( players.items(), key=lambda item: float((item[1] or {}).get("power_sort") or 0), reverse=True, )[:SM_LEADERBOARD_MAX_ROWS] state["players"] = dict(keep) print(f"[SwordMasters leaderboard] capped {len(players)} -> {SM_LEADERBOARD_MAX_ROWS} rows (by power)") scanner = state.setdefault("scanner", {}) scanner["cache_path"] = str(SM_LEADERBOARD_CACHE_PATH) scanner["cache_uses_data_path"] = bool(SM_LEADERBOARD_CACHE_USES_DATA_PATH) scanner["cache_persistent"] = bool(SM_LEADERBOARD_CACHE_PERSISTENT) scanner["rows_stored"] = len(state.get("players") or {}) try: _sm_leaderboard_write_state_unlocked(state) _SM_LEADERBOARD_LAST_SAVE_MONOTONIC = now # Persistenz-Diagnose (damit man sieht, ob /data wirklich beschrieben wird): try: size = SM_LEADERBOARD_CACHE_PATH.stat().st_size if SM_LEADERBOARD_CACHE_PATH.exists() else 0 free = shutil.disk_usage(SM_LEADERBOARD_CACHE_PATH.parent).free scanner["cache_file_bytes"] = int(size) scanner["data_free_bytes"] = int(free) except Exception: # noqa: BLE001 pass scanner["last_save_ok"] = True scanner["last_save_at"] = int(time.time() * 1000) scanner.pop("last_save_error", None) return True except Exception as exc: scanner["last_save_ok"] = False scanner["last_save_error"] = f"{type(exc).__name__}: {str(exc)[:200]}" scanner["last_save_at"] = int(time.time() * 1000) print(f"[SwordMasters leaderboard cache save failed] {type(exc).__name__}: {str(exc)[:240]}") return False def _sm_float_sort_value(value): raw = _sm_number(value) if raw is None: return 0.0 try: number = float(raw) return number if math.isfinite(number) else 0.0 except (TypeError, ValueError): return 0.0 def _sm_leaderboard_storage_key(username: str = "", player_id: str = ""): clean_id = str(player_id or "").strip() if clean_id: return f"id:{clean_id}" username_key = _discord_lookup_key(username) return f"name:{username_key}" if username_key else "" def _sm_leaderboard_row_from_result(result: dict, source: str = "lookup"): if not isinstance(result, dict) or not result.get("ok"): return None username = str(result.get("username") or "").strip() username_key = _discord_lookup_key(username) player_id = str(result.get("player_id") or "").strip() key = _sm_leaderboard_storage_key(username, player_id) if not key: return None try: level = int(result.get("level")) except (TypeError, ValueError): level = None if level is None or level < SM_LEADERBOARD_STORE_MIN_LEVEL: return None power_raw = _sm_number(result.get("power_raw")) gold_raw = _sm_number(result.get("gold_raw")) now_ms = int(time.time() * 1000) row = { "key": key, "username_key": username_key, "username": username, "player_id": player_id, "level": int(level), "power_raw": power_raw or "0", "power": _sm_display_number(power_raw) or result.get("power") or "0", "power_sort": _sm_float_sort_value(power_raw), "gold_raw": gold_raw or "0", "gold": _sm_display_number(gold_raw) or result.get("gold") or "0", "gold_sort": _sm_float_sort_value(gold_raw), "updated_at": now_ms, "source": source, "stats_available": True, } for result_key, row_key in [ ("level_rank", "level_rank"), ("level_rank_page", "level_rank_page"), ("level_rank_page_position", "level_rank_page_position"), ("is_banned", "is_banned"), ("is_dead", "is_dead"), ]: if result.get(result_key) not in (None, ""): row[row_key] = result.get(result_key) # per-enemy kill counts (name -> count) for the mob-kill leaderboard; comes free with # every scanned/looked-up inventory. Only store non-zero so the cache stays compact. kills = {str(name): int(cnt) for name, cnt in (result.get("killed_enemies") or {}).items() if isinstance(cnt, (int, float)) and cnt} if kills: row["kills"] = kills # Aktives Schwert/Schild (Typ-Code + Level) in die Zeile -> landet im naechsten Snapshot, damit # auch der Backfill Gear bekommt. Nur wenn der Scan/Lookup sie geliefert hat. for gear_key in ("sword_type", "sword_level", "shield_type", "shield_level"): val = result.get(gear_key) if val is not None: try: row[gear_key] = int(val) except (TypeError, ValueError): pass return key, row def _sm_leaderboard_take_previous_row_unlocked(players: dict, key: str, player_id: str, username: str = ""): previous = players.get(key) or {} clean_id = str(player_id or "").strip() if not clean_id: return previous for existing_key, existing in list(players.items()): if existing_key == key or str((existing or {}).get("player_id") or "").strip() != clean_id: continue renamed = players.pop(existing_key) if not previous: previous = renamed # Collapse the legacy name-only shadow of this player. Before a player_id is # known a row is stored under "name:"; once the id is known the row # moves to "id:". Absorb the stale shadow so an id row and a name # row for the same account never coexist (write-time guard against the # duplicate leaderboard bug). shadow_key = _sm_leaderboard_storage_key(username, "") if shadow_key and shadow_key != key: shadow = players.pop(shadow_key, None) if shadow and not previous: previous = shadow return previous def _sm_leaderboard_aliases(previous: dict, current_username: str): current = str(current_username or "").strip() out = [] for alias in (previous or {}).get("aliases") or []: alias = str(alias or "").strip() if alias and alias != current and alias not in out: out.append(alias) previous_username = str((previous or {}).get("username") or "").strip() if previous_username and previous_username != current and previous_username not in out: out.insert(0, previous_username) return out def _sm_leaderboard_cleanup_players_unlocked(players: dict): if not isinstance(players, dict) or not players: return 0 changed = 0 id_user_keys = { _discord_lookup_key((row or {}).get("username")) for key, row in players.items() if str(key or "").startswith("id:") and _discord_lookup_key((row or {}).get("username")) } for key in list(players.keys()): row = players.get(key) or {} if ( str(key or "").startswith("name:") and not str(row.get("player_id") or "").strip() and _discord_lookup_key(row.get("username")) in id_user_keys ): players.pop(key, None) changed += 1 current_name_owners = {} for key, row in players.items(): username = str((row or {}).get("username") or "").strip() if username: current_name_owners.setdefault(username.lower(), set()).add(str(key)) for key, row in list(players.items()): if not isinstance(row, dict): continue username = str(row.get("username") or "").strip() kept = [] for alias in row.get("aliases") or []: alias_text = str(alias or "").strip() if not alias_text or alias_text == username: continue alias_l = alias_text.lower() conflicts_current_name = any( owner_key != str(key) for owner_key in current_name_owners.get(alias_l, ()) ) if conflicts_current_name: continue if alias_text not in kept: kept.append(alias_text) if kept: if kept != list(row.get("aliases") or []): row["aliases"] = kept changed += 1 elif row.get("aliases"): row.pop("aliases", None) changed += 1 return changed # ── Live leaderboard SSE broker (single uvicorn worker → in-process pub/sub) ── _SM_LB_SUBSCRIBERS = set() _SM_LB_SUBSCRIBERS_LOCK = threading.Lock() def _sm_lb_subscribe(): subscriber = queue.Queue(maxsize=1000) with _SM_LB_SUBSCRIBERS_LOCK: _SM_LB_SUBSCRIBERS.add(subscriber) return subscriber def _sm_lb_unsubscribe(subscriber): with _SM_LB_SUBSCRIBERS_LOCK: _SM_LB_SUBSCRIBERS.discard(subscriber) def _sm_lb_subscriber_count(): with _SM_LB_SUBSCRIBERS_LOCK: return len(_SM_LB_SUBSCRIBERS) def _sm_lb_publish_rows(rows): """Fan out a batch of changed public leaderboard rows to every SSE listener. Non-blocking: a slow/full listener drops its oldest batch rather than back-pressuring the scanner / upsert path.""" if not rows: return with _SM_LB_SUBSCRIBERS_LOCK: subs = list(_SM_LB_SUBSCRIBERS) for subscriber in subs: try: subscriber.put_nowait(rows) except queue.Full: try: subscriber.get_nowait() subscriber.put_nowait(rows) except Exception: pass # ── Power/Level/Gold Zeitreihen-Store (separat, eigener Lock/Datei) ─────────────────────────── _SM_PSERIES_LOCK = threading.RLock() _SM_PSERIES_STATE = None # {"v":1,"updated_at":ms,"series":{key:{"u":name,"p":[[...]]}}} _SM_PSERIES_DIRTY = False _SM_PSERIES_LAST_SAVE_MONOTONIC = 0.0 _SM_PSERIES_FIELDS = ["t", "level", "power", "gold", "sword", "sword_level", "shield", "shield_level"] def _sm_pseries_read_file(path: Path): if not path.exists(): return None blob = path.read_bytes() if blob[:2] == b"\x1f\x8b": # gzip-Magic blob = gzip.decompress(blob) data = json.loads(blob.decode("utf-8")) if isinstance(data, dict) and isinstance(data.get("series"), dict): return data return None def _sm_pseries_load_unlocked(): global _SM_PSERIES_STATE if _SM_PSERIES_STATE is not None: return _SM_PSERIES_STATE state = {"v": 1, "updated_at": 0, "series": {}} try: data = _sm_pseries_read_file(SM_POWER_SERIES_PATH) if data: series = {} for key, entry in (data.get("series") or {}).items(): if not isinstance(entry, dict) or not isinstance(entry.get("p"), list): continue series[str(key)] = {"u": str(entry.get("u") or ""), "p": entry.get("p")} state["series"] = series state["updated_at"] = int(data.get("updated_at") or 0) except Exception as exc: # noqa: BLE001 print(f"[power-series load failed] {type(exc).__name__}: {str(exc)[:200]}") state = {"v": 1, "updated_at": 0, "series": {}} _SM_PSERIES_STATE = state return state def _sm_pseries_write_unlocked(): state = _sm_pseries_load_unlocked() state["updated_at"] = int(time.time() * 1000) payload = json.dumps(state, ensure_ascii=True, separators=(",", ":")).encode("utf-8") SM_POWER_SERIES_PATH.parent.mkdir(parents=True, exist_ok=True) tmp = SM_POWER_SERIES_PATH.with_suffix(SM_POWER_SERIES_PATH.suffix + ".tmp") tmp.write_bytes(gzip.compress(payload, 4)) tmp.replace(SM_POWER_SERIES_PATH) def _sm_pseries_save_unlocked(force: bool = False): """Throttled: schreibt nur alle SM_POWER_SERIES_SAVE_INTERVAL Sekunden (ausser force).""" global _SM_PSERIES_DIRTY, _SM_PSERIES_LAST_SAVE_MONOTONIC if not _SM_PSERIES_DIRTY and not force: return False now = time.monotonic() if not force and _SM_PSERIES_LAST_SAVE_MONOTONIC: if now - _SM_PSERIES_LAST_SAVE_MONOTONIC < SM_POWER_SERIES_SAVE_INTERVAL: return False try: _sm_pseries_write_unlocked() _SM_PSERIES_LAST_SAVE_MONOTONIC = now _SM_PSERIES_DIRTY = False return True except Exception as exc: # noqa: BLE001 print(f"[power-series save failed] {type(exc).__name__}: {str(exc)[:200]}") return False def _sm_pseries_gear_type(value): try: return int(value) except (TypeError, ValueError): return -1 _SM_PSERIES_FULL_KEYS = None def _sm_pseries_full_name_keys(): """Normalisierte Namens-Keys der Accounts, die ALLE Punkte behalten (auch <1 Qnt).""" global _SM_PSERIES_FULL_KEYS if _SM_PSERIES_FULL_KEYS is None: keys = {_discord_lookup_key(n) for n in SM_POWER_SERIES_FULL_NAMES_RAW} keys.discard("") _SM_PSERIES_FULL_KEYS = keys return _SM_PSERIES_FULL_KEYS def _sm_pseries_is_full(username: str) -> bool: try: return _discord_lookup_key(username or "") in _sm_pseries_full_name_keys() except Exception: # noqa: BLE001 return False def _sm_power_series_record(result: dict): """Haengt einen Zeitreihen-Punkt an, wenn der Account >= 1 Qnt Power hat. Billig: nur in-memory + throttled Datei-Write. Wird NACH dem Leaderboard-Lock pro Ergebnis aufgerufen (kein verschachtelter Lock).""" global _SM_PSERIES_DIRTY if not isinstance(result, dict) or not result.get("ok"): return power = _sm_float_sort_value(result.get("power_raw")) full = _sm_pseries_is_full(result.get("username") or "") if power < SM_POWER_SERIES_MIN_POWER and not full: return # unter 1 Qnt -> gar nicht speichern (Frontend zeigt "unter 1qnt"); Ausnahme: full-Namen try: level = int(result.get("level")) except (TypeError, ValueError): return # Zeitreihe NUR an die Account-ID binden (kein name:-Fallback) -> überlebt Umbenennungen # und wird nie nach Namen fragmentiert. player_id direkt bevorzugt; fehlt sie (normaler # Lookup ohne Rang -> _sm_attach_level_ranks setzt sie nicht), wird sie über das # (scanner-gepflegte) Leaderboard per Name aufgelöst. Keine ID auflösbar -> nicht speichern. key = _sm_pseries_resolve_key(result.get("username") or "", result.get("player_id") or "") if not key.startswith("id:"): return gold = _sm_float_sort_value(result.get("gold_raw")) sword = _sm_pseries_gear_type(result.get("sword_type")) sword_lv = _sm_pseries_gear_type(result.get("sword_level")) shield = _sm_pseries_gear_type(result.get("shield_type")) shield_lv = _sm_pseries_gear_type(result.get("shield_level")) now = int(time.time()) with _SM_PSERIES_LOCK: state = _sm_pseries_load_unlocked() series = state["series"] entry = series.get(key) if entry is None: entry = {"u": str(result.get("username") or ""), "p": []} series[key] = entry else: uname = str(result.get("username") or "") if uname: entry["u"] = uname pts = entry["p"] if pts: last = pts[-1] try: dt = now - int(last[0]) except (TypeError, ValueError, IndexError): dt = SM_POWER_SERIES_MIN_GAP if dt < SM_POWER_SERIES_MIN_GAP: return # zu frisch -> Spam-Schutz (<=1 Punkt/15min/Account) # Level kann NIE fallen. Ein niedrigeres Level ist stale/fehlerhaft (z.B. ein Lookup # mit veralteten Leaderboard-Werten direkt nach einem Neustart) -> nicht speichern. try: if isinstance(last[1], (int, float)) and level < int(last[1]): return except (TypeError, ValueError, IndexError): pass # Identisch zum letzten Punkt (level/power/gold/gear)? -> ueberspringen, ausser der # Heartbeat ist um. So speichert die Reihe nur echte Aenderungen (keine redundanten # Mehrfach-Lookups), haelt aber flache Verlaeufe per Heartbeat bis ~jetzt aktuell. try: same = (int(last[1]) == level and float(last[2]) == power and float(last[3]) == gold and int(last[4]) == sword and int(last[5]) == sword_lv and int(last[6]) == shield and int(last[7]) == shield_lv) except (TypeError, ValueError, IndexError): same = False # altes 6-Feld-Format o.ae. -> nicht deduplizieren, Punkt schreiben if same and dt < SM_POWER_SERIES_HEARTBEAT: return pts.append([now, level, power, gold, sword, sword_lv, shield, shield_lv]) if len(pts) > SM_POWER_SERIES_MAX_POINTS: del pts[: len(pts) - SM_POWER_SERIES_MAX_POINTS] _SM_PSERIES_DIRTY = True _sm_pseries_save_unlocked(force=False) def _sm_power_series_record_many(results): for result in results or []: try: _sm_power_series_record(result) except Exception: # noqa: BLE001 pass _SM_PSERIES_IDCACHE = {} # username_key -> (id_key, monotonic_ts) _SM_PSERIES_IDCACHE_TTL = 600.0 # 10 min; nur erfolgreiche id-Treffer werden gecacht def _sm_pseries_resolve_key(username: str, player_id: str): """Speicher-Key aufloesen. Bevorzugt id:. Bei Nur-Name wird die id ueber den GETEILTEN name->id-Index aufgeloest (einmal pro 5 min gebaut, danach O(1)-Dict-Lookup) statt pro Cache-Miss ueber ~1,7 Mio Zeilen zu scannen -> konsistente, schnelle Chart-Ladezeiten. Zusaetzlicher per-Name-Cache davor fuer wiederholte Ladungen desselben Accounts.""" key = _sm_leaderboard_storage_key(username or "", player_id or "") if key.startswith("id:"): return key want = _discord_lookup_key(username or "") if not want: return key now = time.monotonic() cached = _SM_PSERIES_IDCACHE.get(want) if cached and now - cached[1] < _SM_PSERIES_IDCACHE_TTL: return cached[0] resolved = key try: hit = _sm_pseries_lvlidx_cached().get(want) if hit: resolved = hit[0] except Exception: # noqa: BLE001 pass if resolved.startswith("id:"): _SM_PSERIES_IDCACHE[want] = (resolved, now) return resolved def _sm_pseries_point_out(p, src): """Gespeicherten Punkt in das einheitliche Ausgabeformat bringen und src anhaengen. Alt (6): [t,lvl,pow,gold,sword,shield] -> Level unbekannt (-1). Neu (8): [t,lvl,pow,gold,sword,sword_lv,shield,shield_lv]. Aus: [t,lvl,pow,gold,sword,sword_level,shield,shield_level,src].""" n = len(p) t = p[0] lvl = p[1] if n > 1 else 0 pw = p[2] if n > 2 else 0 gold = p[3] if n > 3 else 0 if n >= 8: sw, sw_lv, sh, sh_lv = p[4], p[5], p[6], p[7] else: sw = p[4] if n > 4 else -1 sh = p[5] if n > 5 else -1 sw_lv = -1 sh_lv = -1 return [t, lvl, pw, gold, sw, sw_lv, sh, sh_lv, src] def _safe_hour(t): try: return int(t) // 3600 except (TypeError, ValueError): return -1 def _sm_pseries_point_sig(p): """Vergleichs-Signatur eines Punktes OHNE Zeit (level/power/gold/gear) fuer die Dedup.""" try: return (int(p[1]), float(p[2]), float(p[3]), int(p[4]) if len(p) > 4 else -1, int(p[5]) if len(p) > 5 else -1, int(p[6]) if len(p) > 6 else -1, int(p[7]) if len(p) > 7 else -1) except (TypeError, ValueError, IndexError): return ("raw",) + tuple(p[1:]) def _sm_pseries_collapse_identical(pts): """Entfernt NUR strikt-innere Punkte einer Folge identischer Punkte (gleiches level/power/gold/ gear). Erster und letzter Punkt jeder Folge bleiben -> flache Abschnitte behalten ihre Zeitspanne (2 Punkte), aber die redundante Mitte faellt weg. Gibt (neue_liste, entfernt) zurueck.""" n = len(pts) if n <= 2: return pts, 0 out = [] removed = 0 prev_sig = None sigs = [_sm_pseries_point_sig(p) for p in pts] for i, p in enumerate(pts): same_prev = (i > 0 and sigs[i - 1] == sigs[i]) same_next = (i < n - 1 and sigs[i + 1] == sigs[i]) if same_prev and same_next: removed += 1 continue out.append(p) return out, removed def _sm_pseries_dedup_store_unlocked(): """Einmalige Aufraeum-Aktion: laeuft ueber ALLE Accounts der exakten Reihe und kollabiert vergangene identische Folge-Punkte. Gibt (accounts, punkte_vorher, punkte_nachher, entfernt).""" global _SM_PSERIES_DIRTY state = _sm_pseries_load_unlocked() series = state.get("series") or {} before = after = removed = 0 for entry in series.values(): pts = entry.get("p") or [] before += len(pts) new_pts, rem = _sm_pseries_collapse_identical(pts) if rem: entry["p"] = new_pts removed += rem after += len(entry.get("p") or []) if removed: _SM_PSERIES_DIRTY = True _sm_pseries_save_unlocked(force=True) return len(series), before, after, removed # ── Discord-Import-Store (src=3, name-basiert, UNSICHER) ──────────────────────────────────────── _SM_PSERIES_DC_LOCK = threading.RLock() _SM_PSERIES_DC_STATE = None # {"v":1,"updated_at":ms,"series":{id-key:{"u":name,"p":[[...]]}}} _SM_PSERIES_DC_NAME2TYPE = None # (sword:{normname:type}, shield:{normname:type}) def _sm_pseries_dc_load_unlocked(): global _SM_PSERIES_DC_STATE if _SM_PSERIES_DC_STATE is not None: return _SM_PSERIES_DC_STATE state = {"v": 1, "updated_at": 0, "series": {}} try: data = _sm_pseries_read_file(SM_POWER_SERIES_DISCORD_PATH) if data: series = {} for key, entry in (data.get("series") or {}).items(): if not isinstance(entry, dict) or not isinstance(entry.get("p"), list): continue series[str(key)] = {"u": str(entry.get("u") or ""), "p": entry.get("p")} state["series"] = series state["updated_at"] = int(data.get("updated_at") or 0) except Exception as exc: # noqa: BLE001 print(f"[power-series discord load failed] {type(exc).__name__}: {str(exc)[:200]}") state = {"v": 1, "updated_at": 0, "series": {}} _SM_PSERIES_DC_STATE = state return state def _sm_pseries_dc_save_unlocked(): state = _sm_pseries_dc_load_unlocked() state["updated_at"] = int(time.time() * 1000) payload = json.dumps(state, ensure_ascii=True, separators=(",", ":")).encode("utf-8") SM_POWER_SERIES_DISCORD_PATH.parent.mkdir(parents=True, exist_ok=True) tmp = SM_POWER_SERIES_DISCORD_PATH.with_suffix(SM_POWER_SERIES_DISCORD_PATH.suffix + ".tmp") tmp.write_bytes(gzip.compress(payload, 4)) tmp.replace(SM_POWER_SERIES_DISCORD_PATH) def _sm_gear_norm_name(s): import re as _re return _re.sub(r"\s+", " ", str(s or "").strip().lower()).replace("’", "'") def _sm_pseries_dc_name2type(): """Reverse-Katalog name->type fuer Schwert/Schild (einmal gebaut). Robust normalisiert.""" global _SM_PSERIES_DC_NAME2TYPE if _SM_PSERIES_DC_NAME2TYPE is not None: return _SM_PSERIES_DC_NAME2TYPE sw = {_sm_gear_norm_name(n): int(t) for t, n in SWORD_TYPE_NAMES.items()} sh = {_sm_gear_norm_name(n): int(t) for t, n in SHIELD_TYPE_NAMES.items()} _SM_PSERIES_DC_NAME2TYPE = (sw, sh) return _SM_PSERIES_DC_NAME2TYPE def _sm_pseries_current_level_index(): """{username_key -> (id_key, current_level)} aus dem Leaderboard. Einmal pro Import-Request gebaut (O(N) ueber ~1,7 Mio Zeilen). current_level = bestbekanntes aktuelles Level des Accounts; es dient der Validierung (ein historisch HOEHERES Level => falscher Account => ganzer Account wird verworfen).""" idx = {} try: with _SM_LEADERBOARD_LOCK: players = _sm_leaderboard_load_unlocked().get("players") or {} for _attempt in range(3): try: for k, r in players.items(): k = str(k) if not k.startswith("id:"): continue r = r or {} uk = r.get("username_key") or _discord_lookup_key(r.get("username")) if not uk: continue try: lvl = int(r.get("level")) except (TypeError, ValueError): continue prev = idx.get(uk) # Bei Namenskollision im Leaderboard (selten): hoechstes Level gewinnt, damit die # Validierung nicht faelschlich verwirft. if prev is None or lvl > prev[1]: idx[uk] = (k, lvl) break except RuntimeError: continue except Exception: # noqa: BLE001 pass return idx _SM_PSERIES_LVLIDX_CACHE = None # (idx, monotonic_ts) # 30 min: name->id aendert sich selten. Lange TTL -> der (GIL-haltende, sekunden-teure) O(1,6-Mio)- # Rebuild passiert nur selten und NUR lazy bei Bedarf (kein Timer im Flusher mehr), damit der # 2-vCPU-Space nicht periodisch fuer alle Requests einfriert. _SM_PSERIES_LVLIDX_TTL = 1800.0 def _sm_pseries_lvlidx_cached(force: bool = False): global _SM_PSERIES_LVLIDX_CACHE now = time.monotonic() c = _SM_PSERIES_LVLIDX_CACHE if not force and c and now - c[1] < _SM_PSERIES_LVLIDX_TTL: return c[0] idx = _sm_pseries_current_level_index() _SM_PSERIES_LVLIDX_CACHE = (idx, now) return idx def _sm_pseries_exact_max_level(id_key): with _SM_PSERIES_LOCK: entry = (_sm_pseries_load_unlocked().get("series") or {}).get(id_key) pts = (entry.get("p") if entry else None) or [] mx = 0 for p in pts: try: if int(p[1]) > mx: mx = int(p[1]) except (TypeError, ValueError, IndexError): pass return mx def _sm_pseries_import_discord_accounts(accounts: dict): """accounts = {name: [[ts_sec, level, power, gold, sword_name, sword_level, shield_name, shield_level], ...]}. Loest Namen->id auf, validiert (ein historisch hoeheres Level als aktuell => GANZER Account verworfen) und schreibt die Punkte als src=3 in den Discord-Store (ersetzt vorhandene Discord-Punkte des Accounts -> idempotent). Gibt eine Zusammenfassung zurueck.""" sw_map, sh_map = _sm_pseries_dc_name2type() lvl_idx = _sm_pseries_lvlidx_cached() imported_accounts = imported_points = 0 discarded_level = skipped_no_id = skipped_empty = 0 discarded_names = [] with _SM_PSERIES_DC_LOCK: state = _sm_pseries_dc_load_unlocked() series = state["series"] for name, rows in (accounts or {}).items(): if not rows: skipped_empty += 1 continue uk = _discord_lookup_key(name) full = uk in _sm_pseries_full_name_keys() # full-Namen: auch <1 Qnt behalten hit = lvl_idx.get(uk) if not hit: skipped_no_id += 1 continue id_key, cur_lvl = hit current = max(int(cur_lvl or 0), _sm_pseries_exact_max_level(id_key)) # Validierung: steht IRGENDWO ein hoeheres Level als aktuell -> Name zeigt (mind. teilweise) # auf einen anderen/zurueckgesetzten Account -> ALLE Nachrichten dieses Accounts verwerfen. bad = False for r in rows: try: if int(r[1]) > current: bad = True break except (TypeError, ValueError, IndexError): continue if bad: discarded_level += 1 if len(discarded_names) < 50: discarded_names.append(str(name)) continue # Punkte bauen (name->type), nach Zeit sortieren, identische Folgen kollabieren, Deckel. pts = [] for r in rows: try: t = int(r[0]); lvl = int(r[1]) pw = float(r[2]); gold = float(r[3]) except (TypeError, ValueError, IndexError): continue if pw < SM_POWER_SERIES_MIN_POWER and not full: continue sw = sw_map.get(_sm_gear_norm_name(r[4]), -1) if len(r) > 4 else -1 sw_lv = _sm_pseries_gear_type(r[5]) if len(r) > 5 else -1 sh = sh_map.get(_sm_gear_norm_name(r[6]), -1) if len(r) > 6 else -1 sh_lv = _sm_pseries_gear_type(r[7]) if len(r) > 7 else -1 if sw < 0: sw_lv = -1 if sh < 0: sh_lv = -1 pts.append([t, lvl, pw, gold, sw, sw_lv, sh, sh_lv]) if not pts: skipped_empty += 1 continue pts.sort(key=lambda x: x[0]) pts, _rem = _sm_pseries_collapse_identical(pts) if len(pts) > SM_POWER_SERIES_MAX_POINTS: pts = pts[-SM_POWER_SERIES_MAX_POINTS:] # Namens-Kollision: mehrere Display-Namen normalisieren auf dieselbe id (z.B. ein winziger # Namensvetter "oettam" vs. der echte Whale "oettaM"). Die REICHERE Reihe (mehr Punkte) # gewinnt, statt dass ein letzter kleiner Push den echten Verlauf ueberschreibt. existing = series.get(id_key) if existing and len(existing.get("p") or []) >= len(pts): skipped_empty += 1 continue series[id_key] = {"u": str(name or ""), "p": pts} # idempotent; reichere Reihe gewinnt imported_accounts += 1 imported_points += len(pts) _sm_pseries_dc_save_unlocked() return { "imported_accounts": imported_accounts, "imported_points": imported_points, "discarded_level_conflict": discarded_level, "skipped_no_id": skipped_no_id, "skipped_empty": skipped_empty, "discarded_sample": discarded_names, "leaderboard_index_size": len(lvl_idx), } # ── Backfill-Snapshot-Cache ─────────────────────────────────────────────────────────────────── # Die Leaderboard-Snapshots (stuendlich ~28h, taeglich ~9 Tage) koennen auf dem Space je ~1,7 Mio # Keys gross sein. Sie bei JEDEM /power-series-Request zu entpacken+parsen war die Hauptursache fuer # die extrem langsamen Ladezeiten. Stattdessen: ein Hintergrund-Warmer parst jede Datei EINMAL (per # mtime gated), filtert auf >= 1 Qnt (nur ~10k Accounts -> wenig RAM) und legt {key:(lvl,pow,gold)} # in den Cache. Der Request-Pfad liest NUR den Cache (keine Datei-IO) -> O(#Dateien) Dict-Lookups. _SM_PSERIES_SNAP_CACHE = {} # path -> {"mtime","ts","src","data":{key:(lvl,pow,gold)}} _SM_PSERIES_SNAP_LOCK = threading.Lock() def _sm_pseries_snapshot_files(): from datetime import datetime as _dt, timezone as _tz out = [] # Schlanke Q1-Snapshots bevorzugen (gleiche Dateinamen, ~10k statt ~1,7 Mio Keys); fuer alte # Zeitpunkte ohne Q1-Begleiter auf den vollen Snapshot zurueckfallen. seen_h = set() try: for f in SM_LB_HISTORY_HOURLY_Q1_DIR.glob("*.json.gz"): try: ts = _dt.strptime(f.name[:13], "%Y-%m-%dT%H").replace(tzinfo=_tz.utc).timestamp() except Exception: # noqa: BLE001 continue seen_h.add(f.name) out.append((f, True, 1, ts)) # src 1 = stuendlich except Exception: # noqa: BLE001 pass try: for f in SM_LB_HISTORY_HOURLY_DIR.glob("*.json.gz"): if f.name in seen_h: continue try: ts = _dt.strptime(f.name[:13], "%Y-%m-%dT%H").replace(tzinfo=_tz.utc).timestamp() except Exception: # noqa: BLE001 continue out.append((f, True, 1, ts)) except Exception: # noqa: BLE001 pass seen_d = set() try: for f in SM_LB_HISTORY_Q1_DIR.glob("*.json"): try: ts = _dt.strptime(f.stem, "%Y-%m-%d").replace(hour=12, tzinfo=_tz.utc).timestamp() except Exception: # noqa: BLE001 continue seen_d.add(f.name) out.append((f, False, 2, ts)) # src 2 = taeglich except Exception: # noqa: BLE001 pass try: for f in SM_LB_HISTORY_DIR.glob("*.json"): if f.name in seen_d: continue try: ts = _dt.strptime(f.stem, "%Y-%m-%d").replace(hour=12, tzinfo=_tz.utc).timestamp() except Exception: # noqa: BLE001 continue out.append((f, False, 2, ts)) except Exception: # noqa: BLE001 pass return out def _sm_pseries_warm_snapshot(f, is_gz, ts, src): """Eine Snapshot-Datei (mtime-gated) parsen, auf >= 1 Qnt filtern und cachen. Laeuft im Warmer- Thread, NICHT im Request-Pfad.""" path = str(f) try: mtime = f.stat().st_mtime except OSError: return with _SM_PSERIES_SNAP_LOCK: c = _SM_PSERIES_SNAP_CACHE.get(path) if c and c.get("mtime") == mtime: return # unveraendert -> schon gecacht try: raw = f.read_bytes() if is_gz: raw = gzip.decompress(raw) full = json.loads(raw.decode("utf-8")) except Exception: # noqa: BLE001 return data = {} for k, v in full.items(): if isinstance(v, list) and len(v) >= 3: try: power = float(v[1] or 0) if power >= SM_POWER_SERIES_MIN_POWER: if len(v) >= 7: # Snapshot mit Gear [lvl,pow,gold,sw,sw_lv,sh,sh_lv] data[str(k)] = (int(v[0] or 0), power, float(v[2] or 0), int(v[3]), int(v[4]), int(v[5]), int(v[6])) else: data[str(k)] = (int(v[0] or 0), power, float(v[2] or 0), -1, -1, -1, -1) except Exception: # noqa: BLE001 pass with _SM_PSERIES_SNAP_LOCK: _SM_PSERIES_SNAP_CACHE[path] = {"mtime": mtime, "ts": ts, "src": src, "data": data} def _sm_pseries_warm_snapshots(): files = _sm_pseries_snapshot_files() for f, is_gz, src, ts in files: try: _sm_pseries_warm_snapshot(f, is_gz, ts, src) except Exception: # noqa: BLE001 pass live = {str(f) for f, _g, _s, _t in files} with _SM_PSERIES_SNAP_LOCK: for p in list(_SM_PSERIES_SNAP_CACHE.keys()): if p not in live: _SM_PSERIES_SNAP_CACHE.pop(p, None) # gelöschte (retention) Dateien raus def _sm_power_series_backfill(key: str): """Grobe historische Punkte aus dem GECACHTEN Snapshot-Index (kein Datei-Parse im Request-Pfad). Snapshots speichern NUR [level,power,gold] -> kein Schwert/Schild (die -1). src 1=stuendl.,2=taegl. -> dict {hour_bucket: (priority, out-point)} im einheitlichen 9-Feld-Ausgabeformat.""" with _SM_PSERIES_SNAP_LOCK: entries = [(c.get("ts"), c.get("src"), (c.get("data") or {}).get(key)) for c in _SM_PSERIES_SNAP_CACHE.values()] buckets = {} # hour_bucket -> (priority, point); kleinere priority gewinnt (stuendlich < taeglich) for ts, src, v in entries: if not v or ts is None: continue level, power, gold = v[0], v[1], v[2] if len(v) >= 7: sw, sw_lv, sh, sh_lv = v[3], v[4], v[5], v[6] else: sw = sw_lv = sh = sh_lv = -1 if power < SM_POWER_SERIES_MIN_POWER: continue priority = 0 if src == 1 else 1 hb = int(ts // 3600) cur = buckets.get(hb) if cur is None or priority < cur[0]: buckets[hb] = (priority, [int(ts), int(level), power, gold, sw, sw_lv, sh, sh_lv, src]) return buckets _SM_PSERIES_LAST_DEDUP_MONOTONIC = 0.0 SM_POWER_SERIES_DEDUP_INTERVAL = max(3600.0, float(_os.getenv("SM_POWER_SERIES_DEDUP_INTERVAL", "86400.0"))) def _sm_power_series_flusher(): global _SM_PSERIES_LAST_DEDUP_MONOTONIC time.sleep(25) # Boot nicht mit dem (evtl. grossen) ersten Snapshot-Parse blockieren while True: try: with _SM_PSERIES_LOCK: _sm_pseries_save_unlocked(force=False) except Exception: # noqa: BLE001 pass try: _sm_pseries_warm_snapshots() # Backfill-Cache aktuell halten (mtime-gated, billig) except Exception: # noqa: BLE001 pass # (Kein proaktiver name->id-Index-Rebuild hier: der O(1,6-Mio)-Aufbau haelt den GIL und # blockiert auf dem 2-vCPU-Space ALLE Requests fuer Sekunden. Ihn alle ~5 min im Hintergrund # laufen zu lassen, verursachte periodische Freezes im Lookup. Der Index wird jetzt NUR noch # lazy bei Bedarf gebaut und lange gecacht; name->id aendert sich ohnehin selten.) # Taeglich die vergangenen identischen Folge-Punkte kollabieren. Der Heartbeat haengt pro # Idle-Account 1 identischen Punkt/Tag an -> ohne das wuerde die Reihe unbegrenzt wachsen. # So bleiben Idle-Accounts dauerhaft bei ~2 Punkten (Jahre-tragfaehig). try: now_m = time.monotonic() if now_m - _SM_PSERIES_LAST_DEDUP_MONOTONIC >= SM_POWER_SERIES_DEDUP_INTERVAL: with _SM_PSERIES_LOCK: _sm_pseries_dedup_store_unlocked() _SM_PSERIES_LAST_DEDUP_MONOTONIC = now_m except Exception: # noqa: BLE001 pass time.sleep(SM_POWER_SERIES_SAVE_INTERVAL) threading.Thread(target=_sm_power_series_flusher, name="sm-power-series-flush", daemon=True).start() def _sm_leaderboard_upsert_lookup_results(results, source: str = "lookup", save: bool = True): updated = 0 changed = [] with _SM_LEADERBOARD_LOCK: state = _sm_leaderboard_load_unlocked() players = state.setdefault("players", {}) for result in results or []: parsed = _sm_leaderboard_row_from_result(result, source=source) if not parsed: continue key, row = parsed previous = _sm_leaderboard_take_previous_row_unlocked( players, key, row.get("player_id"), row.get("username") ) if previous.get("created_at"): row["created_at"] = previous["created_at"] else: row["created_at"] = row["updated_at"] aliases = _sm_leaderboard_aliases(previous, row.get("username")) if aliases: row["aliases"] = aliases merged = {**previous, **row} players[key] = merged updated += 1 if int(merged.get("level") or 0) >= SM_LEADERBOARD_MIN_LEVEL: changed.append(merged) if updated: state["updated_at"] = int(time.time() * 1000) if save: _sm_leaderboard_save_unlocked(min_interval=SM_LEADERBOARD_SAVE_INTERVAL) # Zeitreihe (>= 1 Qnt) ausserhalb des Leaderboard-Locks mitschreiben. _sm_power_series_record_many(results) # Push changed rows to live (SSE) listeners, but only if someone is watching. if changed and _sm_lb_subscriber_count(): try: _sm_lb_publish_rows([_sm_leaderboard_public_row(row) for row in changed]) except Exception: pass return updated def _sm_leaderboard_ingest_rows(results, save: bool = True): """Schneller Ingest-Pfad fuer gepushte Daten: wie upsert_lookup_results, aber OHNE den O(N)-Rename-Kollaps pro Zeile (der bei ~370k Zeilen jeden POST auf zig Sekunden aufblaeht). Ingest ist immer id-basiert -> vorherige Zeile per players.get(key) in O(1). Damit ist ein Push von hunderten Accounts in Millisekunden durch.""" updated = 0 with _SM_LEADERBOARD_LOCK: state = _sm_leaderboard_load_unlocked() players = state.setdefault("players", {}) for result in results or []: parsed = _sm_leaderboard_row_from_result(result, source="ingest") if not parsed: continue key, row = parsed previous = players.get(key) or {} if previous.get("created_at"): row["created_at"] = previous["created_at"] else: row["created_at"] = row["updated_at"] aliases = _sm_leaderboard_aliases(previous, row.get("username")) if aliases: row["aliases"] = aliases players[key] = {**previous, **row} updated += 1 if updated: state["updated_at"] = int(time.time() * 1000) if save: _sm_leaderboard_save_unlocked(min_interval=SM_LEADERBOARD_SAVE_INTERVAL) # Zeitreihe (>= 1 Qnt) ausserhalb des Leaderboard-Locks mitschreiben. _sm_power_series_record_many(results) if updated: _SM_LB_DIRTY.set() # neue Daten -> Hintergrund-Warmer rechnet die Views neu return updated def _sm_leaderboard_lookup_worker(): while True: rows = _SM_LEADERBOARD_LOOKUP_QUEUE.get() time.sleep(0.05) batch = list(rows or []) while True: try: batch.extend(_SM_LEADERBOARD_LOOKUP_QUEUE.get_nowait() or []) except queue.Empty: break try: _sm_leaderboard_upsert_lookup_results(batch, source="lookup") except Exception as exc: print(f"[SwordMasters leaderboard async upsert failed] {type(exc).__name__}: {str(exc)[:240]}") def _sm_leaderboard_enqueue_lookup_results(results): global _SM_LEADERBOARD_LOOKUP_THREAD rows = [dict(result) for result in (results or []) if isinstance(result, dict) and result.get("ok")] if not rows: return False with _SM_LEADERBOARD_LOOKUP_THREAD_LOCK: if not _SM_LEADERBOARD_LOOKUP_THREAD or not _SM_LEADERBOARD_LOOKUP_THREAD.is_alive(): _SM_LEADERBOARD_LOOKUP_THREAD = threading.Thread( target=_sm_leaderboard_lookup_worker, name="swordmasters-leaderboard-lookups", daemon=True, ) _SM_LEADERBOARD_LOOKUP_THREAD.start() try: _SM_LEADERBOARD_LOOKUP_QUEUE.put_nowait(rows) return True except queue.Full: print("[SwordMasters leaderboard async upsert skipped] lookup queue is full") return False def _sm_leaderboard_upsert_rank_entries(entries, source: str = "rank-scanner"): updated = 0 with _SM_LEADERBOARD_LOCK: state = _sm_leaderboard_load_unlocked() players = state.setdefault("players", {}) for key, entry in entries or []: try: level = int(entry.get("level")) except (TypeError, ValueError): continue if level < SM_LEADERBOARD_STORE_MIN_LEVEL: continue username = str(entry.get("username") or "") username_key = _discord_lookup_key(username) player_id = str(entry.get("player_id") or "") storage_key = _sm_leaderboard_storage_key(username, player_id) previous = _sm_leaderboard_take_previous_row_unlocked( players, storage_key, player_id, username ) row = { **previous, "key": storage_key, "username_key": username_key, "username": str(username or previous.get("username") or ""), "player_id": str(player_id or previous.get("player_id") or ""), "level": level, "level_rank": entry.get("rank"), "level_rank_page": entry.get("page"), "level_rank_page_position": entry.get("page_position"), "rank_updated_at": int(time.time() * 1000), # Fresh ban/dead status from this pagination sweep (authoritative, # so an unban flips it back). Used to hide banned accounts. "is_banned": bool(entry.get("is_banned")), "is_dead": bool(entry.get("is_dead")), } aliases = _sm_leaderboard_aliases(previous, username) if aliases: row["aliases"] = aliases if not previous: row.update({ "power_raw": "0", "power": "", "power_sort": 0.0, "gold_raw": "0", "gold": "", "gold_sort": 0.0, "updated_at": 0, "created_at": int(time.time() * 1000), "source": source, "stats_available": False, }) players[storage_key] = row updated += 1 if updated: state["updated_at"] = int(time.time() * 1000) if updated: _SM_LB_DIRTY.set() return updated def _sm_leaderboard_public_row(row: dict): return { "key": row.get("key") or _discord_lookup_key(row.get("username")), "username_key": row.get("username_key") or _discord_lookup_key(row.get("username")), "username": row.get("username") or "", "player_id": row.get("player_id") or "", "level": int(row.get("level") or 0), "level_rank": row.get("level_rank"), "power": row.get("power") or "0", "power_raw": row.get("power_raw") or "0", "power_sort": float(row.get("power_sort") or 0), "gold": row.get("gold") or "0", "gold_raw": row.get("gold_raw") or "0", "gold_sort": float(row.get("gold_sort") or 0), "updated_at": int(row.get("updated_at") or 0), "source": row.get("source") or "", "stats_available": bool(row.get("stats_available", True)), "aliases": list(row.get("aliases") or []), } def _sm_leaderboard_sort_value(row: dict, sort_by: str): if sort_by == "gold": return float(row.get("gold_sort") or 0) if sort_by == "level": return int(row.get("level") or 0) return float(row.get("power_sort") or 0) def _sm_leaderboard_row_names(row: dict): names = [ str(row.get("username") or "").strip(), str(row.get("username_key") or "").strip(), str(row.get("player_id") or "").strip(), ] for alias in row.get("aliases") or []: alias_text = str(alias or "").strip() if alias_text: names.append(alias_text) names.append(_discord_lookup_key(alias_text)) return [name for name in names if name] def _sm_lb_display_names(row: dict): """Human-readable names of a row (current username + former display names/aliases), with original capitalisation preserved. Deliberately EXCLUDES the normalised lowercase username_key and the player_id, so a case-sensitive search actually respects case.""" out = [str(row.get("username") or "").strip()] for alias in (row.get("aliases") or []): a = str(alias or "").strip() if a: out.append(a) return [n for n in out if n] def _sm_leaderboard_row_matches_query(row: dict, query: str, query_key: str, exact: bool, case_sensitive: bool = False): query_text = str(query or "").strip() query_key_l = str(query_key or "").lower() if not query_text and not query_key_l: return False if case_sensitive: # Match ONLY display names (case preserved) — not the lowercase username_key/player_id. for nm in _sm_lb_display_names(row): if (nm == query_text) if exact else (query_text and query_text in nm): return True return False # Case-insensitive (default): robust match over all identifiers incl. the normalised key. q_cmp = query_text.lower() for name in _sm_leaderboard_row_names(row): n_cmp = str(name or "").strip().lower() if not n_cmp: continue if exact: if n_cmp == q_cmp: return True else: if q_cmp and q_cmp in n_cmp: return True if query_key_l and query_key_l in n_cmp: return True return False def _sm_lb_wildcard_matcher(query: str, case_sensitive: bool = False): """Predicate(row)->bool for a glob query (``*`` = any run, ``?`` = one char) matched, ANCHORED, against the row's DISPLAY name(s): ``niceface*`` = starts-with, ``*face`` = ends-with, ``*face*`` = contains. Case-insensitive unless ``case_sensitive``. Matches only display names (not the lowercase key/player_id) so case-sensitivity is honoured. Used by the mod-only wildcard leaderboard filter (alt-hunting).""" import fnmatch q = str(query or "").strip() try: rx = re.compile(fnmatch.translate(q), 0 if case_sensitive else re.IGNORECASE) except re.error: rx = None def match(row): if rx is None: return False for nm in _sm_lb_display_names(row): if rx.match(nm): return True return False return match # Glob-Metazeichen, an denen der literale Kern zerschnitten wird (fnmatch: * ? [ ]). _SM_WILDCARD_SPLIT = re.compile(r"[*?\[\]]") def _sm_wildcard_literal_core(query: str) -> str: """Laengster wildcard-freier Literal-Abschnitt einer Glob-Query, kleingeschrieben. ``niceface*`` -> ``niceface``, ``*face*`` -> ``face``, ``ni?eface`` -> ``eface``. Jeder String, der auf das Glob passt, MUSS diesen Abschnitt als Teilstring enthalten -> ein billiger ``in``-Vorfilter darauf verwirft Nicht-Treffer ohne Regex und laesst garantiert keinen echten Treffer aus (Obermenge). Leer = kein brauchbarer Kern.""" q = str(query or "").strip().lower() if not q: return "" segs = [s for s in _SM_WILDCARD_SPLIT.split(q) if s] return max(segs, key=len) if segs else "" # Vorberechneter Wildcard-Scan-Index, EINMAL je Index-Generation gebaut (base ist bei jedem # Rebuild ein frisches Listen-Objekt -> Identitaet als Cache-Schluessel). Als ein Tupel # gehalten, damit Lese-/Schreibzugriff atomar ist (kein zerrissenes Paar bei parallelem Bau). # big = alle Display-Namen kleingeschrieben zu EINEM String verkettet, jede Zeile als # ``\x01name1\x01name2\x01`` (Trenner \x01 kommt in keinem Kern vor -> ein Kern kann # keine Zeilengrenze ueberspannen). # starts = numpy-Startoffset jeder Zeile in ``big`` -> ein Treffer-Offset wird per # vektorisiertem searchsorted seiner Zeile zugeordnet. # Damit wird der Kern-Vorfilter EIN C-Level-Substring-Scan (re.finditer) ueber den grossen # String statt ~1,5 Mio Python-Iterationen. Rein ein Vorfilter -- die Wahrheit bleibt der # Matcher, also aendert sich kein angezeigtes Ergebnis. _SM_LB_WILDCARD_SCAN = (None, None, None) def _sm_lb_haystack_names(row: dict): """Kleingeschriebene, entdupte Namen einer Zeile fuer den SUCH-Heuhaufen: sichtbare Namen (username + aliases) UND ihre normalisierten Keys (nur a-z0-9). So findet der schnelle Big-String-Scan dieselben Zeilen wie das Such-Praedikat -- das ebenfalls gegen Name UND Key matcht, damit auch Fancy-/Sonderzeichen-Namen ueber ihre normalisierte Form gefunden werden. OHNE player_id (danach sucht niemand namentlich).""" out = [] seen = set() def add(s): s = str(s or "").strip().lower() if s and s not in seen: seen.add(s) out.append(s) add(row.get("username")) add(row.get("username_key")) for alias in (row.get("aliases") or []): add(alias) add(_discord_lookup_key(alias)) return out def _sm_lb_wildcard_scan_index(base: list): """(big, starts) fuer ``base``; ``(None, None)`` ohne numpy (dann Loop-Pfad). Normalfall: der aktuelle Index traegt big/starts bereits (atomar im Rebuild gebaut) -> reiner Lookup. Fallback (pytest / eine aeltere base): einmal bauen und modulweit cachen.""" idx = _SM_LB_INDEX if idx is not None and idx.get("base") is base and idx.get("wc_big") is not None: return idx["wc_big"], idx["wc_starts"] global _SM_LB_WILDCARD_SCAN cached = _SM_LB_WILDCARD_SCAN if cached[0] is base: return cached[1], cached[2] if not _HAS_NUMPY: return None, None n = len(base) hay = [""] * n for i, r in enumerate(base): # Segment je Zeile: "\x01name\x01key\x01alias\x01..." -- Trenner \x01 kommt in keinem # Namen/Kern vor, ein Treffer kann also keine Zeilengrenze ueberspannen. Nie leer. hay[i] = "\x01" + "\x01".join(_sm_lb_haystack_names(r)) + "\x01" big = "".join(hay) lens = _np.fromiter((len(h) for h in hay), dtype=_np.int64, count=n) starts = _np.zeros(n, dtype=_np.int64) if n > 1: _np.cumsum(lens[:-1], out=starts[1:]) _SM_LB_WILDCARD_SCAN = (base, big, starts) # atomarer Rebind (CPython) -> kein torn read return big, starts def _sm_lb_exact_match_pairs(base, order, query, query_key, case_sensitive): """Alle SICHTBAREN Zeilen, deren DISPLAY-Name (case-insensitiv) GENAU der Query entspricht -- also alle Accounts, die denselben Namen in unterschiedlicher Schreibweise tragen (oettaM / OETTAM / OeTtAm) -- als ``[(rang0, row), ...]`` in Rangfolge. Nutzt denselben schnellen Big-String-Scan wie die Wildcard-Suche: das needle ``\\x01name\\x01`` trifft genau ganze Namens-Segmente (kein Teilstring). Bestaetigt jeden Kandidaten mit dem echten exact-Praedikat (haelt ``case_sensitive``). Rang kommt aus der inversen Permutation des aktuellen Views; Kandidaten ausserhalb des Views (unter min_level / anderes Measure) fallen per -1-Sentinel raus. Rueckgabe ``None`` = Schnell-Index nicht verfuegbar -> alter Pfad.""" if order is None: return None big, starts = _sm_lb_wildcard_scan_index(base) if big is None: return None q_lc = str(query or "").strip().lower() if not q_lc: return None needle = "\x01" + q_lc + "\x01" hits = [] i = big.find(needle) while i != -1: hits.append(i + 1) # in den Namen hinein zeigen (hinter den fuehrenden Trenner) i = big.find(needle, i + 1) if not hits: return [] cand = _np.unique(_np.searchsorted(starts, _np.asarray(hits, dtype=_np.int64), side="right") - 1) inv = _np.full(len(base), -1, dtype=_np.int64) # base-Index -> Rang (-1 = nicht in dieser View) inv[order] = _np.arange(len(order)) pairs = [] for bi in cand.tolist(): rank0 = int(inv[bi]) if rank0 < 0: continue # unter min_level / nicht im aktuellen Measure-View -> unsichtbar r = base[bi] if _sm_leaderboard_row_matches_query(r, query, query_key, exact=True, case_sensitive=case_sensitive): pairs.append((rank0, r)) pairs.sort(key=lambda t: t[0]) return pairs # So viele Top-Raenge werden zuerst direkt (billiges Segment-``in``) auf einen Fuzzy-Treffer # geprueft. Deckt haeufige Begriffe (Treffer ganz oben) UND ranghohe Treffer sofort ab, ohne den # schweren Per-Zeile-Scan. Nur wenn hier NICHTS liegt, wird ueber alle Vorkommen eingesammelt. _SM_LB_FUZZY_TOP_K = 20000 def _sm_lb_fuzzy_focus(base, order, query, query_key, case_sensitive): """(focus_index, focus_key) der Zeile mit dem KLEINSTEN Rang, deren Name/Key den Suchbegriff als Teilzeichenkette enthaelt (fuzzy) -- fuer den Sprung, wenn es keinen exakten Treffer gibt. Ersetzt den alten O(N)-Scan ueber die ganze View (der bei haeufigen/rangtiefen/keinen Treffern bis ~20-30s brauchte). Gleiche Semantik (ranghoechster Fuzzy-Treffer). ``(None, "")`` = kein Treffer.""" big, starts = _sm_lb_wildcard_scan_index(base) if order is not None else (None, None) if order is not None and big is not None: q_lc = str(query or "").strip().lower() terms = [t for t in {q_lc, str(query_key or "").lower()} if t] if not terms: return None, "" n = len(base) big_len = len(big) def _seg_has_term(bi): end = int(starts[bi + 1]) if bi + 1 < n else big_len seg = big[int(starts[bi]):end] # kleingeschriebenes "\x01name\x01key\x01" return any(t in seg for t in terms) # 1) Die Top-Raenge direkt pruefen: haeufige Begriffe (Treffer ganz oben) und ranghohe # Treffer sind so nach wenigen Iterationen gefunden -- ohne O(N) und ohne schweres # Praedikat. Ein Segment-Treffer IST der Fuzzy-Match (cs=aus); cs=an nur bestaetigen. k = min(_SM_LB_FUZZY_TOP_K, len(order)) for index, bi in enumerate(order[:k].tolist()): if _seg_has_term(bi): if case_sensitive and not _sm_leaderboard_row_matches_query( base[bi], query, query_key, exact=False, case_sensitive=True): continue return index, base[bi].get("key") or "" # 2) Nichts in den Top-Raengen -> Treffer (falls es welche gibt) liegen tiefer/sind selten. # Alle Vorkommen einsammeln, Kandidaten -> Raenge, kleinsten in-View-Rang vektorisiert. if all(big.find(t) == -1 for t in terms): return None, "" # gar kein Treffer -> sofort offs = [] for t in terms: i = big.find(t) while i != -1: offs.append(i) i = big.find(t, i + 1) cand = _np.unique(_np.searchsorted(starts, _np.asarray(offs, dtype=_np.int64), side="right") - 1) inv = _np.full(n, -1, dtype=_np.int64) inv[order] = _np.arange(len(order)) ranks = inv[cand] # Rang jeder Kandidatenzeile (-1 = nicht in View) if not case_sensitive: BIG = _np.iinfo(_np.int64).max masked = _np.where(ranks >= 0, ranks, BIG) j = int(masked.argmin()) if int(masked[j]) == BIG: return None, "" # alle Treffer ausserhalb der View return int(ranks[j]), base[int(cand[j])].get("key") or "" for j in _np.argsort(ranks).tolist(): # cs=an: in Rangfolge bestaetigen rank0 = int(ranks[j]) if rank0 < 0: continue bi = int(cand[j]) if _sm_leaderboard_row_matches_query(base[bi], query, query_key, exact=False, case_sensitive=True): return rank0, base[bi].get("key") or "" return None, "" # Fallback (kein numpy / kein Scan-Index): alter Scan ueber base in Reihenfolge. seq = base if order is None else [base[int(p)] for p in order.tolist()] for index, row in enumerate(seq): if _sm_leaderboard_row_matches_query(row, query, query_key, exact=False, case_sensitive=case_sensitive): return index, row.get("key") or "" return None, "" def _sm_leaderboard_dedupe_public_rows(rows: list) -> list: """Drop legacy name-only shadow rows that duplicate an id-keyed account. A row keyed ``name:`` with no player_id is an older snapshot of an account that is now stored under ``id:``. When both are present the same player would be listed twice, so the shadow is removed. Two different accounts that merely share a display name each keep their distinct id rows. """ id_username_keys = { row.get("username_key") for row in rows if str(row.get("key") or "").startswith("id:") and row.get("username_key") } deduped = [] for row in rows: key = str(row.get("key") or "") if ( key.startswith("name:") and not str(row.get("player_id") or "").strip() and row.get("username_key") in id_username_keys ): continue deduped.append(row) return deduped # ── Leaderboard-Historie: taegliche Snapshots -> Tages-/Wochen-Diff ───────── # Kompakte Snapshots {key:[level,power_sort,gold_sort]} pro Tag neben dem Cache # (auf /data persistent). Damit zeigt das Leaderboard, wie sich Level/Power/Gold # seit gestern und seit letzter Woche geaendert haben. Rueckwirkend gibt es keine # Daten -> die Diffs fuellen sich ueber die naechsten Tage. SM_LB_HISTORY_DIR = SM_LEADERBOARD_CACHE_PATH.parent / "lb_history" SM_LB_HISTORY_KEEP_DAYS = max(9, int(_os.getenv("SM_LB_HISTORY_KEEP_DAYS", "9"))) # Stuendliche, gzip-komprimierte Snapshots fuer das ROLLIERENDE 24h-Delta (Basiswert ~24h # zurueck). Wochendelta bleibt bewusst auf den Tages-Snapshots (grobe Granularitaet reicht). SM_LB_HISTORY_HOURLY_DIR = SM_LEADERBOARD_CACHE_PATH.parent / "lb_history_hourly" SM_LB_HISTORY_HOURLY_KEEP = max(26, int(_os.getenv("SM_LB_HISTORY_HOURLY_KEEP", "28"))) # Stunden # Schlanke "nur >= 1 Qnt"-Begleit-Snapshots (gleiche Dateinamen, eigene Ordner). Der Power-Series- # Backfill-Warmer braucht NUR diese ~10k Accounts; so parst er ~10k statt ~1,7 Mio Keys pro Datei # -> kein stuendlicher/Boot-Freeze mehr. Die vollen Snapshots bleiben fuer d1/d7-Deltas unveraendert. SM_LB_HISTORY_Q1_DIR = SM_LEADERBOARD_CACHE_PATH.parent / "lb_history_q1" SM_LB_HISTORY_HOURLY_Q1_DIR = SM_LEADERBOARD_CACHE_PATH.parent / "lb_history_hourly_q1" # Ein Tages-Snapshot mit weniger als so vielen Leaderboard-Spielern (Level >= MIN_LEVEL) gilt # als "Dud" (z.B. Census-/Cold-Start-Write) und wird weder als Wochen-Basiswert benutzt noch # ueberhaupt geschrieben. Real sind es ~370k; der kaputte 7-Tage-Snapshot hatte 7. SM_LB_SNAPSHOT_MIN_QUALIFIED = max(0, int(_os.getenv("SM_LB_SNAPSHOT_MIN_QUALIFIED", "5000"))) # d1 (rollierend, stuendlich neu ausgewaehlt) und d7 (taeglich) getrennt gecacht. _SM_LB_HISTORY_CACHE = {"hour": None, "d1": {}, "d1_age": None, "day": None, "d7": {}, "d7_age": None} _SM_LB_HISTORY_LOCK = threading.Lock() def _sm_lb_now_hour(): from datetime import datetime as _dt, timezone as _tz return _dt.now(_tz.utc).replace(minute=0, second=0, microsecond=0) def _sm_lb_today(): from datetime import datetime as _dt, timezone as _tz return _dt.now(_tz.utc).date() def _sm_snapshot_row_compact(row: dict): """Kompakte Snapshot-Zeile. Basis [level, power, gold] (len 3); hat die Zeile aktives Schwert/Schild, werden [sword_type,sword_level,shield_type,shield_level] angehaengt (len 7). Accounts OHNE Gear bleiben len 3 -> kein Aufblaehen der 1,7-Mio-Zeilen-Snapshots. Die d1/d7- Delta-Leser nutzen nur Index 0/1/2, bleiben also unberuehrt.""" base = [int(row.get("level") or 0), float(row.get("power_sort") or 0), float(row.get("gold_sort") or 0)] if row.get("sword_type") is None and row.get("shield_type") is None: return base def g(k): v = row.get(k) try: return int(v) except (TypeError, ValueError): return -1 return base + [g("sword_type"), g("sword_level"), g("shield_type"), g("shield_level")] def _sm_snapshot_slim(compact: dict) -> dict: """Nur die >= 1 Qnt-Zeilen (Power = Index 1) fuer den Power-Series-Backfill. ~10k statt ~1,7 Mio Keys -> der Warmer parst winzige Dateien statt der vollen Leaderboard-Snapshots.""" thr = SM_POWER_SERIES_MIN_POWER out = {} for k, v in compact.items(): try: if v and float(v[1] or 0) >= thr: out[k] = v except (TypeError, ValueError, IndexError): pass return out def _sm_leaderboard_snapshot_daily(state: dict): """Einmal pro Tag einen kompakten Snapshot speichern (billiger Datei-Check sonst).""" from datetime import datetime as _dt, timedelta as _td try: today = _sm_lb_today().isoformat() SM_LB_HISTORY_DIR.mkdir(parents=True, exist_ok=True) f = SM_LB_HISTORY_DIR / f"{today}.json" if f.exists(): return players = state.get("players") or {} compact = {} for key, row in players.items(): if not key or not isinstance(row, dict): continue compact[key] = _sm_snapshot_row_compact(row) qualified = sum(1 for v in compact.values() if v and (v[0] or 0) >= SM_LEADERBOARD_MIN_LEVEL) if qualified < SM_LB_SNAPSHOT_MIN_QUALIFIED: # Dud nicht festschreiben (sonst filtert das Wochen-Delta spaeter fast alle raus). # Kein f schreiben -> ein spaeterer, gesunder Write desselben Tages holt es nach. print(f"[lb-history] daily snapshot skipped: only {qualified} players " f">= {SM_LEADERBOARD_MIN_LEVEL} (cold/census state?)") return tmp = f.with_suffix(".json.tmp") tmp.write_text(json.dumps(compact, separators=(",", ":")), encoding="utf-8") tmp.replace(f) # Schlanker >=1Qnt-Begleit-Snapshot fuer den Power-Series-Backfill (gleicher Dateiname). try: SM_LB_HISTORY_Q1_DIR.mkdir(parents=True, exist_ok=True) qf = SM_LB_HISTORY_Q1_DIR / f"{today}.json" qtmp = qf.with_suffix(".json.tmp") qtmp.write_text(json.dumps(_sm_snapshot_slim(compact), separators=(",", ":")), encoding="utf-8") qtmp.replace(qf) except Exception as _exc: # noqa: BLE001 print(f"[lb-history] daily q1 slim skipped: {_exc}") cutoff = _sm_lb_today() - _td(days=SM_LB_HISTORY_KEEP_DAYS + 1) for base_dir in (SM_LB_HISTORY_DIR, SM_LB_HISTORY_Q1_DIR): for old in base_dir.glob("*.json"): try: if _dt.fromisoformat(old.stem).date() < cutoff: old.unlink() except Exception: # noqa: BLE001 pass # Cache invalidieren, damit neue Historie sofort genutzt wird with _SM_LB_HISTORY_LOCK: _SM_LB_HISTORY_CACHE["day"] = None except Exception as exc: # noqa: BLE001 print(f"[lb-history] snapshot skipped: {exc}") def _sm_lb_load_snapshot_near(target_date, max_back: int = 4): """Snapshot fuer target_date, sonst der naechst-aeltere (bis max_back Tage). -> (dict, tatsaechliches_alter_in_tagen) oder (None, None).""" from datetime import timedelta as _td for back in range(0, max_back + 1): d = target_date - _td(days=back) f = SM_LB_HISTORY_DIR / f"{d.isoformat()}.json" if f.exists(): try: data = json.loads(f.read_text(encoding="utf-8")) return data, (_sm_lb_today() - d).days except Exception: # noqa: BLE001 continue return None, None def _sm_lb_snapshot_qualified(data): """Anzahl Leaderboard-Spieler (Level >= MIN_LEVEL) in einem Snapshot -> Dichte-Mass, um Dud-Snapshots (Census-/Cold-Start-Writes mit fast keinen echten Spielern) zu erkennen.""" try: return sum(1 for v in data.values() if isinstance(v, (list, tuple)) and v and (v[0] or 0) >= SM_LEADERBOARD_MIN_LEVEL) except Exception: # noqa: BLE001 return 0 def _sm_lb_week_snapshot(today): """Referenz-Snapshot fuer 'In 1 week': moeglichst ~7 Tage alt, ABER brauchbar. Zwei Fallstricke, die das Wochen-Delta sonst leeren: 1) Dud-Snapshots (Census-/Cold-Start-Write) mit fast keinen echten Spielern. 2) Aeltere Snapshots, die NUR >=1000er enthalten (~370k) -> in der Mod-Ansicht (Level 100+) fehlt damit die ganze 100..999-Population (~1,3 Mio) im Basiswert, und fast alle fallen aus dem Wochen-Delta. Daher: unter den Snapshots im Fenster (3..11 Tage) den an 7 Tagen naechsten waehlen, der (a) KEIN Dud ist (genug >=MIN_LEVEL-Spieler) UND (b) die aktuelle Population abdeckt (Groesse >= 50% des groessten Kandidaten). -> (dict, alter_in_tagen) o. (None,None).""" from datetime import timedelta as _td ordered = sorted(range(3, 12), key=lambda a: (abs(a - 7), -a)) # 7,8,6,9,5,10,4,11,3 cands = [] for age in ordered: f = SM_LB_HISTORY_DIR / f"{(today - _td(days=age)).isoformat()}.json" if not f.exists(): continue try: data = json.loads(f.read_text(encoding="utf-8")) except Exception: # noqa: BLE001 continue cands.append((age, data, len(data), _sm_lb_snapshot_qualified(data))) if not cands: return None, None size_floor = max(sz for _, _, sz, _ in cands) * 0.5 # bevorzugt: kein Dud UND volle Abdeckung, naechster an 7 Tagen (cands ist so sortiert) for age, data, sz, qual in cands: if qual >= SM_LB_SNAPSHOT_MIN_QUALIFIED and sz >= size_floor: return data, age # Fallback: groesster Nicht-Dud; sonst der naechste an 7 Tagen non_dud = [(age, data, sz) for age, data, sz, qual in cands if qual >= SM_LB_SNAPSHOT_MIN_QUALIFIED] if non_dud: age, data, _sz = max(non_dud, key=lambda t: t[2]) return data, age age, data, _sz, _q = cands[0] return data, age def _sm_leaderboard_snapshot_hourly(): """Einmal pro Stunde einen kompakten, gzip-komprimierten Snapshot {key:[level,power,gold]} schreiben -> Basiswert fuers rollierende 24h-Delta. Billiger Datei-Check, wenn die Stunde schon gespeichert ist.""" from datetime import timezone as _tz, datetime as _dt, timedelta as _td try: hour = _sm_lb_now_hour() stamp = hour.strftime("%Y-%m-%dT%H") SM_LB_HISTORY_HOURLY_DIR.mkdir(parents=True, exist_ok=True) f = SM_LB_HISTORY_HOURLY_DIR / f"{stamp}.json.gz" if f.exists(): return with _SM_LEADERBOARD_LOCK: raw = list((_sm_leaderboard_load_unlocked().get("players") or {}).items()) compact = {} for key, row in raw: if not key or not isinstance(row, dict): continue compact[key] = _sm_snapshot_row_compact(row) payload = json.dumps(compact, separators=(",", ":")).encode("utf-8") tmp = f.with_suffix(".gz.tmp") tmp.write_bytes(gzip.compress(payload, 6)) tmp.replace(f) # Schlanker >=1Qnt-Begleit-Snapshot fuer den Power-Series-Backfill (gleicher Dateiname). try: SM_LB_HISTORY_HOURLY_Q1_DIR.mkdir(parents=True, exist_ok=True) qf = SM_LB_HISTORY_HOURLY_Q1_DIR / f"{stamp}.json.gz" qtmp = qf.with_suffix(".gz.tmp") qtmp.write_bytes(gzip.compress( json.dumps(_sm_snapshot_slim(compact), separators=(",", ":")).encode("utf-8"), 6)) qtmp.replace(qf) except Exception as _exc: # noqa: BLE001 print(f"[lb-history] hourly q1 slim skipped: {_exc}") keep_cutoff = hour - _td(hours=SM_LB_HISTORY_HOURLY_KEEP) for base_dir in (SM_LB_HISTORY_HOURLY_DIR, SM_LB_HISTORY_HOURLY_Q1_DIR): for old in base_dir.glob("*.json.gz"): try: dt = _dt.strptime(old.name[:13], "%Y-%m-%dT%H").replace(tzinfo=_tz.utc) if dt < keep_cutoff: old.unlink() except Exception: # noqa: BLE001 pass with _SM_LB_HISTORY_LOCK: _SM_LB_HISTORY_CACHE["hour"] = None # neuen Snapshot sofort nutzen except Exception as exc: # noqa: BLE001 print(f"[lb-history] hourly snapshot skipped: {exc}") def _sm_lb_load_hourly_near(target_dt, max_span: int = 8): """Stuendlichen Snapshot am naechsten zu target_dt laden (erst gleiche/aeltere Stunde, dann bis max_span Stunden umher). -> (dict, alter_in_stunden) oder ({}, None).""" from datetime import timedelta as _td now_h = _sm_lb_now_hour() for span in range(0, max_span + 1): for sign in ((-1, 1) if span else (0,)): # gleiche Stunde, dann aeltere/juengere dt = target_dt + _td(hours=sign * span) f = SM_LB_HISTORY_HOURLY_DIR / f"{dt.strftime('%Y-%m-%dT%H')}.json.gz" if f.exists(): try: data = json.loads(gzip.decompress(f.read_bytes()).decode("utf-8")) return data, int(round((now_h - dt).total_seconds() / 3600)) except Exception: # noqa: BLE001 continue return {}, None def _sm_lb_history_maps(): """(d1, d7)-Maps {key:[lvl,power,gold]}. d1 = ROLLIEREND ~24h zurueck (stuendliche Snapshots, stuendlich neu ausgewaehlt); d7 = ~7-Tage-Snapshot (taeglich). Beide getrennt gecacht, damit sich das 24h-Fenster jede Stunde mitbewegt.""" from datetime import timedelta as _td now_h = _sm_lb_now_hour() hour_key = now_h.strftime("%Y-%m-%dT%H") today = _sm_lb_today() day_key = today.isoformat() with _SM_LB_HISTORY_LOCK: if _SM_LB_HISTORY_CACHE.get("hour") != hour_key: d1, d1_age = _sm_lb_load_hourly_near(now_h - _td(hours=24)) if not d1: # Anlaufphase (noch kein 24h-alter Stunden-Snapshot) -> Tages-Snapshot als Notnagel d1, d1_age_days = _sm_lb_load_snapshot_near(today - _td(days=1)) d1_age = (d1_age_days * 24) if d1_age_days else None _SM_LB_HISTORY_CACHE.update({"hour": hour_key, "d1": d1 or {}, "d1_age": d1_age}) if _SM_LB_HISTORY_CACHE.get("day") != day_key: d7, d7_age = _sm_lb_week_snapshot(today) _SM_LB_HISTORY_CACHE.update({"day": day_key, "d7": d7 or {}, "d7_age": d7_age}) return _SM_LB_HISTORY_CACHE["d1"], _SM_LB_HISTORY_CACHE["d7"] def _sm_lb_hist_get(histmap, row): """Historischen Basiswert [lvl,power,gold] einer Zeile robust holen. Ein Snapshot ist unter dem DAMALIGEN Key gespeichert (`id:` ODER `name:`). Zeilen wandern aber von `name:` zu `id:`, sobald ein Live-Lookup die player_id fuellt -> ein direkter key-Match verliert JEDE migrierte Zeile. Ueber 7 Tage sind das sehr viele (Hauptursache, dass das Wochen-Delta ~3/4 der Spieler verlor); ueber 24h kaum. Fix: ueber BEIDE stabilen Identitaeten suchen -> erst player_id, dann username_key.""" if not histmap: return None pid = str(row.get("player_id") or "").strip() if pid: past = histmap.get("id:" + pid) if past: return past uk = row.get("username_key") if uk: past = histmap.get("name:" + uk) if past: return past key = row.get("key") if key: return histmap.get(key) return None def _sm_lb_enrich_diffs(page_rows): """Fuegt den (wenigen) Seiten-Zeilen Tages-/Wochen-Diffs hinzu.""" d1, d7 = _sm_lb_history_maps() if not d1 and not d7: return for row in page_rows: key = row.get("key") if not key: continue cl = int(row.get("level") or 0) cp = float(row.get("power_sort") or 0) cg = float(row.get("gold_sort") or 0) for tag, hist in (("d1", d1), ("d7", d7)): past = _sm_lb_hist_get(hist, row) if not past: continue row[tag + "_level"] = cl - int(past[0] or 0) row[tag + "_power"] = cp - float(past[1] or 0) row[tag + "_gold"] = cg - float(past[2] or 0) def _sm_lb_current_field(row, field): if field == "level": return int(row.get("level") or 0) if field == "gold": return float(row.get("gold_sort") or 0) return float(row.get("power_sort") or 0) # Gecachte, fertig sortierte Zeilen-Liste je (sort, richtung, measure, min_level). Der teure # Teil (alle Zeilen als public_row bauen + sortieren) laeuft so nur alle paar Sekunden statt # bei JEDEM Seitenaufruf -> Seite laedt sofort, auch bei >1 Mio Accounts (Mod-Ansicht). _SM_LB_SNAPSHOT_CACHE = {} # TTL grosszuegig: der Hintergrund-Warmer haelt die heissen Views ohnehin frisch (Stempel # wird bei jedem Warm-Durchlauf erneuert), also serviert der Request-Pfad praktisch immer # aus dem Cache und rechnet nie selbst. Der TTL faengt nur den seltenen Kaltfall ab. _SM_LB_SNAPSHOT_TTL = max(2.0, float(_os.getenv("SM_LEADERBOARD_SNAPSHOT_TTL", "90.0"))) _SM_LB_SNAPSHOT_MAX = 32 # RAM-Deckel: hoechstens so viele sortierte Listen zwischenspeichern _SM_LB_SNAPSHOT_LOCK = threading.Lock() # ── Hintergrund-Warmer: sortierte Views neu berechnen, sobald neue Daten reinkommen ── # (statt bei jedem Seitenaufruf). Der Warmer haelt genau die Views warm, die zuletzt # angefragt wurden; der Request-Pfad liest dann nur noch fertig aus dem Cache. _SM_LB_HOT_KEYS = {} # cache_key -> monotonic ts der letzten Anfrage _SM_LB_HOT_TTL = 900.0 # Views der letzten 15 min warm halten _SM_LB_DIRTY = threading.Event() # Ingest setzt das -> Warmer rechnet neu _SM_LB_WARM_MIN_INTERVAL = max(2.0, float(_os.getenv("SM_LEADERBOARD_WARM_INTERVAL", "12.0"))) _SM_LB_WARM_IDLE = 30.0 # auch ohne neue Daten so oft nachziehen _SM_LB_MAINT_THREAD = None _SM_LB_MAINT_LOCK = threading.Lock() def _sm_lb_warm_key(cache_key): """Eine sortierte View (sort, richtung, measure, min_level) bauen und in den Cache legen.""" sort_by, dir_str, measure, min_level = cache_key entry = _sm_leaderboard_build_sorted(sort_by, dir_str != "asc", measure, min_level) entry["stamp"] = time.monotonic() with _SM_LB_SNAPSHOT_LOCK: _SM_LB_SNAPSHOT_CACHE[cache_key] = entry return entry def _sm_lb_default_hot_keys(): """Views, die IMMER warm gehalten werden. Mit numpy ist eine View nur noch ein argsort (~1 s) auf dem geteilten Index, also koennen wir auch die grossen Mod-Views (100+) auf Vorrat halten -> Mod-Leaderboard laedt sofort (nicht erst nach 20 s Kaltbau). Ohne numpy waeren die 1,6-Mio-Views zu teuer -> dann nur die oeffentlichen.""" pub = [(s, "desc", "total", SM_LEADERBOARD_MIN_LEVEL) for s in ("power", "gold", "level")] if not _HAS_NUMPY or SM_LEADERBOARD_STORE_MIN_LEVEL == SM_LEADERBOARD_MIN_LEVEL: return pub ml = SM_LEADERBOARD_STORE_MIN_LEVEL mod = [(s, "desc", "total", ml) for s in ("power", "gold", "level")] mod += [("power", "desc", "d1", ml), ("power", "desc", "d7", ml)] # Gainer-Views (Cheat-Suche) return pub + mod def _sm_lb_warm_all(hot): for k in hot: try: _sm_lb_warm_key(k) except Exception as exc: # noqa: BLE001 — ein defekter Key darf den Warmer nie stoppen print(f"[lb-warm] {type(exc).__name__}: {str(exc)[:160]}") def _sm_leaderboard_maintenance_worker(): """Haelt den numpy-Index frisch (die teure O(N)-Extraktion GEBUENDELT ~alle INDEX_TTL s, NICHT bei jedem Push) und daraus die heissen Views warm; zieht stuendlich den History- Snapshot. Serves lesen immer den vorhandenen Index/Cache -> nie Extrakt-/Sortierkosten im Request. Laeuft IMMER (auch wenn der eigene Scanner aus ist).""" seed = time.monotonic() with _SM_LB_SNAPSHOT_LOCK: for k in _sm_lb_default_hot_keys(): _SM_LB_HOT_KEYS.setdefault(k, seed) # Sofort-Aufbau, damit schon der erste Seitenaufruf warm ist. Single-Flight, damit # Requests waehrend dieses Erstbuilds sich denselben Rebuild teilen (kein Herd beim Boot). if _HAS_NUMPY: try: _sm_lb_rebuild_index_coalesced() except Exception as exc: # noqa: BLE001 print(f"[lb-index] {type(exc).__name__}: {str(exc)[:160]}") _sm_lb_warm_all(list(_SM_LB_HOT_KEYS)) last_rebuild = time.monotonic() last_stamp = (_SM_LB_INDEX or {}).get("stamp") if _HAS_NUMPY else None last_loop = time.monotonic() while True: gap = _SM_LB_WARM_MIN_INTERVAL - (time.monotonic() - last_loop) if gap > 0: time.sleep(gap) # Loop hoechstens alle paar Sekunden (kein Spin bei Dauer-Push) _SM_LB_DIRTY.wait(timeout=_SM_LB_WARM_IDLE) _SM_LB_DIRTY.clear() last_loop = time.monotonic() # Index nur gebuendelt neu bauen (Reihenfolge aendert sich langsam). if _HAS_NUMPY and (last_loop - last_rebuild >= _SM_LB_INDEX_TTL): try: _sm_lb_rebuild_index_coalesced() last_rebuild = time.monotonic() except Exception as exc: # noqa: BLE001 print(f"[lb-index] {type(exc).__name__}: {str(exc)[:160]}") cutoff = time.monotonic() - _SM_LB_HOT_TTL with _SM_LB_SNAPSHOT_LOCK: for k in [k for k, ts in _SM_LB_HOT_KEYS.items() if ts < cutoff]: _SM_LB_HOT_KEYS.pop(k, None) hot = list(_SM_LB_HOT_KEYS) cur_stamp = (_SM_LB_INDEX or {}).get("stamp") if _HAS_NUMPY else None if cur_stamp != last_stamp or not _HAS_NUMPY: _sm_lb_warm_all(hot) # frischer Index -> Views (order) neu berechnen last_stamp = cur_stamp else: fresh = time.monotonic() # Index unveraendert -> nur Stempel auffrischen (kein Ablauf) with _SM_LB_SNAPSHOT_LOCK: for k in hot: e = _SM_LB_SNAPSHOT_CACHE.get(k) if e: e["stamp"] = fresh try: _sm_leaderboard_snapshot_hourly() # nur einmal/Stunde teuer (sonst billiger Datei-Check) except Exception as exc: # noqa: BLE001 print(f"[lb-hourly] {type(exc).__name__}: {str(exc)[:160]}") def _sm_leaderboard_start_maintenance(): global _SM_LB_MAINT_THREAD if SM_LEADERBOARD_PROXY_URL: return # proxy mode: prices runs the expensive periodic index rebuild; A stays idle here with _SM_LB_MAINT_LOCK: if _SM_LB_MAINT_THREAD and _SM_LB_MAINT_THREAD.is_alive(): return _SM_LB_MAINT_THREAD = threading.Thread( target=_sm_leaderboard_maintenance_worker, name="swordmasters-leaderboard-maint", daemon=True, ) _SM_LB_MAINT_THREAD.start() # ── numpy-Index: die teure O(N)-Extraktion (alle Zeilen -> Arrays) laeuft EINMAL je # Zyklus im Hintergrund. Danach ist jede sortierte View nur noch ein argsort (~1 s # statt ~20 s), und der Warmer kann ALLE gaengigen Views (auch die 1,6-Mio-Mod-Views) # dauerhaft warm halten -> Seitenaufrufe schneiden nur noch eine Integer-Reihenfolge # (Millisekunden). Ohne numpy faellt alles auf den reinen Python-Pfad zurueck. ────── try: import numpy as _np _HAS_NUMPY = True except Exception: # noqa: BLE001 _np = None _HAS_NUMPY = False _SM_LB_INDEX = None _SM_LB_INDEX_LOCK = threading.Lock() # Single-Flight-Gate fuer den teuren Rebuild: nur EIN Thread darf gleichzeitig die O(N)- # Extraktion laufen lassen; parallele Aufrufer warten darauf und nehmen dessen Ergebnis. # Verhindert den Thundering-Herd-Hang beim Kaltstart (Index noch None -> jeder Request # startete sonst seinen eigenen ~17s-GIL-haltenden Rebuild -> alle frieren ein). _SM_LB_BUILD_LOCK = threading.Lock() # Wie oft der teure Index (O(N)-Extraktion + Namens-Scan-Index) neu gebaut wird. Das Leaderboard # muss nicht extrem live sein, der Reindex ist aber CPU-teuer -> grosszuegig, um die Dauerlast auf # den 2 vCPU zu senken. 1800 s = 30 min (per SM_LEADERBOARD_INDEX_TTL uebersteuerbar). _SM_LB_INDEX_TTL = max(15.0, float(_os.getenv("SM_LEADERBOARD_INDEX_TTL", "1800.0"))) def _sm_lb_rebuild_index(): """Alle (entdupten, nicht gebannten) Zeilen EINMAL in numpy-Arrays extrahieren. Teuerer O(N)-Python-Durchlauf -> laeuft nur im Hintergrund-Warmer, hoechstens alle paar Sekunden. base[i] = Roh-Zeile; pw/gd/lv[i] = Sortierwerte; d1_*/d7_* = Deltas.""" with _SM_LEADERBOARD_LOCK: state = _sm_leaderboard_load_unlocked() raw = list((state.get("players") or {}).values()) scanner = dict(state.get("scanner") or {}) updated_at = int(state.get("updated_at") or 0) d1map, d7map = _sm_lb_history_maps() have_d1, have_d7 = bool(d1map), bool(d7map) # Schatten-Dedupe: username_keys aller id-Zeilen (Namens-Schatten ohne id fliegen raus). idset = {r.get("username_key") for r in raw if str(r.get("key") or "").startswith("id:") and r.get("username_key")} base = []; pw = []; gd = []; lv = [] d1p = []; d1g = []; d1l = []; h1 = [] d7p = []; d7g = []; d7l = []; h7 = [] ab = base.append NAN = float("nan") # Der Extraktions-Durchlauf ist reiner (GIL-haltender) Python. Alle paar zehntausend Zeilen # den GIL kurz freigeben, damit Requests (Seite laden, Lookups) waehrend des Reindex nicht # sekundenlang warten. Reiner Scheduling-Effekt -- Ergebnis/Daten unveraendert. for _ri, r in enumerate(raw): if _ri and _ri % 25000 == 0: time.sleep(0) if r.get("is_banned"): continue key = r.get("key") or "" if (key.startswith("name:") and not str(r.get("player_id") or "").strip() and r.get("username_key") in idset): continue p = float(r.get("power_sort") or 0); g = float(r.get("gold_sort") or 0); l = int(r.get("level") or 0) ab(r); pw.append(p); gd.append(g); lv.append(l) if have_d1: past = _sm_lb_hist_get(d1map, r) if past: d1p.append(p - float(past[1] or 0)); d1g.append(g - float(past[2] or 0)); d1l.append(l - int(past[0] or 0)); h1.append(True) else: d1p.append(NAN); d1g.append(NAN); d1l.append(NAN); h1.append(False) if have_d7: past = _sm_lb_hist_get(d7map, r) if past: d7p.append(p - float(past[1] or 0)); d7g.append(g - float(past[2] or 0)); d7l.append(l - int(past[0] or 0)); h7.append(True) else: d7p.append(NAN); d7g.append(NAN); d7l.append(NAN); h7.append(False) idx = { "stamp": time.monotonic(), "base": base, "scanner": scanner, "updated_at": updated_at, "power": _np.asarray(pw, dtype=_np.float64), "gold": _np.asarray(gd, dtype=_np.float64), "level": _np.asarray(lv, dtype=_np.int64), } if have_d1: idx["d1_power"] = _np.asarray(d1p); idx["d1_gold"] = _np.asarray(d1g) idx["d1_level"] = _np.asarray(d1l); idx["has_d1"] = _np.asarray(h1, dtype=bool) if have_d7: idx["d7_power"] = _np.asarray(d7p); idx["d7_gold"] = _np.asarray(d7g) idx["d7_level"] = _np.asarray(d7l); idx["has_d7"] = _np.asarray(h7, dtype=bool) # Namens-Such-Index (big/starts) GLEICH mitbauen und in den Index legen -> er ist ATOMAR mit # dem Index verfuegbar (publish unten). Kein Fenster mehr, in dem eine Suche ihn selbst -- und # dann konkurrierend zu weiteren Suchen (GIL, Thundering Herd) -- lazy baut. Dieser Bau laeuft # ohnehin im single-flight-geschuetzten Rebuild, also genau EINMAL je Generation. if _HAS_NUMPY: n = len(base) hay = [""] * n for i, r in enumerate(base): if i and i % 25000 == 0: time.sleep(0) # GIL kurz freigeben -> Requests haengen nicht waehrend des Baus hay[i] = "\x01" + "\x01".join(_sm_lb_haystack_names(r)) + "\x01" wc_starts = _np.zeros(n, dtype=_np.int64) if n > 1: _np.cumsum(_np.fromiter((len(h) for h in hay), dtype=_np.int64, count=n)[:-1], out=wc_starts[1:]) idx["wc_big"] = "".join(hay) idx["wc_starts"] = wc_starts with _SM_LB_INDEX_LOCK: global _SM_LB_INDEX _SM_LB_INDEX = idx return idx def _sm_lb_rebuild_index_coalesced(): """Single-Flight-Rebuild: nur EIN Thread laeuft durch die teure O(N)-Extraktion; alle parallelen Aufrufer warten darauf und uebernehmen dessen frischen Index, statt jeweils einen eigenen (GIL-haltenden ~17s) Rebuild zu starten. Genau das fing beim Kaltstart den Thundering-Herd-Hang ein (Index None -> N Requests bauen gleichzeitig -> Seite haengt).""" if _SM_LB_BUILD_LOCK.acquire(blocking=False): # Wir sind der Builder. try: return _sm_lb_rebuild_index() finally: _SM_LB_BUILD_LOCK.release() # Ein anderer baut gerade -> nur auf ihn warten (gibt den GIL frei), dann sein Ergebnis # nehmen. Der Lock-Wait kostet keine CPU und startet keinen zweiten Rebuild. with _SM_LB_BUILD_LOCK: idx = _SM_LB_INDEX if idx is not None: return idx # Sehr unwahrscheinlich (Builder ist gescheitert und hat nichts gesetzt) -> selbst bauen. return _sm_lb_rebuild_index() def _sm_lb_get_index(max_age=None): # Unter pytest immer frisch bauen: Tests aendern den Zustand und erwarten sofort # das aktuelle Ergebnis (wie beim Snapshot-Cache). if _os.getenv("PYTEST_CURRENT_TEST"): return _sm_lb_rebuild_index() max_age = _SM_LB_INDEX_TTL if max_age is None else max_age idx = _SM_LB_INDEX if idx is None: # Kaltstart: es gibt noch keinen Index zum Ausliefern -> einmal synchron bauen. # Single-Flight: gleichzeitige Erstaufrufe teilen sich denselben Rebuild (kein Herd). return _sm_lb_rebuild_index_coalesced() age = time.monotonic() - idx["stamp"] if age >= max_age: # Stale-while-revalidate: den (leicht veralteten) Index SOFORT zurueckgeben und den # Hintergrund-Warmer den teuren O(N)-Rebuild uebernehmen lassen. Ein Request darf NIE # selbst ~17 s im Rebuild haengen -- die reine Python-Schleife haelt dabei den GIL und # wuerde sonst JEDEN anderen Request fuer die Dauer einfrieren. Reihenfolge/Totals # aendern sich langsam (Werte werden ohnehin live nachgepatcht), ein Zyklus Verzoegerung # ist unkritisch -- und weil Liste UND Summary jetzt denselben Index lesen, bleiben sie # konsistent (kein Auseinanderlaufen von angezeigter Zahl und Rangliste). _SM_LB_DIRTY.set() # Sicherheitsnetz: ist der Index absurd alt (Warmer tot/haengt), doch synchron neu bauen, # statt uralte Daten auszuliefern -- schuetzt Aktualitaet/Vollstaendigkeit im Fehlerfall. if age >= max(4.0 * max_age, 900.0): return _sm_lb_rebuild_index_coalesced() return idx def _sm_leaderboard_build_sorted(sort_by: str, reverse: bool, measure: str, min_level: int): """Schnelle View: aus dem numpy-Index nur noch maskieren + argsort -> Integer-Reihenfolge (order) in Positionen von base. Kein O(N)-Python-Sortieren mehr. Rueckgabe traegt base+order; die teuren public_row-Kopien entstehen erst fuer die gezeigte Seite (im Serve).""" if not _HAS_NUMPY: return _sm_leaderboard_build_sorted_py(sort_by, reverse, measure, min_level) idx = _sm_lb_get_index() lv = idx["level"] eff_measure = "total" if measure in ("d1", "d7") and (f"{measure}_{sort_by}" in idx): sortarr = idx[f"{measure}_{sort_by}"] mask = (lv >= min_level) & idx[f"has_{measure}"] eff_measure = measure else: sortarr = idx[sort_by] # "power"/"gold"/"level" mask = lv >= min_level pos = _np.nonzero(mask)[0] vals = sortarr[pos] tie = idx["power"][pos] # Sekundaerschluessel Power (nahezu eindeutig) -> stabile, # sinnvolle Reihenfolge bei Gleichstand (v.a. Level-Sortierung) if reverse: order = pos[_np.lexsort((-tie, -vals))] else: order = pos[_np.lexsort((tie, vals))] return {"base": idx["base"], "order": order, "measure": eff_measure, "scanner": idx["scanner"], "updated_at": idx["updated_at"]} def _sm_leaderboard_build_sorted_py(sort_by: str, reverse: bool, measure: str, min_level: int): """Reiner Python-Fallback (ohne numpy): filtern + entduplizieren + sortieren. Langsam bei >1 Mio Zeilen (~20 s), daher nur als Notnagel, wenn numpy fehlt.""" with _SM_LEADERBOARD_LOCK: state = _sm_leaderboard_load_unlocked() raw = list((state.get("players") or {}).values()) scanner = dict(state.get("scanner") or {}) updated_at = int(state.get("updated_at") or 0) rows = [ row for row in raw if int((row or {}).get("level") or 0) >= min_level and not bool((row or {}).get("is_banned")) ] rows = _sm_leaderboard_dedupe_public_rows(rows) hist_map = None if measure != "total": d1, d7 = _sm_lb_history_maps() hist_map = d1 if measure == "d1" else d7 if not hist_map: measure = "total" if measure == "total": rows.sort( key=lambda row: ( _sm_leaderboard_sort_value(row, sort_by), int(row.get("level") or 0), str(row.get("username") or "").lower(), ), reverse=reverse, ) else: idx = 0 if sort_by == "level" else (2 if sort_by == "gold" else 1) deltas = {} known = [] for row in rows: past = _sm_lb_hist_get(hist_map, row) if not past: continue deltas[id(row)] = _sm_lb_current_field(row, sort_by) - float(past[idx] or 0) known.append(row) known.sort( key=lambda row: ( deltas.get(id(row), 0.0), int(row.get("level") or 0), str(row.get("username") or "").lower(), ), reverse=reverse, ) rows = known return {"rows": rows, "measure": measure, "scanner": scanner, "updated_at": updated_at} def _sm_leaderboard_snapshot( sort_by: str = "power", direction: str = "desc", q: str = "", offset: int = 0, limit: int | None = None, measure: str = "total", min_level: int | None = None, case_sensitive: bool = False, allow_wildcard: bool = False, ): sort_by = sort_by if sort_by in {"power", "gold", "level"} else "power" req_measure = measure if measure in {"total", "d1", "d7"} else "total" reverse = str(direction or "desc").lower() != "asc" limit = max(25, min(int(limit or SM_LEADERBOARD_QUERY_LIMIT), 1000)) query = str(q or "").strip() query_key = _discord_lookup_key(query) min_level = SM_LEADERBOARD_MIN_LEVEL if min_level is None else max(1, int(min_level)) cache_key = (sort_by, "desc" if reverse else "asc", req_measure, min_level) now = time.monotonic() # Cache aus unter pytest (Tests wollen sofort den frischen Zustand sehen). use_cache = _SM_LB_SNAPSHOT_TTL > 0 and not _os.getenv("PYTEST_CURRENT_TEST") entry = None if use_cache: with _SM_LB_SNAPSHOT_LOCK: _SM_LB_HOT_KEYS[cache_key] = now # als "heiss" merken -> Warmer haelt sie frisch cached = _SM_LB_SNAPSHOT_CACHE.get(cache_key) if cached and now - cached["stamp"] < _SM_LB_SNAPSHOT_TTL: entry = cached if entry is None: entry = _sm_leaderboard_build_sorted(sort_by, reverse, req_measure, min_level) entry["stamp"] = now if use_cache: with _SM_LB_SNAPSHOT_LOCK: _SM_LB_SNAPSHOT_CACHE[cache_key] = entry if len(_SM_LB_SNAPSHOT_CACHE) > _SM_LB_SNAPSHOT_MAX: oldest = min(_SM_LB_SNAPSHOT_CACHE, key=lambda k: _SM_LB_SNAPSHOT_CACHE[k]["stamp"]) if oldest != cache_key: _SM_LB_SNAPSHOT_CACHE.pop(oldest, None) measure = entry["measure"] scanner = entry["scanner"] updated_at = entry["updated_at"] # Zwei Formen: numpy-Pfad -> base (Liste) + order (Integer-Positionen); Fallback -> rows. base = entry.get("base") order = entry.get("order") if base is None: base = entry.get("rows") or [] order = None total = len(order) if order is not None else len(base) # Gemeinsamer Bau einer gefilterten Rangliste (Wildcard-Filter ODER mehrfach-exakte # Namenssuche): jede Zeile mit ihrer ECHTEN Position im vollen Leaderboard, statt zu einer # Zeile zu springen. ``pairs`` = [(rang0, row), ...] bereits in Rangfolge. def _filtered_return(pairs): matched = len(pairs) off = max(0, min(int(offset or 0), max(0, matched - 1))) if matched else 0 page = [] for rank0, r in pairs[off:off + limit]: pr = _sm_leaderboard_public_row(r) pr["rank"] = rank0 + 1 # true position in the full leaderboard page.append(pr) _sm_lb_enrich_diffs(page) return { "ok": True, "rows": page, "total": matched, "offset": off, "limit": limit, "sort": sort_by, "measure": measure, "direction": "desc" if reverse else "asc", "query": query, "filtered": True, "case_sensitive": bool(case_sensitive), "focus_key": "", "focus_index": None, "min_level": min_level, "updated_at": updated_at, "scanner": scanner, "history": {"d1_age": _SM_LB_HISTORY_CACHE.get("d1_age"), "d7_age": _SM_LB_HISTORY_CACHE.get("d7_age")}, } # ── Wildcard FILTER mode (mod-only alt-hunting): a '*' in the query returns ALL matching # accounts as a ranked list -- each with its REAL position in the full leaderboard -- # instead of the usual focus-jump to one row. `niceface*` = every niceface, etc. ── if allow_wildcard and query and "*" in query: matcher = _sm_lb_wildcard_matcher(query, case_sensitive) core = _sm_wildcard_literal_core(query) big, starts = _sm_lb_wildcard_scan_index(base) if order is not None else (None, None) pairs = [] # (rank0, row) in leaderboard order if order is not None and big is not None and core: # Schneller Pfad: den literalen Kern per EINEM C-Level-Scan (finditer) im grossen # verketteten Namens-String finden, die Treffer-Offsets per vektorisiertem # searchsorted ihren Zeilen zuordnen, und nur diese wenigen Kandidaten mit der echten # (verankerten) Regex bestaetigen. Der Kern ist notwendige Teilzeichenkette jedes # Glob-Treffers -> es geht kein echter Treffer verloren; das Ergebnis ist identisch # zum Voll-Scan, nur ~20-60x schneller (kein Per-Zeile-Python fuer die ~1,5 Mio Nicht- # Treffer). Kern immer kleingeschrieben gegen den kleingeschriebenen String -> auch bei # case_sensitive korrekt (Obermenge; die Regex haelt die Gross-/Kleinschreibung). hits = _np.fromiter((mo.start() for mo in re.finditer(re.escape(core), big)), dtype=_np.int64) if len(hits): cand = _np.unique(_np.searchsorted(starts, hits, side="right") - 1) inv = _np.full(len(base), -1, dtype=_np.int64) # base-Index -> Rang (-1 = nicht in View) inv[order] = _np.arange(len(order)) for bi in cand.tolist(): rank0 = int(inv[bi]) if rank0 < 0: continue # nicht im aktuellen View (min_level/Measure) -> unsichtbar r = base[bi] if matcher(r): pairs.append((rank0, r)) pairs.sort(key=lambda t: t[0]) elif order is not None: # Fallback (Kern leer, z.B. nur Wildcards): Voll-Scan in Rang-Reihenfolge. for rank0, p in enumerate(order.tolist()): r = base[int(p)] if matcher(r): pairs.append((rank0, r)) else: # Kein numpy: reiner Python-Fallback ueber base in Reihenfolge. for rank0, r in enumerate(base): if matcher(r): pairs.append((rank0, r)) return _filtered_return(pairs) focus_key = "" focus_index = None if query_key: # Tragen MEHRERE Accounts denselben Namen in unterschiedlicher Schreibweise (oettaM / # OETTAM / OeTtAm)? -> alle als Rangliste zeigen (wie der Wildcard-Filter). GENAU einer # -> zu ihm springen wie gehabt. Keiner -> der bisherige exact/fuzzy-Scan (deckt auch # player_id-/Teiltreffer ab). Der Exakt-Scan nutzt denselben schnellen Big-String-Index. exact_pairs = _sm_lb_exact_match_pairs(base, order, query, query_key, case_sensitive) if exact_pairs and len(exact_pairs) >= 2: return _filtered_return(exact_pairs) if exact_pairs and len(exact_pairs) == 1: focus_index, focus_row = exact_pairs[0] focus_key = focus_row.get("key") or "" elif order is not None: # numpy da: exact ist oben schon abgehandelt (exact_pairs) -> hier nur noch der # (jetzt schnelle) Fuzzy-Sprung zum ranghoechsten Teiltreffer. focus_index, focus_key = _sm_lb_fuzzy_focus(base, order, query, query_key, case_sensitive) else: # Kein numpy: originaler exact-first-then-fuzzy Scan (exact hat Vorrang, ein Durchlauf). fuzzy_key = "" fuzzy_index = None for index, row in enumerate(base): if _sm_leaderboard_row_matches_query(row, query, query_key, exact=True, case_sensitive=case_sensitive): focus_key = row.get("key") or "" focus_index = index break if fuzzy_index is None and _sm_leaderboard_row_matches_query(row, query, query_key, exact=False, case_sensitive=case_sensitive): fuzzy_key = row.get("key") or "" fuzzy_index = index if focus_index is None and fuzzy_index is not None: focus_key = fuzzy_key focus_index = fuzzy_index if focus_index is not None: offset = max(0, focus_index - limit // 2) offset = max(0, min(int(offset or 0), max(0, total - 1))) if total else 0 # Erst hier die (teuren) public_row-Kopien bauen: nur fuer die eine gezeigte Seite. if order is not None: page_src = [base[int(p)] for p in order[offset:offset + limit]] else: page_src = base[offset:offset + limit] page = [_sm_leaderboard_public_row(r) for r in page_src] _sm_lb_enrich_diffs(page) # Tages-/Wochen-Diffs nur fuer die gezeigten Zeilen return { "ok": True, "rows": page, "total": total, "offset": offset, "limit": limit, "sort": sort_by, "measure": measure, "direction": "desc" if reverse else "asc", "query": query, "focus_key": focus_key, "focus_index": focus_index, "min_level": min_level, "updated_at": updated_at, "scanner": scanner, "history": {"d1_age": _SM_LB_HISTORY_CACHE.get("d1_age"), "d7_age": _SM_LB_HISTORY_CACHE.get("d7_age")}, } # Je Mindest-Level ein eigener Slot: {min_level: {"stamp","value","ttl"}}. Oeffentlich # (1000+) und Mod (100+) fragen unterschiedliche Aggregate ab -> getrennt gecacht. _SM_LB_SUMMARY_CACHE = {} _SM_LB_SUMMARY_TTL = 45.0 _SM_LB_SUMMARY_TTL_PENDING = 8.0 def _sm_call_with_timeout(fn, timeout, default=None): """Run fn() but never block the caller longer than ``timeout`` seconds. If it doesn't finish in time the work keeps running in its daemon thread (warming any module-level cache it fills) and ``default`` is returned. Used so the leaderboard summary never blocks on a cold global-count binary search against the game API.""" box = {"value": default, "done": False} def _run(): try: box["value"] = fn() except Exception: pass finally: box["done"] = True thread = threading.Thread(target=_run, daemon=True) thread.start() thread.join(timeout) return box["value"] if box["done"] else default def _sm_call_all_with_timeout(jobs): """Wie _sm_call_with_timeout, aber mehrere ``(fn, timeout, default)``-Jobs laufen PARALLEL -> Gesamtwartezeit ~= max(timeout) statt der Summe. Jeder Job liefert exakt dasselbe wie sein einzelner _sm_call_with_timeout-Aufruf (Wert bei rechtzeitigem Erfolg, sonst default; der Rest laeuft im Daemon-Thread weiter und waermt seinen Cache). Damit sind die angezeigten Werte identisch -- nur die drei kalten Game-API-Lookups der Summary blockieren nicht mehr nacheinander. box["value"] startet als default und wird nur bei Erfolg ueberschrieben, also ist die Rueckgabe fuer jeden nicht-fertigen/fehlgeschlagenen Job automatisch der default.""" boxes = [] start = time.monotonic() for fn, timeout, default in jobs: box = {"value": default, "done": False} def _run(fn=fn, box=box): try: box["value"] = fn() except Exception: pass finally: box["done"] = True th = threading.Thread(target=_run, daemon=True) th.start() boxes.append((box, th, timeout)) out = [] for box, th, timeout in boxes: remaining = timeout - (time.monotonic() - start) if remaining > 0: th.join(remaining) out.append(box["value"]) # bleibt der default, falls nicht rechtzeitig fertig return out def _sm_lb_summary_aggregates(min_level: int): """(count, total_power, total_gold, total_xp, top_desc, updated_at) over the deduped, non-banned level-``min_level``+ set. Fast path reuses the shared numpy index (the same arrays the leaderboard itself sorts), so the whole aggregate is vectorised (mask + sum, ~milliseconds even at >1M rows) instead of a per-row Python loop (~seconds). Because it reuses the index dedupe, the count matches the leaderboard's own total exactly. Falls back to the pure-Python loop when numpy/the index is unavailable (identical output).""" if _HAS_NUMPY: try: idx = _sm_lb_get_index() lv = idx.get("level"); pw = idx.get("power"); gd = idx.get("gold") if lv is not None and pw is not None and gd is not None and len(lv): mask = lv >= min_level n = int(mask.sum()) if not n: return 0, 0.0, 0.0, 0.0, [], int(idx.get("updated_at") or 0) p = pw[mask]; g = gd[mask]; lvi = lv[mask] total_power = float(p.sum()); total_gold = float(g.sum()) total_xp = float((lvi.astype(_np.float64) ** 4).sum()) k = min(500, n) part = _np.argpartition(p, n - k)[n - k:] if k < n else _np.arange(n) order = part[_np.argsort(-p[part], kind="stable")] top_desc = [(float(p[i]), float(g[i]), int(lvi[i])) for i in order] return n, total_power, total_gold, total_xp, top_desc, int(idx.get("updated_at") or 0) except Exception as exc: # noqa: BLE001 -- Index kaputt/leer? Auf Python-Pfad zurueckfallen. print(f"[lb-summary] numpy path fell back: {type(exc).__name__}: {str(exc)[:120]}") # ── Python fallback: identisch zum vorherigen Verhalten (ohne numpy oder ohne Index) ── with _SM_LEADERBOARD_LOCK: state = _sm_leaderboard_load_unlocked() raw_rows = [ row for row in (state.get("players") or {}).values() if int((row or {}).get("level") or 0) >= min_level and not bool((row or {}).get("is_banned")) ] updated_at = int(state.get("updated_at") or 0) id_username_keys = { row.get("username_key") for row in raw_rows if str(row.get("key") or "").startswith("id:") and row.get("username_key") } import heapq count = 0; total_power = 0.0; total_gold = 0.0; total_xp = 0.0 top_heap = [] for row in raw_rows: key = str(row.get("key") or "") if ( key.startswith("name:") and not str(row.get("player_id") or "").strip() and row.get("username_key") in id_username_keys ): continue count += 1 power = float(row.get("power_sort") or 0) gold = float(row.get("gold_sort") or 0) total_power += power total_gold += gold lvl = int(row.get("level") or 0) total_xp += float(lvl) ** 4 entry = (power, gold, lvl) if len(top_heap) < 500: heapq.heappush(top_heap, entry) elif power > top_heap[0][0]: heapq.heapreplace(top_heap, entry) top_desc = sorted(top_heap, key=lambda item: item[0], reverse=True) return count, total_power, total_gold, total_xp, top_desc, updated_at def _sm_leaderboard_summary(include_global_counts: bool = True, min_level: int | None = None): """Aggregate stats over the full cached level-``min_level``+ set: total power, total gold, account count, strongest/richest, plus (optionally) the exact global account count and the number of accounts at/above the min level. ``min_level`` defaults to the public floor (1000); the hidden mod view passes the store floor (100) so its totals/counts cover ALL tracked 100+ accounts. Sums use the sortable float fields directly (fast: the cache can hold hundreds of thousands of rows, so building per-row Decimal/public-row dicts would take seconds). Float precision is ample for a suffix display. The whole summary is memoised (per min_level) for a few seconds so repeated tab loads are cheap; the global-count lookups hit the game API but are cached (~6h) and guarded so a network failure never breaks the summary.""" min_level = SM_LEADERBOARD_MIN_LEVEL if min_level is None else max(1, int(min_level)) now = time.monotonic() slot = _SM_LB_SUMMARY_CACHE.get(min_level) if ( slot is not None and slot.get("value") is not None and now - slot.get("stamp", 0.0) < slot.get("ttl", _SM_LB_SUMMARY_TTL) ): return slot["value"] # Aggregate ueber den geteilten numpy-Index (vektorisiert, ~ms) statt per-Row-Python. (count, total_power, total_gold, total_xp, top_desc, updated_at) = _sm_lb_summary_aggregates(min_level) avg_power = (total_power / count) if count else 0.0 combined_level = level_from_total_xp(total_xp) # one account holding all that XP summary = { "ok": True, "min_level": min_level, "accounts_min_level": count, "total_power_display": _sm_display_number(f"{total_power:.2f}") or "0", "total_gold_display": _sm_display_number(f"{total_gold:.2f}") or "0", "total_xp_display": _sm_display_number(f"{total_xp:.2f}") or "0", "combined_level": combined_level, "avg_power_display": _sm_display_number(f"{avg_power:.2f}") or "0", "updated_at": updated_at, "total_accounts": None, "qualified_min_level": None, "ccu": None, "_top": top_desc, "_total_power": total_power, "_total_gold": total_gold, } # 'Online now' + die zwei globalen Zaehlungen sind Game-API-Lookups (bounded, damit ein # kalter 6h-Cache-Miss den Request nie stallt; die Arbeit laeuft im Hintergrund weiter und # der Wert erscheint beim naechsten Load). Sie sind unabhaengig -> PARALLEL statt sequentiell # (worst case ~2 s statt ~5,5 s). Gleiche Werte/Defaults wie vorher. if include_global_counts: summary["ccu"], summary["total_accounts"], summary["qualified_min_level"] = ( _sm_call_all_with_timeout([ (_sm_ccu, 1.5, None), (_sm_level_player_count, 2.0, None), (lambda: _sm_level_qualified_count(min_level), 2.0, None), ]) ) else: summary["ccu"] = _sm_call_with_timeout(_sm_ccu, 1.5) have_counts = ( summary["total_accounts"] is not None and summary["qualified_min_level"] is not None ) # Short TTL while counts are still warming so they show up soon; full TTL once complete. _SM_LB_SUMMARY_CACHE[min_level] = { "value": summary, "stamp": now, "ttl": _SM_LB_SUMMARY_TTL if have_counts else _SM_LB_SUMMARY_TTL_PENDING, } return summary def _sm_leaderboard_summary_public(top_n: int = 10, min_level: int | None = None): """Public leaderboard summary: the cached aggregate base plus a configurable Top-N block (avg power/gold/level of the strongest N accounts and the share of total power/gold they hold). Internal helper fields are stripped. ``min_level`` defaults to the public floor; mods pass the store floor (100).""" top_n = max(1, min(int(top_n or 10), 500)) base = _sm_leaderboard_summary(include_global_counts=True, min_level=min_level) top = base.get("_top") or [] selected = top[:top_n] n = len(selected) sum_power = sum(item[0] for item in selected) sum_gold = sum(item[1] for item in selected) sum_level = sum(item[2] for item in selected) total_power = base.get("_total_power") or 0.0 total_gold = base.get("_total_gold") or 0.0 out = {key: value for key, value in base.items() if not key.startswith("_")} out["top_n"] = n out["top_avg_power_display"] = _sm_display_number(f"{(sum_power / n if n else 0):.2f}") or "0" out["top_avg_gold_display"] = _sm_display_number(f"{(sum_gold / n if n else 0):.2f}") or "0" out["top_avg_level"] = int(round(sum_level / n)) if n else 0 out["top_power_share_pct"] = round(100.0 * sum_power / total_power, 2) if total_power > 0 else 0.0 out["top_gold_share_pct"] = round(100.0 * sum_gold / total_gold, 2) if total_gold > 0 else 0.0 return out def _sm_leaderboard_set_scanner_error(exc): with _SM_LEADERBOARD_LOCK: state = _sm_leaderboard_load_unlocked() scanner = state.setdefault("scanner", {}) scanner["running"] = bool(SM_LEADERBOARD_SCANNER_ENABLED) scanner["last_error"] = f"{type(exc).__name__}: {str(exc)[:220]}" scanner["last_error_at"] = int(time.time() * 1000) _sm_leaderboard_save_unlocked() def _sm_leaderboard_scan_batch(token: str): with _SM_LEADERBOARD_LOCK: state = _sm_leaderboard_load_unlocked() scanner = state.setdefault("scanner", {}) page = max(1, int(scanner.get("page") or 1)) row_index = max(0, int(scanner.get("row") or 0)) users = _sm_level_rank_page(page, token, force_refresh=True) if not users: with _SM_LEADERBOARD_LOCK: state = _sm_leaderboard_load_unlocked() scanner = state.setdefault("scanner", {}) scanner["page"] = 1 scanner["row"] = 0 scanner["cycle"] = int(scanner.get("cycle") or 0) + 1 scanner["last_batch_at"] = int(time.time() * 1000) _sm_leaderboard_save_unlocked() return {"scanned": 0, "updated": 0, "wrapped": True} entries = [] next_page = page next_row = row_index wrapped = False while next_row < len(users) and len(entries) < SM_LEADERBOARD_BATCH_SIZE: parsed = _sm_level_rank_entry(users[next_row], page, next_row) next_row += 1 if not parsed: continue key, entry = parsed try: level = int(entry.get("level") or 0) except (TypeError, ValueError): level = 0 if level < SM_LEADERBOARD_STORE_MIN_LEVEL: wrapped = True break entries.append((key, entry)) if wrapped: next_page, next_row = 1, 0 elif next_row >= len(users): next_page, next_row = page + 1, 0 if not entries: with _SM_LEADERBOARD_LOCK: state = _sm_leaderboard_load_unlocked() scanner = state.setdefault("scanner", {}) scanner["page"] = next_page scanner["row"] = next_row if wrapped: scanner["cycle"] = int(scanner.get("cycle") or 0) + 1 scanner["last_batch_at"] = int(time.time() * 1000) _sm_leaderboard_save_unlocked(min_interval=SM_LEADERBOARD_SAVE_INTERVAL) return {"scanned": 0, "updated": 0, "wrapped": wrapped} _sm_leaderboard_upsert_rank_entries(entries) usernames = [entry["username"] for _, entry in entries] inventories = _sm_fetch_live_inventories_scanner(usernames) results = [] for key, entry in entries: inventory_key = _sm_live_username_key(entry.get("username")) result = _sm_inventory_result(entry["username"], inventories.get(inventory_key)) if not result.get("ok"): continue result["level_rank"] = entry.get("rank") result["level_rank_page"] = entry.get("page") result["level_rank_page_position"] = entry.get("page_position") if entry.get("player_id"): result["player_id"] = entry.get("player_id") results.append(result) updated = _sm_leaderboard_upsert_lookup_results(results, source="scanner", save=False) with _SM_LEADERBOARD_LOCK: state = _sm_leaderboard_load_unlocked() scanner = state.setdefault("scanner", {}) scanner["running"] = bool(SM_LEADERBOARD_SCANNER_ENABLED) scanner["page"] = next_page scanner["row"] = next_row scanner["scanned"] = int(scanner.get("scanned") or 0) + len(entries) scanner["updated"] = int(scanner.get("updated") or 0) + updated scanner["last_batch_at"] = int(time.time() * 1000) scanner["last_error"] = "" scanner["last_user"] = usernames[-1] if usernames else "" if wrapped: scanner["cycle"] = int(scanner.get("cycle") or 0) + 1 _sm_leaderboard_save_unlocked(min_interval=SM_LEADERBOARD_SAVE_INTERVAL) return {"scanned": len(entries), "updated": updated, "wrapped": wrapped} def _sm_leaderboard_seed_top_ranks(token: str): users = _sm_level_rank_page(1, token, force_refresh=True) entries = [] for row_index, user in enumerate(users): parsed = _sm_level_rank_entry(user, 1, row_index) if parsed: entries.append(parsed) return _sm_leaderboard_upsert_rank_entries(entries) def _sm_leaderboard_worker(): token = None token_at = 0.0 top_ranks_seeded = False while SM_LEADERBOARD_SCANNER_ENABLED: sleep_seconds = SM_LEADERBOARD_SCAN_INTERVAL try: if not token or time.monotonic() - token_at > 1800: token = _sm_get_guest_token() token_at = time.monotonic() if not top_ranks_seeded: _sm_leaderboard_seed_top_ranks(token) top_ranks_seeded = True _sm_leaderboard_scan_batch(token) except Exception as exc: token = None _sm_leaderboard_set_scanner_error(exc) sleep_seconds = SM_LEADERBOARD_ERROR_INTERVAL time.sleep(sleep_seconds) def _sm_leaderboard_start_background(initial_delay: float = 0.0): global _SM_LEADERBOARD_THREAD _sm_leaderboard_start_maintenance() # immer: Views warm halten + stuendliche Snapshots if not SM_LEADERBOARD_SCANNER_ENABLED: return False with _SM_LEADERBOARD_LOCK: if _SM_LEADERBOARD_THREAD and _SM_LEADERBOARD_THREAD.is_alive(): return True def _run_after_startup_delay(): delay = max(0.0, float(initial_delay or 0.0)) if delay: time.sleep(delay) with _SM_LEADERBOARD_LOCK: state = _sm_leaderboard_load_unlocked() state.setdefault("scanner", {})["running"] = True _sm_leaderboard_worker() _SM_LEADERBOARD_THREAD = threading.Thread( target=_run_after_startup_delay, name="swordmasters-leaderboard-scanner", daemon=True, ) _SM_LEADERBOARD_THREAD.start() return True def _sm_type_name(table, item_type, *, offset=0): try: index = int(item_type) + int(offset) except (TypeError, ValueError): return None names = list(table) return names[index] if 0 <= index < len(names) else None def _sm_ore_display(ore_type, melted): """Ore-Anzeige aus dem Typ-Index. Die Katalog-'ores' sind alphabetisch sortierte Bild-Assets, NICHT der Spiel-Enum - deshalb kommt die Reihenfolge allein aus SM_ORE_TYPE_NAMES. Ist der Index dort unbekannt, wird bewusst kein Name geraten.""" name = _sm_type_name(SM_ORE_TYPE_NAMES, ore_type) if ore_type is not None else None if not name: return {"name": "Ore" if melted else "Raw Ore", "base_name": "", "known": False} # Spiel-Benennung: roh heisst " Ore", geschmolzen nur "". return { "name": name if melted else f"{name} Ore", "base_name": name, "known": True, } def _sm_enchantment_level(item, enchantment_type): if not isinstance(item, dict): return 0 for enchantment in item.get("enchantments") or []: if isinstance(enchantment, dict) and enchantment.get("type") == enchantment_type: try: return max(0, int(enchantment.get("level") or 0)) except (TypeError, ValueError): return 0 return 0 def _sm_int(value): if value is None or isinstance(value, bool): return None try: return int(value) except (TypeError, ValueError): return None def _sm_number(value): if value is None or isinstance(value, bool): return None try: number = float(value) except (TypeError, ValueError): return None return str(value) if math.isfinite(number) else None def _sm_display_number(value): raw = _sm_number(value) if raw is None: return None try: number = Decimal(raw) except Exception: return raw absolute = abs(number) for suffix, magnitude in reversed(_DISPLAY_DEC): if suffix and absolute >= magnitude: return f"{(number / magnitude).quantize(Decimal('1.00'))}{suffix}" return raw # Rüstungs-Slot -> Katalog-Kategorie. Der Spiel-Typ ist direkt der Katalog-Index # (die Rüstungs-Arrays behalten das None/Empty-Item bei Typ 0), daher KEIN Offset # und KEINE Overrides mehr — die direkte type->name-Abbildung ist live als exakt bestaetigt. _SM_ARMOUR_SOURCE_CATEGORIES = { "activeHelmet": "helmets", "activeTorso": "chestplates", "activePant": "shorts", "activeShoe": "boots", } _SM_ENEMY_TYPE_NAMES = { 0: "Roundhead", 1: "Forest Orc", 2: "Bonechewer", 3: "Cocoa Guardian", 4: "Chocolate Knight", 5: "Mallowbeast", 6: "Ice King", 7: "Spiked Tortoise", 8: "Cursed Stone", 9: "Mummy", 10: "Giga Moai", 11: "Lava Rock", 12: "Nether Lord", 13: "Zeus", 14: "Crokora", 15: "Hydrino", 16: "Temple Guardian", 17: "Dragon Guardian", 18: "Dragon", 19: "Bouugo", 20: "Anaxion", 21: "Xenomor", 22: "Reaper", 23: "The Rival", 24: "Seraphim", 25: "Thrones", 26: "Wargmaw", 27: "Lornblade", 28: "Mordolith", } _SM_ENCHANTMENT_NAMES = { 0: "Flame", 1: "Thorns", 2: "Looting", 3: "Freeze", 4: "Fortune", } def _sm_enemy_type_names() -> dict: """Enemy type -> display name, taken from the synced game catalog so newly added enemies (e.g. 26 Wargmaw, 27 Lornblade, 28 Mordolith) are named automatically. Falls back to the static map above.""" names = dict(_SM_ENEMY_TYPE_NAMES) for enemy in (_GAME_CATALOG.get("enemies") or []): try: enemy_type = int(enemy.get("type")) except (TypeError, ValueError): continue name = str(enemy.get("name") or "").strip() if name: names[enemy_type] = name return names def _sm_gear_type_name(type_map, table, item_type): """Schwert/Schild-Name aus dem Spiel-Typ. Bevorzugt die direkte Typ->Name-Abbildung aus dem Katalog (type_map). Faellt nur ohne geladenen Katalog auf die list()-Position zurueck (offset=-1, weil die Katalog-Waffenlisten kein Empty bei Typ 0 haben).""" raw_type = _sm_int(item_type) if raw_type is None: return None if type_map: direct = type_map.get(raw_type) if direct: return direct return _sm_type_name(table, raw_type, offset=-1) def _sm_armour_name(source_key, table, item_type): raw_type = _sm_int(item_type) if raw_type is None: return None # Direkt aus dem aktuellen Katalog: die Rüstungs-Arrays behalten das "None"/Empty-Item # bei Typ 0, also entspricht der Katalog-Typ 1:1 dem Spiel-Typ (Helm/Schuh/Torso/Hose). # (Frueher: fuer Helm/Schuh ein +1-Offset + Solarflare-Overrides aus einem alten # Katalog-Versatz -> resolvte Helm/Schuh EINE Stufe zu stark, HP/Power ~1 Tier zu hoch. # Live gegen die echte maxHealth verifiziert: die direkte type->name-Abbildung ist exakt.) category = _SM_ARMOUR_SOURCE_CATEGORIES.get(source_key) if category: direct = ARMOUR_TYPE_NAMES.get(category, {}).get(raw_type) if direct: return direct return _sm_type_name(table, raw_type) def _sm_inventory_enchantments(item: dict): out = [] if not isinstance(item, dict): return out for enchantment in item.get("enchantments") or []: if not isinstance(enchantment, dict): continue try: enc_type = int(enchantment.get("type")) enc_level = int(enchantment.get("level") or 0) except (TypeError, ValueError): continue if enc_level > 0: out.append({ "type": enc_type, "name": _SM_ENCHANTMENT_NAMES.get(enc_type, f"Enchant {enc_type}"), "level": enc_level, }) return out def _sm_inventory_gear_item(table, item: dict, *, offset=0, active_id=None, type_map=None): if not isinstance(item, dict): return None if type_map: name = _sm_gear_type_name(type_map, table, item.get("type")) else: name = _sm_type_name(table, item.get("type"), offset=offset) if not name: return None try: level = max(1, int(item.get("level") or 1)) except (TypeError, ValueError): level = 1 item_id = str(item.get("itemId") or "") return { "name": name, "level": level, "item_id": item_id, "active": bool(active_id and item_id == active_id), "flame": _sm_enchantment_level(item, 0), "looting": _sm_enchantment_level(item, 2), "freeze": _sm_enchantment_level(item, 3), "thorns": _sm_enchantment_level(item, 1), "fortune": _sm_enchantment_level(item, 4), "enchantments": _sm_inventory_enchantments(item), } def _sm_inventory_name_list(source_key, table, values): out = [] if not isinstance(values, list): return out for value in values: name = _sm_armour_name(source_key, table, value) if name: out.append(name) return out def _sm_avatar_appearance(inventory: dict): if not isinstance(inventory, dict): return {} appearance = {} for source_key, result_key in ( ("activeSkinColor", "skin_color"), ("activeFace", "face"), ("activeHair", "hair"), ("activeHelmet", "helmet"), ("activeTorso", "torso"), ("activePant", "pant"), ("activeShoe", "shoe"), ): value = _sm_int(inventory.get(source_key)) if value is not None: appearance[result_key] = value if "helmetEnabled" in inventory: appearance["helmet_enabled"] = bool(inventory.get("helmetEnabled")) helmet_name = _sm_armour_name("activeHelmet", HELMETS, inventory.get("activeHelmet")) if helmet_name: appearance["helmet_name"] = helmet_name return appearance def _sm_inventory_pet_item(item: dict, *, active_ids=None): if not isinstance(item, dict): return None name = _sm_type_name(SM_PET_TYPE_NAMES, item.get("type")) if not name: return None item_id = str(item.get("itemId") or "") return {"name": name, "item_id": item_id, "active": bool(active_ids and item_id in active_ids)} def _sm_inventory_ore_item(item: dict): if not isinstance(item, dict): return None ore_type = _sm_int(item.get("type")) name = _sm_type_name(SM_ORE_TYPE_NAMES, ore_type) if ore_type is not None else None melted = bool(item.get("isMelted") or item.get("is_melted")) item_id = str(item.get("itemId") or item.get("item_id") or "") return { "kind": "ore", "name": name or (f"Ore {ore_type}" if ore_type is not None else "Ore"), "type": ore_type, "item_id": item_id, "is_melted": melted, "state": "Smelted" if melted else "Raw", } def _sm_storage_item(item: dict): if not isinstance(item, dict): return None item_type = _sm_int(item.get("itemType") if "itemType" in item else item.get("item_type")) kind = str(item.get("kind") or "").lower() if not kind: kind = {0: "sword", 1: "shield", 2: "pet", 3: "ore"}.get(item_type, "item") if kind == "sword" or item_type == 0: row = _sm_inventory_gear_item(SCHWERTER, item, offset=-1, type_map=SWORD_TYPE_NAMES) elif kind == "shield" or item_type == 1: row = _sm_inventory_gear_item(SCHILDE, item, offset=-1, type_map=SHIELD_TYPE_NAMES) elif kind == "pet" or item_type == 2: row = _sm_inventory_pet_item(item) elif kind == "ore" or item_type == 3: row = _sm_inventory_ore_item(item) else: row = { "name": f"Item {item.get('type')}" if item.get("type") is not None else "Item", "item_id": str(item.get("itemId") or item.get("item_id") or ""), } if not row: return None row = dict(row) row["kind"] = kind row["item_type"] = item_type raw_type = _sm_int(item.get("type")) if raw_type is not None: row["type"] = raw_type return row def _sm_inventory_details(inventory: dict): active_sword_id = str((inventory.get("activeSword") or {}).get("itemId") or "") active_shield_id = str((inventory.get("activeShield") or {}).get("itemId") or "") active_pet_ids = { str(pet.get("itemId") or "") for pet in inventory.get("activePets") or [] if isinstance(pet, dict) } details = { "swords": [ row for row in ( _sm_inventory_gear_item(SCHWERTER, item, offset=-1, active_id=active_sword_id, type_map=SWORD_TYPE_NAMES) for item in inventory.get("swords") or [] ) if row ], "shields": [ row for row in ( _sm_inventory_gear_item(SCHILDE, item, offset=-1, active_id=active_shield_id, type_map=SHIELD_TYPE_NAMES) for item in inventory.get("shields") or [] ) if row ], "pets": [ row for row in ( _sm_inventory_pet_item(item, active_ids=active_pet_ids) for item in inventory.get("pets") or [] ) if row ], "armour": { "helmets": _sm_inventory_name_list("activeHelmet", HELMETS, inventory.get("helmets")), "chestplates": _sm_inventory_name_list("activeTorso", CHESTPLATES, inventory.get("torsos")), "pants": _sm_inventory_name_list("activePant", SHORTS, inventory.get("pants")), "boots": _sm_inventory_name_list("activeShoe", BOOTS, inventory.get("shoes")), }, } storage = inventory.get("storage") if isinstance(storage, list): details["storage"] = [row for row in (_sm_storage_item(item) for item in storage) if row] details["storage_count"] = len(storage) ores = inventory.get("ores") if isinstance(ores, list): details["ores"] = [row for row in (_sm_inventory_ore_item(item) for item in ores) if row] details["ores_count"] = len(ores) return details def _sm_killed_enemies(inventory: dict): raw = inventory.get("killedEnemyTypes") if not isinstance(raw, dict): return {} out = {} names = _sm_enemy_type_names() for key, value in raw.items(): try: enemy_type = int(key) count = int(float(value or 0)) except (TypeError, ValueError): continue out[names.get(enemy_type, f"Enemy {enemy_type}")] = count return out def _sm_inventory_result(username: str, inventory: dict): if not isinstance(inventory, dict): return {"username": username, "ok": False, "error": f"No live stats found for '{username}'."} result = {"username": username, "ok": True, "source": "swordmasters-live"} numeric_raw = {} for source_key, result_key in [ ("power", "power"), ("gold", "gold"), ("health", "hp"), ("experience", "experience"), ]: raw = _sm_number(inventory.get(source_key)) if raw is not None: numeric_raw[result_key] = raw result[result_key] = _sm_display_number(raw) result[f"{result_key}_raw"] = raw if numeric_raw.get("experience") is not None: result["total_experience"] = numeric_raw["experience"] try: result["level"] = int(inventory.get("level")) except (TypeError, ValueError): pass sword = inventory.get("activeSword") if isinstance(sword, dict): sword_name = _sm_gear_type_name(SWORD_TYPE_NAMES, SCHWERTER, sword.get("type")) result["sword"] = sword_name or "" # Roher Spiel-Typ-Code der AKTIVEN Waffe (kleine Zahl) fuer die Zeitreihe/Translation. try: result["sword_type"] = int(sword.get("type")) except (TypeError, ValueError): pass try: result["sword_level"] = max(1, int(sword.get("level") or 1)) except (TypeError, ValueError): pass result["sword_flame"] = _sm_enchantment_level(sword, 0) result["sword_looting"] = _sm_enchantment_level(sword, 2) result["sword_freeze"] = _sm_enchantment_level(sword, 3) shield = inventory.get("activeShield") if isinstance(shield, dict): shield_name = _sm_gear_type_name(SHIELD_TYPE_NAMES, SCHILDE, shield.get("type")) result["shield"] = shield_name or "" try: result["shield_type"] = int(shield.get("type")) except (TypeError, ValueError): pass try: result["shield_level"] = max(1, int(shield.get("level") or 1)) except (TypeError, ValueError): pass result["shield_looting"] = _sm_enchantment_level(shield, 2) result["shield_thorns"] = _sm_enchantment_level(shield, 1) result["shield_fortune"] = _sm_enchantment_level(shield, 4) pets = [] for pet in inventory.get("activePets") or []: if isinstance(pet, dict): name = _sm_type_name(SM_PET_TYPE_NAMES, pet.get("type")) if name: pets.append(name) result["pets"] = pets for source_key, result_key, table in [ ("activeHelmet", "helmet", HELMETS), ("activeTorso", "chest", CHESTPLATES), ("activePant", "short", SHORTS), ("activeShoe", "boots", BOOTS), ]: name = _sm_armour_name(source_key, table, inventory.get(source_key)) if name: result[result_key] = name appearance = _sm_avatar_appearance(inventory) if appearance: result["appearance"] = appearance result["inventory"] = _sm_inventory_details(inventory) result["killed_enemies"] = _sm_killed_enemies(inventory) return result def _sm_probe_inventory_from_report(report: dict): if not isinstance(report, dict): return {} inventory = {} for key in ("swords", "shields", "pets", "storage", "ores"): if isinstance(report.get(key), list): inventory[key] = report.get(key) or [] limits = report.get("limits") if isinstance(report.get("limits"), dict) else {} if limits.get("storageLimit") is not None: inventory["storageLimit"] = limits.get("storageLimit") if limits.get("oreLimit") is not None: inventory["oreLimit"] = limits.get("oreLimit") return inventory def _sm_probe_result_from_report(username: str, report: dict): clean_username = str(username or (report or {}).get("username") or "").strip() inventory = _sm_probe_inventory_from_report(report or {}) return { "username": clean_username, "ok": True, "source": "swordmasters-live-probe", "inventory": _sm_inventory_details(inventory), } def _sm_apply_probe_inventory_unlocked(result: dict, username: str): if not isinstance(result, dict): return result key = _discord_lookup_key(username or result.get("username")) if not key: return result cached = _SM_PROBE_CACHE.get(key) if not cached or time.monotonic() - cached.get("stamp", 0.0) > SM_PROBE_CACHE_SECONDS: _SM_PROBE_CACHE.pop(key, None) return result probe_inventory = cached.get("inventory") if not isinstance(probe_inventory, dict): return result merged = dict(result) merged_inventory = dict(merged.get("inventory") or {}) for field in ("storage", "storage_count", "ores", "ores_count"): if field in probe_inventory: merged_inventory[field] = probe_inventory[field] merged["inventory"] = merged_inventory merged["probe_inventory_source"] = "swordmasters-live-probe" return merged def _sm_room_message(frame): if not isinstance(frame, (bytes, bytearray)) or not frame or frame[0] != 13: return None, None unpacker = _msgpack.Unpacker(raw=False, strict_map_key=False) unpacker.feed(bytes(frame[1:])) try: return next(unpacker), next(unpacker) except StopIteration: return None, None def _sm_send_room_message(socket, message_type: str, payload: dict): socket.send_binary( bytes([13]) + _msgpack.packb(message_type, use_bin_type=True) + _msgpack.packb(payload, use_bin_type=True) ) def _sm_open_guest_room(): guest = _sm_json_post(f"{SM_LOADBALANCER_BASE}/api/server/findServer", {"token": None}) host = str((guest.get("data") or {}).get("foundServer") or "") token = guest.get("token") if not re.fullmatch(r"[a-zA-Z0-9.-]+", host) or not token: raise ValueError("SwordMasters loadbalancer returned an invalid guest reservation.") reservation = _sm_json_post( f"https://{host}.swordmasters.io/matchmake/joinOrCreate/{_url_quote(SM_LOOKUP_WORLD)}", {"accessToken": token}, ) room = reservation.get("room") or {} public_address = str(room.get("publicAddress") or f"{host}.swordmasters.io") process_id = str(room.get("processId") or "") room_id = str(room.get("roomId") or "") session_id = str(reservation.get("sessionId") or "") if ( not re.fullmatch(r"[a-zA-Z0-9.-]+", public_address) or not public_address.endswith(".swordmasters.io") or not process_id or not room_id or not session_id ): raise ValueError("SwordMasters matchmaker returned an invalid room reservation.") ws_url = ( f"wss://{public_address}/{_url_quote(process_id)}/{_url_quote(room_id)}" f"?sessionId={_url_quote(session_id)}" ) socket = _websocket.create_connection( ws_url, timeout=SM_LOOKUP_TIMEOUT, origin="https://swordmasters.io", ) socket.settimeout(SM_LOOKUP_TIMEOUT) join_deadline = time.monotonic() + SM_LOOKUP_TIMEOUT while time.monotonic() < join_deadline: frame = socket.recv() if isinstance(frame, (bytes, bytearray)) and frame and frame[0] == 10: socket.send_binary(bytes([10])) return socket socket.close() raise TimeoutError("SwordMasters room join timed out.") def _sm_wait_room_message(socket, expected_type: str): deadline = time.monotonic() + SM_LOOKUP_TIMEOUT while time.monotonic() < deadline: try: frame = socket.recv() except _websocket.WebSocketTimeoutException: break message_type, message = _sm_room_message(frame) if message_type == expected_type and isinstance(message, dict): return message raise TimeoutError(f"SwordMasters did not return {expected_type} in time.") _SM_CLAN_ROLE_NAMES = { 0: "Member", 1: "Elder", 2: "Co-Leader", 3: "Leader", } _SM_CLAN_BUILDING_NAMES = ("Castle", "Tent A", "Tent B", "Tent C", "Tent D") _SM_CLAN_CAPACITY_BY_LEVEL = {0: 0, 1: 10, 2: 25} def _sm_clan_member(entry: dict): entry = entry if isinstance(entry, dict) else {} user = entry.get("user") if isinstance(entry.get("user"), dict) else {} role = _sm_int(entry.get("role")) level = _sm_int(user.get("level")) power = _sm_number(user.get("power")) donation = _sm_int(entry.get("campfireDonate")) or 0 return { "player_id": str(user.get("_id") or entry.get("_id") or ""), "username": str(user.get("username") or "").strip(), "role": role if role is not None else 0, "role_name": _SM_CLAN_ROLE_NAMES.get(role, "Member"), "level": level or 0, "power": power or "0", "power_display": _sm_display_number(power) or "0", "campfire_donate_seconds": max(0, donation), } def _sm_clan_chest_item(item: dict): item = item if isinstance(item, dict) else {} item_type = _sm_int(item.get("itemType")) raw_type = _sm_int(item.get("type")) kind = {0: "sword", 1: "shield", 2: "pet", 3: "ore"}.get(item_type, "item") name = None if item_type == 0: name = _sm_gear_type_name(SWORD_TYPE_NAMES, SCHWERTER, raw_type) elif item_type == 1: name = _sm_gear_type_name(SHIELD_TYPE_NAMES, SCHILDE, raw_type) elif item_type == 2: # Pets zaehlen ab 0 (Piggie), Schwerter/Schilde ab 1 -> kein Offset. Und die # nach Typ sortierte Liste nehmen, nicht PETS (das ist Katalog-Dateireihenfolge). name = _sm_type_name(SM_PET_TYPE_NAMES, raw_type) melted = bool(item.get("isMelted")) ore_base = "" if item_type == 3: ore = _sm_ore_display(raw_type, melted) name = ore["name"] ore_base = ore["base_name"] return { "item_id": str(item.get("itemId") or item.get("_id") or ""), "kind": kind, "name": name or f"{kind.title()} type {raw_type if raw_type is not None else '?'}", "type": raw_type, "ore_base_name": ore_base, "level": max(0, _sm_int(item.get("level")) or 0), "flame": _sm_enchantment_level(item, 0), "thorns": _sm_enchantment_level(item, 1), "looting": _sm_enchantment_level(item, 2), "freeze": _sm_enchantment_level(item, 3), "fortune": _sm_enchantment_level(item, 4), "is_melted": melted, } def _sm_clan_leaderboard_entries(payload: dict, mode: str): if isinstance(payload, dict) and isinstance(payload.get("response"), dict): payload = payload["response"] entries = payload.get("clans") if isinstance(payload, dict) else [] output = [] for rank, entry in enumerate(entries or [], 1): if not isinstance(entry, dict): continue item = { "rank": rank, "clan_id": str(entry.get("_id") or ""), "name": str(entry.get("name") or "").strip(), "player_count": max(0, _sm_int(entry.get("playerCount")) or 0), } if mode == "power": item["total_power"] = _sm_number(entry.get("totalPower")) or "0" item["total_power_display"] = _sm_display_number(item["total_power"]) or "0" else: item["campfire_date"] = _sm_int(entry.get("campfireDate")) output.append(item) return output def _sm_prepare_clan(raw_clan: dict, power_board=None, campfire_board=None): raw_clan = raw_clan if isinstance(raw_clan, dict) else {} members = [ _sm_clan_member(entry) for entry in (raw_clan.get("players") or []) if isinstance(entry, dict) ] members = [member for member in members if member.get("username")] members.sort(key=lambda member: (-float(member.get("power") or 0), member["username"].lower())) total_power = sum((Decimal(member["power"]) for member in members), Decimal(0)) average_level = ( sum(member["level"] for member in members) / len(members) if members else 0.0 ) average_power = total_power / len(members) if members else Decimal(0) total_donation = sum(member["campfire_donate_seconds"] for member in members) campfire_donor_count = sum( 1 for member in members if member["campfire_donate_seconds"] > 0 ) average_donation = total_donation / len(members) if members else 0.0 building_levels = [] for raw_level in raw_clan.get("buildingLevels") or []: if isinstance(raw_level, dict): raw_level = raw_level.get("level") building_levels.append(min(2, max(0, _sm_int(raw_level) or 0))) building_levels = (building_levels + [0] * len(_SM_CLAN_BUILDING_NAMES))[ :len(_SM_CLAN_BUILDING_NAMES) ] buildings = [] for index, (name, level) in enumerate(zip(_SM_CLAN_BUILDING_NAMES, building_levels)): next_cost = None if level < 2: next_cost = Decimal("1e16") * (level + 1) * (100 if index == 0 else 1) buildings.append({ "name": name, "level": level, "capacity": _SM_CLAN_CAPACITY_BY_LEVEL[level], "next_upgrade_gold": str(next_cost) if next_cost is not None else None, "next_upgrade_gold_display": ( _sm_display_number(str(next_cost)) if next_cost is not None else None ), "chest_slots": 0 if index else (50 if level >= 2 else 25 if level >= 1 else 0), }) pending = [] for request in raw_clan.get("pendingRequests") or []: if not isinstance(request, dict): continue user = request.get("user") if isinstance(request.get("user"), dict) else {} pending.append({ "request_id": str(request.get("_id") or ""), "date": _sm_int(request.get("date")), "player_id": str(user.get("_id") or ""), "username": str(user.get("username") or "").strip(), "level": max(0, _sm_int(user.get("level")) or 0), "power": _sm_number(user.get("power")) or "0", "power_display": _sm_display_number(user.get("power")) or "0", }) power_top = _sm_clan_leaderboard_entries(power_board or {}, "power") campfire_top = _sm_clan_leaderboard_entries(campfire_board or {}, "campfire") clan_id = str(raw_clan.get("_id") or "") clan_name = str(raw_clan.get("name") or "").strip() match = lambda entry: ( (clan_id and entry.get("clan_id") == clan_id) or entry.get("name", "").lower() == clan_name.lower() ) power_rank = next((entry["rank"] for entry in power_top if match(entry)), None) campfire_rank = next((entry["rank"] for entry in campfire_top if match(entry)), None) campfire_date = _sm_int(raw_clan.get("campfireDate")) now_ms = int(time.time() * 1000) remaining_seconds = max(0, ((campfire_date or 0) - now_ms) // 1000) max_players = max( 0, _sm_int(raw_clan.get("maxPlayers")) or 10 + sum(building["capacity"] for building in buildings), ) owner_user = raw_clan.get("ownerUser") if isinstance(raw_clan.get("ownerUser"), dict) else {} owner_id = str(raw_clan.get("owner") or owner_user.get("_id") or "") owner = next((member for member in members if member["player_id"] == owner_id), None) owner_name = str(owner_user.get("username") or (owner or {}).get("username") or "").strip() return { "clan_id": clan_id, "name": clan_name, "description": str(raw_clan.get("description") or "").strip(), "creation_date": _sm_int(raw_clan.get("creationDate")), "owner_id": owner_id, "owner_name": owner_name, "join_type": max(0, _sm_int(raw_clan.get("clanJoinType")) or 0), "join_type_name": "Join request" if _sm_int(raw_clan.get("clanJoinType")) == 1 else "Public", "min_level": max(0, _sm_int(raw_clan.get("minLevel")) or 0), "min_power": _sm_number(raw_clan.get("minPower")) or "0", "min_power_display": _sm_display_number(raw_clan.get("minPower")) or "0", "member_count": len(members), "max_players": max_players, "open_slots": max(0, max_players - len(members)), "total_power": str(total_power), "total_power_display": _sm_display_number(str(total_power)) or "0", "average_power": str(average_power), "average_power_display": _sm_display_number(str(average_power)) or "0", "average_level": average_level, "total_campfire_donate_seconds": total_donation, "average_campfire_donate_seconds": average_donation, "campfire_donor_count": campfire_donor_count, "campfire_date": campfire_date, "campfire_remaining_seconds": remaining_seconds, "campfire_active": remaining_seconds > 0, "chest_limit": max(0, _sm_int(raw_clan.get("chestLimit")) or 0), "chest_count": len(raw_clan.get("chest") or []), "chest": [ _sm_clan_chest_item(item) for item in (raw_clan.get("chest") or []) if isinstance(item, dict) ], "pending_requests": pending, "buildings": buildings, "members": members, "global_power_rank": power_rank, "global_campfire_rank": campfire_rank, "global_power_top10": power_top, "global_campfire_top10": campfire_top, } def _sm_fetch_clan_lookup(clan_name: str): socket = None try: socket = _sm_open_guest_room() _sm_send_room_message(socket, "Client:SearchClan", {"clanName": clan_name}) search = _sm_wait_room_message(socket, "Server:SearchClanResults") _sm_send_room_message( socket, "Client:Clan:GetLeaderboard", {"leaderboardType": "GlobalPower", "clanName": "-"}, ) power_board = _sm_wait_room_message(socket, "Server:Clan:GetLeaderboard") _sm_send_room_message( socket, "Client:Clan:GetLeaderboard", {"leaderboardType": "GlobalCampfire", "clanName": "-"}, ) campfire_board = _sm_wait_room_message(socket, "Server:Clan:GetLeaderboard") return search, power_board, campfire_board finally: if socket is not None: try: socket.send_binary(bytes([12])) except Exception: pass try: socket.close() except Exception: pass def _sm_fetch_clan_top10(): socket = None try: socket = _sm_open_guest_room() _sm_send_room_message( socket, "Client:Clan:GetLeaderboard", {"leaderboardType": "GlobalPower", "clanName": "-"}, ) power_board = _sm_wait_room_message(socket, "Server:Clan:GetLeaderboard") _sm_send_room_message( socket, "Client:Clan:GetLeaderboard", {"leaderboardType": "GlobalCampfire", "clanName": "-"}, ) campfire_board = _sm_wait_room_message(socket, "Server:Clan:GetLeaderboard") return power_board, campfire_board finally: if socket is not None: try: socket.send_binary(bytes([12])) except Exception: pass try: socket.close() except Exception: pass def _sm_clan_top10(): cache_key = "__top10__" with _SM_CLAN_LOCK: cached = _SM_CLAN_CACHE.get(cache_key) if cached and time.monotonic() - cached.get("stamp", 0.0) < SM_CLAN_CACHE_SECONDS: return dict(cached["result"]) power_board, campfire_board = _sm_fetch_clan_top10() result = { "global_power_top10": _sm_clan_leaderboard_entries(power_board or {}, "power"), "global_campfire_top10": _sm_clan_leaderboard_entries(campfire_board or {}, "campfire"), } with _SM_CLAN_LOCK: _SM_CLAN_CACHE[cache_key] = {"stamp": time.monotonic(), "result": dict(result)} return result def _sm_clan_lookup(clan_name: str): clean = str(clan_name or "").strip() if not re.fullmatch(r"[A-Za-z0-9]{2,3}", clean): raise HTTPException(400, "Clan tags contain 2-3 letters or numbers.") cache_key = clean.lower() with _SM_CLAN_LOCK: cached = _SM_CLAN_CACHE.get(cache_key) if cached and time.monotonic() - cached.get("stamp", 0.0) < SM_CLAN_CACHE_SECONDS: return dict(cached["result"]) search, power_board, campfire_board = _sm_fetch_clan_lookup(clean) raw_matches = [ clan for clan in (search.get("clans") or []) if isinstance(clan, dict) ] exact = next( (clan for clan in raw_matches if str(clan.get("name") or "").lower() == cache_key), None, ) selected = exact or (raw_matches[0] if len(raw_matches) == 1 else None) result = { "ok": selected is not None, "query": clean, "matches": [ { "clan_id": str(clan.get("_id") or ""), "name": str(clan.get("name") or "").strip(), "member_count": len(clan.get("players") or []), "max_players": max(0, _sm_int(clan.get("maxPlayers")) or 0), } for clan in raw_matches ], } if selected is not None: result["clan"] = _sm_prepare_clan(selected, power_board, campfire_board) with _SM_CLAN_LOCK: _SM_CLAN_CACHE[cache_key] = {"stamp": time.monotonic(), "result": dict(result)} return result def _sm_fetch_live_inventories_once(usernames: list[str]): guest = _sm_json_post(f"{SM_LOADBALANCER_BASE}/api/server/findServer", {"token": None}) host = str((guest.get("data") or {}).get("foundServer") or "") token = guest.get("token") if not re.fullmatch(r"[a-zA-Z0-9.-]+", host) or not token: raise ValueError("SwordMasters loadbalancer returned an invalid guest reservation.") reservation = _sm_json_post( f"https://{host}.swordmasters.io/matchmake/joinOrCreate/{_url_quote(SM_LOOKUP_WORLD)}", {"accessToken": token}, ) room = reservation.get("room") or {} public_address = str(room.get("publicAddress") or f"{host}.swordmasters.io") process_id = str(room.get("processId") or "") room_id = str(room.get("roomId") or "") session_id = str(reservation.get("sessionId") or "") if ( not re.fullmatch(r"[a-zA-Z0-9.-]+", public_address) or not public_address.endswith(".swordmasters.io") or not process_id or not room_id or not session_id ): raise ValueError("SwordMasters matchmaker returned an invalid room reservation.") socket = None try: ws_url = ( f"wss://{public_address}/{_url_quote(process_id)}/{_url_quote(room_id)}" f"?sessionId={_url_quote(session_id)}" ) socket = _websocket.create_connection( ws_url, timeout=SM_LOOKUP_TIMEOUT, origin="https://swordmasters.io", ) socket.settimeout(SM_LOOKUP_TIMEOUT) join_deadline = time.monotonic() + SM_LOOKUP_TIMEOUT while time.monotonic() < join_deadline: frame = socket.recv() if isinstance(frame, (bytes, bytearray)) and frame and frame[0] == 10: socket.send_binary(bytes([10])) break else: raise TimeoutError("SwordMasters room join timed out.") pending = {_sm_live_username_key(username): username for username in usernames} for username in usernames: payload = ( bytes([13]) + _msgpack.packb("Client:SkinStatue:GetPlayerInfo", use_bin_type=True) + _msgpack.packb({"username": username}, use_bin_type=True) ) socket.send_binary(payload) if SM_LOOKUP_QUERY_INTERVAL: time.sleep(SM_LOOKUP_QUERY_INTERVAL) inventories = {} reply_deadline = time.monotonic() + SM_LOOKUP_TIMEOUT + len(pending) * 0.15 while pending and time.monotonic() < reply_deadline: try: frame = socket.recv() except _websocket.WebSocketTimeoutException: break message_type, message = _sm_room_message(frame) if message_type != "Server:SkinStatue:GetPlayerInfo" or not isinstance(message, dict): continue key = _sm_live_username_key(message.get("username")) if key in pending: inventories[key] = message.get("inv") pending.pop(key, None) return inventories finally: if socket is not None: try: socket.send_binary(bytes([12])) except Exception: pass try: socket.close() except Exception: pass def _sm_live_username_key(username: str): return str(username or "").strip() def _sm_inventory_payload_for_username(inventories: dict, username: str): if not isinstance(inventories, dict): return None exact_key = _sm_live_username_key(username) if exact_key in inventories: return inventories.get(exact_key) legacy_key = _discord_lookup_key(username) if legacy_key in inventories: return inventories.get(legacy_key) return None def _sm_fetch_live_inventories_direct(usernames: list[str]): pending = [] seen = set() for username in usernames: clean = str(username or "").strip() key = _sm_live_username_key(clean) if clean and key and key not in seen: pending.append(clean) seen.add(key) inventories = {} last_error = None for attempt in range(SM_LOOKUP_ROOM_ATTEMPTS): if not pending: break try: received = _sm_fetch_live_inventories_once(pending) inventories.update(received) pending = [ username for username in pending if _sm_inventory_payload_for_username(inventories, username) is None ] if pending: last_error = TimeoutError( f"No live reply for {len(pending)} of {len(usernames)} requested accounts." ) except Exception as exc: last_error = exc if pending and attempt + 1 < SM_LOOKUP_ROOM_ATTEMPTS and SM_LOOKUP_RETRY_DELAY: time.sleep(SM_LOOKUP_RETRY_DELAY * (attempt + 1)) if not inventories and last_error is not None: raise last_error return inventories def _sm_close_live_room_socket(socket): if socket is None: return try: socket.send_binary(bytes([12])) except Exception: pass try: socket.close() except Exception: pass def _sm_query_live_room_socket(socket, usernames: list[str]): pending = { _sm_live_username_key(username): username for username in usernames if _sm_live_username_key(username) } for username in pending.values(): _sm_send_room_message( socket, "Client:SkinStatue:GetPlayerInfo", {"username": username}, ) if SM_LOOKUP_QUERY_INTERVAL: time.sleep(SM_LOOKUP_QUERY_INTERVAL) inventories = {} socket.settimeout(0.25) deadline = time.monotonic() + SM_LOOKUP_TIMEOUT + len(pending) * 0.15 while pending and time.monotonic() < deadline: try: frame = socket.recv() except _websocket.WebSocketTimeoutException: continue if frame in (None, "", b""): raise ConnectionError("SwordMasters live room closed the lookup connection.") if isinstance(frame, (bytes, bytearray)) and frame and frame[0] == 10: socket.send_binary(bytes([10])) continue message_type, message = _sm_room_message(frame) if message_type != "Server:SkinStatue:GetPlayerInfo" or not isinstance(message, dict): continue key = _sm_live_username_key(message.get("username")) if key in pending: inventories[key] = message.get("inv") pending.pop(key, None) return inventories def _sm_live_room_worker(): socket = None retry_at = 0.0 while True: if socket is None: if time.monotonic() < retry_at: time.sleep(min(0.25, retry_at - time.monotonic())) continue try: socket = _sm_open_guest_room() socket.settimeout(0.25) except Exception as exc: print(f"[SwordMasters warm live room failed] {type(exc).__name__}: {str(exc)[:180]}") socket = None retry_at = time.monotonic() + 3.0 continue try: first_job = _SM_LIVE_ROOM_QUEUE.get(timeout=0.15) except queue.Empty: try: frame = socket.recv() if isinstance(frame, (bytes, bytearray)) and frame and frame[0] == 10: socket.send_binary(bytes([10])) elif frame in (None, "", b""): raise ConnectionError("SwordMasters warm live room closed.") except _websocket.WebSocketTimeoutException: pass except Exception: _sm_close_live_room_socket(socket) socket = None continue time.sleep(0.01) jobs = [first_job] while True: try: jobs.append(_SM_LIVE_ROOM_QUEUE.get_nowait()) except queue.Empty: break usernames = [] seen = set() for job in jobs: for username in job["usernames"]: key = _sm_live_username_key(username) if key and key not in seen: seen.add(key) usernames.append(username) received = {} last_error = None for attempt in range(SM_LOOKUP_ROOM_ATTEMPTS): remaining = [ username for username in usernames if _sm_inventory_payload_for_username(received, username) is None ] if not remaining: break try: if socket is None: socket = _sm_open_guest_room() received.update(_sm_query_live_room_socket(socket, remaining)) remaining = [ username for username in remaining if _sm_inventory_payload_for_username(received, username) is None ] if remaining: last_error = TimeoutError( f"No warm live reply for {len(remaining)} of {len(usernames)} requested accounts." ) _sm_close_live_room_socket(socket) socket = None except Exception as exc: last_error = exc _sm_close_live_room_socket(socket) socket = None if socket is None and attempt + 1 < SM_LOOKUP_ROOM_ATTEMPTS: try: socket = _sm_open_guest_room() socket.settimeout(0.25) except Exception as exc: last_error = exc socket = None for job in jobs: job_result = { _sm_live_username_key(username): _sm_inventory_payload_for_username(received, username) for username in job["usernames"] if _sm_inventory_payload_for_username(received, username) is not None } job["result"] = job_result if not job_result and last_error is not None: job["error"] = last_error job["event"].set() def _sm_start_live_room_background(): global _SM_LIVE_ROOM_THREAD with _SM_LIVE_ROOM_THREAD_LOCK: if _SM_LIVE_ROOM_THREAD and _SM_LIVE_ROOM_THREAD.is_alive(): return True _SM_LIVE_ROOM_THREAD = threading.Thread( target=_sm_live_room_worker, name="swordmasters-live-room", daemon=True, ) _SM_LIVE_ROOM_THREAD.start() return True def _sm_fetch_live_inventories(usernames: list[str]): clean = [] seen = set() for username in usernames: text = str(username or "").strip() key = _sm_live_username_key(text) if text and key and key not in seen: clean.append(text) seen.add(key) if not clean: return {} _sm_start_live_room_background() job = {"usernames": clean, "event": threading.Event(), "result": {}, "error": None} try: _SM_LIVE_ROOM_QUEUE.put(job, timeout=0.5) except queue.Full: return _sm_fetch_live_inventories_direct(clean) wait_seconds = ( SM_LOOKUP_ROOM_ATTEMPTS * (SM_LOOKUP_TIMEOUT + len(clean) * 0.15 + 1.0) + 2.0 ) if not job["event"].wait(wait_seconds): raise TimeoutError("SwordMasters warm live lookup queue timed out.") if not job["result"] and job["error"] is not None: raise job["error"] return job["result"] def _sm_fetch_live_inventories_scanner(usernames: list[str]): """Scanner-Variante: holt die Inventare ueber einen EIGENEN, dauerhaften Room-Socket (`_SM_SCANNER_SOCKET`), getrennt vom warm-room der Lookups. Der Socket wird ueber Batches wiederverwendet -> kein Room-Churn (der die Lookup-Ausfaelle verursachte) und keine Stoerung der interaktiven Lookups. Nur der Scanner-Thread ruft das auf.""" global _SM_SCANNER_SOCKET clean = [] seen = set() for username in usernames: text = str(username or "").strip() key = _sm_live_username_key(text) if text and key and key not in seen: clean.append(text) seen.add(key) if not clean: return {} received = {} for attempt in range(SM_LOOKUP_ROOM_ATTEMPTS): remaining = [u for u in clean if _sm_inventory_payload_for_username(received, u) is None] if not remaining: break try: if _SM_SCANNER_SOCKET is None: _SM_SCANNER_SOCKET = _sm_open_guest_room() _SM_SCANNER_SOCKET.settimeout(0.25) received.update(_sm_query_live_room_socket(_SM_SCANNER_SOCKET, remaining)) except Exception: # noqa: BLE001 _sm_close_live_room_socket(_SM_SCANNER_SOCKET) _SM_SCANNER_SOCKET = None # naechster Versuch oeffnet frisch return received def _sm_cached_leaderboard_row_for_username(username: str): wanted = str(username or "").strip() if not wanted: return None with _SM_LEADERBOARD_LOCK: state = _sm_leaderboard_load_unlocked() rows = list((state.get("players") or {}).values()) for row in rows: current = str((row or {}).get("username") or "").strip() if current == wanted: return dict(row) for row in rows: aliases = [str(alias or "").strip() for alias in ((row or {}).get("aliases") or [])] if wanted in aliases: return dict(row) return None def _sm_live_lookup_bulk(usernames: list[str], *, include_ranks: bool = True): unique = [] original_by_key = {} for username in usernames: clean = str(username or "").strip() key = _sm_live_username_key(clean) if not clean or not key: continue if key not in original_by_key: unique.append(clean) original_by_key[key] = clean if len(unique) > SM_LOOKUP_MAX_USERNAMES: raise HTTPException(400, f"At most {SM_LOOKUP_MAX_USERNAMES} accounts can be refreshed at once.") with _sm_lookup_key_scope(unique): now = time.monotonic() by_key = {} missing = [] for username in unique: key = _sm_live_username_key(username) cached = _SM_LOOKUP_CACHE.get(key) if cached and now - cached["stamp"] < SM_LOOKUP_CACHE_SECONDS: by_key[key] = _sm_apply_probe_inventory_unlocked(dict(cached["result"]), username) else: missing.append(username) if missing: try: inventories = _sm_fetch_live_inventories(list(missing)) except Exception as exc: error = _sm_public_error(exc) inventories = {} for username in missing: failure = { "username": username, "ok": False, "error": error, } cached_leaderboard = _sm_cached_leaderboard_row_for_username(username) if cached_leaderboard: public_row = _sm_leaderboard_public_row(cached_leaderboard) failure["cached_leaderboard"] = public_row level = public_row.get("level") power = public_row.get("power") or "-" gold = public_row.get("gold") or "-" failure["error"] = ( f"Live stats did not respond for exact username '{username}'. " f"Cached leaderboard match: Level {level}, Power {power}, Gold {gold}. " "No gear was applied to avoid mixing this account with another similarly named player." ) by_key[_sm_live_username_key(username)] = failure for username in missing: key = _sm_live_username_key(username) if key in by_key: continue result = _sm_inventory_result(username, _sm_inventory_payload_for_username(inventories, username)) if not result.get("ok"): cached_leaderboard = _sm_cached_leaderboard_row_for_username(username) if cached_leaderboard: public_row = _sm_leaderboard_public_row(cached_leaderboard) current_username = str(public_row.get("username") or "").strip() current_inventory = _sm_inventory_payload_for_username(inventories, current_username) if current_username and current_username != username and current_inventory is None: try: alias_inventories = _sm_fetch_live_inventories([current_username]) current_inventory = _sm_inventory_payload_for_username( alias_inventories, current_username ) except Exception: current_inventory = None if current_username and current_username != username and current_inventory is not None: alias_result = _sm_inventory_result(current_username, current_inventory) if alias_result.get("ok"): result = alias_result result["requested_username"] = username result["lookup_alias"] = current_username result["lookup_note"] = ( f"'{username}' appears to be renamed to '{current_username}' " f"based on player ID {public_row.get('player_id') or 'unknown'}." ) for field in ( "player_id", "level_rank", "level_rank_page", "level_rank_page_position", ): if public_row.get(field) is not None: result[field] = public_row.get(field) if result.get("ok"): output_result = _sm_apply_probe_inventory_unlocked(result, username) by_key[key] = output_result _SM_LOOKUP_CACHE[key] = {"stamp": time.monotonic(), "result": dict(result)} continue result["cached_leaderboard"] = public_row level = public_row.get("level") power = public_row.get("power") or "-" gold = public_row.get("gold") or "-" result["error"] = ( f"Live stats did not respond for exact username '{username}'. " f"Cached leaderboard match: Level {level}, Power {power}, Gold {gold}. " "No gear was applied to avoid mixing this account with another similarly named player." ) output_result = _sm_apply_probe_inventory_unlocked(result, username) by_key[key] = output_result if result.get("ok"): _SM_LOOKUP_CACHE[key] = {"stamp": time.monotonic(), "result": dict(result)} if include_ranks: rank_map = { _sm_live_username_key(username): by_key.get(_sm_live_username_key(username)) for username in unique if by_key.get(_sm_live_username_key(username)) is not None } _sm_attach_level_ranks_unlocked(rank_map, unique) return { username: dict(by_key.get(_sm_live_username_key(username)) or { "username": username, "ok": False, "error": f"No live stats found for '{username}'.", }) for username in usernames } @app.get("/discord-debug2") def discord_debug2(username: str): if _os.getenv("ENABLE_DISCORD_DEBUG", "").lower() not in {"1", "true", "yes"}: raise HTTPException(404, "Discord debug endpoint is disabled.") import random, time active_token = _get_token() # Bug 1 fix: was undefined, caused NameError nonce = str(random.randint(1_000_000_000_000_000_000, 9_999_999_999_999_999_999)) msg_url = f"https://discord.com/api/v10/channels/{DISCORD_CHANNEL}/messages" msg_headers = {"Authorization": active_token, "User-Agent": "Mozilla/5.0"} # Ankerpunkt VOR dem Command anchor_id = _discord_anchor_id(msg_url, msg_headers) # Command abschicken send_ok, send_error = _discord_send_info_command(username, active_token, nonce) time.sleep(3.0) # Alles was danach kam msgs_resp = _discord_request("GET", f"{msg_url}?after={anchor_id}&limit=10", headers=msg_headers, retries=2) return { "send_status": 204 if send_ok else 502, "send_error": "" if send_ok else send_error, "anchor_id": anchor_id, "messages_after": msgs_resp.json() if msgs_resp.status_code == 200 else msgs_resp.text } def _discord_clean_text(value) -> str: text = str(value or "") text = re.sub(r"[*_`~>|]", " ", text) text = re.sub(r"<:[^>]+>", " ", text) text = re.sub(r"\s+", " ", text) return text.strip() def _discord_lookup_key(value) -> str: return re.sub(r"[^a-z0-9]+", "", _discord_clean_text(value).lower()) def _discord_number_token(value: str): text = _discord_clean_text(value).replace(",", "") m = re.search(r"([0-9]+(?:\.[0-9]+)?\s*[a-zA-Z]{0,6})", text) return re.sub(r"\s+", "", m.group(1)) if m else None def _discord_int_token(value: str): text = _discord_clean_text(value).replace(",", "") m = re.search(r"\d+", text) return int(m.group(0)) if m else None def _known_item_in_text(value: str, names) -> str | None: key = _discord_lookup_key(value) matches = [name for name in names if _discord_lookup_key(name) in key] return max(matches, key=len) if matches else None def _parse_discord_gear(value: str, names): text = _discord_clean_text(value) level = None m = re.search(r"(?i)\b(?:level|lvl|lv)\s*[:#-]?\s*(\d+)\b", text) if m: level = int(m.group(1)) known = _known_item_in_text(text, names) name = known if not name: cleaned = re.sub(r"(?i)\b(?:sword|shield|weapon)\s*[:#-]?\s*", " ", text) cleaned = re.sub(r"(?i)[,;|/-]?\s*\b(?:level|lvl|lv)\s*[:#-]?\s*\d+\b", " ", cleaned) cleaned = re.sub(r"\s+", " ", cleaned).strip(" ,;:-") name = cleaned or None return name, level def _discord_embed_matches_username(embed: dict, username: str) -> bool: user_key = _discord_lookup_key(username) if not user_key: return False parts = [ embed.get("title", ""), embed.get("description", ""), embed.get("author", {}).get("name", "") if isinstance(embed.get("author"), dict) else "", ] for field in embed.get("fields", []) or []: if isinstance(field, dict): name_key = _discord_lookup_key(field.get("name", "")) if name_key in {"username", "user", "player", "name"}: parts.append(field.get("value", "")) for part in parts: raw = _discord_clean_text(part).lower() tokens = [_discord_lookup_key(t) for t in re.findall(r"[a-z0-9_.'-]+", raw)] if user_key in tokens or f"{user_key}s" in tokens: return True return False def _parse_info_response(fields: list) -> dict: result = {} for field in fields or []: if not isinstance(field, dict): continue name = str(field.get("name") or "").strip() value = str(field.get("value") or "").strip() if not name: continue field_key = _discord_lookup_key(name) if "power" in field_key: token = _discord_number_token(value) if token: if "total" in field_key: result["total_power"] = token else: result["power"] = token elif "sword" in field_key or "weapon" in field_key: gear_name, level = _parse_discord_gear(value, SCHWERTER.keys()) if gear_name: result["sword"] = gear_name if level is not None: result["sword_level"] = level elif "shield" in field_key: gear_name, level = _parse_discord_gear(value, SCHILDE.keys()) if gear_name: result["shield"] = gear_name if level is not None: result["shield_level"] = level elif field_key in {"hp", "health"} or "health" in field_key: token = _discord_number_token(value) if token: result["hp"] = token elif "gold" in field_key: token = _discord_number_token(value) if token: result["gold"] = token elif field_key in {"level", "lvl", "lv"} or "playerlevel" in field_key: level = _discord_int_token(value) if level is not None: result["level"] = level elif field_key in {"xp", "exp", "experience"} or "experience" in field_key: token = _discord_number_token(value) if token: result["experience"] = token if "total" in field_key: result["total_experience"] = token return result def _has_info_payload(parsed: dict) -> bool: return any(parsed.get(k) for k in ( "power", "total_power", "sword", "sword_level", "shield", "shield_level", "level", "experience", "total_experience", )) _DISCORD_TOKEN_USER_CACHE = {} _DISCORD_TOKEN_LOCKS = {} _DISCORD_TOKEN_LOCKS_GUARD = threading.Lock() _DISCORD_CHANNEL_READ_LOCK = threading.Lock() _DISCORD_CHANNEL_READ_CACHE = {"stamp": 0.0, "messages": [], "error": None} _DISCORD_PREFERRED_READ_HOST = "discord.com" def _discord_make_channel_read_session(): session_factory = getattr(_requests, "Session", None) if not callable(session_factory): return None session = session_factory() adapter_factory = getattr(getattr(_requests, "adapters", None), "HTTPAdapter", None) if callable(adapter_factory): session.mount("https://", adapter_factory(pool_connections=3, pool_maxsize=3, max_retries=0)) return session _DISCORD_CHANNEL_READ_SESSION = _discord_make_channel_read_session() def _discord_read_host_variants(url: str): if "://discord.com/" not in url: return [url] hosts = [_DISCORD_PREFERRED_READ_HOST, "discord.com", "canary.discord.com", "ptb.discord.com"] return [url.replace("://discord.com/", f"://{host}/", 1) for host in dict.fromkeys(hosts)] def _discord_merge_messages(existing, incoming, limit=300): merged = {} for msg in list(existing or []) + list(incoming or []): if isinstance(msg, dict) and msg.get("id"): merged[str(msg["id"])] = msg def _snowflake(msg): try: return int(str(msg.get("id") or "0")) except (AttributeError, TypeError, ValueError): return 0 return sorted(merged.values(), key=_snowflake, reverse=True)[:limit] def _discord_token_lock(token: str): with _DISCORD_TOKEN_LOCKS_GUARD: lock = _DISCORD_TOKEN_LOCKS.get(token) if lock is None: lock = threading.Lock() _DISCORD_TOKEN_LOCKS[token] = lock return lock def _discord_token_user_id(token: str): if not token: return None if token in _DISCORD_TOKEN_USER_CACHE: return _DISCORD_TOKEN_USER_CACHE[token] for url in _discord_host_variants("https://discord.com/api/v10/users/@me"): try: resp = _discord_request("GET", url, headers={"Authorization": token, "User-Agent": "Mozilla/5.0"}, retries=1, timeout=(2, 3)) if resp.status_code == 200: user_id = str(resp.json().get("id") or "") _DISCORD_TOKEN_USER_CACHE[token] = user_id or None return _DISCORD_TOKEN_USER_CACHE[token] except Exception as exc: print(f"[Discord user id lookup failed] {url}: {type(exc).__name__}: {str(exc)[:120]}") _DISCORD_TOKEN_USER_CACHE[token] = None return None def _discord_message_invoker_id(msg: dict): meta = msg.get("interaction_metadata") if isinstance(meta, dict): user = meta.get("user") if isinstance(user, dict) and user.get("id"): return str(user.get("id")) interaction = msg.get("interaction") if isinstance(interaction, dict): user = interaction.get("user") if isinstance(user, dict) and user.get("id"): return str(user.get("id")) return None def _discord_messages_after(messages, anchor_id: str): try: anchor_num = int(str(anchor_id)) except (TypeError, ValueError): return list(messages or []) filtered = [] for msg in messages or []: try: if int(str(msg.get("id") or "0")) > anchor_num: filtered.append(msg) except (AttributeError, TypeError, ValueError): continue return filtered def _discord_read_new_messages(msg_url: str, anchor_id: str, headers: dict): # All bulk workers watch the same channel. One persistent HTTP session and # one buffered snapshot keep TLS handshakes and Discord reads independent # of account count. The snowflake anchor still isolates each lookup locally. global _DISCORD_PREFERRED_READ_HOST with _DISCORD_CHANNEL_READ_LOCK: now = time.monotonic() cached_messages = list(_DISCORD_CHANNEL_READ_CACHE.get("messages") or []) cached_error = _DISCORD_CHANNEL_READ_CACHE.get("error") cache_seconds = DISCORD_READ_ERROR_CACHE_SECONDS if cached_error else DISCORD_READ_CACHE_SECONDS if now - float(_DISCORD_CHANNEL_READ_CACHE.get("stamp", 0.0)) < cache_seconds: return _discord_messages_after(cached_messages, anchor_id), cached_error errors = [] for url in _discord_read_host_variants(msg_url): try: resp = _discord_request("GET", f"{url}?limit=50", headers=headers, retries=1, timeout=(2.5, 5.0), session=_DISCORD_CHANNEL_READ_SESSION) except HTTPException as exc: errors.append(str(exc.detail)) continue if resp.status_code == 401: raise HTTPException(401, "Discord token is invalid or expired.") if resp.status_code == 403: raise HTTPException(403, "Discord token cannot read the configured channel.") if resp.status_code != 200: errors.append(f"read status {resp.status_code}: {resp.text[:140]}") continue try: messages = resp.json() except Exception as exc: errors.append(f"read JSON parse failed: {type(exc).__name__}") continue if not isinstance(messages, list): errors.append("read JSON payload was not a message list") continue cached_messages = _discord_merge_messages(cached_messages, messages) _DISCORD_PREFERRED_READ_HOST = url.split("://", 1)[1].split("/", 1)[0] _DISCORD_CHANNEL_READ_CACHE.update({ "stamp": time.monotonic(), "messages": cached_messages, "error": None, }) return _discord_messages_after(cached_messages, anchor_id), None error = " | ".join(errors[-3:]) _DISCORD_CHANNEL_READ_CACHE.update({ "stamp": time.monotonic(), "messages": cached_messages, "error": error, }) return _discord_messages_after(cached_messages, anchor_id), error def _discord_find_info_payload(messages, username: str, token_user_id=None): seen = [] invoker_candidate = None bot_author_ids = _discord_bot_author_ids() for msg in messages or []: if str(msg.get("author", {}).get("id") or "") not in bot_author_ids: continue invoker_id = _discord_message_invoker_id(msg) for emb in msg.get("embeds", []) or []: fields = emb.get("fields", []) if not fields: continue parsed = _parse_info_response(fields) if not _has_info_payload(parsed): continue title = _discord_clean_text(emb.get("title", ""))[:80] seen.append(title or "(untitled embed)") if _discord_embed_matches_username(emb, username): return parsed, seen if token_user_id and invoker_id == token_user_id and invoker_candidate is None: invoker_candidate = parsed if invoker_candidate: return invoker_candidate, seen return None, seen def _discord_lookup_with_token_locked(username: str, token: str, *, timeout: float = 18.0): import random nonce = str(random.randint(1_000_000_000_000_000_000, 9_999_999_999_999_999_999)) msg_url = f"https://discord.com/api/v10/channels/{DISCORD_CHANNEL}/messages" msg_headers = {"Authorization": token, "User-Agent": "Mozilla/5.0"} anchor_id = _discord_after_now_id() send_ok, send_error = _discord_send_info_command(username, token, nonce) if "401" in send_error: raise HTTPException(401, "Discord token is invalid or expired.") if not send_ok: raise HTTPException(502, send_error) token_user_id = None token_user_id_checked = False deadline = time.time() + timeout resend_at = time.time() + min(2.0, max(timeout * 0.4, 0.8)) resend_uncertain = bool(send_error) resent = False seen_titles = [] last_send_error = send_error or None last_poll_error = None time.sleep(0.35) while time.time() < deadline: messages, poll_error = _discord_read_new_messages(msg_url, anchor_id, msg_headers) if poll_error: last_poll_error = poll_error if messages is None: time.sleep(0.35) continue parsed, seen = _discord_find_info_payload(messages, username, token_user_id) seen_titles.extend(t for t in seen if t not in seen_titles) if parsed: return parsed if seen and not token_user_id_checked: token_user_id_checked = True token_user_id = _discord_token_user_id(token) if token_user_id: parsed, _seen = _discord_find_info_payload(messages, username, token_user_id) if parsed: return parsed if resend_uncertain and not resent and time.time() >= resend_at: resent = True retry_nonce = str(random.randint(1_000_000_000_000_000_000, 9_999_999_999_999_999_999)) retry_ok, retry_error = _discord_send_info_command(username, token, retry_nonce) if retry_error: last_send_error = retry_error if not retry_ok: last_poll_error = retry_error time.sleep(0.35) send_note = f" Discord send warning: {last_send_error}" if last_send_error else "" suffix = f" Last Discord read error: {last_poll_error}" if last_poll_error else "" seen_note = f" Seen parseable bot replies: {', '.join(seen_titles[:3])}." if seen_titles else "" raise HTTPException(504, f"Keine passende Bot-Antwort fuer '{username}' innerhalb von {timeout:.0f} Sekunden.{send_note}{suffix}{seen_note}") def _discord_lookup_with_token(username: str, token: str, *, timeout: float = 18.0): with _discord_token_lock(token): return _discord_lookup_with_token_locked(username, token, timeout=timeout) def _discord_public_lookup_error(detail) -> str: text = str(detail or "") if "invalid or expired" in text.lower() or "401" in text: return "Discord token is invalid or expired." if "slash-command submission could not be confirmed" in text: return "Discord did not confirm the slash command. Retry shortly." if "Keine passende Bot-Antwort" in text: return "No matching Discord bot reply arrived in time. Retry shortly." if "Discord connection failed" in text or "ReadTimeout" in text: return "Discord connection failed temporarily. Retry shortly." return text[:240] or "Discord lookup failed." def _discord_slot_failover(exc, slot: int): status_code = getattr(exc, "status_code", None) detail = str(getattr(exc, "detail", exc) or "") if status_code == 401: return f"DISCORD_TOKENS slot {slot} is invalid or expired. Replace this secret entry." if status_code == 403: return f"DISCORD_TOKENS slot {slot} cannot read the configured channel. Check this secret entry." if status_code in (502, 504) and "429" not in detail: return f"DISCORD_TOKENS slot {slot} was temporarily unavailable and was skipped for this update." return None # ───────────────────────────────────────────────────────────────── # ROOM LOOKUP (server-side Colyseus join via Node sidecar) # Developer-authorized (Emre Şahin, 2026-09-15): the tool may connect with the # Origin header set to swordmasters.io, incl. this feature. Uses a dedicated # high-level account so it can enter high-bracket worlds. The account token is # supplied as a Space secret; if it stops working and login secrets are present, # a fresh token is fetched and kept ONLY in memory (never written to disk). # ───────────────────────────────────────────────────────────────── SM_ROOM_LOOKUP_TOKEN = (_os.getenv("SM_ROOM_LOOKUP_TOKEN") or "").strip() SM_ROOM_LOOKUP_USER = (_os.getenv("SM_ROOM_LOOKUP_USER") or "").strip() SM_ROOM_LOOKUP_HAS_PASS = bool(_os.getenv("SM_ROOM_LOOKUP_PASS")) SM_ROOM_LOOKUP_DIR = APP_DIR / "room_lookup" SM_ROOM_LOOKUP_SCRIPT = SM_ROOM_LOOKUP_DIR / "room_lookup.js" SM_ROOM_LOOKUP_NODE = _os.getenv("SM_ROOM_LOOKUP_NODE", "node") SM_ROOM_LOOKUP_WAIT_MS = max(2000, int(_os.getenv("SM_ROOM_LOOKUP_WAIT_MS", "8000"))) SM_ROOM_LOOKUP_TIMEOUT = max(10.0, float(_os.getenv("SM_ROOM_LOOKUP_TIMEOUT", "30.0"))) SM_ROOM_LOOKUP_CACHE_SECONDS = max(0.0, float(_os.getenv("SM_ROOM_LOOKUP_CACHE_SECONDS", "30.0"))) # Kill-switch for room lookup: a non-empty value (default here, or the env var) disables the # feature and is shown to users as a maintenance notice. Currently ENABLED (empty default) — the # shared lookup account has been leveled to 5000 so it can enter every world (incl. the mine world, # which needs 5k, not 3.5k like the last boss world). To disable again, set a message string here # or the SM_ROOM_LOOKUP_DISABLED env var. SM_ROOM_LOOKUP_DISABLED = _os.getenv("SM_ROOM_LOOKUP_DISABLED", "").strip() _sm_rl_lock = threading.Lock() # serialize joins: ONE shared account, one room at a time _sm_rl_token_lock = threading.Lock() _sm_rl_token = None # in-memory-only current token (never persisted) _sm_rl_cache = {} # code -> (monotonic_ts, result) _sm_rl_last_manual_ts = 0.0 # wall time of the last real MANUAL join (scanner coordination) # ── Automated room-roster scanner store (fed by the SP7 scanner) ────────────── # The continuous discovery + roster collection runs OFF the Space (SP7 home worker, # home IP); this Space does almost no work. It holds the latest LIVE snapshot as the # exact gzip bytes the scanner sent (served untouched at /room-scan) and a small SQLite # history DB it writes only on a history delta and reads with tiny, index-backed, # LIMITed queries. All heavy computation stays on the SP7. Full contract (schema 2): # room_lookup/ROOM_SCAN_DATA.md. _SM_ROOMSCAN_DIR = Path("/data") if _os.getenv("SPACE_ID") else APP_DIR _SM_ROOMSCAN_SAVE_SEC = 300 # how often the live snapshot is also copied to disk # ── Room-lookup abuse/diagnostics audit log ────────────────────────────────── # Security-scoped, NOT the anonymous /track analytics. Records who asked US for which # room code and what happened, so endpoint-abuse (a flood of requests HERE, from some # source IP) can be told apart from a credential leak (the shared account joins rooms in # the game WITHOUT any request ever reaching this server). Bounded in-memory ring plus a # size-capped JSONL on /data so it survives a Space restart. View it (mod/ingest-gated) # at /room-lookup-log. "joined": true marks entries where we actually joined the game. _RL_AUDIT = [] _RL_AUDIT_MAX = 8000 _RL_AUDIT_LOCK = threading.Lock() _RL_AUDIT_PATH = (Path("/data") / "room_lookup_audit.jsonl") if _os.getenv("SPACE_ID") else (APP_DIR / "room_lookup_audit.jsonl") _RL_AUDIT_MAX_BYTES = 8 * 1024 * 1024 def _rl_client_ip(request): """Best-effort real client IP behind the HF/CF proxy (X-Forwarded-For first hop).""" try: xff = request.headers.get("x-forwarded-for") or "" if xff: return (xff.split(",")[0].strip()[:64]) or "?" xri = request.headers.get("x-real-ip") or "" if xri: return (xri.strip()[:64]) or "?" return ((request.client.host if request.client else "") or "?")[:64] except Exception: return "?" def _rl_audit(request, ip, code, result, extra=None): """Record one /room-lookup call. Never raises.""" try: from datetime import datetime as _dt, timezone as _tz entry = { "t": round(time.time(), 3), "ts": _dt.now(_tz.utc).isoformat(timespec="seconds"), "ip": ip, "code": code, "result": result, "ua": (request.headers.get("user-agent") or "")[:180], "origin": (request.headers.get("origin") or request.headers.get("referer") or "")[:200], } if extra: entry.update(extra) with _RL_AUDIT_LOCK: _RL_AUDIT.append(entry) if len(_RL_AUDIT) > _RL_AUDIT_MAX: del _RL_AUDIT[: len(_RL_AUDIT) - _RL_AUDIT_MAX] try: p = _RL_AUDIT_PATH if p.exists() and p.stat().st_size > _RL_AUDIT_MAX_BYTES: try: tail = p.read_text(encoding="utf-8", errors="ignore").splitlines()[-4000:] p.write_text("\n".join(tail) + "\n", encoding="utf-8") except Exception: pass with open(p, "a", encoding="utf-8") as f: f.write(json.dumps(entry, ensure_ascii=False) + "\n") except Exception: pass except Exception: pass def _sm_rl_current_token(): return _sm_rl_token or SM_ROOM_LOOKUP_TOKEN or None def _sm_rl_refresh_token(): """Exchange the login secrets for a fresh account token, held only in memory. Reads the password straight from the environment at call time so it is never stored in a module global; never logs it.""" global _sm_rl_token if not (SM_ROOM_LOOKUP_USER and _os.getenv("SM_ROOM_LOOKUP_PASS")): raise RuntimeError("no login secrets configured (set SM_ROOM_LOOKUP_USER / SM_ROOM_LOOKUP_PASS)") resp = _requests.post( f"{SM_LOADBALANCER_BASE}/api/user/login", json={"username": SM_ROOM_LOOKUP_USER, "password": _os.getenv("SM_ROOM_LOOKUP_PASS")}, timeout=15, ) if resp.status_code == 400: raise RuntimeError("login rejected: wrong username or password") if resp.status_code != 200: raise RuntimeError(f"login failed: HTTP {resp.status_code}") tok = (resp.json() or {}).get("token") if not tok: raise RuntimeError("login returned no token") _sm_rl_token = tok return tok def _sm_rl_run_node(code, token): """Run the Node sidecar once. Returns (data_dict, exit_code). Token is passed via the child env (not argv), so it is not visible in the process list.""" env = dict(_os.environ) env["SM_LOOKUP_ACCESS_TOKEN"] = token env["SM_LOOKUP_WAIT_MS"] = str(SM_ROOM_LOOKUP_WAIT_MS) env["SM_LOADBALANCER_BASE"] = SM_LOADBALANCER_BASE try: proc = subprocess.run( [SM_ROOM_LOOKUP_NODE, str(SM_ROOM_LOOKUP_SCRIPT), code], cwd=str(SM_ROOM_LOOKUP_DIR), env=env, capture_output=True, text=True, timeout=SM_ROOM_LOOKUP_TIMEOUT, ) except subprocess.TimeoutExpired: return ({"ok": False, "error": "lookup timed out", "authFail": False}, 124) except FileNotFoundError: return ({"ok": False, "error": "node runtime not available", "authFail": False}, 127) # Scan stdout for the JSON result line (last valid `{...}` carrying an "ok" key) instead # of blindly taking the last line — tolerant of any stray output on stdout. data = None for line in reversed((proc.stdout or "").splitlines()): line = line.strip() if not line.startswith("{"): continue try: obj = json.loads(line) except Exception: continue if isinstance(obj, dict) and "ok" in obj: data = obj break if data is None: # No parseable result -> surface the sidecar's stderr (now carries [join-failed]/[ok] # diagnostics) so a real failure isn't hidden behind a bare "no output". err = (proc.stderr or "").strip() or "no output" data = {"ok": False, "error": err[:300], "authFail": False} return (data, proc.returncode) @app.get("/room-lookup") def room_lookup(code: str, request: Request): ip = _rl_client_ip(request) clean = re.sub(r".*#", "", str(code or "")).strip().upper() result = "error" joined = False # True once we actually attempt a join against the game data = None try: # Room lookup is restricted (the shared account joins real game rooms, which must not be # driveable by the public). Access = mod cookie (browser) OR ingest key (operator tooling). # To avoid advertising that a moderator tier exists, a denied request returns the SAME # generic failure a normal lookup does (502 "Room lookup failed.") — indistinguishable from # a full/expired room — rather than a 403. We still tag it "forbidden" in the (mod-only) # audit log so we can see who is probing the endpoint. import hmac as _hmac _ingest_ok = bool(SM_LEADERBOARD_INGEST_KEY) and _hmac.compare_digest( str(request.headers.get("X-Ingest-Key") or request.query_params.get("key") or ""), SM_LEADERBOARD_INGEST_KEY) if not (_ingest_ok or _sm_is_mod(request)): result = "forbidden" raise HTTPException(502, "Room lookup failed.") if SM_ROOM_LOOKUP_DISABLED: # 503 + clear message; the room tab renders this as a neutral maintenance notice. result = "disabled" raise HTTPException(503, SM_ROOM_LOOKUP_DISABLED) if not re.fullmatch(r"[A-Z0-9]{6}", clean): result = "bad_code" raise HTTPException(400, "Code must be 6 alphanumeric characters.") def _cache_fresh(): if SM_ROOM_LOOKUP_CACHE_SECONDS: hit = _sm_rl_cache.get(clean) if hit and (time.monotonic() - hit[0]) < SM_ROOM_LOOKUP_CACHE_SECONDS: return hit[1] return None cached = _cache_fresh() if cached is not None: result = "cache_hit" return cached token = _sm_rl_current_token() if not token: try: with _sm_rl_token_lock: token = _sm_rl_current_token() or _sm_rl_refresh_token() except Exception as exc: result = "no_token" raise HTTPException(503, f"Room lookup not configured: {exc}") with _sm_rl_lock: # Double-checked cache INSIDE the join lock. A burst of requests for the SAME room # (rapid clicks, Enter key-repeat, several viewers) all miss the pre-lock cache while # the first join is still running (~8-10 s) and queue here; without this re-check EACH # would run its own join -> N joins of one room ("joined the room many times a minute"). # Coalesce them onto the first result: one actual join per room per cache window. cached = _cache_fresh() if cached is not None: result = "cache_hit" return cached data, rc = _sm_rl_run_node(clean, token) joined = True # a real join was attempted against the game as the lookup account global _sm_rl_last_manual_ts _sm_rl_last_manual_ts = time.time() # tell the SP7 scanner to yield the shared account # Dead/expired token -> refresh once and retry (only when login secrets exist). if rc == 3 and data.get("authFail") and SM_ROOM_LOOKUP_USER and SM_ROOM_LOOKUP_HAS_PASS: try: with _sm_rl_token_lock: token = _sm_rl_refresh_token() data, rc = _sm_rl_run_node(clean, token) except Exception as exc: data, rc = {"ok": False, "error": f"token refresh failed: {exc}"}, 3 # Cache the successful result INSIDE the lock so the next queued request coalesces onto # it immediately (previously written only after the lock -> the queue still re-joined). if SM_ROOM_LOOKUP_CACHE_SECONDS and rc != 2 and data and data.get("ok"): _sm_rl_cache[clean] = (time.monotonic(), data) if rc == 2: result = "room_not_found" raise HTTPException(404, "Room not found or code expired.") if not data or not data.get("ok"): result = "join_failed" raise HTTPException(502, (data or {}).get("error") or "Room lookup failed.") result = "ok" return data finally: extra = {"joined": joined} if result == "ok" and isinstance(data, dict): extra["players"] = data.get("count") _rl_audit(request, ip, clean, result, extra) @app.get("/room-lookup-log", include_in_schema=False) def room_lookup_log(request: Request, mod_key: str = "", limit: int = 300, code: str = "", ip: str = "", minutes: int = 0): """MOD/ingest-gated audit view of /room-lookup traffic. Shows WHO (source IP) asked us for WHICH room code, WHEN, and the outcome. If a victim's code is being hammered here, the joins come from THIS endpoint (abuse — and we see the caller's IP). If this log stays quiet while the account keeps joining in-game, the joins bypass us -> credential leak. Access: mod cookie (browser) OR the ingest key (Header X-Ingest-Key / ?key=). Filters: ?code=ABC123 ?ip=1.2.3.4 ?minutes=30 ?limit=500.""" import hmac from collections import Counter ingest_ok = bool(SM_LEADERBOARD_INGEST_KEY) and hmac.compare_digest( str(request.headers.get("X-Ingest-Key") or request.query_params.get("key") or ""), SM_LEADERBOARD_INGEST_KEY) if not (ingest_ok or _sm_is_mod(request)): raise HTTPException(404) with _RL_AUDIT_LOCK: rows = list(_RL_AUDIT) cf = re.sub(r".*#", "", str(code or "")).strip().upper() if cf: rows = [r for r in rows if r.get("code") == cf] if ip: rows = [r for r in rows if r.get("ip") == ip] if minutes and minutes > 0: cutoff = time.time() - minutes * 60 rows = [r for r in rows if (r.get("t") or 0) >= cutoff] now = time.time() joined_rows = [r for r in rows if r.get("joined")] last1 = sum(1 for r in rows if (r.get("t") or 0) >= now - 60) last5 = sum(1 for r in rows if (r.get("t") or 0) >= now - 300) joined_last1 = sum(1 for r in joined_rows if (r.get("t") or 0) >= now - 60) lim = max(1, min(int(limit or 300), 3000)) return { "total_in_memory": len(_RL_AUDIT), "matched": len(rows), "actual_joins_matched": len(joined_rows), "requests_last_1min": last1, "requests_last_5min": last5, "actual_joins_last_1min": joined_last1, "by_ip": Counter(r.get("ip") for r in rows).most_common(25), "by_code": Counter(r.get("code") for r in rows).most_common(25), "by_result": dict(Counter(r.get("result") for r in rows)), "by_origin": Counter((r.get("origin") or "(none)") for r in rows).most_common(10), "recent": rows[-lim:], } # ── Automated room-roster scanner: live snapshot + history DB (schema 2) ────── # Full contract in room_lookup/ROOM_SCAN_DATA.md. The live snapshot is kept as the # gzip bytes the scanner sent and served untouched; the history (players, stays, visit # runs, closed rooms) lives in a small SQLite DB that the Space only writes on a # /ingest-rooms-history delta and reads with tiny LIMITed, index-backed queries. import contextlib as _contextlib _RS_KEEP_DAYS = int(_os.getenv("SM_ROOMSCAN_KEEP_DAYS", "90") or "90") _RS_MAX_DB_MB = int(_os.getenv("SM_ROOMSCAN_MAX_DB_MB", "1500") or "1500") _RS_MIN_FREE_DISK = 500 * 1024 * 1024 # keep >=500 MB free on /data (0 disables) _RS_RETENTION_PAUSE = 1.0 # sleep between delete batches (seconds) _RS_RETENTION_INTERVAL = 6 * 3600 # a retention pass at most this often _RS_READER_POOL = 3 _RS_HISTORY_MAX_BYTES = 64 * 1024 * 1024 _RS_OUTCOME = {0: "other", 1: "ok", 2: "staff_reserved", 3: "full_needs_staff", 4: "room_not_found", 5: "timeout", 6: "join_failed", 7: "partial", 8: "id_mismatch"} _RS_DB_PATH = Path(_os.getenv("SM_ROOMSCAN_DB") or (_SM_ROOMSCAN_DIR / "room_scan.sqlite")) _RS_LIVE_PATH = _SM_ROOMSCAN_DIR / "room_scan.json.gz" _rs_writer = None # the single write connection (used only under the lock) _rs_write_lock = threading.Lock() _rs_readers = queue.Queue() # pool of read-only connections _rs_totals = None # (closed_all, closed_with_roster) or None = "unknown" _rs_stats = {} # full row counts, refreshed by the maintenance loop _rs_live = None # {"gz": bytes, "received_at": iso, "received_ms": float} _rs_seq = 0 # bumped on every live push (the /room-scan ETag) _rs_live_lock = threading.Lock() _rs_live_saved_at = 0.0 _rs_maint_started = False _rs_last_retention = 0.0 _RS_SCHEMA = """ CREATE TABLE IF NOT EXISTS players(pid INTEGER PRIMARY KEY, key TEXT NOT NULL UNIQUE, name TEXT, level INTEGER, power REAL, gold REAL, clan TEXT, banned INTEGER, first_seen INTEGER, last_seen INTEGER); CREATE TABLE IF NOT EXISTS player_names(pid INTEGER NOT NULL, name_lc TEXT NOT NULL, name TEXT, first_seen INTEGER, last_seen INTEGER, PRIMARY KEY(pid, name_lc)) WITHOUT ROWID; CREATE INDEX IF NOT EXISTS player_names_lc ON player_names(name_lc); CREATE TABLE IF NOT EXISTS rooms(rid INTEGER PRIMARY KEY, room_id TEXT NOT NULL UNIQUE, rev INTEGER, world TEXT, wnum INTEGER, tier INTEGER, region TEXT, code TEXT, code_status TEXT, code_resolved_while TEXT, max_clients INTEGER, first_seen INTEGER, last_listed INTEGER, peak INTEGER, gone_count INTEGER, last_visit_at INTEGER, last_outcome INTEGER, visits INTEGER, roster_at INTEGER, roster_count INTEGER, last_count INTEGER, closed_at INTEGER, close_reason TEXT, t_end INTEGER NOT NULL); CREATE INDEX IF NOT EXISTS rooms_arch ON rooms(closed_at, rid) WHERE closed_at IS NOT NULL; CREATE INDEX IF NOT EXISTS rooms_arch_w ON rooms(wnum, closed_at, rid) WHERE closed_at IS NOT NULL; CREATE INDEX IF NOT EXISTS rooms_t_end ON rooms(t_end); CREATE INDEX IF NOT EXISTS rooms_code ON rooms(code); CREATE TABLE IF NOT EXISTS stays(rid INTEGER NOT NULL, pid INTEGER NOT NULL, t_from INTEGER NOT NULL, t_to INTEGER, n INTEGER, lvl_from INTEGER, lvl_to INTEGER, pow_from REAL, pow_to REAL, open INTEGER, PRIMARY KEY(rid, pid, t_from)) WITHOUT ROWID; CREATE INDEX IF NOT EXISTS stays_pid ON stays(pid, t_to, rid); DROP TABLE IF EXISTS visits; -- visit-run log removed; rooms.visits + last_visit_at aggregates stay CREATE TABLE IF NOT EXISTS merged(key TEXT NOT NULL, into_key TEXT NOT NULL, at INTEGER, PRIMARY KEY(key, into_key)) WITHOUT ROWID; CREATE INDEX IF NOT EXISTS merged_key ON merged(key, at); CREATE TABLE IF NOT EXISTS counters(k TEXT PRIMARY KEY, n INTEGER NOT NULL) WITHOUT ROWID; CREATE TABLE IF NOT EXISTS meta(k TEXT PRIMARY KEY, v TEXT); CREATE TRIGGER IF NOT EXISTS rooms_ins AFTER INSERT ON rooms WHEN NEW.closed_at IS NOT NULL BEGIN INSERT INTO counters(k, n) VALUES('closed'||(CASE WHEN coalesce(NEW.roster_count,0)>0 THEN '_r' ELSE '' END) ||':'||coalesce(NEW.wnum,'x'), 1) ON CONFLICT(k) DO UPDATE SET n = n + 1; END; CREATE TRIGGER IF NOT EXISTS rooms_del AFTER DELETE ON rooms WHEN OLD.closed_at IS NOT NULL BEGIN INSERT INTO counters(k, n) VALUES('closed'||(CASE WHEN coalesce(OLD.roster_count,0)>0 THEN '_r' ELSE '' END) ||':'||coalesce(OLD.wnum,'x'), -1) ON CONFLICT(k) DO UPDATE SET n = n - 1; END; CREATE TRIGGER IF NOT EXISTS rooms_upd AFTER UPDATE OF closed_at, roster_count, wnum ON rooms BEGIN INSERT INTO counters(k, n) SELECT 'closed'||(CASE WHEN coalesce(OLD.roster_count,0)>0 THEN '_r' ELSE '' END) ||':'||coalesce(OLD.wnum,'x'), -1 WHERE OLD.closed_at IS NOT NULL ON CONFLICT(k) DO UPDATE SET n = n - 1; INSERT INTO counters(k, n) SELECT 'closed'||(CASE WHEN coalesce(NEW.roster_count,0)>0 THEN '_r' ELSE '' END) ||':'||coalesce(NEW.wnum,'x'), 1 WHERE NEW.closed_at IS NOT NULL ON CONFLICT(k) DO UPDATE SET n = n + 1; END; """ def _rs_wnum_tier(world): if not world: return (None, None) m = re.match(r"world_(\d+)(?:_(\d+))?$", str(world)) if not m: return (None, None) return (int(m.group(1)), int(m.group(2)) if m.group(2) else None) def _rs_open_writer(path): path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) new = not path.exists() conn = sqlite3.connect(str(path), isolation_level=None, check_same_thread=False) conn.execute("PRAGMA busy_timeout=5000") if new: conn.execute("PRAGMA auto_vacuum=INCREMENTAL") conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA synchronous=NORMAL") conn.execute("PRAGMA cache_size=-32000") conn.executescript(_RS_SCHEMA) return conn def _rs_make_readers(path, n): pool = queue.Queue() for _ in range(n): c = sqlite3.connect(str(path), check_same_thread=False) c.row_factory = sqlite3.Row c.execute("PRAGMA query_only=1") c.execute("PRAGMA busy_timeout=2000") c.execute("PRAGMA temp_store=MEMORY") c.execute("PRAGMA cache_size=-16000") pool.put(c) return pool def _rs_reset(path): """Open (or re-open) the history DB at `path`, resetting all in-memory state. A restart keeps the DB file but drops the cached totals/snapshot (they are rebuilt from the DB and from the next scanner push).""" global _RS_DB_PATH, _rs_writer, _rs_readers, _rs_totals, _rs_stats, _rs_live, _rs_seq global _rs_maint_started, _rs_last_retention, _rs_live_saved_at with _rs_write_lock: try: if _rs_writer is not None: _rs_writer.close() except Exception: pass try: while True: _rs_readers.get_nowait().close() except Exception: pass _RS_DB_PATH = Path(path) _rs_writer = _rs_open_writer(_RS_DB_PATH) _rs_readers = _rs_make_readers(_RS_DB_PATH, _RS_READER_POOL) _rs_totals = None _rs_stats = {} _rs_live = None _rs_seq = 0 _rs_live_saved_at = 0.0 _rs_maint_started = False _rs_last_retention = 0.0 @_contextlib.contextmanager def _rs_reader(): conn = _rs_readers.get() try: yield conn finally: _rs_readers.put(conn) def _rs_db_ok(): return _rs_writer is not None def _rs_load_totals(): global _rs_totals try: with _rs_reader() as c: allc = c.execute("SELECT coalesce(sum(n),0) FROM counters").fetchone()[0] rc = c.execute("SELECT coalesce(sum(n),0) FROM counters WHERE k GLOB 'closed_r:*'").fetchone()[0] _rs_totals = (int(allc or 0), int(rc or 0)) except Exception: pass def _rs_load_stats(): global _rs_stats with _rs_reader() as c: row = c.execute("SELECT (SELECT count(*) FROM players), (SELECT count(*) FROM rooms), " "(SELECT count(*) FROM rooms WHERE closed_at IS NOT NULL), " "(SELECT count(*) FROM stays), " "(SELECT min(t_end) FROM rooms)").fetchone() mt = c.execute("SELECT v FROM meta WHERE k='max_ts'").fetchone() _rs_stats = {"players": row[0], "rooms": row[1], "closed_rooms": row[2], "stays": row[3], "oldest_ms": row[4], "max_ts": int(mt[0]) if mt and mt[0] else None} # ── history ingest ──────────────────────────────────────────────────────────── def _rs_route(cur, key, ts): """Follow a recorded merge if one covers this time (ts <= merge.at); else the key itself.""" if not key: return key r = cur.execute("SELECT into_key FROM merged WHERE key=? AND at>=? ORDER BY at ASC LIMIT 1", (key, ts if ts is not None else 0)).fetchone() return r[0] if r else key def _rs_get_pid(cur, key): r = cur.execute("SELECT pid FROM players WHERE key=?", (key,)).fetchone() return r[0] if r else None def _rs_get_or_create_pid(cur, key, t): pid = _rs_get_pid(cur, key) if pid is not None: return pid cur.execute("INSERT INTO players(key, first_seen, last_seen) VALUES(?,?,?)", (key, t, t)) return _rs_get_pid(cur, key) def _rs_get_or_create_rid(cur, room_id, t): r = cur.execute("SELECT rid FROM rooms WHERE room_id=?", (room_id,)).fetchone() if r: return r[0] cur.execute("INSERT INTO rooms(room_id, first_seen, last_listed, t_end) VALUES(?,?,?,?)", (room_id, t, t, t)) return cur.execute("SELECT rid FROM rooms WHERE room_id=?", (room_id,)).fetchone()[0] _RS_PLAYER_UPSERT = """ INSERT INTO players(key,name,level,power,gold,clan,banned,first_seen,last_seen) VALUES(:key,:name,:level,:power,:gold,:clan,:banned,:first_seen,:last_seen) ON CONFLICT(key) DO UPDATE SET first_seen=min(coalesce(players.first_seen,excluded.first_seen),coalesce(excluded.first_seen,players.first_seen)), last_seen=max(coalesce(players.last_seen,0),coalesce(excluded.last_seen,0)), name=CASE WHEN coalesce(excluded.last_seen,0)>=coalesce(players.last_seen,0) THEN excluded.name ELSE players.name END, level=CASE WHEN coalesce(excluded.last_seen,0)>=coalesce(players.last_seen,0) THEN excluded.level ELSE players.level END, power=CASE WHEN coalesce(excluded.last_seen,0)>=coalesce(players.last_seen,0) THEN excluded.power ELSE players.power END, gold=CASE WHEN coalesce(excluded.last_seen,0)>=coalesce(players.last_seen,0) THEN excluded.gold ELSE players.gold END, clan=CASE WHEN coalesce(excluded.last_seen,0)>=coalesce(players.last_seen,0) THEN excluded.clan ELSE players.clan END, banned=CASE WHEN coalesce(excluded.last_seen,0)>=coalesce(players.last_seen,0) THEN excluded.banned ELSE players.banned END """ _RS_NAME_UPSERT = """ INSERT INTO player_names(pid,name_lc,name,first_seen,last_seen) VALUES(?,?,?,?,?) ON CONFLICT(pid,name_lc) DO UPDATE SET first_seen=min(coalesce(player_names.first_seen,excluded.first_seen),coalesce(excluded.first_seen,player_names.first_seen)), last_seen=max(coalesce(player_names.last_seen,0),coalesce(excluded.last_seen,0)), name=CASE WHEN coalesce(excluded.last_seen,0)>=coalesce(player_names.last_seen,0) THEN excluded.name ELSE player_names.name END """ _RS_STAY_UPSERT = """ INSERT INTO stays(rid,pid,t_from,t_to,n,lvl_from,lvl_to,pow_from,pow_to,open) VALUES(:rid,:pid,:t_from,:t_to,:n,:lvl_from,:lvl_to,:pow_from,:pow_to,:open) ON CONFLICT(rid,pid,t_from) DO UPDATE SET lvl_to=CASE WHEN coalesce(excluded.t_to,0)>coalesce(stays.t_to,0) THEN excluded.lvl_to ELSE stays.lvl_to END, pow_to=CASE WHEN coalesce(excluded.t_to,0)>coalesce(stays.t_to,0) THEN excluded.pow_to ELSE stays.pow_to END, open=CASE WHEN coalesce(excluded.t_to,0)>coalesce(stays.t_to,0) THEN excluded.open WHEN coalesce(excluded.t_to,0)=coalesce(stays.t_to,0) THEN min(coalesce(stays.open,0),coalesce(excluded.open,0)) ELSE stays.open END, n=max(coalesce(stays.n,0),coalesce(excluded.n,0)), lvl_from=coalesce(stays.lvl_from,excluded.lvl_from), pow_from=coalesce(stays.pow_from,excluded.pow_from), t_to=max(coalesce(stays.t_to,0),coalesce(excluded.t_to,0)) """ # _RS_VISIT_UPSERT removed together with the visit-run log _RS_ROOM_UPSERT = """ INSERT INTO rooms(room_id,rev,world,wnum,tier,region,code,code_status,code_resolved_while,max_clients, first_seen,last_listed,peak,gone_count,last_visit_at,last_outcome,visits,roster_at,roster_count,last_count, closed_at,close_reason,t_end) VALUES(:room_id,:rev,:world,:wnum,:tier,:region,:code,:code_status,:code_resolved_while,:max_clients, :first_seen,:last_listed,:peak,:gone_count,:last_visit_at,:last_outcome,:visits,:roster_at,:roster_count,:last_count, :closed_at,:close_reason,:t_end) ON CONFLICT(room_id) DO UPDATE SET first_seen=min(coalesce(rooms.first_seen,excluded.first_seen),coalesce(excluded.first_seen,rooms.first_seen)), last_listed=max(coalesce(rooms.last_listed,0),coalesce(excluded.last_listed,0)), peak=max(coalesce(rooms.peak,0),coalesce(excluded.peak,0)), visits=max(coalesce(rooms.visits,0),coalesce(excluded.visits,0)), gone_count=max(coalesce(rooms.gone_count,0),coalesce(excluded.gone_count,0)), code=coalesce(excluded.code,rooms.code), rev=CASE WHEN coalesce(excluded.rev,0)>=coalesce(rooms.rev,0) THEN excluded.rev ELSE rooms.rev END, world=CASE WHEN coalesce(excluded.rev,0)>=coalesce(rooms.rev,0) THEN excluded.world ELSE rooms.world END, wnum=CASE WHEN coalesce(excluded.rev,0)>=coalesce(rooms.rev,0) THEN excluded.wnum ELSE rooms.wnum END, tier=CASE WHEN coalesce(excluded.rev,0)>=coalesce(rooms.rev,0) THEN excluded.tier ELSE rooms.tier END, region=CASE WHEN coalesce(excluded.rev,0)>=coalesce(rooms.rev,0) THEN excluded.region ELSE rooms.region END, code_status=CASE WHEN coalesce(excluded.rev,0)>=coalesce(rooms.rev,0) THEN excluded.code_status ELSE rooms.code_status END, code_resolved_while=CASE WHEN coalesce(excluded.rev,0)>=coalesce(rooms.rev,0) THEN excluded.code_resolved_while ELSE rooms.code_resolved_while END, max_clients=CASE WHEN coalesce(excluded.rev,0)>=coalesce(rooms.rev,0) THEN excluded.max_clients ELSE rooms.max_clients END, last_visit_at=CASE WHEN coalesce(excluded.rev,0)>=coalesce(rooms.rev,0) THEN excluded.last_visit_at ELSE rooms.last_visit_at END, last_outcome=CASE WHEN coalesce(excluded.rev,0)>=coalesce(rooms.rev,0) THEN excluded.last_outcome ELSE rooms.last_outcome END, closed_at=CASE WHEN coalesce(excluded.rev,0)>=coalesce(rooms.rev,0) THEN excluded.closed_at ELSE rooms.closed_at END, close_reason=CASE WHEN coalesce(excluded.rev,0)>=coalesce(rooms.rev,0) THEN excluded.close_reason ELSE rooms.close_reason END, roster_at=CASE WHEN coalesce(excluded.rev,0)>=coalesce(rooms.rev,0) THEN coalesce(excluded.roster_at,rooms.roster_at) ELSE rooms.roster_at END, roster_count=CASE WHEN coalesce(excluded.rev,0)>=coalesce(rooms.rev,0) THEN coalesce(excluded.roster_count,rooms.roster_count) ELSE rooms.roster_count END, last_count=CASE WHEN coalesce(excluded.rev,0)>=coalesce(rooms.rev,0) THEN coalesce(excluded.last_count,rooms.last_count) ELSE rooms.last_count END """ def _rs_name_lc(name): return (str(name or "").strip().lower()) or None def _rs_real(v): """Bind a number as REAL. power/gold (and stay pow_from/pow_to) are idle-game values that routinely exceed SQLite's signed 64-bit INTEGER range (>= ~9.2e18); binding such a Python int raises OverflowError and aborts the WHOLE delta (-> 503), so the history DB never fills. float() keeps the value and the batch; garbage beyond float range is clamped, not fatal.""" if isinstance(v, bool) or not isinstance(v, (int, float)): return v try: return float(v) except (OverflowError, ValueError): return 1.7976931348623157e308 def _rs_int(v): """Keep a normal int; clamp one outside int64 range (hacked/garbage stats) so it can't abort the delta. Non-ints pass through unchanged.""" if isinstance(v, bool) or not isinstance(v, int): return v if v > 9223372036854775807: return 9223372036854775807 if v < -9223372036854775808: return -9223372036854775808 return v def _rs_ingest_history(payload): players = payload.get("players") or [] merges = payload.get("merges") or [] rooms = payload.get("rooms") or [] stays = payload.get("stays") or [] accounts = payload.get("accounts") or {} max_ts = 0 def bump(*vals): nonlocal max_ts for v in vals: if isinstance(v, (int, float)) and v > max_ts: max_ts = int(v) with _rs_write_lock: c = _rs_writer c.execute("BEGIN IMMEDIATE") try: for k, label in (accounts.items() if isinstance(accounts, dict) else []): c.execute("INSERT INTO meta(k,v) VALUES(?,?) ON CONFLICT(k) DO UPDATE SET v=excluded.v", ("account:%s" % k, str(label))) # players for p in players: ls = p.get("lastSeen") fs = p.get("firstSeen") bump(ls, fs) key = _rs_route(c, p.get("key"), ls) c.execute(_RS_PLAYER_UPSERT, {"key": key, "name": p.get("name"), "level": _rs_int(p.get("level")), "power": _rs_real(p.get("power")), "gold": _rs_real(p.get("gold")), "clan": p.get("clan"), "banned": 1 if p.get("banned") else 0, "first_seen": fs, "last_seen": ls}) nlc = _rs_name_lc(p.get("name")) if nlc: pid = _rs_get_pid(c, key) c.execute(_RS_NAME_UPSERT, (pid, nlc, p.get("name"), fs, ls)) # merges for m in merges: frm = m.get("from") into = m.get("into") if not frm or not into: continue into_pid = _rs_get_or_create_pid(c, into, max_ts or 0) src = c.execute("SELECT pid, first_seen, last_seen FROM players WHERE key=?", (frm,)).fetchone() if src and src[0] != into_pid: src_pid = src[0] # move stays: re-insert under into_pid with the stay upsert rules, then drop source rows for s in c.execute("SELECT rid,t_from,t_to,n,lvl_from,lvl_to,pow_from,pow_to,open " "FROM stays WHERE pid=?", (src_pid,)).fetchall(): c.execute(_RS_STAY_UPSERT, {"rid": s[0], "pid": into_pid, "t_from": s[1], "t_to": s[2], "n": s[3], "lvl_from": s[4], "lvl_to": s[5], "pow_from": s[6], "pow_to": s[7], "open": s[8]}) c.execute("DELETE FROM stays WHERE pid=?", (src_pid,)) for nm in c.execute("SELECT name_lc,name,first_seen,last_seen FROM player_names WHERE pid=?", (src_pid,)).fetchall(): c.execute(_RS_NAME_UPSERT, (into_pid, nm[0], nm[1], nm[2], nm[3])) c.execute("DELETE FROM player_names WHERE pid=?", (src_pid,)) c.execute("UPDATE players SET first_seen=min(coalesce(first_seen,?),?), " "last_seen=max(coalesce(last_seen,0),?) WHERE pid=?", (src[1], src[1] if src[1] is not None else 0, src[2] or 0, into_pid)) c.execute("DELETE FROM players WHERE pid=?", (src_pid,)) at = c.execute("SELECT last_seen FROM players WHERE pid=?", (into_pid,)).fetchone() at = (at[0] if at and at[0] is not None else (max_ts or 0)) c.execute("INSERT INTO merged(key,into_key,at) VALUES(?,?,?) " "ON CONFLICT(key,into_key) DO UPDATE SET at=max(coalesce(merged.at,0),excluded.at)", (frm, into, at)) # rooms touched_rids = set() for r in rooms: wnum, tier = _rs_wnum_tier(r.get("world")) fs = r.get("firstSeen") ll = r.get("lastListed") lva = r.get("lastVisitAt") closed = r.get("closedAt") bump(fs, ll, lva, closed, r.get("rosterAt")) t_end = closed if closed is not None else max(ll or 0, lva or 0, fs or 0) c.execute(_RS_ROOM_UPSERT, {"room_id": r.get("roomId"), "rev": r.get("rev"), "world": r.get("world"), "wnum": wnum, "tier": tier, "region": r.get("region"), "code": r.get("code"), "code_status": r.get("codeStatus"), "code_resolved_while": r.get("codeResolvedWhile"), "max_clients": r.get("maxClients"), "first_seen": fs, "last_listed": ll, "peak": r.get("peak"), "gone_count": r.get("goneCount"), "last_visit_at": lva, "last_outcome": r.get("lastOutcome"), "visits": r.get("visits"), "roster_at": r.get("rosterAt"), "roster_count": r.get("rosterCount"), "last_count": r.get("lastCount"), "closed_at": closed, "close_reason": r.get("closeReason"), "t_end": t_end}) rr = c.execute("SELECT rid FROM rooms WHERE room_id=?", (r.get("roomId"),)).fetchone() if rr: touched_rids.add(rr[0]) # stays for s in stays: t_from = s.get("from") t_to = s.get("to") bump(t_from, t_to) key = _rs_route(c, s.get("playerKey"), t_from) pid = _rs_get_or_create_pid(c, key, t_from) rid = _rs_get_or_create_rid(c, s.get("roomId"), t_from) touched_rids.add(rid) c.execute(_RS_STAY_UPSERT, {"rid": rid, "pid": pid, "t_from": t_from, "t_to": t_to, "n": s.get("n"), "lvl_from": _rs_int(s.get("lvlFrom")), "lvl_to": _rs_int(s.get("lvlTo")), "pow_from": _rs_real(s.get("powFrom")), "pow_to": _rs_real(s.get("powTo")), "open": 1 if s.get("open") else 0}) # recompute t_end for every touched room from its final fields for rid in touched_rids: c.execute("UPDATE rooms SET t_end=coalesce(closed_at, max(coalesce(last_listed,0)," "coalesce(last_visit_at,0),coalesce(first_seen,0))) WHERE rid=?", (rid,)) if max_ts: c.execute("INSERT INTO meta(k,v) VALUES('max_ts',?) ON CONFLICT(k) DO UPDATE SET " "v=CASE WHEN CAST(excluded.v AS INTEGER)>CAST(meta.v AS INTEGER) THEN excluded.v ELSE meta.v END", (str(max_ts),)) c.execute("COMMIT") except Exception: try: c.execute("ROLLBACK") except Exception: pass raise _rs_load_totals() return {"players": len(players), "rooms": len(rooms), "stays": len(stays), "merges": len(merges)} # ── retention ─────────────────────────────────────────────────────────────── def _rs_retention_pass(): summary = {"rooms": 0, "stays": 0, "players": 0, "merges": 0} with _rs_write_lock: c = _rs_writer row = c.execute("SELECT v FROM meta WHERE k='max_ts'").fetchone() max_ts = int(row[0]) if row and row[0] is not None else None if max_ts is None: globals()["_rs_last_retention"] = time.time() return summary cutoff = max_ts - _RS_KEEP_DAYS * 86400000 # 1) rooms (closed or abandoned) whose whole life is older than the window while True: with _rs_write_lock: c = _rs_writer rids = [r[0] for r in c.execute("SELECT rid FROM rooms WHERE t_end < ? ORDER BY t_end LIMIT 500", (cutoff,)).fetchall()] if rids: qs = ",".join("?" * len(rids)) c.execute("BEGIN IMMEDIATE") summary["stays"] += c.execute("DELETE FROM stays WHERE rid IN (%s)" % qs, rids).rowcount summary["rooms"] += c.execute("DELETE FROM rooms WHERE rid IN (%s)" % qs, rids).rowcount c.execute("COMMIT") if not rids: break if _RS_RETENTION_PAUSE: time.sleep(_RS_RETENTION_PAUSE) if len(rids) < 500: break with _rs_write_lock: c = _rs_writer c.execute("BEGIN IMMEDIATE") # 2) long-lived open rooms: trim old closed stays c.execute("DELETE FROM stays WHERE open = 0 AND t_to < ? AND rid IN " "(SELECT rid FROM rooms WHERE closed_at IS NULL AND first_seen < ?)", (cutoff, cutoff)) # 3) players last seen before the window with no stays left c.execute("DELETE FROM player_names WHERE pid IN (SELECT pid FROM players WHERE " "coalesce(last_seen,0) < ? AND pid NOT IN (SELECT pid FROM stays))", (cutoff,)) summary["players"] += c.execute("DELETE FROM players WHERE coalesce(last_seen,0) < ? AND " "pid NOT IN (SELECT pid FROM stays)", (cutoff,)).rowcount # 4) merges older than the window summary["merges"] += c.execute("DELETE FROM merged WHERE coalesce(at,0) < ?", (cutoff,)).rowcount c.execute("COMMIT") # 5) size guard try: _rs_retention_size_guard() except Exception: pass # 6) reclaim space only if there is meaningful slack try: with _rs_write_lock: c = _rs_writer free = c.execute("PRAGMA freelist_count").fetchone()[0] total = c.execute("PRAGMA page_count").fetchone()[0] if total and free > 0.2 * total: for _ in range(64): if c.execute("PRAGMA incremental_vacuum(1024)").fetchone() is None: break c.execute("PRAGMA wal_checkpoint(PASSIVE)") c.execute("PRAGMA optimize") except Exception: pass globals()["_rs_last_retention"] = time.time() _rs_load_totals() _rs_load_stats() return summary def _rs_retention_size_guard(): if not _RS_MIN_FREE_DISK and _RS_MAX_DB_MB <= 0: return while True: with _rs_write_lock: c = _rs_writer db_bytes = 0 try: db_bytes = _RS_DB_PATH.stat().st_size wal = _RS_DB_PATH.with_suffix(".sqlite-wal") if wal.exists(): db_bytes += wal.stat().st_size except Exception: pass free_ok = True if _RS_MIN_FREE_DISK: try: free_ok = shutil.disk_usage(str(_RS_DB_PATH.parent)).free >= _RS_MIN_FREE_DISK except Exception: free_ok = True too_big = (_RS_MAX_DB_MB > 0 and db_bytes > _RS_MAX_DB_MB * 1024 * 1024) or not free_ok if not too_big: return rids = [r[0] for r in c.execute("SELECT rid FROM rooms ORDER BY t_end LIMIT 500").fetchall()] if not rids: return qs = ",".join("?" * len(rids)) c.execute("BEGIN IMMEDIATE") c.execute("DELETE FROM stays WHERE rid IN (%s)" % qs, rids) c.execute("DELETE FROM rooms WHERE rid IN (%s)" % qs, rids) c.execute("COMMIT") if _RS_RETENTION_PAUSE: time.sleep(_RS_RETENTION_PAUSE) def _rs_maintenance_loop(): try: _rs_load_totals() except Exception: pass time.sleep(20) while True: try: _rs_load_stats() except Exception: pass try: if time.time() - _rs_last_retention >= _RS_RETENTION_INTERVAL: _rs_retention_pass() except Exception: pass time.sleep(900) def _rs_start_maintenance(): global _rs_maint_started if _rs_maint_started: return _rs_maint_started = True threading.Thread(target=_rs_maintenance_loop, name="room-scan-maintenance", daemon=True).start() # ── row shaping for the query endpoints ─────────────────────────────────────── def _rs_name_at(c, pid, t): """The display name this pid carried at time t (the alias whose [first,last] covers t).""" if pid is None: return None row = c.execute("SELECT name FROM player_names WHERE pid=? AND coalesce(first_seen,0)<=? " "AND coalesce(last_seen, 1e18)>=? ORDER BY last_seen DESC LIMIT 1", (pid, t or 0, t or 0)).fetchone() if row: return row[0] row = c.execute("SELECT name FROM players WHERE pid=?", (pid,)).fetchone() return row[0] if row else None def _rs_room_row(c, r, roster_limit=4): rid = r["rid"] closed = r["closed_at"] out = { "roomId": r["room_id"], "world": r["world"], "code": r["code"], "codeStatus": r["code_status"], "codeResolvedWhile": r["code_resolved_while"], "archived": closed is not None, "archivedAt": closed, "closeReason": r["close_reason"], "dirFirstSeenAt": r["first_seen"], "dirLastSeenAt": r["last_listed"], "dirClients": r["last_count"], "dirMaxClients": r["max_clients"], "dirPeak": r["peak"], "dirGoneCount": r["gone_count"], "rosterCount": r["roster_count"], "rosterCapturedAt": r["roster_at"], "lastVisitAt": r["last_visit_at"], "lastVisitOutcome": _RS_OUTCOME.get(r["last_outcome"]) if r["last_outcome"] is not None else None, "visits": r["visits"], "inDirectory": False, } roster = [] if r["roster_at"] is not None: rows = c.execute("SELECT s.pid, s.lvl_to, s.pow_to, p.key FROM stays s JOIN players p ON p.pid=s.pid " "WHERE s.rid=? AND s.t_to=?" + ("" if roster_limit is None else " LIMIT ?"), (rid, r["roster_at"]) if roster_limit is None else (rid, r["roster_at"], roster_limit)).fetchall() for row in rows: roster.append({"key": row["key"], "id": row["key"], "self": False, "level": row["lvl_to"], "power": row["pow_to"], "username": _rs_name_at(c, row["pid"], r["roster_at"]), "nameNow": c.execute("SELECT name FROM players WHERE pid=?", (row["pid"],)).fetchone()[0]}) out["roster"] = roster return out def _rs_stay_row(c, s): nm_now = c.execute("SELECT name FROM players WHERE pid=?", (s["pid"],)).fetchone() return {"key": c.execute("SELECT key FROM players WHERE pid=?", (s["pid"],)).fetchone()[0], "name": _rs_name_at(c, s["pid"], s["t_to"]), "nameNow": nm_now[0] if nm_now else None, "from": s["t_from"], "to": s["t_to"], "n": s["n"], "lvlFrom": s["lvl_from"], "lvlTo": s["lvl_to"], "powFrom": s["pow_from"], "powTo": s["pow_to"], "open": s["open"]} def _rs_acct_label(c, n): if n is None: return None row = c.execute("SELECT v FROM meta WHERE k=?", ("account:%d" % int(n),)).fetchone() return row[0] if row else n def _rs_json(obj, request): body = json.dumps(obj, separators=(",", ":")).encode() if "gzip" in (request.headers.get("accept-encoding") or ""): return Response(gzip.compress(body, 5), media_type="application/json", headers={"Content-Encoding": "gzip", "Vary": "Accept-Encoding"}) return Response(body, media_type="application/json") def _rs_gate(request): import hmac as _hmac ingest_ok = bool(SM_LEADERBOARD_INGEST_KEY) and _hmac.compare_digest( str(request.headers.get("X-Ingest-Key") or request.query_params.get("key") or ""), SM_LEADERBOARD_INGEST_KEY) if not (ingest_ok or _sm_is_mod(request)): raise HTTPException(404) def _rs_save_live(gz): global _rs_live_saved_at if time.time() - _rs_live_saved_at < _SM_ROOMSCAN_SAVE_SEC: return _rs_live_saved_at = time.time() try: f = _RS_LIVE_PATH f.parent.mkdir(parents=True, exist_ok=True) tmp = f.with_suffix(".gz.tmp") tmp.write_bytes(gz) tmp.replace(f) except Exception: pass # ── endpoints ───────────────────────────────────────────────────────────────── @app.post("/ingest-rooms", include_in_schema=False) async def ingest_rooms(request: Request): """Receive the SP7 scanner's LIVE snapshot (active rooms + slim closed docs). Ingest-key gated, else 404. The gzip bytes are kept as-is and served untouched at /room-scan; a cheap peek only checks they are a JSON object with "rooms". The answer tells the scanner whether to send history deltas and when to yield the shared account after a manual lookup.""" global _rs_live, _rs_seq import hmac provided = request.headers.get("X-Ingest-Key") or request.query_params.get("key") or "" if not SM_LEADERBOARD_INGEST_KEY or not hmac.compare_digest(str(provided), SM_LEADERBOARD_INGEST_KEY): raise HTTPException(404) # An older scanner may push the closed-room archive separately; the DB keeps the archive # now, so acknowledge and ignore it (the live snapshot is untouched). if request.query_params.get("part") == "archive": return {"ok": True, "ignored": "archive"} raw = await request.body() if raw[:2] == b"\x1f\x8b" or (request.headers.get("content-encoding") or "").lower() == "gzip": gz = raw try: head = zlib.decompressobj(16 + zlib.MAX_WBITS).decompress(gz, 64 * 1024) except Exception: raise HTTPException(400, "bad gzip body") else: head, gz = raw[:64 * 1024], gzip.compress(raw, 6) if not head.lstrip().startswith(b"{") or b'"rooms"' not in head: raise HTTPException(400, "expected {rooms: [...]}") from datetime import datetime as _dt, timezone as _tz now = time.time() with _rs_live_lock: _rs_seq += 1 seq = _rs_seq _rs_live = {"gz": gz, "received_at": _dt.now(_tz.utc).isoformat(), "received_ms": now, "seq": seq} _rs_save_live(gz) busy_until = None if _sm_rl_last_manual_ts and (now - _sm_rl_last_manual_ts) < 20: busy_until = _dt.fromtimestamp(_sm_rl_last_manual_ts + 20, _tz.utc).isoformat() return {"ok": True, "history": _rs_db_ok(), "seq": seq, "bytes": len(gz), "manual_busy_until": busy_until} def _rs_live_headers(): from datetime import datetime as _dt, timezone as _tz h = {"Cache-Control": "no-cache", "Vary": "Accept-Encoding", "X-Server-Time": _dt.now(_tz.utc).isoformat(), "X-Keep-Days": str(_RS_KEEP_DAYS), "X-History-Ok": "1" if _rs_db_ok() else "0"} if _rs_totals is not None: h["X-Archive-All"] = str(_rs_totals[0]) h["X-Archive-Total"] = str(_rs_totals[1]) return h @app.get("/room-scan", include_in_schema=False) async def room_scan(request: Request): """Serve the scanner's latest LIVE snapshot (the gzip bytes it sent, untouched) plus metadata headers. Mod/ingest gated, else 404. The archive, search, per-room and per-player views are their own /room-scan/* endpoints backed by the history DB.""" _rs_gate(request) with _rs_live_lock: live = _rs_live headers = _rs_live_headers() if not live: return Response(json.dumps({"rooms": []}), media_type="application/json", headers=headers) etag = '"%d"' % live.get("seq", 0) headers["ETag"] = etag headers["X-Received-At"] = live["received_at"] inm = request.headers.get("if-none-match", "") if inm and etag in [t.strip() for t in inm.split(",")]: return Response(status_code=304, headers=headers) gz = live["gz"] if "gzip" in (request.headers.get("accept-encoding") or ""): headers["Content-Encoding"] = "gzip" return Response(gz, media_type="application/json", headers=headers) body = await asyncio.to_thread(lambda: zlib.decompressobj(16 + zlib.MAX_WBITS).decompress(gz, 256 * 1024 * 1024)) return Response(body, media_type="application/json", headers=headers) @app.post("/ingest-rooms-history", include_in_schema=False) async def ingest_rooms_history(request: Request): """Receive one history delta (players / merges / rooms / stays) and upsert it into the SQLite DB. Idempotent and order-independent (section 2 of the contract). Ingest-key or mod gated, else 404.""" _rs_gate(request) raw = await request.body() try: if raw[:2] == b"\x1f\x8b" or (request.headers.get("content-encoding") or "").lower() == "gzip": d = zlib.decompressobj(16 + zlib.MAX_WBITS) data = d.decompress(raw, _RS_HISTORY_MAX_BYTES) if d.unconsumed_tail: raise HTTPException(413, "history body too large") payload = json.loads(data) else: payload = json.loads(raw) except HTTPException: raise except Exception: raise HTTPException(400, "bad history body") if not isinstance(payload, dict): raise HTTPException(400, "expected an object") try: counts = await asyncio.to_thread(_rs_ingest_history, payload) except HTTPException: raise except Exception: raise HTTPException(503, "history store unavailable") return {"ok": True, "counts": counts} def _rs_cursor_before(s): """Parse a '.' cursor into (a:int, b:int) or a plain ms into (ms, +inf).""" s = str(s or "").strip() if not s: return None if "." in s: a, _, b = s.partition(".") try: return (int(a), int(b)) except Exception: return None try: return (int(s), 1 << 62) except Exception: return None @app.get("/room-scan/archive", include_in_schema=False) def room_scan_archive(request: Request, w: str = "", with_roster: int = 1, before: str = "", after: str = "", limit: int = 100): """Closed rooms, newest first, paged by (closed_at, rid). ?w=, ?with_roster=1 (default), ?before=. (older), ?after=. (rooms closed since, for refresh), ?limit (max 200).""" _rs_gate(request) lim = max(1, min(int(limit or 100), 200)) wnum = None if str(w).strip(): try: wnum = int(str(w).strip()) except Exception: wnum = _rs_wnum_tier(w)[0] where = ["closed_at IS NOT NULL"] args = [] if int(with_roster): where.append("coalesce(roster_count,0) > 0") if wnum is not None: where.append("wnum = ?") args.append(wnum) try: with _rs_reader() as c: cur_after = _rs_cursor_before(after) if cur_after: w2 = where + ["(closed_at > ? OR (closed_at = ? AND rid > ?))"] a2 = args + [cur_after[0], cur_after[0], cur_after[1]] rows = c.execute("SELECT * FROM rooms WHERE %s ORDER BY closed_at ASC, rid ASC LIMIT ?" % " AND ".join(w2), a2 + [lim]).fetchall() rows = list(reversed(rows)) nxt = None else: cur_b = _rs_cursor_before(before) w2 = list(where) a2 = list(args) if cur_b: w2.append("(closed_at < ? OR (closed_at = ? AND rid < ?))") a2 += [cur_b[0], cur_b[0], cur_b[1]] rows = c.execute("SELECT * FROM rooms WHERE %s ORDER BY closed_at DESC, rid DESC LIMIT ?" % " AND ".join(w2), a2 + [lim]).fetchall() nxt = None if len(rows) == lim: last = rows[-1] nxt = "%d.%d" % (last["closed_at"], last["rid"]) out = [_rs_room_row(c, r, roster_limit=4) for r in rows] total = None if not cur_after and not _rs_cursor_before(before): if _rs_totals is not None and wnum is None: total = _rs_totals[1] if int(with_roster) else _rs_totals[0] else: total = c.execute("SELECT count(*) FROM rooms WHERE %s" % " AND ".join(where), args).fetchone()[0] except HTTPException: raise except Exception: return Response(json.dumps({"error": "history_unavailable"}), status_code=503, media_type="application/json", headers={"Retry-After": "30"}) return _rs_json({"rooms": out, "next_before": nxt, "total": total}, request) @app.get("/room-scan/search", include_in_schema=False) def room_scan_search(request: Request, q: str = ""): """Search players (by name prefix/substring), a room code, or a room id. Returns matching player summaries and the rooms they were seen in, each tagged current/last/past/code.""" _rs_gate(request) qq = str(q or "").strip() if len(qq) < 2: return _rs_json({"players": [], "rooms": [], "more": False}, request) name_lc = qq.lower() code = re.sub(r"^#", "", qq).upper() code = code if re.fullmatch(r"[A-Z0-9]{6}", code) else None players, rooms, more = [], [], False try: with _rs_reader() as c: mt = c.execute("SELECT v FROM meta WHERE k='max_ts'").fetchone() max_ts = int(mt[0]) if mt and mt[0] else 0 hi = name_lc + "\U0010ffff" pids = [] seen = set() for row in c.execute("SELECT DISTINCT pid FROM player_names WHERE name_lc >= ? AND name_lc < ? LIMIT 500", (name_lc, hi)).fetchall(): if row[0] not in seen: seen.add(row[0]); pids.append((row[0], 0)) if len(qq) >= 3: for row in c.execute("SELECT DISTINCT pid FROM player_names WHERE instr(name_lc, ?)>0 LIMIT 500", (name_lc,)).fetchall(): if row[0] not in seen: seen.add(row[0]); pids.append((row[0], 1)) pids.sort(key=lambda t: t[1]) for pid, _rank in pids[:20]: p = c.execute("SELECT key,name,level,power,gold,clan,banned,first_seen,last_seen FROM players " "WHERE pid=?", (pid,)).fetchone() if p: players.append({"key": p["key"], "name": p["name"], "level": p["level"], "power": p["power"], "gold": p["gold"], "clan": p["clan"], "banned": p["banned"], "firstSeen": p["first_seen"], "lastSeen": p["last_seen"]}) # rooms where the top players were seen room_hits = {} for pid, _rank in pids[:5]: for s in c.execute("SELECT rid, t_to, open FROM stays WHERE pid=? ORDER BY t_to DESC LIMIT 100", (pid,)).fetchall(): cur = room_hits.get(s["rid"]) if cur is None or (s["t_to"] or 0) > cur[1]: room_hits[s["rid"]] = (pid, s["t_to"] or 0, s["open"]) ranked = sorted(room_hits.items(), key=lambda kv: kv[1][1], reverse=True) more = len(ranked) > 50 for rid, (pid, t_to, is_open) in ranked[:50]: r = c.execute("SELECT * FROM rooms WHERE rid=?", (rid,)).fetchone() if not r: continue rr = _rs_room_row(c, r, roster_limit=4) if is_open and r["closed_at"] is None and (r["t_end"] or 0) > max_ts - 2 * 3600000: match = "current" elif r["closed_at"] is not None and t_to == r["roster_at"]: match = "last" else: match = "past" key = c.execute("SELECT key FROM players WHERE pid=?", (pid,)).fetchone()[0] rr.update({"match": match, "matchKey": key, "matchName": _rs_name_at(c, pid, t_to), "matchNameNow": c.execute("SELECT name FROM players WHERE pid=?", (pid,)).fetchone()[0], "matchLastAt": t_to}) rooms.append(rr) if code: for r in c.execute("SELECT * FROM rooms WHERE code=? ORDER BY t_end DESC LIMIT 10", (code,)).fetchall(): rr = _rs_room_row(c, r, roster_limit=4) rr["match"] = "code" rooms.insert(0, rr) # room id exact rid_hit = c.execute("SELECT * FROM rooms WHERE room_id=?", (qq,)).fetchone() if rid_hit: rr = _rs_room_row(c, rid_hit, roster_limit=4) rr["match"] = "roomid" rooms.insert(0, rr) except Exception: return Response(json.dumps({"error": "history_unavailable"}), status_code=503, media_type="application/json", headers={"Retry-After": "30"}) return _rs_json({"players": players, "rooms": rooms, "more": more}, request) @app.get("/room-scan/room", include_in_schema=False) def room_scan_room(request: Request, id: str = "", before: str = ""): """One room from the history DB with its stays (newest first).""" _rs_gate(request) rid_q = str(id or "").strip() if not rid_q: raise HTTPException(400, "id required") try: with _rs_reader() as c: r = c.execute("SELECT * FROM rooms WHERE room_id=?", (rid_q,)).fetchone() if not r: return _rs_json({"room": None, "stays": [], "staysTotal": 0, "next_before": None}, request) rid = r["rid"] etag = '"%s-%s-%s-%s-%s"' % (rid, r["rev"], r["last_listed"], r["closed_at"], c.execute("SELECT max(t_to) FROM stays WHERE rid=?", (rid,)).fetchone()[0]) inm = request.headers.get("if-none-match", "") if inm and etag in [t.strip() for t in inm.split(",")]: return Response(status_code=304, headers={"ETag": etag}) cur_b = _rs_cursor_before(before) sw = "rid=?" sa = [rid] if cur_b: sw += " AND (t_to < ? OR (t_to = ? AND rid < ?))" sa += [cur_b[0], cur_b[0], cur_b[1]] srows = c.execute("SELECT * FROM stays WHERE %s ORDER BY t_to DESC, rid DESC LIMIT 500" % sw, sa).fetchall() stays_total = c.execute("SELECT count(*) FROM stays WHERE rid=?", (rid,)).fetchone()[0] nxt = None if len(srows) == 500: last = srows[-1] nxt = "%d.%d" % (last["t_to"] or 0, last["rid"]) out = {"room": _rs_room_row(c, r, roster_limit=None), "stays": [_rs_stay_row(c, s) for s in srows], "next_before": nxt, "staysTotal": stays_total} except HTTPException: raise except Exception: return Response(json.dumps({"error": "history_unavailable"}), status_code=503, media_type="application/json", headers={"Retry-After": "30"}) resp = _rs_json(out, request) resp.headers["ETag"] = etag return resp @app.get("/room-scan/player", include_in_schema=False) def room_scan_player(request: Request, key: str = "", name: str = "", before: str = "", limit: int = 100): """One player's timeline: identity + aliases, their stays (newest first, paged), and on the first page a per-day overview, the rooms they are currently in, and who they were seen with.""" _rs_gate(request) lim = max(1, min(int(limit or 100), 300)) try: with _rs_reader() as c: others = [] pid = None if str(key).strip(): row = c.execute("SELECT pid FROM players WHERE key=?", (str(key).strip(),)).fetchone() pid = row[0] if row else None elif str(name).strip(): nlc = str(name).strip().lower() cands = c.execute("SELECT p.pid, p.key, p.name, p.last_seen FROM player_names n JOIN players p " "ON p.pid=n.pid WHERE n.name_lc=? ORDER BY p.last_seen DESC", (nlc,)).fetchall() if cands: pid = cands[0]["pid"] others = [{"key": r["key"], "name": r["name"], "lastSeen": r["last_seen"]} for r in cands[1:]] if pid is None: return _rs_json({"player": None, "others": others, "stays": [], "next_before": None}, request) p = c.execute("SELECT * FROM players WHERE pid=?", (pid,)).fetchone() aliases = [{"name": r["name"], "firstSeen": r["first_seen"], "lastSeen": r["last_seen"]} for r in c.execute("SELECT name,first_seen,last_seen FROM player_names WHERE pid=? " "ORDER BY last_seen DESC", (pid,)).fetchall()] stay_count = c.execute("SELECT count(*) FROM stays WHERE pid=?", (pid,)).fetchone()[0] room_count = c.execute("SELECT count(DISTINCT rid) FROM stays WHERE pid=?", (pid,)).fetchone()[0] player = {"key": p["key"], "name": p["name"], "aliases": aliases, "level": p["level"], "power": p["power"], "gold": p["gold"], "clan": p["clan"], "banned": p["banned"], "firstSeen": p["first_seen"], "lastSeen": p["last_seen"], "stayCount": stay_count, "roomCount": room_count} cur_b = _rs_cursor_before(before) sw = "s.pid=?" sa = [pid] if cur_b: sw += " AND (s.t_to < ? OR (s.t_to = ? AND s.rid < ?))" sa += [cur_b[0], cur_b[0], cur_b[1]] srows = c.execute("SELECT s.*, r.room_id, r.world, r.code, r.closed_at FROM stays s JOIN rooms r " "ON r.rid=s.rid WHERE %s ORDER BY s.t_to DESC, s.rid DESC LIMIT ?" % sw, sa + [lim]).fetchall() stays = [] for s in srows: row = _rs_stay_row(c, s) row.update({"roomId": s["room_id"], "world": s["world"], "code": s["code"], "closedAt": s["closed_at"]}) stays.append(row) nxt = None if len(srows) == lim: last = srows[-1] nxt = "%d.%d" % (last["t_to"] or 0, last["rid"]) out = {"player": player, "others": others, "stays": stays, "next_before": nxt} if not cur_b: current = [r["room_id"] for r in c.execute( "SELECT r.room_id FROM stays s JOIN rooms r ON r.rid=s.rid WHERE s.pid=? AND s.open=1 " "AND r.closed_at IS NULL", (pid,)).fetchall()] out["current"] = current out["days"] = _rs_player_days(c, pid) out["seenWith"] = _rs_seen_with(c, pid) except HTTPException: raise except Exception: return Response(json.dumps({"error": "history_unavailable"}), status_code=503, media_type="application/json", headers={"Retry-After": "30"}) return _rs_json(out, request) def _rs_player_days(c, pid): from datetime import datetime as _dt, timezone as _tz days = {} for s in c.execute("SELECT s.t_from, s.t_to, r.world FROM stays s JOIN rooms r ON r.rid=s.rid " "WHERE s.pid=?", (pid,)).fetchall(): t = s["t_to"] or s["t_from"] if t is None: continue day = _dt.fromtimestamp(t / 1000.0, _tz.utc).strftime("%Y-%m-%d") d = days.setdefault(day, {"day": day, "stays": 0, "rooms": set(), "minutes": 0.0, "worlds": set()}) d["stays"] += 1 if s["world"]: d["worlds"].add(s["world"]) if s["t_from"] is not None and s["t_to"] is not None: d["minutes"] += max(0.0, (s["t_to"] - s["t_from"]) / 60000.0) out = [] for day in sorted(days.keys(), reverse=True): d = days[day] out.append({"day": day, "stays": d["stays"], "rooms": 0, "minutes": round(d["minutes"], 1), "worlds": sorted(d["worlds"])}) return out def _rs_seen_with(c, pid): cutoff_row = c.execute("SELECT v FROM meta WHERE k='max_ts'").fetchone() max_ts = int(cutoff_row[0]) if cutoff_row and cutoff_row[0] else 0 cutoff = max_ts - 30 * 86400000 mine = c.execute("SELECT rid, t_from, t_to FROM stays WHERE pid=? AND coalesce(t_to,0) >= ?", (pid, cutoff)).fetchall() agg = {} for m in mine: rid, mf, mt = m["rid"], m["t_from"] or 0, m["t_to"] or 0 for o in c.execute("SELECT pid, t_from, t_to FROM stays WHERE rid=? AND pid<>?", (rid, pid)).fetchall(): of, ot = o["t_from"] or 0, o["t_to"] or 0 if of <= mt and ot >= mf: a = agg.setdefault(o["pid"], {"rooms": set(), "overlap": 0.0}) a["rooms"].add(rid) a["overlap"] += max(0.0, (min(mt, ot) - max(mf, of)) / 60000.0) out = [] for opid, a in agg.items(): p = c.execute("SELECT key, name FROM players WHERE pid=?", (opid,)).fetchone() if not p: continue if str(p["key"] or "").startswith("scan:") or str(p["key"] or "").startswith("self:"): continue out.append({"key": p["key"], "name": p["name"], "rooms": len(a["rooms"]), "overlapMin": round(a["overlap"], 1)}) out.sort(key=lambda x: (x["rooms"], x["overlapMin"]), reverse=True) return out[:15] @app.get("/room-scan/db-stats", include_in_schema=False) def room_scan_db_stats(request: Request): """Size + row counts of the history DB (for the mod view).""" _rs_gate(request) db_bytes = wal_bytes = free_bytes = 0 try: db_bytes = _RS_DB_PATH.stat().st_size except Exception: pass try: wal = _RS_DB_PATH.with_suffix(".sqlite-wal") wal_bytes = wal.stat().st_size if wal.exists() else 0 except Exception: pass try: free_bytes = shutil.disk_usage(str(_RS_DB_PATH.parent)).free except Exception: pass st = dict(_rs_stats) return _rs_json({"db_bytes": db_bytes, "wal_bytes": wal_bytes, "free_bytes": free_bytes, "players": st.get("players"), "rooms": st.get("rooms"), "closed_rooms": st.get("closed_rooms"), "stays": st.get("stays"), "oldest_ms": st.get("oldest_ms"), "max_ts": st.get("max_ts"), "keep_days": _RS_KEEP_DAYS, "last_retention": _rs_last_retention or None}, request) # open the history DB at import so the endpoints are ready before the first push try: _rs_reset(_RS_DB_PATH) except Exception: pass # ── The Rival (daily Asgard boss) ──────────────────────────────────────────── # "The Rival" is a rotating top-100 player whose identity + loadout live only in # the Colyseus STATE of the Asgard world. We join it server-side with the shared # lookup account (level 5000 -> can enter any world) via a Node sidecar that # decodes the schema state, cache the result, and refresh it hourly in the # background. The frontend renders the avatar live from this data. SM_RIVAL_WORLD = (_os.getenv("SM_RIVAL_WORLD", "world_10") or "world_10").strip() SM_RIVAL_SCRIPT = SM_ROOM_LOOKUP_DIR / "rival_lookup.js" SM_RIVAL_REFRESH_SECONDS = max(600, int(_os.getenv("SM_RIVAL_REFRESH_SECONDS", "3600"))) SM_RIVAL_CACHE_PATH = (Path("/data") / "rival_info.json") if _os.getenv("SPACE_ID") else (APP_DIR / "rival_info.json") _sm_rival_lock = threading.Lock() _sm_rival_cache = None # {"epoch": float, "fetched_at": iso, "world": str, "rival": {...}} def _sm_rival_load_disk(): global _sm_rival_cache if _sm_rival_cache is not None: return _sm_rival_cache try: with open(SM_RIVAL_CACHE_PATH, encoding="utf-8") as f: obj = json.load(f) if isinstance(obj, dict) and obj.get("rival"): _sm_rival_cache = obj except Exception: pass return _sm_rival_cache def _sm_rival_save_disk(entry: dict): try: SM_RIVAL_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) tmp = SM_RIVAL_CACHE_PATH.with_suffix(".json.tmp") with open(tmp, "w", encoding="utf-8") as f: json.dump(entry, f) tmp.replace(SM_RIVAL_CACHE_PATH) except Exception: pass def _sm_rival_run_node(token): """Run the rival Node sidecar once. Returns (data_dict, exit_code).""" env = dict(_os.environ) env["SM_LOOKUP_ACCESS_TOKEN"] = token env["SM_LOOKUP_WAIT_MS"] = str(SM_ROOM_LOOKUP_WAIT_MS) env["SM_LOADBALANCER_BASE"] = SM_LOADBALANCER_BASE env["SM_RIVAL_WORLD"] = SM_RIVAL_WORLD try: proc = subprocess.run( [SM_ROOM_LOOKUP_NODE, str(SM_RIVAL_SCRIPT), SM_RIVAL_WORLD], cwd=str(SM_ROOM_LOOKUP_DIR), env=env, capture_output=True, text=True, timeout=SM_ROOM_LOOKUP_TIMEOUT + 10, ) except subprocess.TimeoutExpired: return ({"ok": False, "error": "rival lookup timed out", "authFail": False}, 124) except FileNotFoundError: return ({"ok": False, "error": "node runtime not available", "authFail": False}, 127) data = None for line in reversed((proc.stdout or "").splitlines()): line = line.strip() if not line.startswith("{"): continue try: obj = json.loads(line) except Exception: continue if isinstance(obj, dict) and "ok" in obj: data = obj break if data is None: err = (proc.stderr or "").strip() or "no output" data = {"ok": False, "error": err[:300], "authFail": False} return (data, proc.returncode) def _sm_rival_refresh(): """Join the Asgard world via the lookup account, read rivalBossInfo, update cache.""" global _sm_rival_cache if not (SM_ROOM_LOOKUP_USER and SM_ROOM_LOOKUP_HAS_PASS): raise RuntimeError("rival lookup not configured (needs SM_ROOM_LOOKUP_USER / SM_ROOM_LOOKUP_PASS)") with _sm_rival_lock: token = _sm_rl_current_token() if not token: with _sm_rl_token_lock: token = _sm_rl_current_token() or _sm_rl_refresh_token() data, rc = _sm_rival_run_node(token) if rc == 3 and data.get("authFail"): with _sm_rl_token_lock: token = _sm_rl_refresh_token() data, rc = _sm_rival_run_node(token) if not data or not data.get("ok") or not data.get("rival"): raise RuntimeError((data or {}).get("error") or "rival lookup failed") entry = { "epoch": time.time(), "fetched_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "world": data.get("world") or SM_RIVAL_WORLD, "rival": data["rival"], } _sm_rival_cache = entry _sm_rival_save_disk(entry) return entry def _sm_rival_worker(): # Small initial delay so boot isn't blocked; then refresh hourly. time.sleep(20) while True: try: _sm_rival_refresh() except Exception as exc: try: print(f"[rival] refresh failed: {exc}") except Exception: pass time.sleep(SM_RIVAL_REFRESH_SECONDS) if SM_ROOM_LOOKUP_USER and SM_ROOM_LOOKUP_HAS_PASS and not SM_ROOM_LOOKUP_DISABLED: threading.Thread(target=_sm_rival_worker, name="sm-rival-refresh", daemon=True).start() @app.get("/rival-info") def rival_info(): """Current daily Rival (cached; refreshed hourly in the background).""" entry = _sm_rival_cache or _sm_rival_load_disk() # Lazy refresh if we have nothing yet, or the cache is very stale (> 2 cycles). if entry is None or (time.time() - float(entry.get("epoch") or 0)) > SM_RIVAL_REFRESH_SECONDS * 2: try: entry = _sm_rival_refresh() except Exception as exc: if entry is None: raise HTTPException(503, f"Rival not available yet: {exc}") rival = entry.get("rival") or {} return { "ok": True, "world": entry.get("world"), "fetched_at": entry.get("fetched_at"), "age_seconds": int(time.time() - float(entry.get("epoch") or time.time())), "rival": rival, } # ── VIP-Liste: Space-seitig lesen (wie The Rival) ──────────────────────────── # VIPLeaderboard (Namen) + VIPCount liegen im Room-STATE der HAUPT-Welt (world_1) — die # Gameplay-Shards wie world_10 fuehren sie nicht. Wir joinen server-seitig mit dem Lookup-Account # (Node-Sidecar vip_lookup.js liest die State-Felder, mit Message-Fallback) und aktualisieren den # VIP-Cache (gleicher Store wie /vip-status) im Hintergrund. KEIN Gast-Join (den blockt Cloudflare- # Turnstile), KEIN SP7-Worker: der Space hat den Lookup-Account bereits. SM_VIP_WORLD = (_os.getenv("SM_VIP_WORLD", "world_1") or "world_1").strip() # VIP-Statue steht in der Haupt-/ersten Welt SM_VIP_SCRIPT = SM_ROOM_LOOKUP_DIR / "vip_lookup.js" SM_VIP_REFRESH_SECONDS = max(1800, int(_os.getenv("SM_VIP_REFRESH_SECONDS", "21600"))) # 6 h bei Erfolg SM_VIP_RETRY_SECONDS = max(60, int(_os.getenv("SM_VIP_RETRY_SECONDS", "600"))) # 10 min nach Fehler SM_VIP_WAIT_MS = max(3000, int(_os.getenv("SM_VIP_WAIT_MS", "12000"))) # Lausch-/State-Sync-Fenster SM_VIP_TIMEOUT = max(20.0, float(_os.getenv("SM_VIP_TIMEOUT", str(SM_VIP_WAIT_MS / 1000.0 + 25)))) _sm_vip_refresh_lock = threading.Lock() def _sm_vip_run_node(token): """Den VIP-Node-Sidecar einmal laufen lassen. (data, exit_code). Token via env, nicht argv.""" env = dict(_os.environ) env["SM_LOOKUP_ACCESS_TOKEN"] = token env["SM_VIP_WAIT_MS"] = str(SM_VIP_WAIT_MS) env["SM_LOADBALANCER_BASE"] = SM_LOADBALANCER_BASE env["SM_VIP_WORLD"] = SM_VIP_WORLD try: proc = subprocess.run( [SM_ROOM_LOOKUP_NODE, str(SM_VIP_SCRIPT), SM_VIP_WORLD], cwd=str(SM_ROOM_LOOKUP_DIR), env=env, capture_output=True, text=True, timeout=SM_VIP_TIMEOUT, ) except subprocess.TimeoutExpired: return ({"ok": False, "error": "vip lookup timed out", "authFail": False}, 124) except FileNotFoundError: return ({"ok": False, "error": "node runtime not available", "authFail": False}, 127) data = None for line in reversed((proc.stdout or "").splitlines()): line = line.strip() if not line.startswith("{"): continue try: obj = json.loads(line) except Exception: # noqa: BLE001 continue if isinstance(obj, dict) and "ok" in obj: data = obj break if data is None: err = (proc.stderr or "").strip() or "no output" data = {"ok": False, "error": err[:300], "authFail": False} return (data, proc.returncode) def _sm_vip_refresh(): """Welt joinen (Lookup-Account), VIPLeaderboard/VIPCount lesen, VIP-Cache aktualisieren.""" if not (SM_ROOM_LOOKUP_USER and SM_ROOM_LOOKUP_HAS_PASS): raise RuntimeError("vip lookup not configured (needs SM_ROOM_LOOKUP_USER / SM_ROOM_LOOKUP_PASS)") with _sm_vip_refresh_lock: token = _sm_rl_current_token() if not token: with _sm_rl_token_lock: token = _sm_rl_current_token() or _sm_rl_refresh_token() data, rc = _sm_vip_run_node(token) if rc == 3 and data.get("authFail"): with _sm_rl_token_lock: token = _sm_rl_refresh_token() data, rc = _sm_vip_run_node(token) if not data or not data.get("ok"): d = data or {} diag = "" if d.get("types"): diag += " types=[" + ",".join(str(t) for t in d["types"][:40]) + "]" if d.get("keys"): diag += " keys=[" + ",".join(str(k) for k in d["keys"][:40]) + "]" if d.get("rawType") or d.get("rawSample"): diag += f" rawType={d.get('rawType')} rawSample={str(d.get('rawSample'))[:400]!r}" raise RuntimeError((d.get("error") or "vip lookup failed") + diag) vips = data.get("vips") or [] cnt = int(data.get("count") or len(vips)) # Nur bei Parse-Anomalie (deutlich weniger Namen als VIPCount) die Rohprobe loggen, # damit die Logs im Normalbetrieb nicht bei jedem Refresh die Namensliste enthalten. if vips and len(vips) < max(1, cnt) // 2: try: print(f"[vip] WARN parsed={len(vips)} count={cnt} src={data.get('source')} " f"rawType={data.get('rawType')} rawSample={str(data.get('rawSample'))[:400]!r}") except Exception: # noqa: BLE001 pass return _sm_vip_store(vips, cnt, data.get("world") or SM_VIP_WORLD) def _sm_vip_worker(): time.sleep(35) # nach dem Rival-Worker starten (teilt sich die Lookup-Token-Logik) while True: ok = False try: res = _sm_vip_refresh() ok = bool(res and (res.get("ok") or res.get("skipped"))) # skipped = guter Stand bleibt try: if isinstance(res, dict): print(f"[vip] ok: stored {res.get('stored')} names (count {res.get('count')})" + (f" skipped={res.get('skipped')}" if res.get('skipped') else "")) except Exception: # noqa: BLE001 pass except Exception as exc: # noqa: BLE001 try: print(f"[vip] refresh failed: {exc}") except Exception: # noqa: BLE001 pass # Bei Erfolg (oder gehaltenem Stand) erst in 6 h wieder; bei Join-/Netzfehler bald erneut. time.sleep(SM_VIP_REFRESH_SECONDS if ok else SM_VIP_RETRY_SECONDS) if SM_ROOM_LOOKUP_USER and SM_ROOM_LOOKUP_HAS_PASS and not SM_ROOM_LOOKUP_DISABLED: threading.Thread(target=_sm_vip_worker, name="sm-vip-refresh", daemon=True).start() @app.get("/live-lookup") @app.get("/discord-lookup") def discord_lookup(username: str, include_rank: bool = True): result = _sm_live_lookup_bulk([username], include_ranks=include_rank).get(username) if not result or not result.get("ok"): raise HTTPException(502, (result or {}).get("error") or f"No live stats found for '{username}'.") _sm_leaderboard_enqueue_lookup_results([result]) return result @app.get("/power-series") def power_series(request: Request, username: str = "", player_id: str = "", backfill: bool = True, mod_key: str = ""): """Power/Level/Gold-Zeitreihe eines Accounts (nur Accounts mit >= 1 Qnt Power). ÖFFENTLICH. Bevorzugt player_id (exakter Match); username-Fallback ueber das Leaderboard. points = [t_sec, level, power, gold, sword_type, sword_level, shield_type, shield_level, src]. src 0 = exakt (pro Scan, sekundengenau), 1 = stuendl. Backfill, 2 = taegl. Backfill. Schwert/Schild (-1 = unbekannt) nur bei exakten Punkten; Snapshot-Backfill hat sie nie. backfill=false blendet die groben Leaderboard-Snapshot-Punkte aus (nur exakte Reihe).""" # Öffentlich: Power/Level/Gold sind öffentliche Leaderboard-/Spielwerte (werden im Lookup # ohnehin für jeden angezeigt). mod_key bleibt als No-op-Parameter für Altaufrufer erhalten. # Per-Request billig: In-Memory-Serie + O(Snapshot-Dateien) Backfill-Lookups, kein Datei-Parse. key = _sm_pseries_resolve_key(username or "", player_id or "") if not key: raise HTTPException(400, "username oder player_id erforderlich.") with _SM_PSERIES_LOCK: state = _sm_pseries_load_unlocked() entry = (state.get("series") or {}).get(key) precise = [_sm_pseries_point_out(p, 0) for p in (entry.get("p") if entry else []) or []] uname = (entry.get("u") if entry else "") or username # Aus dem Discord-#bot-commands-Kanal importierte HISTORISCHE Punkte (src=3, name-basiert, # UNSICHERER). Getrennter Store; das Frontend markiert sie als andere Punktart. with _SM_PSERIES_DC_LOCK: dstate = _sm_pseries_dc_load_unlocked() dentry = (dstate.get("series") or {}).get(key) discord = [_sm_pseries_point_out(p, 3) for p in (dentry.get("p") if dentry else []) or []] if dentry and not (entry and entry.get("u")): uname = dentry.get("u") or uname # Grobe historische Punkte aus den Leaderboard-Snapshots dazumischen. Prioritaet im selben # Stunden-Bucket: exakt (src0) > Discord-Import (src3) > Snapshot-Backfill (src1/2). buckets = _sm_power_series_backfill(key) if backfill else {} precise_hours = set() for p in precise: try: precise_hours.add(int(p[0]) // 3600) except Exception: # noqa: BLE001 pass discord = [p for p in discord if _safe_hour(p[0]) not in precise_hours] claimed = precise_hours | {_safe_hour(p[0]) for p in discord} buckets = {hb: v for hb, v in buckets.items() if hb not in claimed} merged = precise + discord + [b[1] for b in buckets.values()] merged.sort(key=lambda x: x[0]) # Level ist monoton (kann nie fallen). Punkte unter dem bisher gesehenen Max-Level sind stale/ # fehlerhaft (z.B. ein Snapshot direkt nach einem Neustart, bevor der Ingest das Leaderboard # aktualisiert hat -> alte Werte) -> rauswerfen. Entfernt die "Level-Dips". cleaned = [] max_lv = None for p in merged: lv = p[1] if max_lv is not None and isinstance(lv, (int, float)) and lv < max_lv: continue cleaned.append(p) if isinstance(lv, (int, float)) and (max_lv is None or lv > max_lv): max_lv = lv merged = cleaned # Redundante Mitte flacher Abschnitte entfernen — ueber ALLE Quellen gemeinsam (exakt ist beim # Recorden/Dedup schon kollabiert, aber der Discord-Import src=3 und der Snapshot-Backfill sind # es NICHT). Erster+letzter Punkt jeder identischen Folge bleiben -> flache Strecke rendert als # Linie, nur die identischen Zwischenpunkte fallen weg. Haelt den Graphen sauber + die Antwort # klein (wichtig seit der Endpoint oeffentlich ist). merged, _ = _sm_pseries_collapse_identical(merged) exact_count = sum(1 for p in merged if p[-1] == 0) discord_count = sum(1 for p in merged if p[-1] == 3) return { "ok": True, "key": key, "username": uname, "under_1qnt": len(merged) == 0, "min_power": SM_POWER_SERIES_MIN_POWER, "fields": _SM_PSERIES_FIELDS + ["src"], "count": len(merged), "exact_count": exact_count, "discord_count": discord_count, "backfill_count": len(merged) - exact_count - discord_count, "sword_names": {str(t): n for t, n in SWORD_TYPE_NAMES.items()}, "shield_names": {str(t): n for t, n in SHIELD_TYPE_NAMES.items()}, "points": merged, } @app.post("/power-series-import-discord") async def power_series_import_discord(request: Request): """EINMALIGER Import historischer Spieler-Stats aus dem Discord-#bot-commands-Kanal als src=3- Punkte (name-basiert, UNSICHER). Body: {"accounts": {name: [[ts_sec, level, power, gold, sword_name, sword_level, shield_name, shield_level], ...]}}. Geschuetzt per SM_LEADERBOARD_INGEST_KEY (Header X-Ingest-Key oder ?key=), genau wie der Scanner-Push -> der lokale Importer nutzt denselben Key. Validierung (ein historisch hoeheres Level als aktuell => ganzer Account verworfen) + Name->id laeuft serverseitig (hat das aktuelle Leaderboard).""" import hmac if not SM_LEADERBOARD_INGEST_KEY: raise HTTPException(404, "Ingest is not enabled on this server.") provided = request.headers.get("X-Ingest-Key") or request.query_params.get("key") or "" if not hmac.compare_digest(str(provided), SM_LEADERBOARD_INGEST_KEY): raise HTTPException(403, "Invalid ingest key.") try: body = await request.json() except Exception: # noqa: BLE001 raise HTTPException(400, "Invalid JSON body.") accounts = (body or {}).get("accounts") if isinstance(body, dict) else None if not isinstance(accounts, dict): raise HTTPException(400, "Body must be {\"accounts\": {name: [[...]]}}.") summary = _sm_pseries_import_discord_accounts(accounts) summary["ok"] = True summary["received_accounts"] = len(accounts) return summary @app.get("/power-series-admin") def power_series_admin(request: Request, action: str = "stats", mod_key: str = "", name: str = "", player_id: str = ""): """MOD-ONLY Wartung/Diagnose der Power-Zeitreihe. action=stats (Groessen/Verteilung/Projektion), action=dedup (vergangene identische Folge-Punkte der EXAKTEN Reihe einmalig kollabieren), action=clear_discord (Discord-Import-Store leeren -> sauberer Re-Import). Zugang: Mod-Sitzung (Browser) ODER der Ingest-Key (Operator-Tooling, Header/?key=).""" import hmac ingest_ok = bool(SM_LEADERBOARD_INGEST_KEY) and hmac.compare_digest( str(request.headers.get("X-Ingest-Key") or request.query_params.get("key") or ""), SM_LEADERBOARD_INGEST_KEY) if not (ingest_ok or _sm_is_mod(request)): raise HTTPException(404) act = (action or "stats").strip().lower() if act == "dedup": with _SM_PSERIES_LOCK: accts, before, after, removed = _sm_pseries_dedup_store_unlocked() return {"ok": True, "action": "dedup", "accounts": accts, "points_before": before, "points_after": after, "removed": removed} if act == "clear_discord": with _SM_PSERIES_DC_LOCK: state = _sm_pseries_dc_load_unlocked() n = len(state.get("series") or {}) state["series"] = {} _sm_pseries_dc_save_unlocked() return {"ok": True, "action": "clear_discord", "cleared_accounts": n} if act == "account": # Diagnose eines einzelnen Accounts: Punktzahlen + Power-Spanne je Quelle. key = _sm_pseries_resolve_key(name or "", player_id or "") def _summ(pts): pts = pts or [] if not pts: return {"points": 0} pw = [float(p[2]) for p in pts if len(p) > 2] lv = [int(p[1]) for p in pts if len(p) > 1] return {"points": len(pts), "min_power": min(pw) if pw else None, "max_power": max(pw) if pw else None, "min_level": min(lv) if lv else None, "max_level": max(lv) if lv else None, "t_first": pts[0][0], "t_last": pts[-1][0], "under_1qnt_points": sum(1 for x in pw if x < SM_POWER_SERIES_MIN_POWER)} with _SM_PSERIES_LOCK: ex = ((_sm_pseries_load_unlocked().get("series") or {}).get(key) or {}).get("p") with _SM_PSERIES_DC_LOCK: dc = ((_sm_pseries_dc_load_unlocked().get("series") or {}).get(key) or {}).get("p") return {"ok": True, "action": "account", "name": name, "key": key, "is_full": _sm_pseries_is_full(name), "exact": _summ(ex), "discord": _summ(dc)} # ── stats ────────────────────────────────────────────────────────────────────────────────── def _file_size(p): try: return int(p.stat().st_size) except OSError: return 0 def _hb(n): n = float(n or 0) for unit in ("B", "KB", "MB", "GB"): if n < 1024 or unit == "GB": return (f"{n:.0f} {unit}" if unit == "B" else f"{n:.2f} {unit}") n /= 1024.0 def _dist(counts): if not counts: return {"accounts": 0} s = sorted(counts) n = len(s) total = sum(s) return {"accounts": n, "points_total": total, "points_avg": round(total / n, 2), "min": s[0], "median": s[n // 2], "p95": s[min(n - 1, int(n * 0.95))], "max": s[-1]} with _SM_PSERIES_LOCK: series = (_sm_pseries_load_unlocked().get("series") or {}) exact_counts = [len(e.get("p") or []) for e in series.values()] exact_updated = _SM_PSERIES_STATE.get("updated_at") if _SM_PSERIES_STATE else 0 with _SM_PSERIES_DC_LOCK: dseries = (_sm_pseries_dc_load_unlocked().get("series") or {}) discord_counts = [len(e.get("p") or []) for e in dseries.values()] with _SM_PSERIES_SNAP_LOCK: snap_files = len(_SM_PSERIES_SNAP_CACHE) snap_keys = sum(len(c.get("data") or {}) for c in _SM_PSERIES_SNAP_CACHE.values()) exact_total = sum(exact_counts) exact_bytes = _file_size(SM_POWER_SERIES_PATH) return { "ok": True, "action": "stats", "exact": _dist(exact_counts), "discord": _dist(discord_counts), "exact_file_bytes": exact_bytes, "exact_file_bytes_h": _hb(exact_bytes), "discord_file_bytes": _file_size(SM_POWER_SERIES_DISCORD_PATH), "bytes_per_point": round(exact_bytes / exact_total, 1) if exact_total else 0, "backfill_cache_files": snap_files, "backfill_cache_keys": snap_keys, "idcache_entries": len(_SM_PSERIES_IDCACHE), "lvlidx_cached": bool(_SM_PSERIES_LVLIDX_CACHE), "min_gap_s": SM_POWER_SERIES_MIN_GAP, "heartbeat_s": SM_POWER_SERIES_HEARTBEAT, "max_points_per_account": SM_POWER_SERIES_MAX_POINTS, "exact_updated_at": exact_updated, "server_time": int(time.time()), } @app.get("/live-rank") def live_rank(username: str): clean = str(username or "").strip() if not clean: raise HTTPException(400, "Username is required.") result = _sm_live_lookup_bulk([clean], include_ranks=False).get(clean) if not result or not result.get("ok"): raise HTTPException(502, (result or {}).get("error") or f"No live stats found for '{clean}'.") rank_map = {_sm_live_username_key(clean): result} _sm_attach_level_ranks_unlocked(rank_map, [clean]) _sm_leaderboard_enqueue_lookup_results([result]) return result @app.get("/live-ban") def live_ban(username: str, level: Optional[int] = None): """Schneller Ban-Status: nur die (level-gezielte, gecachte) Rang-Suche, OHNE die teure Gesamt-Spielerzahl fuer den Perzentil. Damit erscheint das BANNED-Badge im Lookup sofort statt erst nach der langsamen /live-rank.""" clean = str(username or "").strip() if not clean: raise HTTPException(400, "Username is required.") key = _sm_live_username_key(clean) levels_by_key = {key: int(level)} if level is not None else None try: ranks = _sm_level_ranks_for_usernames([clean], levels_by_key=levels_by_key) except Exception as exc: # noqa: BLE001 raise HTTPException(502, f"Rank lookup failed: {exc}") entry = ranks.get(key) if ranks else None if not entry: return {"username": clean, "found": False} return { "username": clean, "found": True, "is_banned": bool(entry.get("is_banned")), "is_dead": bool(entry.get("is_dead")), "level_rank": entry.get("rank"), "level_rank_page": entry.get("page"), "player_id": entry.get("player_id") or "", } # ───────────────────────────────────────────────────────────────── # /get-tokens — gibt nur Status ans Frontend, keine Secrets # ───────────────────────────────────────────────────────────────── @app.get("/get-tokens") def get_tokens(): """Compatibility endpoint for the frontend. Never returns token secrets.""" tokens = _get_all_tokens() return {"configured": True, "count": len(tokens), "source": "swordmasters-live"} # ───────────────────────────────────────────────────────────────── # /live-lookup-bulk # ───────────────────────────────────────────────────────────────── class BulkLookupRequest(BaseModel): usernames: list[str] include_rank: bool = True class ClanLookupRequest(BaseModel): clan_name: str class SavedPlayerResolveRequest(BaseModel): player_id: str username: Optional[str] = "" level: Optional[int] = None page: Optional[int] = None class LeaderboardRefreshRequest(BaseModel): username: str class LeaderboardRefreshBatchRequest(BaseModel): usernames: list[str] = [] class ProbeReportRequest(BaseModel): username: Optional[str] = "" report: Optional[dict] = None @app.post("/live-probe-report") def live_probe_report(data: ProbeReportRequest): result = _sm_probe_result_from_report(data.username or "", data.report or {}) key = _discord_lookup_key(result.get("username")) if not key: raise HTTPException(400, "Pass a username, for example SM_INVENTORY_PROBE.pushLiveJson(\"oettaM\").") with _SM_LOOKUP_LOCK: _SM_PROBE_CACHE[key] = { "stamp": time.monotonic(), "inventory": dict(result.get("inventory") or {}), } return result @app.post("/live-lookup-bulk") @app.post("/discord-lookup-bulk") def discord_lookup_bulk(data: BulkLookupRequest): usernames = [str(u).strip() for u in (data.usernames or []) if str(u).strip()] if not usernames: return {"results": {}} results = _sm_live_lookup_bulk(usernames, include_ranks=data.include_rank) # DDoS-Resilienz: bei kleinen Lookups transiente Fehlschlaege serverseitig nachziehen, # statt den Nutzer selbst mehrfach klicken zu lassen. Ein no-reply vom (ueberlasteten) # Live-Server sieht sonst aus wie "Account existiert nicht", obwohl er nur nicht antwortete. if len(usernames) <= SM_LOOKUP_RETRY_MAX_NAMES: for attempt in range(SM_LOOKUP_RETRY_ROUNDS): failed = [u for u in usernames if not (results.get(u) or {}).get("ok")] if not failed: break time.sleep(SM_LOOKUP_RETRY_DELAY * (attempt + 1)) retry = _sm_live_lookup_bulk(failed, include_ranks=data.include_rank) for u, r in retry.items(): if (r or {}).get("ok"): results[u] = r _sm_leaderboard_enqueue_lookup_results(results.values()) return {"results": results, "token_warnings": []} # ───────────────────────────────────────────────────────────────── # Mod access: one-time invite links -> personal, revocable per-device sessions # ───────────────────────────────────────────────────────────────── # The old scheme put the shared SM_MOD_KEY into a ?mod= link and into a 30-day # cookie, so one leaked link made everyone a mod until the key was rotated. Now: # - The owner creates a ONE-TIME invite per mod at /mod/admin. Opening it shows a # confirm button (link previews / prefetchers only GET, so they can't burn it); # pressing it turns the link into a session cookie for THAT device only. A used # link is worthless, an unused one expires (default 24 h). # - The cookie holds a random token; only its SHA-256 is stored (/data/mod_access.json). # Every device is listed at /mod/admin (name, device, network, last seen) and can be # revoked; sessions idle for SM_MOD_SESSION_IDLE_DAYS expire. # - No secret appears in a URL any more. The owner logs in with SM_OWNER_KEY through a # POST form (rate-limited, page only reachable via /mod/admin?login). SM_MOD_KEY is # now just the server-to-server secret for the leaderboard proxy to the prices Space; # browsers never receive it. SM_MOD_SESSION_COOKIE = "sm_sess" SM_MOD_SESSION_IDLE_DAYS = max(1, int(_os.getenv("SM_MOD_SESSION_IDLE_DAYS", "30") or 30)) SM_MOD_INVITE_HOURS = (1, 24, 168) _MOD_STORE_PATH = (Path("/data") / "mod_access.json") if _os.getenv("SPACE_ID") else (APP_DIR / "mod_access.json") _MOD_LOCK = threading.RLock() _MOD = None # {"sessions": {sha256(token): rec}, "invites": {sha256(token): rec}, "events": [..]} _MOD_LAST_SAVE = 0.0 _MOD_LOGIN_FAILS = {} # ip -> [failure timestamps] _MOD_FLASH = {} # owner sid -> (new invite link shown once, created ts) def _mod_hash(token) -> str: import hashlib return hashlib.sha256(str(token or "").encode("utf-8")).hexdigest() def _mod_state(): global _MOD if _MOD is None: st = {"sessions": {}, "invites": {}, "events": []} try: with open(_MOD_STORE_PATH, encoding="utf-8") as f: obj = json.load(f) if isinstance(obj, dict): for k in st: if isinstance(obj.get(k), type(st[k])): st[k] = obj[k] except Exception: pass _MOD = st return _MOD def _mod_prune(st, now): for key, rec in list(st["sessions"].items()): if rec.get("revoked_at") and now - rec["revoked_at"] > 30 * 86400: st["sessions"].pop(key, None) for key, rec in list(st["invites"].items()): done = rec.get("used_at") or rec.get("revoked_at") or (rec.get("expires_at") or 0) < now if done and now - (rec.get("used_at") or rec.get("revoked_at") or rec.get("expires_at") or now) > 7 * 86400: st["invites"].pop(key, None) st["events"] = st["events"][-300:] def _mod_save(force: bool = False): """Persist the store. last_seen-only updates are throttled; structural changes force.""" global _MOD_LAST_SAVE now = time.time() if not force and now - _MOD_LAST_SAVE < 60: return with _MOD_LOCK: st = _mod_state() _mod_prune(st, now) try: _MOD_STORE_PATH.parent.mkdir(parents=True, exist_ok=True) tmp = _MOD_STORE_PATH.with_suffix(".json.tmp") with open(tmp, "w", encoding="utf-8") as f: json.dump(st, f) tmp.replace(_MOD_STORE_PATH) _MOD_LAST_SAVE = now except Exception as exc: # noqa: BLE001 print(f"[mod-access] save failed: {exc}") def _mod_ip_masked(request) -> str: """Network-level IP for the device list (a.b.c.x / first 4 IPv6 groups) - enough to spot a cookie used from a new network without storing full addresses.""" ip = _rl_client_ip(request) if ":" in ip: return ":".join(ip.split(":")[:4]) + "::/64" parts = ip.split(".") return ".".join(parts[:3]) + ".x" if len(parts) == 4 else ip def _mod_device(request) -> str: ua = str(request.headers.get("user-agent") or "") browser = ("Edge" if "Edg/" in ua else "Opera" if "OPR/" in ua else "Firefox" if "Firefox/" in ua else "Chrome" if "Chrome/" in ua else "Safari" if "Safari/" in ua else "Browser") osname = ("iPhone" if "iPhone" in ua else "iPad" if "iPad" in ua else "Android" if "Android" in ua else "Windows" if "Windows" in ua else "Mac" if "Mac OS" in ua else "ChromeOS" if "CrOS" in ua else "Linux" if "Linux" in ua else "?") return f"{browser} · {osname}" def _mod_event(kind: str, **fields): with _MOD_LOCK: st = _mod_state() st["events"].append({"t": time.time(), "kind": kind, **fields}) st["events"] = st["events"][-300:] def _mod_session(request, touch: bool = True): """The valid session record behind this request's cookie, or None.""" tok = request.cookies.get(SM_MOD_SESSION_COOKIE) or "" if not tok or len(tok) > 200: return None with _MOD_LOCK: st = _mod_state() rec = st["sessions"].get(_mod_hash(tok)) if not rec or rec.get("revoked_at"): return None now = time.time() if now - (rec.get("last_seen") or rec.get("created_at") or 0) > SM_MOD_SESSION_IDLE_DAYS * 86400: rec["revoked_at"] = now rec["revoke_reason"] = "expired (unused)" _mod_event("session_expired", name=rec.get("name"), device=rec.get("device")) _mod_save(force=True) return None if touch and now - (rec.get("last_seen") or 0) > 300: rec["last_seen"] = now net = _mod_ip_masked(request) rec["last_ip"] = net nets = rec.setdefault("ips", []) if net not in nets: nets.append(net) del nets[:-6] if len(nets) > 1: _mod_event("new_network", name=rec.get("name"), device=rec.get("device"), ip=net, sid=rec.get("sid")) _mod_save() return rec def _sm_is_mod(request) -> bool: """Single source of truth for "is this browser a mod" (owner counts as mod).""" try: return _mod_session(request) is not None except Exception: # noqa: BLE001 -- never let the auth check take a page down return False def _sm_is_owner(request) -> bool: s = _mod_session(request) return bool(s and s.get("role") == "owner") def _mod_new_session(request, name: str, role: str, via: str): import secrets token = secrets.token_urlsafe(32) now = time.time() net = _mod_ip_masked(request) rec = {"sid": secrets.token_hex(5), "name": name, "role": role, "created_at": now, "last_seen": now, "last_ip": net, "ips": [net], "device": _mod_device(request), "via": via} with _MOD_LOCK: _mod_state()["sessions"][_mod_hash(token)] = rec _mod_event("session_created", name=name, role=role, device=rec["device"], ip=net, via=via) _mod_save(force=True) return token, rec def _mod_secure(request) -> bool: return bool(_os.getenv("SPACE_ID")) or request.url.scheme == "https" \ or (request.headers.get("x-forwarded-proto") or "").lower() == "https" def _mod_set_cookie(resp, request, token: str): resp.set_cookie(SM_MOD_SESSION_COOKIE, token, max_age=SM_MOD_SESSION_IDLE_DAYS * 86400 * 3, httponly=True, samesite="lax", secure=_mod_secure(request), path="/") resp.delete_cookie("sm_mod") # the old cookie carried the raw shared key def _mod_same_origin(request) -> bool: """Reject cross-site POSTs (SameSite=Lax already blocks the cookie; this is belt and braces).""" from urllib.parse import urlparse origin = request.headers.get("origin") or "" if not origin or origin == "null": return True hosts = {request.headers.get("host") or "", request.headers.get("x-forwarded-host") or ""} return urlparse(origin).netloc in hosts async def _mod_form(request) -> dict: """Minimal urlencoded form parser (python-multipart is not a dependency here).""" from urllib.parse import parse_qs body = await request.body() if len(body) > 8192: return {} return {k: v[0] for k, v in parse_qs(body.decode("utf-8", "replace")).items() if v} def _mod_login_allowed(ip: str) -> bool: now = time.time() for k in list(_MOD_LOGIN_FAILS): _MOD_LOGIN_FAILS[k] = [t for t in _MOD_LOGIN_FAILS[k] if now - t < 900] if not _MOD_LOGIN_FAILS[k]: _MOD_LOGIN_FAILS.pop(k, None) total = sum(len(v) for v in _MOD_LOGIN_FAILS.values()) return len(_MOD_LOGIN_FAILS.get(ip, [])) < 5 and total < 40 def _mod_owner_key_ok(provided: str) -> bool: import hmac key = (_os.getenv("SM_OWNER_KEY") or "").strip() if len(key) < 16 or not provided: return False return hmac.compare_digest(_mod_hash(provided), _mod_hash(key)) def _mod_ago(ts) -> str: if not ts: return "—" s = max(0, int(time.time() - ts)) if s < 60: return f"{s}s ago" if s < 3600: return f"{s // 60}m ago" if s < 86400: return f"{s // 3600}h ago" return f"{s // 86400}d ago" def _mod_page(title: str, body: str, status: int = 200): html_doc = f""" {title}
{body}
""" return Response(content=html_doc, status_code=status, media_type="text/html; charset=utf-8", headers={"Cache-Control": "no-store", "Referrer-Policy": "no-referrer"}) def _mod_invite_valid(inv, now) -> bool: return bool(inv) and not inv.get("used_at") and not inv.get("revoked_at") and (inv.get("expires_at") or 0) > now @app.get("/mod/join", include_in_schema=False) def mod_join_page(request: Request, t: str = ""): """Confirm page for a one-time invite. GET never redeems, so Discord/Slack link previews and browser prefetching can't use the link up before the mod opens it.""" import html with _MOD_LOCK: inv = _mod_state()["invites"].get(_mod_hash(t)) if t else None valid = _mod_invite_valid(inv, time.time()) if not valid: return _mod_page("Link not valid", "

Link not valid

This link is invalid, " "expired or has already been used. Ask for a new one.

", 404) return _mod_page("Activate access", f"""

Activate access on this device

Invite for {html.escape(str(inv.get('name')))}.

This link works once: the device that activates it gets access, the link is then used up. For another device, ask for another link. Don't share this link.

""") @app.post("/mod/join", include_in_schema=False) async def mod_join_redeem(request: Request): if not _mod_same_origin(request): raise HTTPException(404) t = (await _mod_form(request)).get("t", "") now = time.time() with _MOD_LOCK: inv = _mod_state()["invites"].get(_mod_hash(t)) if t else None if not _mod_invite_valid(inv, now): _mod_event("invite_rejected", device=_mod_device(request), ip=_mod_ip_masked(request), name=(inv or {}).get("name"), reason="used" if (inv or {}).get("used_at") else "invalid/expired") _mod_save(force=True) return _mod_page("Link not valid", "

Link not valid

This link is invalid, " "expired or has already been used. Ask for a new one.

", 404) token, rec = _mod_new_session(request, inv.get("name") or "mod", inv.get("role") or "mod", via=f"invite {inv.get('id')}") inv["used_at"] = now inv["used_sid"] = rec["sid"] inv["used_device"] = rec["device"] _mod_event("invite_redeemed", name=rec["name"], device=rec["device"], ip=rec["last_ip"], invite=inv.get("id")) _mod_save(force=True) resp = RedirectResponse(url="/mod/admin" if rec["role"] == "owner" else "/", status_code=303) _mod_set_cookie(resp, request, token) return resp @app.post("/mod/logout", include_in_schema=False) async def mod_logout(request: Request): if not _mod_same_origin(request): raise HTTPException(404) _mod_revoke_current(request, "logged out") resp = RedirectResponse(url="/", status_code=303) resp.delete_cookie(SM_MOD_SESSION_COOKIE, path="/") resp.delete_cookie("sm_mod") return resp def _mod_revoke_current(request, reason: str): tok = request.cookies.get(SM_MOD_SESSION_COOKIE) or "" if not tok: return with _MOD_LOCK: rec = _mod_state()["sessions"].get(_mod_hash(tok)) if rec and not rec.get("revoked_at"): rec["revoked_at"] = time.time() rec["revoke_reason"] = reason _mod_event("session_revoked", name=rec.get("name"), device=rec.get("device"), reason=reason) _mod_save(force=True) @app.get("/mod/admin", include_in_schema=False) def mod_admin(request: Request): if _sm_is_owner(request): return _mod_admin_page(request) if "login" in request.query_params: configured = len((_os.getenv("SM_OWNER_KEY") or "").strip()) >= 16 return _mod_page("Owner login", f"""

Owner login

{'' if configured else '

Owner login is not configured on this server (SM_OWNER_KEY missing or shorter than 16 characters).

'}

Creates an owner session for this device only.

""") raise HTTPException(404) @app.post("/mod/admin/login", include_in_schema=False) async def mod_admin_login(request: Request): if not _mod_same_origin(request): raise HTTPException(404) ip = _rl_client_ip(request) f = await _mod_form(request) failed = _mod_page("Login failed", "

Login failed

", 403) with _MOD_LOCK: if not _mod_login_allowed(ip): _mod_event("owner_login_blocked", ip=_mod_ip_masked(request), device=_mod_device(request)) _mod_save(force=True) return failed if not _mod_owner_key_ok(f.get("key", "")): _MOD_LOGIN_FAILS.setdefault(ip, []).append(time.time()) _mod_event("owner_login_failed", ip=_mod_ip_masked(request), device=_mod_device(request)) _mod_save(force=True) return failed _MOD_LOGIN_FAILS.pop(ip, None) label = (f.get("name") or "").strip()[:40] or "owner" token, _rec = _mod_new_session(request, label, "owner", via="owner key") resp = RedirectResponse(url="/mod/admin", status_code=303) _mod_set_cookie(resp, request, token) return resp @app.post("/mod/admin/action", include_in_schema=False) async def mod_admin_action(request: Request): import secrets if not _mod_same_origin(request) or not _sm_is_owner(request): raise HTTPException(404) f = await _mod_form(request) do = f.get("do", "") me = _mod_session(request, touch=False) or {} now = time.time() new_link = None with _MOD_LOCK: st = _mod_state() if do == "invite": name = (f.get("name") or "").strip()[:40] role = "owner" if f.get("role") == "owner" else "mod" try: hours = int(f.get("hours") or 24) except ValueError: hours = 24 hours = hours if hours in SM_MOD_INVITE_HOURS else 24 if name: token = secrets.token_urlsafe(32) inv = {"id": secrets.token_hex(4), "name": name, "role": role, "created_at": now, "expires_at": now + hours * 3600, "created_by": me.get("name")} st["invites"][_mod_hash(token)] = inv _mod_event("invite_created", name=name, role=role, invite=inv["id"], hours=hours) proto = (request.headers.get("x-forwarded-proto") or request.url.scheme or "https").split(",")[0].strip() host = request.headers.get("x-forwarded-host") or request.headers.get("host") or "" new_link = (f"{proto}://{host}/mod/join?t={token}", name, role, hours) elif do == "revoke_session": for rec in st["sessions"].values(): if rec.get("sid") == f.get("sid") and not rec.get("revoked_at"): rec["revoked_at"] = now rec["revoke_reason"] = f"revoked by {me.get('name') or 'owner'}" _mod_event("session_revoked", name=rec.get("name"), device=rec.get("device"), reason=rec["revoke_reason"]) elif do == "revoke_others": for rec in st["sessions"].values(): if rec.get("sid") != me.get("sid") and not rec.get("revoked_at"): rec["revoked_at"] = now rec["revoke_reason"] = "revoked (all other devices)" _mod_event("session_revoked", name=rec.get("name"), device=rec.get("device"), reason=rec["revoke_reason"]) elif do == "revoke_invite": for inv in st["invites"].values(): if inv.get("id") == f.get("id") and _mod_invite_valid(inv, now): inv["revoked_at"] = now _mod_event("invite_revoked", name=inv.get("name"), invite=inv.get("id")) _mod_save(force=True) if new_link: # Post/redirect/get: show the link once on /mod/admin, so a page reload can't # silently create a second invite. _MOD_FLASH[me.get("sid")] = (new_link, now) if not _sm_is_owner(request): # the owner just revoked their own device resp = RedirectResponse(url="/", status_code=303) resp.delete_cookie(SM_MOD_SESSION_COOKIE, path="/") return resp return RedirectResponse(url="/mod/admin", status_code=303) _MOD_EVENT_LABEL = { "session_created": "new device logged in", "invite_created": "invite created", "invite_redeemed": "invite used", "invite_rejected": "used/invalid invite opened", "invite_revoked": "invite cancelled", "session_revoked": "device removed", "session_expired": "device expired (unused)", "new_network": "device seen on a NEW network", "owner_login_failed": "wrong owner key", "owner_login_blocked": "owner login blocked (too many attempts)", } def _mod_admin_page(request): import html esc = lambda v: html.escape(str(v if v is not None else "")) # noqa: E731 me = _mod_session(request, touch=False) or {} now = time.time() flash = _MOD_FLASH.pop(me.get("sid"), None) new_link = flash[0] if flash and now - flash[1] < 300 else None with _MOD_LOCK: st = _mod_state() sessions = sorted(st["sessions"].values(), key=lambda r: r.get("last_seen") or 0, reverse=True) active = [s for s in sessions if not s.get("revoked_at")] ended = [s for s in sessions if s.get("revoked_at")][:15] pending = sorted((i for i in st["invites"].values() if _mod_invite_valid(i, now)), key=lambda i: i.get("created_at") or 0, reverse=True) events = list(reversed(st["events"][-40:])) def btn(do, field, value, label, confirm): return (f"
" f"" f"
") rows = [] for s in active: nets = s.get("ips") or [] net = esc(s.get("last_ip")) + (f" ({len(nets)} networks)" if len(nets) > 1 else "") this = " this device" if s.get("sid") == me.get("sid") else "" rows.append(f"{esc(s.get('name'))}{this}
{esc(s.get('role'))} · via {esc(s.get('via'))}
" f"{esc(s.get('device'))}{net}" f"{_mod_ago(s.get('created_at'))}" f"{_mod_ago(s.get('last_seen'))}" f"{btn('revoke_session', 'sid', s.get('sid'), 'Remove', 'Remove access for this device?')}") ended_rows = "".join( f"{esc(s.get('name'))}{esc(s.get('device'))}{esc(s.get('last_ip'))}" f"{esc(s.get('revoke_reason') or 'removed')}{_mod_ago(s.get('revoked_at'))}" for s in ended) inv_rows = "".join( f"{esc(i.get('name'))} {esc(i.get('role'))}{_mod_ago(i.get('created_at'))}" f"in {max(0, int(((i.get('expires_at') or now) - now) // 3600))}h" f"{btn('revoke_invite', 'id', i.get('id'), 'Cancel', 'Cancel this unused invite link?')}" for i in pending) ev_rows = "".join( f"{_mod_ago(e.get('t'))}" f"" f"{esc(_MOD_EVENT_LABEL.get(e.get('kind'), e.get('kind')))}" f"{esc(e.get('name') or '')}{esc(e.get('device') or '')} {esc(e.get('ip') or '')}" for e in events) link_box = "" if new_link: url, name, role, hours = new_link link_box = f"""
Invite link for {esc(name)} ({esc(role)})

Shown only now. Works once, valid for {hours} h. Send it privately (DM) - never post it in a channel or an AI chat.

{esc(url)}

""" no_owner_key = "" if len((_os.getenv("SM_OWNER_KEY") or "").strip()) >= 16 else \ "

SM_OWNER_KEY is not set - nobody can log in as owner on a new device.

" body = f"""

Mod access

Logged in as {esc(me.get('name'))} (owner) · back to the site
{no_owner_key} {link_box}

Invite a mod

Each link logs in exactly one device. A mod with a phone and a PC needs two links.

Devices with access ({len(active)})

{'' + ''.join(rows) + '
WhoDeviceNetworkAddedLast seen
' if rows else '

No devices.

'} {('

' + btn('revoke_others', 'x', '1', 'Remove ALL other devices', 'Remove access for every device except this one?') + '

') if len(active) > 1 else ''}

Devices unused for {SM_MOD_SESSION_IDLE_DAYS} days lose access automatically. "networks" in orange = the same login was used from more than one network (normal for phones on mobile data, suspicious otherwise).

Unused invite links ({len(pending)})

{'' + inv_rows + '
ForCreatedExpires
' if inv_rows else '

None.

'}

Security log

{'' + ev_rows + '
' if ev_rows else '

Nothing yet.

'}
{('

Removed devices

' + ended_rows + '
') if ended_rows else ''}""" return _mod_page("Mod access", body) # ───────────────────────────────────────────────────────────────── # USAGE-ANALYTICS (anonym, extrem leicht, non-blocking) # Der Client feuert per navigator.sendBeacon('/track', {...}) winzige Event- # Zaehler (Tab-Wechsel, welche Lookups, ob Market/Suche genutzt wird, welche # Forge-Stats angeschaut werden, ...). Der Server haelt NUR In-Memory-Zaehler # pro UTC-Tag und flusht GEDROSSELT (<=1x/60s) als winziges JSON nach # /data/usage_stats.json. Es wird NICHTS Personenbezogenes gespeichert: keine # IP, kein Name, kein User-Agent, nur aggregierte Event->Count je Tag. Die # heissen Pfade (Leaderboard/Stream/Lookups) werden nicht angefasst. Lesen nur # fuer Mods (/usage-admin). Ziel: ~1 Monat sammeln -> wissen, was genutzt wird. # ───────────────────────────────────────────────────────────────── def _sm_usage_path() -> Path: configured = (_os.getenv("SM_USAGE_FILE") or "").strip() if configured: p = Path(configured) return p if p.is_absolute() else APP_DIR / p if _os.getenv("SPACE_ID") and _os.path.isdir("/data"): return Path("/data") / "usage_stats.json" return APP_DIR / "usage_stats.json" SM_USAGE_PATH = _sm_usage_path() SM_USAGE_SAVE_INTERVAL = max(5.0, float(_os.getenv("SM_USAGE_SAVE_INTERVAL", "60.0"))) SM_USAGE_MAX_KEYS_PER_DAY = max(50, int(_os.getenv("SM_USAGE_MAX_KEYS_PER_DAY", "3000"))) SM_USAGE_MAX_BATCH = max(1, int(_os.getenv("SM_USAGE_MAX_BATCH", "40"))) SM_USAGE_MAX_DAYS = max(7, int(_os.getenv("SM_USAGE_MAX_DAYS", "400"))) # Erlaubte Event-Kategorien (vor dem Doppelpunkt). Haelt die Kardinalitaet im Zaum: # ein boeser Client kann keine beliebigen Kategorien erfinden; die Namen dahinter # sind frei (begrenzter Zeichensatz + Laenge + Tagesdeckel). SM_USAGE_CATEGORIES = frozenset({ "tab", "action", "stat", "lookup", "opt", "lb", "view", "sort", "filter", "gear", "info", }) _SM_USAGE_KEY_RE = re.compile(r"^[a-z0-9]{1,16}:[a-z0-9][a-z0-9_.\-]{0,39}$") _SM_USAGE = {"days": {}, "updated_at": 0.0, "loaded": False, "dirty": False, "last_save": 0.0} _SM_USAGE_LOCK = threading.Lock() def _sm_usage_day_key(ts=None) -> str: return time.strftime("%Y-%m-%d", time.gmtime(ts if ts is not None else time.time())) def _sm_usage_load_unlocked(): if _SM_USAGE["loaded"]: return _SM_USAGE["loaded"] = True try: with open(SM_USAGE_PATH, "r", encoding="utf-8") as fh: data = json.load(fh) days = data.get("days") if isinstance(days, dict): clean = {} for d, ev in days.items(): if isinstance(ev, dict): clean[str(d)] = {str(k): int(v) for k, v in ev.items() if isinstance(v, (int, float))} _SM_USAGE["days"] = clean _SM_USAGE["updated_at"] = float(data.get("updated_at") or 0.0) except FileNotFoundError: pass except Exception: # eine kaputte Datei darf das Feature nie crashen pass def _sm_usage_save_unlocked(force=False): if not _SM_USAGE["dirty"]: return now = time.time() if not force and (now - _SM_USAGE["last_save"]) < SM_USAGE_SAVE_INTERVAL: return days = _SM_USAGE["days"] if len(days) > SM_USAGE_MAX_DAYS: # Tages-Retention: nur juengste N Tage for d in sorted(days)[:-SM_USAGE_MAX_DAYS]: days.pop(d, None) tmp = str(SM_USAGE_PATH) + ".tmp" try: SM_USAGE_PATH.parent.mkdir(parents=True, exist_ok=True) with open(tmp, "w", encoding="utf-8") as fh: json.dump({"v": 1, "days": days, "updated_at": now}, fh, separators=(",", ":")) _os.replace(tmp, SM_USAGE_PATH) _SM_USAGE["last_save"] = now _SM_USAGE["dirty"] = False except Exception: pass def _sm_usage_valid_event(ev) -> bool: if not isinstance(ev, str) or not _SM_USAGE_KEY_RE.match(ev): return False return ev.split(":", 1)[0] in SM_USAGE_CATEGORIES def _sm_usage_record(events): """Zaehlt eine Liste Events in den heutigen (UTC) Tages-Bucket. Billig, mit gedrosseltem Save; verwirft ungueltige/zu viele Events still.""" day = _sm_usage_day_key() with _SM_USAGE_LOCK: _sm_usage_load_unlocked() bucket = _SM_USAGE["days"].setdefault(day, {}) changed = False for ev in events[:SM_USAGE_MAX_BATCH]: if not _sm_usage_valid_event(ev): continue if ev not in bucket and len(bucket) >= SM_USAGE_MAX_KEYS_PER_DAY: continue # Kardinalitaetsdeckel -> eine Flut laeuft ins Leere bucket[ev] = int(bucket.get(ev, 0)) + 1 changed = True if changed: _SM_USAGE["dirty"] = True _sm_usage_save_unlocked() @app.post("/track", include_in_schema=False) async def track_usage(request: Request): """Nimmt winzige, anonyme Usage-Events (sendBeacon). Antwortet sofort 204 und macht im Request-Pfad nichts Teures ausser In-Memory-Count.""" try: raw = await request.body() if raw and len(raw) <= 4096: payload = json.loads(raw.decode("utf-8", "ignore") or "{}") e = payload.get("e") events = e if isinstance(e, list) else ([e] if e else []) if events: _sm_usage_record(events) except Exception: pass return Response(status_code=204) @app.get("/usage-admin", include_in_schema=False) def usage_admin(request: Request, action: str = "stats", days: int = 0, mod_key: str = ""): """MOD-ONLY Ansicht der anonymen Usage-Zaehler. action=stats (Default: Totals + Top-Events + je-Tag + je-Kategorie), raw (ganzer Store), csv (Download), clear. Zugang: Mod-Sitzung (Browser) ODER Ingest-Key (Header/?key=). ?mod_key= wird ignoriert.""" import hmac ingest_ok = bool(SM_LEADERBOARD_INGEST_KEY) and hmac.compare_digest( str(request.headers.get("X-Ingest-Key") or request.query_params.get("key") or ""), SM_LEADERBOARD_INGEST_KEY) if not (ingest_ok or _sm_is_mod(request)): raise HTTPException(404) act = (action or "stats").strip().lower() with _SM_USAGE_LOCK: _sm_usage_load_unlocked() if act == "clear": n = len(_SM_USAGE["days"]) _SM_USAGE["days"] = {} _SM_USAGE["dirty"] = True _sm_usage_save_unlocked(force=True) return {"ok": True, "action": "clear", "cleared_days": n} store = {d: dict(ev) for d, ev in _SM_USAGE["days"].items()} # Kopie unterm Lock updated = _SM_USAGE["updated_at"] all_days = sorted(store) if days and days > 0: all_days = all_days[-days:] totals = {} for d in all_days: for k, v in store[d].items(): totals[k] = totals.get(k, 0) + int(v) if act == "raw": return {"ok": True, "action": "raw", "days": {d: store[d] for d in all_days}, "updated_at": updated} if act == "csv": lines = ["day,event,count"] for d in all_days: for k in sorted(store[d]): lines.append(f"{d},{k},{store[d][k]}") return Response("\n".join(lines), media_type="text/csv", headers={"Content-Disposition": "attachment; filename=usage.csv"}) by_cat = {} for k, v in totals.items(): cat = k.split(":", 1)[0] by_cat[cat] = by_cat.get(cat, 0) + v top = sorted(totals.items(), key=lambda kv: kv[1], reverse=True) return { "ok": True, "action": "stats", "days_tracked": len(all_days), "events_total": sum(totals.values()), "distinct_events": len(totals), "by_category": dict(sorted(by_cat.items(), key=lambda kv: kv[1], reverse=True)), "top_events": [{"event": k, "count": v} for k, v in top[:150]], "per_day_total": {d: sum(store[d].values()) for d in all_days}, "updated_at": updated, "server_time": int(time.time()), "store_path": str(SM_USAGE_PATH), } # ───────────────────────────────────────────────────────────────── # FEEDBACK / SUGGESTIONS BOARD (Discord-forum-style: posts + threaded # comments + upvotes). Stored in SQLite. On the Space the DB lives under /data # (survives restarts AND rebuilds — same persistent mount the leaderboard cache # uses); locally it sits next to app.py. Moderation (status / delete) is gated by # the existing mod cookie (SM_MOD_KEY). Identity is a browser-generated uid (no # login on the tool) — enough for one-vote-per-browser and "your own" posts. # ───────────────────────────────────────────────────────────────── def _sm_feedback_db_path() -> Path: configured = (_os.getenv("SM_FEEDBACK_DB") or "").strip() if configured: p = Path(configured) return p if p.is_absolute() else APP_DIR / p if _os.getenv("SPACE_ID") and _os.path.isdir("/data"): return Path("/data") / "feedback.db" return APP_DIR / "feedback.db" SM_FEEDBACK_DB_PATH = _sm_feedback_db_path() SM_FEEDBACK_MAX_TITLE = 120 SM_FEEDBACK_MAX_BODY = 4000 SM_FEEDBACK_MAX_COMMENT = 2000 SM_FEEDBACK_MAX_NAME = 32 SM_FEEDBACK_STATUSES = ("open", "planned", "implemented", "declined", "closed") _sm_fb_lock = threading.Lock() _sm_fb_conn = None _sm_fb_last_post = {} # uid -> monotonic ts (light anti-spam) # ── live pub/sub (single uvicorn worker → in-process; mirrors the leaderboard SSE) ── _SM_FB_SUBSCRIBERS = set() _SM_FB_SUBSCRIBERS_LOCK = threading.Lock() def _sm_fb_subscribe(): q_ = queue.Queue(maxsize=500) with _SM_FB_SUBSCRIBERS_LOCK: _SM_FB_SUBSCRIBERS.add(q_) return q_ def _sm_fb_unsubscribe(q_): with _SM_FB_SUBSCRIBERS_LOCK: _SM_FB_SUBSCRIBERS.discard(q_) def _sm_fb_publish(event): """Fan one event out to every open Suggestions SSE listener. Non-blocking: a slow/full listener drops its oldest event rather than back-pressuring writers.""" with _SM_FB_SUBSCRIBERS_LOCK: subs = list(_SM_FB_SUBSCRIBERS) for s in subs: try: s.put_nowait(event) except queue.Full: try: s.get_nowait(); s.put_nowait(event) except Exception: pass def _sm_fb_db(): global _sm_fb_conn if _sm_fb_conn is None: try: SM_FEEDBACK_DB_PATH.parent.mkdir(parents=True, exist_ok=True) except Exception: pass conn = sqlite3.connect(str(SM_FEEDBACK_DB_PATH), check_same_thread=False) conn.row_factory = sqlite3.Row try: conn.execute("PRAGMA journal_mode=WAL") except Exception: pass conn.executescript( """ CREATE TABLE IF NOT EXISTS fb_posts( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, body TEXT NOT NULL, author TEXT NOT NULL DEFAULT 'Anonymous', uid TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'open', created REAL NOT NULL, updated REAL NOT NULL ); CREATE TABLE IF NOT EXISTS fb_comments( id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER NOT NULL, body TEXT NOT NULL, author TEXT NOT NULL DEFAULT 'Anonymous', uid TEXT NOT NULL DEFAULT '', created REAL NOT NULL ); CREATE TABLE IF NOT EXISTS fb_votes( kind TEXT NOT NULL, target_id INTEGER NOT NULL, uid TEXT NOT NULL, PRIMARY KEY(kind, target_id, uid) ); CREATE INDEX IF NOT EXISTS ix_fb_comments_post ON fb_comments(post_id); """ ) conn.commit() _sm_fb_conn = conn return _sm_fb_conn def _sm_fb_is_mod(request: Request) -> bool: return _sm_is_mod(request) def _sm_fb_clean(s, limit): return str(s or "").replace("\x00", "").strip()[:limit] def _sm_fb_name(s): return _sm_fb_clean(s, SM_FEEDBACK_MAX_NAME) or "Anonymous" def _sm_fb_uid(s): return re.sub(r"[^A-Za-z0-9_-]", "", str(s or ""))[:64] def _sm_fb_score(conn, kind, target_id): row = conn.execute("SELECT COUNT(*) AS n FROM fb_votes WHERE kind=? AND target_id=?", (kind, target_id)).fetchone() return int(row["n"]) if row else 0 def _sm_fb_voted(conn, kind, target_id, uid): if not uid: return False return conn.execute("SELECT 1 FROM fb_votes WHERE kind=? AND target_id=? AND uid=?", (kind, target_id, uid)).fetchone() is not None def _sm_fb_post_dict(conn, row, uid): pid = row["id"] ccount = conn.execute("SELECT COUNT(*) AS n FROM fb_comments WHERE post_id=?", (pid,)).fetchone()["n"] return { "id": pid, "title": row["title"], "body": row["body"], "author": row["author"], "status": row["status"], "created": row["created"], "updated": row["updated"], "score": _sm_fb_score(conn, "post", pid), "comments": int(ccount), "voted": _sm_fb_voted(conn, "post", pid, uid), "own": bool(uid and row["uid"] and row["uid"] == uid), } def _sm_fb_comment_dict(conn, row, uid): cid = row["id"] return { "id": cid, "post_id": row["post_id"], "body": row["body"], "author": row["author"], "created": row["created"], "score": _sm_fb_score(conn, "comment", cid), "voted": _sm_fb_voted(conn, "comment", cid, uid), "own": bool(uid and row["uid"] and row["uid"] == uid), } @app.get("/feedback/whoami") def feedback_whoami(request: Request): return {"is_mod": _sm_fb_is_mod(request)} @app.get("/feedback/posts") def feedback_posts(request: Request, sort: str = "top", uid: str = "", limit: int = 100, offset: int = 0): uid = _sm_fb_uid(uid) limit = max(1, min(200, int(limit or 100))) offset = max(0, int(offset or 0)) order = ("created DESC" if sort == "new" else "(SELECT COUNT(*) FROM fb_votes v WHERE v.kind='post' AND v.target_id=fb_posts.id) DESC, created DESC") with _sm_fb_lock: conn = _sm_fb_db() rows = conn.execute(f"SELECT * FROM fb_posts ORDER BY {order} LIMIT ? OFFSET ?", (limit, offset)).fetchall() posts = [_sm_fb_post_dict(conn, r, uid) for r in rows] total = conn.execute("SELECT COUNT(*) AS n FROM fb_posts").fetchone()["n"] return {"posts": posts, "total": int(total), "is_mod": _sm_fb_is_mod(request)} @app.get("/feedback/post/{post_id}") def feedback_post(post_id: int, request: Request, uid: str = ""): uid = _sm_fb_uid(uid) with _sm_fb_lock: conn = _sm_fb_db() row = conn.execute("SELECT * FROM fb_posts WHERE id=?", (post_id,)).fetchone() if not row: raise HTTPException(404, "Post not found.") post = _sm_fb_post_dict(conn, row, uid) crows = conn.execute("SELECT * FROM fb_comments WHERE post_id=? ORDER BY created ASC", (post_id,)).fetchall() post["commentList"] = [_sm_fb_comment_dict(conn, c, uid) for c in crows] post["is_mod"] = _sm_fb_is_mod(request) return post class FbPostReq(BaseModel): title: str = "" body: str = "" author: str = "" uid: str = "" @app.post("/feedback/post") def feedback_create(data: FbPostReq): title = _sm_fb_clean(data.title, SM_FEEDBACK_MAX_TITLE) body = _sm_fb_clean(data.body, SM_FEEDBACK_MAX_BODY) if len(title) < 3: raise HTTPException(400, "Title must be at least 3 characters.") if len(body) < 1: raise HTTPException(400, "Please add some detail to your suggestion.") uid = _sm_fb_uid(data.uid) if uid and (time.monotonic() - _sm_fb_last_post.get(uid, 0.0)) < 15: raise HTTPException(429, "Please wait a few seconds before posting again.") now = time.time() with _sm_fb_lock: conn = _sm_fb_db() cur = conn.execute( "INSERT INTO fb_posts(title, body, author, uid, status, created, updated) " "VALUES(?,?,?,?,'open',?,?)", (title, body, _sm_fb_name(data.author), uid, now, now)) conn.commit() row = conn.execute("SELECT * FROM fb_posts WHERE id=?", (cur.lastrowid,)).fetchone() post = _sm_fb_post_dict(conn, row, uid) bcast = _sm_fb_post_dict(conn, row, "") if uid: _sm_fb_last_post[uid] = time.monotonic() _sm_fb_publish({"type": "post", "post": bcast}) return post class FbCommentReq(BaseModel): post_id: int body: str = "" author: str = "" uid: str = "" @app.post("/feedback/comment") def feedback_comment(data: FbCommentReq, request: Request): body = _sm_fb_clean(data.body, SM_FEEDBACK_MAX_COMMENT) if len(body) < 1: raise HTTPException(400, "Comment cannot be empty.") uid = _sm_fb_uid(data.uid) now = time.time() with _sm_fb_lock: conn = _sm_fb_db() prow = conn.execute("SELECT status FROM fb_posts WHERE id=?", (data.post_id,)).fetchone() if not prow: raise HTTPException(404, "Post not found.") if prow["status"] not in ("open", "planned") and not _sm_fb_is_mod(request): raise HTTPException(403, "This suggestion is closed for new comments.") cur = conn.execute( "INSERT INTO fb_comments(post_id, body, author, uid, created) VALUES(?,?,?,?,?)", (data.post_id, body, _sm_fb_name(data.author), uid, now)) conn.commit() row = conn.execute("SELECT * FROM fb_comments WHERE id=?", (cur.lastrowid,)).fetchone() comment = _sm_fb_comment_dict(conn, row, uid) bcast = _sm_fb_comment_dict(conn, row, "") _sm_fb_publish({"type": "comment", "comment": bcast}) return comment class FbVoteReq(BaseModel): kind: str = "post" target_id: int uid: str = "" @app.post("/feedback/vote") def feedback_vote(data: FbVoteReq): kind = "comment" if data.kind == "comment" else "post" uid = _sm_fb_uid(data.uid) if not uid: raise HTTPException(400, "Missing voter id.") table = "fb_comments" if kind == "comment" else "fb_posts" with _sm_fb_lock: conn = _sm_fb_db() if not conn.execute(f"SELECT 1 FROM {table} WHERE id=?", (data.target_id,)).fetchone(): raise HTTPException(404, "Target not found.") if _sm_fb_voted(conn, kind, data.target_id, uid): conn.execute("DELETE FROM fb_votes WHERE kind=? AND target_id=? AND uid=?", (kind, data.target_id, uid)) voted = False else: conn.execute("INSERT OR IGNORE INTO fb_votes(kind, target_id, uid) VALUES(?,?,?)", (kind, data.target_id, uid)) voted = True conn.commit() score = _sm_fb_score(conn, kind, data.target_id) _sm_fb_publish({"type": "vote", "kind": kind, "target_id": data.target_id, "score": score}) return {"score": score, "voted": voted} class FbModStatusReq(BaseModel): post_id: int status: str = "open" @app.post("/feedback/mod/status") def feedback_mod_status(data: FbModStatusReq, request: Request): if not _sm_fb_is_mod(request): raise HTTPException(403, "Moderators only.") status = data.status if data.status in SM_FEEDBACK_STATUSES else "open" with _sm_fb_lock: conn = _sm_fb_db() if not conn.execute("SELECT 1 FROM fb_posts WHERE id=?", (data.post_id,)).fetchone(): raise HTTPException(404, "Post not found.") conn.execute("UPDATE fb_posts SET status=?, updated=? WHERE id=?", (status, time.time(), data.post_id)) conn.commit() _sm_fb_publish({"type": "status", "post_id": data.post_id, "status": status}) return {"ok": True, "status": status} class FbModIdReq(BaseModel): post_id: int = 0 comment_id: int = 0 @app.post("/feedback/mod/delete") def feedback_mod_delete(data: FbModIdReq, request: Request): if not _sm_fb_is_mod(request): raise HTTPException(403, "Moderators only.") ev = None with _sm_fb_lock: conn = _sm_fb_db() if data.comment_id: prow = conn.execute("SELECT post_id FROM fb_comments WHERE id=?", (data.comment_id,)).fetchone() conn.execute("DELETE FROM fb_votes WHERE kind='comment' AND target_id=?", (data.comment_id,)) conn.execute("DELETE FROM fb_comments WHERE id=?", (data.comment_id,)) ev = {"type": "comment_delete", "comment_id": data.comment_id, "post_id": (prow["post_id"] if prow else None)} elif data.post_id: cids = [r["id"] for r in conn.execute( "SELECT id FROM fb_comments WHERE post_id=?", (data.post_id,)).fetchall()] conn.execute("DELETE FROM fb_votes WHERE kind='post' AND target_id=?", (data.post_id,)) for cid in cids: conn.execute("DELETE FROM fb_votes WHERE kind='comment' AND target_id=?", (cid,)) conn.execute("DELETE FROM fb_comments WHERE post_id=?", (data.post_id,)) conn.execute("DELETE FROM fb_posts WHERE id=?", (data.post_id,)) ev = {"type": "delete", "post_id": data.post_id} else: raise HTTPException(400, "Nothing to delete.") conn.commit() if ev: _sm_fb_publish(ev) return {"ok": True} @app.get("/feedback/stream") async def feedback_stream(): """Server-Sent Events: live Suggestions changes (single-worker in-process pub/sub), so posts/comments/votes/status/deletes appear for everyone on the tab without a reload. Async generator -> never holds a threadpool thread.""" sub = _sm_fb_subscribe() async def _gen(): try: yield "retry: 4000\n\n" yield "event: hello\ndata: {\"ok\":true}\n\n" last = time.monotonic() while True: sent = False try: while True: ev = sub.get_nowait() yield "event: fb\ndata: " + json.dumps(ev) + "\n\n" sent = True except queue.Empty: pass if sent: last = time.monotonic() elif time.monotonic() - last >= 15.0: yield ": hb\n\n" last = time.monotonic() await asyncio.sleep(0.4) finally: _sm_fb_unsubscribe(sub) return StreamingResponse( _gen(), media_type="text/event-stream", # Content-Encoding set -> GZipMiddleware leaves the stream alone (otherwise it # buffers the compressed SSE and the browser sees nothing until reconnect). headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no", "Content-Encoding": "identity"}, ) # ───────────────────────────────────────────────────────────────── # MOB-KILL LEADERBOARD (per enemy type). Kills arrive for free with every scanned # inventory (result["killed_enemies"] -> row["kills"], name->count). Per-enemy count # arrays are built lazily from the shared leaderboard index and cached by its stamp, so # switching enemies never recomputes everything (page stays fast). # ───────────────────────────────────────────────────────────────── def _sm_enemy_type_list(): names = _sm_enemy_type_names() return [{"type": t, "name": names[t]} for t in sorted(names)] _SM_MOB_CACHE = {"stamp": None, "arrays": {}, "totals": {}, "order": {}} _SM_MOB_LOCK = threading.Lock() def _sm_mob_seed_local(base): """LOCAL DEV ONLY (no SPACE_ID): the bootstrap cache has no kill data, so synthesize deterministic, plausible kills once purely to make the feature testable locally. Never runs on the Space (real data comes from the scanner there).""" if _os.getenv("SPACE_ID"): return names = [e["name"] for e in _sm_enemy_type_list()] if not names: return seeded = 0 for r in base: if r.get("kills"): continue h = abs(hash(str(r.get("key") or r.get("username") or ""))) lvl = int(r.get("level") or 0) tiers = max(1, min(len(names), lvl // 60 + 2)) # higher level -> more enemy types killed kills = {} for i in range(tiers): n = ((h >> (i * 3)) % 900) + 10 kills[names[i]] = int(n * (tiers - i)) # lower tiers killed far more r["kills"] = kills seeded += 1 if seeded: print(f"[mob] local dev seed: synthetic kills on {seeded} rows") def _sm_mob_index(enemy_name): """(count_array, level_array, base, grand_total) for one enemy, aligned with the LB index.""" idx = _sm_lb_get_index() base = idx["base"] stamp = idx["stamp"] with _SM_MOB_LOCK: if _SM_MOB_CACHE["stamp"] != stamp: _SM_MOB_CACHE.update({"stamp": stamp, "arrays": {}, "totals": {}, "order": {}}) _sm_mob_seed_local(base) if enemy_name not in _SM_MOB_CACHE["arrays"]: raw = [int((r.get("kills") or {}).get(enemy_name, 0) or 0) for r in base] counts = _np.asarray(raw, dtype=_np.int64) if _HAS_NUMPY else raw _SM_MOB_CACHE["arrays"][enemy_name] = counts _SM_MOB_CACHE["totals"][enemy_name] = int(counts.sum()) if _HAS_NUMPY else int(sum(counts)) counts = _SM_MOB_CACHE["arrays"][enemy_name] total = _SM_MOB_CACHE["totals"][enemy_name] return counts, idx.get("level"), base, total def _sm_mob_order(enemy_name, min_level): """Descending order (indices into base) of accounts with >0 kills of this enemy at/above min_level. Cached per (enemy, min_level); rebuilt only when the LB index stamp changes.""" counts, level, base, total = _sm_mob_index(enemy_name) key = (enemy_name, int(min_level)) with _SM_MOB_LOCK: cached = _SM_MOB_CACHE["order"].get(key) if cached is not None and cached[0] == _SM_MOB_CACHE["stamp"]: return cached[1], total if _HAS_NUMPY: lv = level if level is not None else _np.zeros(len(base), dtype=_np.int64) pos = _np.nonzero((counts > 0) & (lv >= min_level))[0] order = pos[_np.argsort(-counts[pos], kind="stable")].tolist() else: order = sorted((i for i in range(len(base)) if counts[i] > 0 and int(base[i].get("level") or 0) >= min_level), key=lambda i: counts[i], reverse=True) with _SM_MOB_LOCK: _SM_MOB_CACHE["order"][key] = (_SM_MOB_CACHE["stamp"], order) return order, total @app.get("/mob-types") def mob_types(): return {"enemies": _sm_enemy_type_list()} # ── Leaderboard proxy fetch (used by the read endpoints when SM_LEADERBOARD_PROXY_URL is set) ── # Session is created LAZILY on first proxied request, never at import: the test suite stubs the # `requests` module (no .Session), and A only ever needs a real session at runtime on the Space. _SM_LB_PROXY_SESSION = None _SM_LB_PROXY_CACHE = {} # (path, query, is_mod) -> {"stamp","value"} last-good response _SM_LB_PROXY_CACHE_LOCK = threading.Lock() # ── proxy health gate ────────────────────────────────────────────────────────────────────── # The mirror (prices) runs on ephemeral storage: an HF restart wipes /tmp, and prices then # re-fills from the pusher LOW-LEVEL-FIRST, so its public (>=1000) total sits at ~0 for a while. # Proxying that blindly is exactly the "totals bei 0" bug. Before proxying, we cheaply gate on # prices' UNFILTERED total (query-independent, so a legit empty search never trips it): if it is # implausibly low (mirror empty / mid-refill / unreachable), A self-serves from its own warm # local state instead. The check is cached briefly so it costs ~1 request / 45s, which also # keeps the free prices Space awake. _SM_LB_PROXY_HEALTH = {"ok": True, "stamp": 0.0, "total": 0} _SM_LB_PROXY_HEALTH_LOCK = threading.Lock() SM_LB_PROXY_HEALTH_TTL = max(10.0, float(_os.getenv("SM_LB_PROXY_HEALTH_TTL", "45"))) # Real qualified population is ~371k; anything below this floor means the mirror is broken, not # merely a bit stale. Well below normal, well above "empty". SM_LB_PROXY_HEALTH_FLOOR = max(1, int(_os.getenv("SM_LB_PROXY_HEALTH_FLOOR", "50000"))) def _sm_lb_proxy_healthy(force: bool = False): """True if the mirror currently holds a plausible (>=FLOOR) public leaderboard. Cached for SM_LB_PROXY_HEALTH_TTL seconds; `force` re-probes now (used right after an auto-heal sync).""" if not SM_LEADERBOARD_PROXY_URL: return False now = time.time() if not force: with _SM_LB_PROXY_HEALTH_LOCK: if now - _SM_LB_PROXY_HEALTH["stamp"] < SM_LB_PROXY_HEALTH_TTL: return _SM_LB_PROXY_HEALTH["ok"] ok = False total = 0 try: global _SM_LB_PROXY_SESSION if _SM_LB_PROXY_SESSION is None: _SM_LB_PROXY_SESSION = _requests.Session() # no sm_mod cookie -> public (>=MIN_LEVEL) count, the number users see r = _SM_LB_PROXY_SESSION.get(f"{SM_LEADERBOARD_PROXY_URL}/leaderboard?limit=1", timeout=6) r.raise_for_status() total = int((r.json() or {}).get("total") or 0) ok = total >= SM_LB_PROXY_HEALTH_FLOOR except Exception as exc: # noqa: BLE001 -- unreachable == unhealthy -> local fallback keeps A up ok = False print(f"[lb-proxy] health probe failed: {type(exc).__name__}: {str(exc)[:90]}") with _SM_LB_PROXY_HEALTH_LOCK: prev = _SM_LB_PROXY_HEALTH["ok"] _SM_LB_PROXY_HEALTH.update({"ok": ok, "stamp": time.time(), "total": total}) if prev != ok: print(f"[lb-proxy] mirror health -> {'OK' if ok else 'DEGRADED'} (public total={total})") return ok def _sm_lb_proxy_fetch(request: Request, path: str, inject_counts: bool = False): """Serve a leaderboard read from the prices Space. Forwards the query string + the sm_mod cookie (so the mod gate is enforced there), caches the last good response, and on ANY failure falls back to that cached response -- or returns None so the caller uses A's local self-serve.""" if not SM_LEADERBOARD_PROXY_URL: return None is_mod = _sm_is_mod(request) if not _sm_lb_proxy_healthy(): # Mirror is empty / mid-refill / unreachable: DON'T proxy its degraded (often total=0) # answer. Return None so the caller self-serves A's warm local leaderboard instead. return None qs = request.url.query ckey = (path, qs, is_mod) url = f"{SM_LEADERBOARD_PROXY_URL}{path}" + (f"?{qs}" if qs else "") cookies = {} if is_mod and SM_MOD_KEY: # Server-to-server only: the prices Space still gates its mod view on this key. cookies["sm_mod"] = SM_MOD_KEY try: global _SM_LB_PROXY_SESSION if _SM_LB_PROXY_SESSION is None: _SM_LB_PROXY_SESSION = _requests.Session() r = _SM_LB_PROXY_SESSION.get(url, cookies=cookies, timeout=20) r.raise_for_status() data = r.json() if inject_counts and isinstance(data, dict): # prices reports ccu/global counts as None (it makes no game calls) -> A fills the real # numbers from its own cached+seeded helpers (fast, never blocks the request). ml = SM_LEADERBOARD_STORE_MIN_LEVEL if is_mod else SM_LEADERBOARD_MIN_LEVEL data["ccu"] = _sm_call_with_timeout(_sm_ccu, 1.5) tot = _sm_level_player_count() qual = _sm_level_qualified_count(ml) if tot is not None: data["total_accounts"] = tot if qual is not None: data["qualified_min_level"] = qual with _SM_LB_PROXY_CACHE_LOCK: _SM_LB_PROXY_CACHE[ckey] = {"stamp": time.time(), "value": data} if len(_SM_LB_PROXY_CACHE) > 256: oldest = min(_SM_LB_PROXY_CACHE, key=lambda k: _SM_LB_PROXY_CACHE[k]["stamp"]) _SM_LB_PROXY_CACHE.pop(oldest, None) return data except Exception as exc: # noqa: BLE001 with _SM_LB_PROXY_CACHE_LOCK: slot = _SM_LB_PROXY_CACHE.get(ckey) if slot is not None: print(f"[lb-proxy] {path} {type(exc).__name__}: {str(exc)[:90]} -> served last-good cache") return slot["value"] print(f"[lb-proxy] {path} {type(exc).__name__}: {str(exc)[:90]} -> local fallback") return None # ── one-time full-mirror sync: push A's ENTIRE accumulated leaderboard (all players incl. the # dead/inactive accounts the game no longer paginates, which a fresh scan on the mirror can't # see) + A's history snapshots (for immediate d1/d7 deltas) to the mirror Space. Runs in a # background thread; guarded by the ingest key (A uses its own key for the outbound push). ── _SM_LB_MIRROR_STATUS = {"running": False, "pushed_rows": 0, "pushed_hist": 0, "total_rows": 0, "done": False, "error": ""} def _sm_lb_push_history(target: str, sess, hdr, st: dict = None): """Push A's daily + hourly history snapshots to the mirror (immediate d1/d7 deltas). Small + quick; done BEFORE the heavy row push so a heal interrupted mid-rows still lands the deltas.""" n = 0 import gzip as _gz for f in sorted(SM_LB_HISTORY_DIR.glob("*.json")): data = json.loads(f.read_text(encoding="utf-8")) sess.post(f"{target}/lb-history-import", json={"kind": "daily", "name": f.stem, "data": data}, headers=hdr, timeout=90).raise_for_status() n += 1 if st is not None: st["pushed_hist"] = n for f in sorted(SM_LB_HISTORY_HOURLY_DIR.glob("*.json.gz")): data = json.loads(_gz.decompress(f.read_bytes()).decode("utf-8")) sess.post(f"{target}/lb-history-import", json={"kind": "hourly", "name": f.name[:-len('.json.gz')], "data": data}, headers=hdr, timeout=90).raise_for_status() n += 1 if st is not None: st["pushed_hist"] = n return n def _sm_lb_mirror_sync_worker(target: str, chunk: int): st = _SM_LB_MIRROR_STATUS st.update({"running": True, "pushed_rows": 0, "pushed_hist": 0, "total_rows": 0, "done": False, "error": ""}) sess = _requests.Session() hdr = {"X-Ingest-Key": SM_LEADERBOARD_INGEST_KEY, "Content-Type": "application/json"} try: # history FIRST: it's tiny and gives deltas immediately; if the long row push below is # interrupted (e.g. A gets restarted under the load) the mirror still has its d1/d7. try: _sm_lb_push_history(target, sess, hdr, st) except Exception as he: # noqa: BLE001 -- best-effort; rows still follow print(f"[lb-mirror-sync] history push warning: {type(he).__name__}: {str(he)[:150]}") with _SM_LEADERBOARD_LOCK: rows_src = list((_sm_leaderboard_load_unlocked().get("players") or {}).values()) st["total_rows"] = len(rows_src) buf = [] def flush(): if not buf: return r = sess.post(f"{target}/ingest-leaderboard", json={"players": buf}, headers=hdr, timeout=90) r.raise_for_status() st["pushed_rows"] += len(buf) buf.clear() for row in rows_src: if not isinstance(row, dict): continue u = str(row.get("username") or "").strip() if not u: continue item = {"username": u, "player_id": str(row.get("player_id") or "").strip(), "level": int(row.get("level") or 0), "power": row.get("power_raw"), "gold": row.get("gold_raw")} if row.get("is_banned") is not None: item["is_banned"] = bool(row.get("is_banned")) if row.get("is_dead") is not None: item["is_dead"] = bool(row.get("is_dead")) if row.get("kills"): item["kills"] = row.get("kills") buf.append(item) if len(buf) >= chunk: flush() time.sleep(0.3) # gentle: don't hammer the mirror during the one-time sync flush() st["done"] = True print(f"[lb-mirror-sync] done: {st['pushed_rows']}/{st['total_rows']} rows, " f"{st['pushed_hist']} history files -> {target}") except Exception as exc: # noqa: BLE001 st["error"] = f"{type(exc).__name__}: {str(exc)[:200]}" print(f"[lb-mirror-sync] FAILED after {st['pushed_rows']} rows: {st['error']}") finally: st["running"] = False @app.post("/lb-mirror-sync") def lb_mirror_sync(request: Request, target: str = "", chunk: int = 5000): import hmac if not SM_LEADERBOARD_INGEST_KEY: raise HTTPException(404, "Ingest is not enabled on this server.") provided = request.headers.get("X-Ingest-Key") or request.query_params.get("key") or "" if not hmac.compare_digest(str(provided), SM_LEADERBOARD_INGEST_KEY): raise HTTPException(403, "Invalid ingest key.") tgt = (target or SM_LEADERBOARD_PROXY_URL or "").strip().rstrip("/") if not tgt: raise HTTPException(400, "Missing target (?target=https://…).") if _SM_LB_MIRROR_STATUS.get("running"): return {"ok": False, "error": "sync already running", "status": dict(_SM_LB_MIRROR_STATUS)} threading.Thread(target=_sm_lb_mirror_sync_worker, args=(tgt, max(500, min(int(chunk or 5000), 20000))), daemon=True, name="lb-mirror-sync").start() return {"ok": True, "started": True, "target": tgt} @app.get("/lb-mirror-status") def lb_mirror_status(): return dict(_SM_LB_MIRROR_STATUS) # ── auto-heal: keep the mirror complete without manual intervention ────────────────────────── # The mirror lives on ephemeral storage, so an HF restart wipes it and it re-fills from the # pusher alone -- which reaches ~353k live accounts but misses the ~18k dead/inactive ones the # game no longer paginates AND all d1/d7 history. This worker watches the mirror and, whenever it # is empty or materially thinner than A's own accumulated state, re-pushes A's FULL players + # history so parity (and deltas) come back on their own. While the mirror is degraded the proxy # fetch already self-serves A's warm local data, so users never see the gap. _SM_LB_AUTOHEAL_THREAD = None _SM_LB_AUTOHEAL_LOCK = threading.Lock() SM_LB_AUTOHEAL_INTERVAL = max(60.0, float(_os.getenv("SM_LB_AUTOHEAL_INTERVAL", "180"))) # re-sync when the mirror holds < this fraction of A's stored rows SM_LB_AUTOHEAL_RATIO = float(_os.getenv("SM_LB_AUTOHEAL_RATIO", "0.9")) # minimum gap between two heals (a full re-push takes minutes; guard against flapping) SM_LB_AUTOHEAL_COOLDOWN = max(120.0, float(_os.getenv("SM_LB_AUTOHEAL_COOLDOWN", "600"))) def _sm_lb_local_stored_count(): """A's total stored rows (all levels) -- the number a healthy mirror must hold. This is what the heal decision keys on (NOT the public >=1000 total): prices' stored count updates the instant it ingests, whereas its public total lags by up to one index-rebuild (~30 min), so keying on the public total would re-push 1.6M rows in a loop while the mirror merely reindexes.""" try: with _SM_LEADERBOARD_LOCK: return len(_sm_leaderboard_load_unlocked().get("players") or {}) except Exception: # noqa: BLE001 return 0 def _sm_lb_mirror_stored_count(): """Mirror's stored-row count via /lb-health, and whether it was reachable.""" try: global _SM_LB_PROXY_SESSION if _SM_LB_PROXY_SESSION is None: _SM_LB_PROXY_SESSION = _requests.Session() r = _SM_LB_PROXY_SESSION.get(f"{SM_LEADERBOARD_PROXY_URL}/lb-health", timeout=6) r.raise_for_status() return int((r.json() or {}).get("rows") or 0), True except Exception: # noqa: BLE001 return 0, False def _sm_lb_mirror_missing_d1(): """True if the mirror is up but has no ~24h-ago snapshot (d1 deltas blank). A fresh mirror (post-restart, before it accumulates its own hourly snapshots) has this until A re-seeds its history -- cheap to fix without re-pushing 1.6M rows.""" try: global _SM_LB_PROXY_SESSION if _SM_LB_PROXY_SESSION is None: _SM_LB_PROXY_SESSION = _requests.Session() r = _SM_LB_PROXY_SESSION.get(f"{SM_LEADERBOARD_PROXY_URL}/leaderboard?limit=1&measure=d1", timeout=8) r.raise_for_status() return (r.json() or {}).get("history", {}).get("d1_age") is None except Exception: # noqa: BLE001 return False # unreachable -> not our concern here (the thin/serving gates handle it) def _sm_lb_keep_history_fresh(): """Proxy mode disables A's heavy maintenance worker AND its 600MB save -- which is also where the hourly/daily history snapshots were taken. So A stops producing history and, days later, can no longer seed the mirror's d1/d7 after a restart. Re-take the (cheap, index-independent) snapshots here so A always holds fresh bootstrap history. Once/hour + once/day of real work; a file-exists check otherwise.""" try: _sm_leaderboard_snapshot_hourly() # acquires the lock itself -> call it OUTSIDE the lock with _SM_LEADERBOARD_LOCK: state = _sm_leaderboard_load_unlocked() _sm_leaderboard_snapshot_daily(state) # iterate players under the lock (no mid-iter mutation) except Exception as exc: # noqa: BLE001 print(f"[lb-autoheal] snapshot keep-fresh: {type(exc).__name__}: {str(exc)[:120]}") def _sm_lb_autoheal_worker(): if not (SM_LEADERBOARD_PROXY_URL and SM_LEADERBOARD_INGEST_KEY): return time.sleep(75) # let boot + the pusher's first sweep settle before judging the mirror last_sync = 0.0 last_hist = 0.0 while True: try: _sm_lb_keep_history_fresh() # A stays the durable source of bootstrap history if not _SM_LB_MIRROR_STATUS.get("running"): mirror_rows, reachable = _sm_lb_mirror_stored_count() local_rows = _sm_lb_local_stored_count() # Only heal when the mirror is REACHABLE (a push would land) and genuinely thinner # than A -- i.e. it lost data (restart) or a pusher-only refill is still missing the # dead/inactive accounts + history. Cooldown guards against any flapping. thin = (local_rows > SM_LB_PROXY_HEALTH_FLOOR and mirror_rows < int(local_rows * SM_LB_AUTOHEAL_RATIO)) cooldown_ok = (time.time() - last_sync) > SM_LB_AUTOHEAL_COOLDOWN if reachable and thin and cooldown_ok: print(f"[lb-autoheal] mirror thin ({mirror_rows}/{local_rows} rows) -> re-syncing A's full state") _sm_lb_mirror_sync_worker(SM_LEADERBOARD_PROXY_URL, 5000) last_sync = time.time() _sm_lb_proxy_healthy(force=True) # refresh the serving gate elif reachable and not thin and (time.time() - last_hist) > SM_LB_AUTOHEAL_COOLDOWN \ and _sm_lb_mirror_missing_d1(): # Rows are fine but the mirror has no d1 baseline (fresh restart): re-seed just # the history -- tiny, no 1.6M row re-push, no starvation risk. print("[lb-autoheal] mirror missing d1 history -> re-seeding history only") try: _sess = _requests.Session() _hdr = {"X-Ingest-Key": SM_LEADERBOARD_INGEST_KEY, "Content-Type": "application/json"} n = _sm_lb_push_history(SM_LEADERBOARD_PROXY_URL, _sess, _hdr) print(f"[lb-autoheal] re-seeded {n} history files") except Exception as he: # noqa: BLE001 print(f"[lb-autoheal] history re-seed failed: {type(he).__name__}: {str(he)[:120]}") last_hist = time.time() except Exception as exc: # noqa: BLE001 print(f"[lb-autoheal] {type(exc).__name__}: {str(exc)[:150]}") time.sleep(SM_LB_AUTOHEAL_INTERVAL) def _sm_lb_start_autoheal(): global _SM_LB_AUTOHEAL_THREAD if not SM_LEADERBOARD_PROXY_URL: return # only meaningful in proxy mode (A is the mirror's source of truth) with _SM_LB_AUTOHEAL_LOCK: if _SM_LB_AUTOHEAL_THREAD and _SM_LB_AUTOHEAL_THREAD.is_alive(): return _SM_LB_AUTOHEAL_THREAD = threading.Thread( target=_sm_lb_autoheal_worker, name="lb-autoheal", daemon=True) _SM_LB_AUTOHEAL_THREAD.start() @app.get("/mob-leaderboard") def mob_leaderboard(request: Request, enemy: str = "", offset: int = 0, limit: int = 100, q: str = ""): if SM_LEADERBOARD_PROXY_URL: pr = _sm_lb_proxy_fetch(request, "/mob-leaderboard") if pr is not None: return pr _sm_leaderboard_start_background() enemies = _sm_enemy_type_list() valid = {e["name"] for e in enemies} if enemy not in valid: enemy = enemies[0]["name"] if enemies else "" if not enemy: raise HTTPException(503, "No enemy types available.") is_mod = _sm_is_mod(request) min_level = SM_LEADERBOARD_STORE_MIN_LEVEL if is_mod else SM_LEADERBOARD_MIN_LEVEL limit = max(1, min(200, int(limit or 100))) offset = max(0, int(offset or 0)) counts, _level, base, total = _sm_mob_index(enemy) order, total = _sm_mob_order(enemy, min_level) ql = q.strip().lower() if ql: # Bei Namenssuche die ECHTE Gesamt-Platzierung behalten (Position im vollen order), # NICHT unter den Treffern neu durchnummerieren -> (rank_pos, base_index) je Treffer. matches = [(pos, i) for pos, i in enumerate(order) if ql in str(base[i].get("username") or "").lower()] ranked = len(matches) page = matches[offset:offset + limit] else: ranked = len(order) # kein O(N)-enumerate im Normalfall: rank aus offset ableiten page = [(offset + k, i) for k, i in enumerate(order[offset:offset + limit])] rows = [] for pos, i in page: r = base[i] rows.append({ "rank": pos + 1, # Platz in der GESAMT-Rangliste (auch bei aktiver Suche) "username": r.get("username") or "", "level": int(r.get("level") or 0), "power": r.get("power") or "0", "power_sort": float(r.get("power_sort") or 0), "kills": int(counts[i]), }) return {"ok": True, "enemy": enemy, "kills_total": int(total), "rows": rows, "offset": offset, "limit": limit, "ranked": ranked, "is_mod": is_mod} @app.get("/leaderboard") def leaderboard( request: Request, sort: str = "power", direction: str = "desc", q: str = "", offset: int = 0, limit: int = SM_LEADERBOARD_QUERY_LIMIT, measure: str = "total", mod_key: str = "", cs: int = 0, ): if SM_LEADERBOARD_PROXY_URL: pr = _sm_lb_proxy_fetch(request, "/leaderboard") if pr is not None: return pr _sm_leaderboard_start_background() # Mod erkennt der Server rein an der Geraete-Sitzung (HttpOnly-Cookie aus einem Einmal-Link); # mod_key-Query wird ignoriert (keine Secrets mehr in URLs). Mod -> bis STORE_MIN # (Level 100), sonst oeffentlich (1000+). is_mod = _sm_is_mod(request) min_level = SM_LEADERBOARD_STORE_MIN_LEVEL if is_mod else None # Wildcard-Filter + Case-Sensitivity sind MOD-Only (Alt-Suche); fuer Nicht-Mods ignoriert. return _sm_leaderboard_snapshot( sort_by=sort, direction=direction, q=q, offset=offset, limit=limit, measure=measure, min_level=min_level, case_sensitive=bool(is_mod and cs), allow_wildcard=bool(is_mod), ) @app.get("/leaderboard/by-names") def leaderboard_by_names(request: Request, names: str = ""): """MOD-ONLY: aktuelle Leaderboard-Zeilen fuer eine Liste exakter Usernamen (Favoriten-Panel) — jede mit ihrem ECHTEN Rang. Match case-insensitiv gegen alle Namen (inkl. Aliase); bei Namensgleichheit gewinnt der hoechstplatzierte Account.""" _sm_leaderboard_start_background() if not (_sm_is_mod(request)): raise HTTPException(403, "Mod only.") if SM_LEADERBOARD_PROXY_URL: pr = _sm_lb_proxy_fetch(request, "/leaderboard/by-names") if pr is not None: return pr wanted_l = {n.strip().lower() for n in str(names or "").split(",") if n.strip()} if not wanted_l: return {"ok": True, "rows": []} min_level = SM_LEADERBOARD_STORE_MIN_LEVEL entry = _sm_leaderboard_build_sorted("power", True, "total", min_level) # default view (power desc) base = entry.get("base"); order = entry.get("order") if base is None: base = entry.get("rows") or []; order = None found = {} # lower_name -> (rank0, row) — first (highest-ranked) occurrence wins # rank0 = POSITION in the sorted order (not the base index): enumerate the order. seq = enumerate(base[int(p)] for p in order.tolist()) if order is not None else enumerate(base) for rank0, r in seq: nl = {str(x).strip().lower() for x in _sm_leaderboard_row_names(r) if x} for h in (wanted_l & nl): if h not in found: found[h] = (rank0, r) if len(found) >= len(wanted_l): break rows = [] for rank0, r in sorted(found.values(), key=lambda t: t[0]): pr = _sm_leaderboard_public_row(r) pr["rank"] = rank0 + 1 rows.append(pr) _sm_lb_enrich_diffs(rows) return {"ok": True, "rows": rows, "min_level": min_level, "updated_at": entry.get("updated_at"), "history": {"d1_age": _SM_LB_HISTORY_CACHE.get("d1_age"), "d7_age": _SM_LB_HISTORY_CACHE.get("d7_age")}} @app.get("/leaderboard-summary") def leaderboard_summary(request: Request, top: int = 10): if SM_LEADERBOARD_PROXY_URL: pr = _sm_lb_proxy_fetch(request, "/leaderboard-summary", inject_counts=True) if pr is not None: return pr _sm_leaderboard_start_background() # Wie /leaderboard: Mod (Cookie) sieht 100+-Totals/Counts, sonst oeffentlich 1000+. is_mod = _sm_is_mod(request) min_level = SM_LEADERBOARD_STORE_MIN_LEVEL if is_mod else None return _sm_leaderboard_summary_public(top_n=top, min_level=min_level) class IngestLeaderboardRequest(BaseModel): players: list[dict] = [] def _sm_ingest_kills(p: dict): """Kill-Daten einer gepushten Zeile in ``{enemy_name: count}`` normalisieren, damit das Mob-Kill-Leaderboard auch aus dem externen Ingest gefuellt wird (killedEnemyTypes kommt bei jedem GetPlayerInfo gratis mit). Akzeptiert BEIDES: die rohe ``killedEnemyTypes``-Map ``{typeId: count}`` direkt aus dem Inventar (per Namens-Tabelle gemappt) ODER eine bereits namens-basierte ``killed_enemies``/``kills``-Map. Nullen/Leeres werden verworfen.""" raw = p.get("killedEnemyTypes") if isinstance(raw, dict) and raw: return _sm_killed_enemies({"killedEnemyTypes": raw}) src = p.get("killed_enemies") if src is None: src = p.get("kills") if isinstance(src, dict) and src: out = {} for name, cnt in src.items(): try: c = int(float(cnt or 0)) except (TypeError, ValueError): continue if c: out[str(name)] = c return out return {} @app.post("/ingest-leaderboard") def ingest_leaderboard(data: IngestLeaderboardRequest, request: Request): """Nimmt Leaderboard-Daten entgegen, die ein externer Scanner (lokaler PC ueber die schnelle Heim-IP) pusht, und schreibt sie ueber die normale Upsert-Logik ins Cache. So muss der Space die gedrosselte Rechenzentrums-IP nicht mehr fuers Massen-Scannen benutzen. Geschuetzt per SM_LEADERBOARD_INGEST_KEY (Header X-Ingest-Key oder ?key=).""" import hmac if not SM_LEADERBOARD_INGEST_KEY: raise HTTPException(404, "Ingest is not enabled on this server.") provided = request.headers.get("X-Ingest-Key") or request.query_params.get("key") or "" if not hmac.compare_digest(str(provided), SM_LEADERBOARD_INGEST_KEY): raise HTTPException(403, "Invalid ingest key.") _sm_leaderboard_start_maintenance() # Warmer laeuft auch ohne Seitenaufruf mit results = [] for p in (data.players or []): if not isinstance(p, dict): continue username = str(p.get("username") or "").strip() if not username: continue try: level = int(p.get("level")) except (TypeError, ValueError): continue res = { "ok": True, "username": username, "player_id": str(p.get("player_id") or "").strip(), "level": level, "power_raw": p.get("power"), "gold_raw": p.get("gold"), } # Optional: aktives Schwert/Schild (Spiel-Typ-Code als kleine Zahl + Level) aus dem Push # fuer die Power-Zeitreihe. Akzeptiert sword_type|sword und shield_type|shield. _sw = p.get("sword_type", p.get("sword")) if _sw is not None: res["sword_type"] = _sw if p.get("sword_level") is not None: res["sword_level"] = p.get("sword_level") _sh = p.get("shield_type", p.get("shield")) if _sh is not None: res["shield_type"] = _sh if p.get("shield_level") is not None: res["shield_level"] = p.get("shield_level") if p.get("is_banned") is not None: res["is_banned"] = bool(p.get("is_banned")) if p.get("is_dead") is not None: res["is_dead"] = bool(p.get("is_dead")) kills = _sm_ingest_kills(p) # Mob-Kills aus dem Push uebernehmen, falls mitgeschickt if kills: res["killed_enemies"] = kills results.append(res) updated = _sm_leaderboard_ingest_rows(results) if results else 0 return {"ok": True, "received": len(data.players or []), "stored": updated} @app.post("/leaderboard-refresh") def leaderboard_refresh(data: LeaderboardRefreshRequest): username = str(data.username or "").strip() if not username: raise HTTPException(400, "Missing username.") result = _sm_live_lookup_bulk([username]).get(username) if not result or not result.get("ok"): raise HTTPException(502, (result or {}).get("error") or f"No live stats found for '{username}'.") _sm_leaderboard_upsert_lookup_results([result], source="lookup") key = _sm_leaderboard_storage_key( result.get("username") or username, result.get("player_id") ) with _SM_LEADERBOARD_LOCK: state = _sm_leaderboard_load_unlocked() row = (state.get("players") or {}).get(key) if not row: return { "ok": False, "error": f"{result.get('username') or username} is below level {SM_LEADERBOARD_MIN_LEVEL} or did not return rankable stats.", "result": result, } return {"ok": True, "player": _sm_leaderboard_public_row(row)} @app.post("/leaderboard-refresh-batch") def leaderboard_refresh_batch(data: LeaderboardRefreshBatchRequest): """On-view refresh: fetch live stats for the currently-visible rows so what the user is looking at is seconds-fresh. Deduped and capped; the underlying lookup has its own ~15s cache so paging back and forth is cheap.""" _sm_leaderboard_start_background() seen = set() usernames = [] for raw in (data.usernames or []): clean = str(raw or "").strip() low = clean.lower() if clean and low not in seen: seen.add(low) usernames.append(clean) if len(usernames) >= SM_LOOKUP_MAX_USERNAMES: break if not usernames: return {"ok": True, "rows": [], "refreshed": 0} try: results_map = _sm_live_lookup_bulk(usernames) except HTTPException: raise except Exception as exc: return {"ok": False, "error": _sm_public_error(exc), "rows": [], "refreshed": 0} results = [r for r in results_map.values() if isinstance(r, dict) and r.get("ok")] if results: _sm_leaderboard_upsert_lookup_results(results, source="live") rows = [] with _SM_LEADERBOARD_LOCK: state = _sm_leaderboard_load_unlocked() players = state.get("players") or {} for result in results: key = _sm_leaderboard_storage_key(result.get("username"), result.get("player_id")) row = players.get(key) if row and int(row.get("level") or 0) >= SM_LEADERBOARD_MIN_LEVEL: rows.append(_sm_leaderboard_public_row(row)) return {"ok": True, "rows": rows, "refreshed": len(rows)} @app.get("/leaderboard/stream") async def leaderboard_stream(): """Server-Sent Events stream of live leaderboard row changes (single-worker in-process pub/sub). The browser patches matching rows in place — no reload. Async generator so it never holds a threadpool thread; ~0.5s delta latency.""" _sm_leaderboard_start_background() subscriber = _sm_lb_subscribe() async def _event_gen(): try: yield "retry: 4000\n\n" yield "event: hello\ndata: {\"ok\": true}\n\n" last_beat = time.monotonic() while True: batch = [] try: while True: batch.extend(subscriber.get_nowait()) except queue.Empty: pass if batch: yield "event: delta\ndata: " + json.dumps({"rows": batch}) + "\n\n" last_beat = time.monotonic() elif time.monotonic() - last_beat >= 15.0: yield ": heartbeat\n\n" last_beat = time.monotonic() await asyncio.sleep(0.5) finally: _sm_lb_unsubscribe(subscriber) return StreamingResponse( _event_gen(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no", "Content-Encoding": "identity", # keep GZipMiddleware from buffering the SSE }, ) def start_background_services(): _sm_start_live_room_background() _sm_leaderboard_start_background(initial_delay=SM_LEADERBOARD_STARTUP_DELAY) _start_price_refresh_loop() # A: Preise periodisch selbst ziehen -> kein taeglicher Rebuild noetig _delayed_forge_warm() # C: Forge-DB erst kurz nach dem Boot laden (ruhiger Start) _sm_lb_start_autoheal() # proxy mode: re-fill the mirror if an HF restart wiped it _rs_start_maintenance() # room-scan history DB: load counters, refresh stats, retention # Der Sync-Threadpool (Standard 40) wird von ALLEN sync-Routen geteilt — inkl. der # langsamen, blockierenden Live-Lookups. Sind alle 40 belegt, warten auch schnelle # Routen. Die Lookups sind I/O-gebunden (warten auf den SM-Websocket, geben dabei den # GIL frei), daher kostet mehr Threads praktisch keine CPU, gibt aber viel Luft, damit # sich Routen nicht gegenseitig aushungern. Die Seite selbst wird ohnehin schon ueber # den Event-Loop ausgeliefert (siehe _MemStatic). SM_THREADPOOL_TOKENS = max(40, int(_os.getenv("SM_THREADPOOL_TOKENS", "128"))) async def _tune_threadpool(): try: import anyio anyio.to_thread.current_default_thread_limiter().total_tokens = SM_THREADPOOL_TOKENS except Exception as exc: # noqa: BLE001 print(f"[startup] threadpool tuning skipped: {exc}") if hasattr(app, "on_event"): app.on_event("startup")(_tune_threadpool) app.on_event("startup")(start_background_services) @app.post("/clan-lookup") def clan_lookup(data: ClanLookupRequest): try: return _sm_clan_lookup(data.clan_name) except HTTPException: raise except Exception as exc: raise HTTPException(502, _sm_public_error(exc)) @app.get("/clan-top10") def clan_top10(): try: return _sm_clan_top10() except HTTPException: raise except Exception as exc: raise HTTPException(502, _sm_public_error(exc)) @app.post("/saved-player-resolve") def saved_player_resolve(data: SavedPlayerResolveRequest): player_id = str(data.player_id or "").strip() if not player_id: raise HTTPException(400, "Missing player_id.") token = _sm_get_guest_token() cached_count = _SM_LEVEL_PLAYER_COUNT_CACHE.get("value") or {} last_page = int(cached_count.get("last_page") or 0) if last_page < 1: last_page = SM_LEVEL_PLAYER_COUNT_HINT_PAGE + 1024 entry = _sm_level_rank_for_player_id( player_id, data.level, token, last_page, data.page, force_refresh=True, ) if not entry: return { "ok": False, "error": ( "Saved player ID was not found near the stored rank/level. " "For renamed low-level accounts, save it again after a normal username lookup." ), } return {"ok": True, "player": entry}

" "Try again